@phnx-labs/agents-cli 1.22.40 → 1.22.41

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/README.md +5 -0
  3. package/dist/bin/agents +0 -0
  4. package/dist/bootstrap.js +0 -1
  5. package/dist/cli/command-registry.js +1 -2
  6. package/dist/commands/browser.d.ts +15 -0
  7. package/dist/commands/browser.js +115 -40
  8. package/dist/commands/feed.js +3 -11
  9. package/dist/commands/secrets.js +87 -97
  10. package/dist/commands/sessions-picker.d.ts +1 -0
  11. package/dist/commands/sessions-picker.js +27 -5
  12. package/dist/commands/sessions-share.d.ts +25 -0
  13. package/dist/commands/sessions-share.js +166 -0
  14. package/dist/commands/sessions.js +2 -0
  15. package/dist/commands/setup-browser.js +1 -1
  16. package/dist/commands/setup-preferences.js +2 -2
  17. package/dist/commands/webhook.js +14 -9
  18. package/dist/lib/browser/cdp.js +4 -0
  19. package/dist/lib/browser/chrome.js +12 -1
  20. package/dist/lib/browser/drivers/ssh.js +1 -0
  21. package/dist/lib/browser/profiles.d.ts +3 -3
  22. package/dist/lib/browser/profiles.js +7 -7
  23. package/dist/lib/browser/service.d.ts +24 -1
  24. package/dist/lib/browser/service.js +38 -14
  25. package/dist/lib/browser/types.d.ts +1 -1
  26. package/dist/lib/daemon-webhooks.d.ts +3 -1
  27. package/dist/lib/daemon-webhooks.js +12 -7
  28. package/dist/lib/device-config.js +1 -1
  29. package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
  30. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  31. package/dist/lib/observe-aliases.d.ts +2 -2
  32. package/dist/lib/observe-aliases.js +2 -11
  33. package/dist/lib/project-key.js +7 -0
  34. package/dist/lib/runner.js +21 -6
  35. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  36. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  37. package/dist/lib/secrets/bundles.d.ts +1 -1
  38. package/dist/lib/secrets/bundles.js +1 -1
  39. package/dist/lib/secrets/headless.d.ts +16 -0
  40. package/dist/lib/secrets/headless.js +21 -0
  41. package/dist/lib/secrets/remote.d.ts +9 -7
  42. package/dist/lib/secrets/remote.js +18 -9
  43. package/dist/lib/session/share-html.d.ts +55 -0
  44. package/dist/lib/session/share-html.js +319 -0
  45. package/dist/lib/settings-manifest.d.ts +2 -0
  46. package/dist/lib/settings-manifest.js +81 -3
  47. package/dist/lib/share/publish.d.ts +38 -0
  48. package/dist/lib/share/publish.js +81 -8
  49. package/dist/lib/startup/command-registry.d.ts +2 -1
  50. package/dist/lib/startup/command-registry.js +4 -2
  51. package/dist/lib/triggers/handlers.d.ts +37 -2
  52. package/dist/lib/triggers/handlers.js +56 -5
  53. package/dist/lib/triggers/webhook.d.ts +80 -6
  54. package/dist/lib/triggers/webhook.js +127 -3
  55. package/dist/lib/types.d.ts +1 -1
  56. package/dist/lib/wrap.d.ts +33 -0
  57. package/dist/lib/wrap.js +70 -0
  58. package/package.json +1 -1
@@ -18,7 +18,7 @@ import { ensureDaemonStarted, isDaemonRunning } from '../lib/daemon/daemon.js';
18
18
  import { parseHostsOption, remoteResolveEnv, remoteSecretsRaw, remoteSecretsStream, resolveHostSshTarget, } from '../lib/secrets/remote.js';
19
19
  import { resolveBundleForPush, pushResolvedBundleToHost, bundleEnvToDotenv } from '../lib/secrets/push.js';
20
20
  export { bundleEnvToDotenv };
21
- import { bundleBackend, bundleExists, bundleItemStore, bundlePolicy, deleteBundle, describeBundle, keychainItemsForBundle, keychainRef, listBundles, healKeychainBundleMetadataAclOnce, isHeadlessSecretsContext, migrateLegacyBundles, parseDotenv, readAndResolveBundleEnv, readBundle, readBundleIfDecryptable, reAclBundleItems, renameBundle, rotateBundleSecret, sanitizeProcessEnv, validateBundleName, validateEnvKey, validateExpiresFutureDated, validateSecretType, writeBundle, writeBundleWithItems, SECRET_TYPES, } from '../lib/secrets/bundles.js';
21
+ import { bundleBackend, bundleExists, bundleItemStore, bundlePolicy, deleteBundle, describeBundle, keychainItemsForBundle, keychainRef, listBundles, healKeychainBundleMetadataAclOnce, isHeadlessSecretsContext, isAgentInvocationContext, 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
24
  import { getKeychainToken, hasKeychainToken, secretsKeychainItem, setKeychainToken, maybeAutoRekey, } from '../lib/secrets/index.js';
@@ -38,7 +38,6 @@ import { frequentlyPromptedBundles } from '../lib/secrets/unlock-hints.js';
38
38
  import { registerCommandGroups, setHelpSections } from '../lib/help.js';
39
39
  import { isInteractiveTerminal, isPromptCancelled } from './utils.js';
40
40
  import { discoverSyncedBundles, importSyncedBundle, } from '../lib/secrets/icloud-import.js';
41
- import { getVaultSession, vaultExists } from '../lib/secrets/vault.js';
42
41
  import { registerSecretsSyncCommands } from './secrets-sync.js';
43
42
  import { registerSecretsMigrateAclCommand } from './secrets-migrate.js';
44
43
  import { registerSecretsImportKeyringCommand } from './secrets-import.js';
@@ -924,14 +923,17 @@ export function registerSecretsCommands(program) {
924
923
  # Stop a noisy automation bundle from prompting every run: ask once a week
925
924
  agents secrets policy prod hold
926
925
 
927
- # Eval the bundle into your current shell
928
- eval "$(agents secrets export prod --plaintext)"
926
+ # Run a one-off command with secrets injected (values ride the child env, never stdout)
927
+ agents secrets exec prod -- ./deploy.sh
928
+
929
+ # Need one value inside a script? Read it under injection, not with a printer
930
+ agents secrets exec prod -- printenv STRIPE_API_KEY
931
+
932
+ # Deliberately reveal values at your terminal (interactive only)
933
+ agents secrets view prod --reveal
929
934
 
930
935
  # Push the bundle to remote machine(s) over SSH (lands as a native bundle there)
931
936
  agents secrets export prod --device yosemite-s0 --device yosemite-s1 --force
932
-
933
- # Run a one-off command with secrets injected
934
- agents secrets exec prod -- ./deploy.sh
935
937
  `,
936
938
  notes: `
937
939
  Bundles are containers; secrets are the variables inside them. Keychain values
@@ -1222,13 +1224,22 @@ export function registerSecretsCommands(program) {
1222
1224
  .command('view [name]')
1223
1225
  .alias('show')
1224
1226
  .description('Show a bundle. Keychain values are masked by default — pass --reveal to see them.')
1225
- .option('--reveal', 'Print keychain-backed values in the clear (TTY only unless --plaintext)')
1226
- .option('--plaintext', 'Allow --reveal in non-interactive shells (use with care)')
1227
+ .option('--reveal', 'Print keychain-backed values in the clear (interactive terminal only)')
1227
1228
  .option('--json', 'Emit machine-readable JSON (values masked unless --reveal) instead of the human view')
1228
1229
  .option('--device <target>', 'Show a bundle on a remote device over SSH (enrolled `agents hosts` name, ssh-config alias, or user@host)')
1229
1230
  .option('--devices <list>', 'Comma-separated devices to show in one shot, e.g. yosemite-s0,yosemite-s1')
1230
1231
  .action(async (name, opts) => {
1231
1232
  try {
1233
+ // `--reveal` is a deliberate HUMAN reveal: interactive terminal only, and
1234
+ // never inside an agent session (an agent under tmux has a TTY, so the
1235
+ // env markers are the load-bearing check). The old `--plaintext` escape
1236
+ // that allowed a piped/non-TTY reveal is removed (RUSH-2774) — printed
1237
+ // values land in an agent's context and session transcript.
1238
+ if (opts.reveal && (isAgentInvocationContext() || !isInteractiveTerminal())) {
1239
+ console.error(chalk.red('--reveal prints values in the clear and needs a real interactive terminal outside an agent session.'));
1240
+ console.error(chalk.dim('Run the consuming command under injection instead: agents secrets exec <bundle> -- <cmd>'));
1241
+ process.exit(1);
1242
+ }
1232
1243
  const targets = parseHostsOption({ device: opts.device, devices: opts.devices });
1233
1244
  if (targets.length > 0) {
1234
1245
  if (!name) {
@@ -1238,14 +1249,13 @@ export function registerSecretsCommands(program) {
1238
1249
  const args = ['view', name];
1239
1250
  if (opts.reveal)
1240
1251
  args.push('--reveal');
1241
- if (opts.plaintext)
1242
- args.push('--plaintext');
1243
1252
  if (opts.json)
1244
1253
  args.push('--json');
1245
1254
  // With --reveal, force a TTY so the remote keychain prompt can surface
1246
- // (and the remote's "--reveal in a non-TTY needs --plaintext" gate is
1247
- // satisfied) only when this side is itself interactive.
1248
- const tty = Boolean(opts.reveal) && Boolean(process.stdin.isTTY) && Boolean(process.stdout.isTTY);
1255
+ // and the remote's own interactive-terminal gate is satisfied. The
1256
+ // early gate above already proved this side is an interactive,
1257
+ // non-agent terminal.
1258
+ const tty = Boolean(opts.reveal);
1249
1259
  // `--reveal` streams the plaintext value back over ssh stdout — a
1250
1260
  // secret-bearing read that must pin the host key and not multiplex,
1251
1261
  // whether it runs on the interactive (tty) or `--plaintext` non-tty path.
@@ -1288,14 +1298,10 @@ export function registerSecretsCommands(program) {
1288
1298
  emitSecretAudit({ event: 'secrets.view', bundle: bundle.name, operation: opts.json ? 'view --json' : 'view', source: 'view', status: 'success' });
1289
1299
  if (opts.json) {
1290
1300
  // Machine-readable discovery for agents. Values are null unless --reveal
1291
- // (which still routes through the same non-TTY/plaintext gate + audit
1292
- // event as the human path). Gated on the explicit flag, not stdout.isTTY,
1293
- // so a piped human view never silently swaps formats.
1301
+ // (already gated above: interactive terminal, outside an agent session).
1302
+ // Gated on the explicit flag, not stdout.isTTY, so a piped human view
1303
+ // never silently swaps formats.
1294
1304
  const reveal = Boolean(opts.reveal);
1295
- if (reveal && !isInteractiveTerminal() && !opts.plaintext) {
1296
- console.error(chalk.red('--reveal in a non-TTY requires --plaintext.'));
1297
- process.exit(1);
1298
- }
1299
1305
  const revealed = new Map();
1300
1306
  if (reveal) {
1301
1307
  const { env } = readAndResolveBundleEnv(bundle.name, {
@@ -1415,10 +1421,6 @@ export function registerSecretsCommands(program) {
1415
1421
  return;
1416
1422
  }
1417
1423
  const reveal = Boolean(opts.reveal);
1418
- if (reveal && !isInteractiveTerminal() && !opts.plaintext) {
1419
- console.error(chalk.red('--reveal in a non-TTY requires --plaintext.'));
1420
- process.exit(1);
1421
- }
1422
1424
  // An explicit human `view --reveal` at a terminal is a deliberate value
1423
1425
  // access: resolve interactively (one Touch ID) so a locked bundle shows
1424
1426
  // its values. Under an agent (AGENTS_RUNTIME) or headless (no TTY) it
@@ -1491,50 +1493,38 @@ export function registerSecretsCommands(program) {
1491
1493
  });
1492
1494
  cmd
1493
1495
  .command('get <item> [key]')
1494
- .description('Print one secret value for shell hooks/automation. One arg = a raw keychain item by name; two args = one KEY out of a bundle (`get <bundle> <KEY>`). Cross-platform.')
1496
+ .description('Print one raw keychain item by name, for shell hooks/automation. Cross-platform. Bundle values are never printed run commands under `agents secrets exec` instead.')
1495
1497
  .action((item, key) => {
1496
- if (key === undefined) {
1497
- // Raw keychain item path unchanged.
1498
- try {
1499
- // Routes through the platform keychain layer: macOS reads bare items
1500
- // via /usr/bin/security (no Touch ID), Linux via secret-tool with the
1501
- // encrypted-file fallback. The value goes to stdout (newline-terminated
1502
- // so `$(agents secrets get NAME)` captures it cleanly); diagnostics go
1503
- // to stderr so they never pollute the captured value.
1504
- const value = getKeychainToken(item);
1505
- // Raw item reads bypass readAndResolveBundleEnv, so audit here too.
1506
- // `item` is the keychain service name, never the value.
1507
- emitSecretAudit({ event: 'secrets.get', item, source: 'raw-item', status: 'success' });
1508
- process.stdout.write(value.endsWith('\n') ? value : `${value}\n`);
1509
- }
1510
- catch {
1511
- // Missing item is a normal, quiet outcome for a hook probe: exit 1,
1512
- // print nothing to stdout. Callers test the exit code / empty capture.
1513
- process.exit(1);
1514
- }
1515
- return;
1498
+ if (key !== undefined) {
1499
+ // The bundle-key form (`get <bundle> <KEY>`) is REMOVED (RUSH-2774): a
1500
+ // one-liner that prints a bundle credential is the exfiltration path
1501
+ // this change closes, for every caller. The injection replacement is
1502
+ // just as short for a human script and never touches this stdout.
1503
+ console.error(chalk.red(`'secrets get <bundle> <KEY>' has been removed — printing a bundle value to stdout is the exfiltration path this blocks.`));
1504
+ console.error(chalk.dim(`Use: agents secrets exec ${item} -- printenv ${key} (or run the consuming command directly under exec)`));
1505
+ process.exit(1);
1516
1506
  }
1517
- // Bundle-key path: `get <bundle> <KEY>` prints exactly one resolved value.
1518
- // Ungated like the raw path (it IS the automation primitive); the
1519
- // `secrets.get` audit event is emitted inside readAndResolveBundleEnv.
1507
+ // Raw keychain item path a single ad-hoc token, not a credential bundle.
1508
+ // Deliberately NOT agent-gated (unlike the removed bundle-key form): shell
1509
+ // hooks that run inside agent sessions inherit the session env markers and
1510
+ // legitimately capture a raw item into their own variables (e.g. the
1511
+ // posthog analytics hook), where the value never reaches the transcript.
1512
+ // A single ad-hoc token is the accepted narrower residual (RUSH-2774).
1520
1513
  try {
1521
- if (!bundleExists(item) && !(vaultExists() && !getVaultSession().loggedIn)) {
1522
- console.error(chalk.red(`Secrets bundle '${item}' not found.`));
1523
- process.exit(1);
1524
- }
1525
- // `secrets get` is the scriptable automation primitive ($(agents secrets
1526
- // get bundle KEY)). Every read is broker-only; a locked bundle points at
1527
- // the explicit unlock command instead of raising Touch ID.
1528
- const { env } = readAndResolveBundleEnv(item, { caller: 'secrets get', keys: [key], keyMode: 'storage', agentOnly: true });
1529
- if (!(key in env)) {
1530
- console.error(chalk.red(`Key '${key}' not in bundle '${item}'.`));
1531
- process.exit(1);
1532
- }
1533
- const value = env[key];
1514
+ // Routes through the platform keychain layer: macOS reads bare items
1515
+ // via /usr/bin/security (no Touch ID), Linux via secret-tool with the
1516
+ // encrypted-file fallback. The value goes to stdout (newline-terminated
1517
+ // so `$(agents secrets get NAME)` captures it cleanly); diagnostics go
1518
+ // to stderr so they never pollute the captured value.
1519
+ const value = getKeychainToken(item);
1520
+ // Raw item reads bypass readAndResolveBundleEnv, so audit here too.
1521
+ // `item` is the keychain service name, never the value.
1522
+ emitSecretAudit({ event: 'secrets.get', item, source: 'raw-item', status: 'success' });
1534
1523
  process.stdout.write(value.endsWith('\n') ? value : `${value}\n`);
1535
1524
  }
1536
- catch (err) {
1537
- console.error(chalk.red(err.message));
1525
+ catch {
1526
+ // Missing item is a normal, quiet outcome for a hook probe: exit 1,
1527
+ // print nothing to stdout. Callers test the exit code / empty capture.
1538
1528
  process.exit(1);
1539
1529
  }
1540
1530
  });
@@ -2133,18 +2123,23 @@ Examples:
2133
2123
  });
2134
2124
  cmd
2135
2125
  .command('export [bundle]')
2136
- .description('Resolve a bundle and print KEY=VALUE lines, push it to a 1Password vault with --to-1password, or push it to remote machine(s) over SSH with --device.')
2137
- .option('--plaintext', 'Acknowledge that the resolved values will be printed in the clear (shell export mode)')
2126
+ .description('Move a bundle without exposing it: push to remote machine(s) over SSH with --device, to a 1Password vault with --to-1password, or to an encrypted file with --to-file.')
2127
+ // Both transport flags are hidden, not documented: the ONLY caller is the
2128
+ // machine-to-machine SSH resolve (remoteResolveEnv / verifyRemoteKeychainPush),
2129
+ // which sets AGENTS_SECRETS_REMOTE_TRANSPORT=1 on the remote invocation. The
2130
+ // old public shell-eval mode (`eval "$(agents secrets export … --plaintext)"`)
2131
+ // was the top agent-exfiltration path and is removed (RUSH-2774).
2132
+ .addOption(new Option('--plaintext', 'internal remote-resolve transport').hideHelp())
2133
+ .addOption(new Option('--format <json>', 'internal remote-resolve transport').hideHelp())
2138
2134
  .option('--to-1password', 'Push every key in the bundle as a PASSWORD item in a 1Password vault')
2139
2135
  .option('--vault <name>', '1Password vault name (used with --to-1password)')
2140
2136
  .option('--device <target...>', 'Push the bundle over SSH to this device (host alias or user@host); repeatable for multiple machines')
2141
2137
  .option('--remote-backend <backend>', 'Backend for the bundle on the remote device (with --device): keychain (default) or file. file is headless-readable via the remote\'s machine-local key; it forwards AGENTS_SECRETS_PASSPHRASE over stdin only if set (opt-in).', 'keychain')
2142
2138
  .option('--force', 'Overwrite existing keys/items on the target (used with --to-1password and --device)')
2143
- .option('--format <shell|json>', 'Output for --plaintext export: shell (default) or json (lossless, machine-readable; used by remote resolve)', 'shell')
2144
2139
  .option('--to-file <path>', `Write the bundle as an AES-256-GCM encrypted offline file (needs ${SYNC_PASSPHRASE_ENV}; symmetric counterpart of import --from-file)`)
2145
2140
  .action(async (bundleName, opts) => {
2146
2141
  try {
2147
- const { readAndResolveBundleEnv, bundleToEnvPrefix, isReservedEnvName } = await import('../lib/secrets/bundles.js');
2142
+ const { readAndResolveBundleEnv } = await import('../lib/secrets/bundles.js');
2148
2143
  const resolvedBundleName = bundleName ?? (await pickBundleName('export'));
2149
2144
  if (opts.toFile) {
2150
2145
  // Transport, not the local store's master key — see lib/secrets/sync-passphrase.ts.
@@ -2251,41 +2246,36 @@ Examples:
2251
2246
  console.log(chalk.green(`Exported to 1Password vault '${vault}': ${parts.join(', ')}.`));
2252
2247
  return;
2253
2248
  }
2254
- if (opts.format && opts.format !== 'shell' && opts.format !== 'json') {
2255
- console.error(chalk.red(`Invalid --format ${JSON.stringify(opts.format)}. Expected 'shell' or 'json'.`));
2249
+ // The shell-eval print mode (`eval "$(agents secrets export … --plaintext)"`)
2250
+ // is REMOVED (RUSH-2774): a whole bundle printed to stdout lands in a
2251
+ // coding agent's context and session transcript, and it was the single
2252
+ // most-copied exfiltration one-liner on the fleet. The sole surviving
2253
+ // stdout emitter is the machine-to-machine SSH resolve transport, which
2254
+ // requires the remote-invocation marker AND a non-agent context — both
2255
+ // set only by remoteResolveEnv / verifyRemoteKeychainPush building the
2256
+ // remote command. Everything else gets the refusal with the paved paths.
2257
+ const isTransportCall = process.env.AGENTS_SECRETS_REMOTE_TRANSPORT === '1' &&
2258
+ Boolean(opts.plaintext) &&
2259
+ opts.format === 'json' &&
2260
+ !isAgentInvocationContext();
2261
+ if (!isTransportCall) {
2262
+ console.error(chalk.red('export no longer prints values. Pick a destination:'));
2263
+ console.error(chalk.red(' --device <host> | --to-1password | --to-file <path>'));
2264
+ console.error(chalk.dim(`Run a command with the values injected instead: agents secrets exec ${resolvedBundleName} -- <cmd>`));
2265
+ console.error(chalk.dim(`Reveal one value at your terminal: agents secrets view ${resolvedBundleName} --reveal`));
2256
2266
  process.exit(1);
2257
2267
  }
2258
- if (!opts.plaintext) {
2259
- console.error(chalk.red('export prints secrets in the clear and requires --plaintext (works for TTY and pipes alike).'));
2260
- process.exit(1);
2261
- }
2262
- // `agents secrets export --plaintext` is what release/CI scripts eval.
2263
- // Every caller is broker-only, including a plain shell, so export never
2264
- // raises Touch ID on the interactive user's screen.
2268
+ // Machine-readable form consumed by `remoteResolveEnv` over SSH: single
2269
+ // object of injected env KEY -> value; values verbatim. Broker-only, so a
2270
+ // headless remote read fails fast rather than raising Touch ID. The
2271
+ // resolve emits its own `secrets.get` audit on the remote; no export
2272
+ // event here that would tally every peer pull as an export.
2265
2273
  const { env } = readAndResolveBundleEnv(resolvedBundleName, {
2266
- caller: `export to shell`,
2274
+ caller: 'remote resolve transport',
2267
2275
  keyMode: 'process',
2268
2276
  agentOnly: true,
2269
2277
  });
2270
- if (opts.format === 'json') {
2271
- // Machine-readable form consumed by `remoteResolveEnv` over SSH.
2272
- // Single object of injected env KEY -> value; values verbatim.
2273
- process.stdout.write(JSON.stringify(env));
2274
- return;
2275
- }
2276
- const prefix = bundleToEnvPrefix(resolvedBundleName);
2277
- for (const [k, v] of Object.entries(env)) {
2278
- const exportKey = isReservedEnvName(k) ? `${prefix}_${k}` : k;
2279
- const needsQuotes = /[\s$`"'\\|&;<>(){}[\]!#~]/.test(v);
2280
- const output = needsQuotes ? `'${v.replace(/'/g, `'\\''`)}'` : v;
2281
- process.stdout.write(`export ${exportKey}=${output}\n`);
2282
- }
2283
- // Record the shell export (the `eval "$(...)"` path). The `--format json`
2284
- // branch above is the machine-to-machine remote-resolve transport (already
2285
- // counted as an access on this machine by the resolve above), so it is
2286
- // deliberately not counted here as an export — that would tally every peer
2287
- // pull as an export.
2288
- emitSecretAudit({ event: 'secrets.export', bundle: resolvedBundleName, operation: 'export --plaintext', source: 'shell', status: 'success', keyCount: Object.keys(env).length });
2278
+ process.stdout.write(JSON.stringify(env));
2289
2279
  }
2290
2280
  catch (err) {
2291
2281
  if (isPromptCancelled(err))
@@ -119,5 +119,6 @@ export declare function buildSessionPreviewDigest(events: SessionEvent[], sessio
119
119
  export declare function directoriesTouched(session: SessionMeta, events: SessionEvent[], changes: ReturnType<typeof classifyFileChanges>): string[];
120
120
  /** Relativize a file path to its parent dir, short enough for one preview line. */
121
121
  export declare function relativizeDir(filePath: string, cwd?: string): string | undefined;
122
+ export declare function renderLastResponse(content: string, maxLines?: number, width?: number): string[];
122
123
  /** Show an interactive session picker and return the selected session with its action (resume or view). */
123
124
  export declare function sessionPicker(config: SessionPickerConfig): Promise<PickedSession | null>;
@@ -17,6 +17,8 @@ import { linkPath, linkUrl, relativeToCwd, shortenModel } from '../lib/session/r
17
17
  import { linearIssueUrl } from '../lib/session/linear.js';
18
18
  import { extractTodoProgress, WORKTREE_RE } from '../lib/session/state.js';
19
19
  import { renderMarkdown } from '../lib/markdown.js';
20
+ import { wrapToWidth } from '../lib/wrap.js';
21
+ import { terminalWidth, stringWidth } from '../lib/session/width.js';
20
22
  import { itemPicker } from '../lib/picker.js';
21
23
  import { createMemoryCache } from '../lib/memory-cache.js';
22
24
  import { classifyFileChanges, changeCounts, toolHistogram, detectTestResult } from '../lib/session/digest.js';
@@ -435,8 +437,6 @@ function formatHeader(session, events) {
435
437
  if (totalTokens !== undefined) {
436
438
  line3.push(chalk.bold.white(formatTokens(totalTokens)) + chalk.gray(' tokens'));
437
439
  }
438
- if (session.label)
439
- line3.push(chalk.white(session.label));
440
440
  if (session.filePath)
441
441
  line3.push(chalk.gray(linkPath(session.filePath, session.id)));
442
442
  else
@@ -452,7 +452,19 @@ function formatHeader(session, events) {
452
452
  const label = session.prNumber ? `PR#${session.prNumber}` : 'PR';
453
453
  line4.push(chalk.blue(linkUrl(session.prUrl, label)));
454
454
  }
455
+ // Lead with the session's human title: `session.label` — an agent-generated
456
+ // name / `/rename`, else the `--name` launch handle. NOT `session.topic`: the
457
+ // topic is the derived first-prompt, already shown on the `Prompt:` line, so
458
+ // using it here too would print the same text twice. Unlabelled sessions keep
459
+ // that `Prompt:` line as their topic indicator and simply lead with the agent
460
+ // line. Wrapped to the pane (the header sits at column 0, full terminal width);
461
+ // nothing renders when there is no label.
462
+ const title = (session.label || '').trim();
463
+ const titleLines = title
464
+ ? wrapToWidth(title, terminalWidth()).map(l => chalk.bold.white(l))
465
+ : [];
455
466
  return [
467
+ ...titleLines,
456
468
  line1.join(DOT),
457
469
  line2.join(DOT),
458
470
  line3.join(DOT),
@@ -821,7 +833,9 @@ function formatCompactPreview(digest, session) {
821
833
  }
822
834
  if (lastAssistant) {
823
835
  const maxLines = todosRendered.length > 0 || compact ? LAST_RESPONSE_MAX_LINES_WITH_TODOS : LAST_RESPONSE_MAX_LINES;
824
- const rendered = renderLastResponse(lastAssistant, maxLines);
836
+ // Content sits at column 4 (outer body indent + the ' ' prefix below), so
837
+ // wrap it to the pane minus that indent.
838
+ const rendered = renderLastResponse(lastAssistant, maxLines, terminalWidth() - 4);
825
839
  if (rendered.length > 0) {
826
840
  lines.push('');
827
841
  lines.push(chalk.cyan('Last response:'));
@@ -999,7 +1013,7 @@ export function relativizeDir(filePath, cwd) {
999
1013
  dir = parts.slice(-3).join('/');
1000
1014
  return dir || undefined;
1001
1015
  }
1002
- function renderLastResponse(content, maxLines = LAST_RESPONSE_MAX_LINES) {
1016
+ export function renderLastResponse(content, maxLines = LAST_RESPONSE_MAX_LINES, width = terminalWidth()) {
1003
1017
  const cleaned = stripTags(content).trim();
1004
1018
  if (!cleaned)
1005
1019
  return [];
@@ -1010,7 +1024,15 @@ function renderLastResponse(content, maxLines = LAST_RESPONSE_MAX_LINES) {
1010
1024
  catch {
1011
1025
  rendered = cleaned;
1012
1026
  }
1013
- const all = rendered.replace(/\s+$/, '').split('\n');
1027
+ // `marked-terminal` runs with `reflowText` off, so a paragraph with no hard
1028
+ // breaks renders as one long line that overflows the pane. Wrap any line whose
1029
+ // VISIBLE width exceeds the budget; leave lines that already fit untouched so
1030
+ // rendered-markdown indentation (lists, code blocks) is preserved. The text is
1031
+ // ANSI-coloured, so width is measured with `stringWidth`, never `String.length`.
1032
+ const all = rendered
1033
+ .replace(/\s+$/, '')
1034
+ .split('\n')
1035
+ .flatMap(line => (stringWidth(line) <= width ? [line] : wrapToWidth(line, width)));
1014
1036
  // Drop leading/trailing empty lines
1015
1037
  while (all.length && !all[0].trim())
1016
1038
  all.shift();
@@ -0,0 +1,25 @@
1
+ import type { Command } from 'commander';
2
+ import type { SessionMeta } from '../lib/session/types.js';
3
+ import { type PublishOptions } from '../lib/share/publish.js';
4
+ interface ShareOptions {
5
+ public?: boolean;
6
+ slug?: string;
7
+ label?: string;
8
+ expire?: string;
9
+ reasoning: string;
10
+ force?: boolean;
11
+ cover?: boolean;
12
+ }
13
+ /** `session-<shortId>` — stable per session, so re-sharing updates the same URL. */
14
+ export declare function defaultSessionSlug(session: SessionMeta): string;
15
+ /**
16
+ * Map the command's flags onto {@link PublishOptions}.
17
+ *
18
+ * Extracted so the defaults are testable directly. `unlisted` in particular is the
19
+ * security-relevant one — it inverts the parent command's public-by-default
20
+ * behavior, and a test that re-implements this mapping would still pass with it
21
+ * flipped.
22
+ */
23
+ export declare function buildSharePublishOptions(session: SessionMeta, options: Pick<ShareOptions, 'public' | 'slug' | 'label' | 'expire' | 'force' | 'cover'>): PublishOptions;
24
+ export declare function registerSessionsShareCommand(sessionsCmd: Command): void;
25
+ export {};
@@ -0,0 +1,166 @@
1
+ /**
2
+ * `agents sessions share <id>` — publish one session transcript as a link.
3
+ *
4
+ * Composes three pieces that already existed but were never wired together:
5
+ * `renderSessionMarkdownDocument()` (redacted transcript),
6
+ * `renderSessionHtmlDocument()` (self-contained branded page), and
7
+ * `publishFile()` (the R2-backed share Worker). Before this, sharing a session
8
+ * meant three commands and a detour through the external artifacts-cli.
9
+ *
10
+ * Unlisted by default, unlike `agents artifacts share`. A transcript carries file
11
+ * paths, command output, error text, and whatever a tool printed — strictly more
12
+ * than a plan does — so it does not belong in the public `/<user>` gallery unless
13
+ * the operator asks for it with `--public`. The URL itself stays world-readable:
14
+ * unlisted is a capability URL, not a secret.
15
+ */
16
+ import * as fs from 'fs';
17
+ import * as os from 'os';
18
+ import * as path from 'path';
19
+ import chalk from 'chalk';
20
+ import { setHelpSections } from '../lib/help.js';
21
+ import { knownSecretValuesFromEnv } from '../lib/redact.js';
22
+ import { discoverSessions } from '../lib/session/discover.js';
23
+ import { renderSessionHtmlDocument } from '../lib/session/share-html.js';
24
+ import { publishFile, redactEmails } from '../lib/share/publish.js';
25
+ import { renderSessionMarkdownDocument } from './sessions-render.js';
26
+ import { selectSessions } from './sessions-export.js';
27
+ import { parseAgentFilter } from './sessions.js';
28
+ function parseReasoning(value) {
29
+ if (value === 'omit' || value === 'fold' || value === 'include')
30
+ return value;
31
+ throw new Error(`Unknown reasoning mode "${value}". Expected omit, fold, or include.`);
32
+ }
33
+ /** `session-<shortId>` — stable per session, so re-sharing updates the same URL. */
34
+ export function defaultSessionSlug(session) {
35
+ return `session-${session.shortId || session.id}`;
36
+ }
37
+ /**
38
+ * Map the command's flags onto {@link PublishOptions}.
39
+ *
40
+ * Extracted so the defaults are testable directly. `unlisted` in particular is the
41
+ * security-relevant one — it inverts the parent command's public-by-default
42
+ * behavior, and a test that re-implements this mapping would still pass with it
43
+ * flipped.
44
+ */
45
+ export function buildSharePublishOptions(session, options) {
46
+ return {
47
+ slug: options.slug ?? defaultSessionSlug(session),
48
+ unlisted: options.public !== true,
49
+ expire: options.expire,
50
+ force: options.force === true,
51
+ cover: options.cover !== false,
52
+ label: options.label,
53
+ meta: { kind: 'session' },
54
+ };
55
+ }
56
+ export function registerSessionsShareCommand(sessionsCmd) {
57
+ const cmd = sessionsCmd
58
+ .command('share <session>')
59
+ .description('Publish one session as a redacted, self-contained web page and print the link.')
60
+ .option('--public', 'List the page in your public share gallery (default: unlisted capability URL)')
61
+ .option('--slug <slug>', 'URL slug under your namespace (default: session-<shortId>)')
62
+ .option('--label <text>', 'Display title in the gallery and `agents artifacts share list`')
63
+ .option('--expire <spec>', 'Auto-expire window: 30d, 12h, a date, or never (default: 30d)')
64
+ .option('--reasoning <mode>', 'Reasoning visibility: omit, fold, or include', 'omit')
65
+ .option('--force', 'Publish despite the sensitive-content scan flagging the transcript')
66
+ .option('--no-cover', 'Skip generating the Open Graph preview image');
67
+ setHelpSections(cmd, {
68
+ examples: `# Share a session — prints an unlisted, redacted link
69
+ agents sessions share a1b2c3d4
70
+
71
+ # Put it in your public gallery at share.agents-cli.sh/<you>
72
+ agents sessions share a1b2c3d4 --public --label "How the retry bug got fixed"
73
+
74
+ # Keep the model's reasoning in collapsible sections
75
+ agents sessions share a1b2c3d4 --reasoning fold
76
+
77
+ # A link that does not decay
78
+ agents sessions share a1b2c3d4 --expire never`,
79
+ notes: `Requires a share endpoint: 'agents artifacts share status' shows it, 'agents artifacts setup'
80
+ provisions one, 'agents artifacts share join <baseUrl>' uses an existing one.
81
+
82
+ Unlisted by default — the URL is world-readable but the page stays out of your public
83
+ gallery and out of 'agents artifacts share list'. Pass --public to list it.
84
+
85
+ Secrets are redacted and the same pre-publish scan as 'agents artifacts share' runs on the
86
+ page, so a transcript carrying emails or credential-shaped strings is refused
87
+ unless you pass --force. --no-redact (the sessions-level flag) disables redaction
88
+ and is a bad idea for anything you publish.
89
+
90
+ Re-running with the same session updates the same URL and keeps the prior version
91
+ as a revision ('agents artifacts share revisions <slug>').
92
+
93
+ Manage published sessions with 'agents artifacts share list' and
94
+ 'agents artifacts share delete <slug>'.`,
95
+ });
96
+ cmd.action(async (selector, options, command) => {
97
+ const globals = command.optsWithGlobals();
98
+ const reasoning = parseReasoning(options.reasoning);
99
+ const limit = Math.max(1, Number.parseInt(globals.limit || '100', 10) || 100);
100
+ const agent = parseAgentFilter(globals.agent).agent;
101
+ const sessions = selectSessions(await discoverSessions({
102
+ all: globals.all !== false,
103
+ since: globals.since,
104
+ limit,
105
+ agent: agent ?? undefined,
106
+ }), [selector]);
107
+ if (sessions.length === 0) {
108
+ process.stderr.write(chalk.yellow(`No session matched "${selector}".\n`));
109
+ process.exitCode = 1;
110
+ return;
111
+ }
112
+ // One link per share: several sessions in one page would give the reader no
113
+ // way to reference just the one that matters, and the slug could only name one.
114
+ if (sessions.length > 1) {
115
+ process.stderr.write(chalk.yellow(`"${selector}" matched ${sessions.length} sessions. Share one at a time — pass a full or unique session id.\n`));
116
+ process.exitCode = 1;
117
+ return;
118
+ }
119
+ const session = sessions[0];
120
+ const redact = globals.redact !== false;
121
+ const markdown = renderSessionMarkdownDocument(session, {
122
+ redact,
123
+ reasoning,
124
+ knownSecrets: redact ? knownSecretValuesFromEnv() : undefined,
125
+ });
126
+ // Emails on top of what the renderer masks. Almost every real transcript
127
+ // carries a few — git author addresses, `gh api user`, a pasted log — and
128
+ // publishToEndpoint refuses a body containing any (RUSH-2428). Masking them
129
+ // means the published page genuinely does not carry them; the alternative,
130
+ // telling people to pass --force, would train everyone to bypass the gate
131
+ // that also catches real credentials.
132
+ //
133
+ // Applied to the RENDERED PAGE, not the Markdown, so the text that is masked
134
+ // is exactly the text `scanShareContent` will scan. Markdown escaping stands
135
+ // between the two: `foo\@example.com` hides from the pattern in the Markdown
136
+ // and reappears as a live address once marked drops the backslash.
137
+ const page = renderSessionHtmlDocument(session, markdown, { redacted: redact });
138
+ const html = redact ? redactEmails(page) : page;
139
+ // A real file on disk is what publishFile() takes, and the OG capturer opens
140
+ // it in a browser. 0600 + a per-run directory keeps the intermediate off a
141
+ // world-readable /tmp path while it exists.
142
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'agents-session-share-'));
143
+ const file = path.join(dir, `${defaultSessionSlug(session)}.html`);
144
+ try {
145
+ fs.writeFileSync(file, html, { mode: 0o600 });
146
+ const result = await publishFile(file, buildSharePublishOptions(session, options));
147
+ if (globals.json) {
148
+ process.stdout.write(JSON.stringify({
149
+ session: session.id,
150
+ agent: session.agent,
151
+ redacted: redact,
152
+ ...result,
153
+ }, null, 2) + '\n');
154
+ return;
155
+ }
156
+ process.stdout.write(`${result.url}\n`);
157
+ const bits = [result.unlisted ? 'unlisted' : 'public', redact ? 'redacted' : chalk.red('NOT redacted')];
158
+ if (result.expiresAt)
159
+ bits.push(`expires ${result.expiresAt.slice(0, 10)}`);
160
+ process.stderr.write(chalk.dim(`${bits.join(' · ')}\n`));
161
+ }
162
+ finally {
163
+ fs.rmSync(dir, { recursive: true, force: true });
164
+ }
165
+ });
166
+ }
@@ -70,6 +70,7 @@ import { registerAttachCommand } from './attach.js';
70
70
  import { registerSessionsInjectCommand } from './sessions-inject.js';
71
71
  import { registerSessionsExportCommand } from './sessions-export.js';
72
72
  import { registerSessionsRenderCommand } from './sessions-render.js';
73
+ import { registerSessionsShareCommand } from './sessions-share.js';
73
74
  import { registerSessionsImportCommand } from './sessions-import.js';
74
75
  import { registerSessionsMigrateCommand, registerSessionsMigrationsCommand } from './sessions-migrate.js';
75
76
  import { registerSessionsBackfillCommand } from './sessions-backfill.js';
@@ -5173,6 +5174,7 @@ export function registerSessionsCommands(program) {
5173
5174
  registerSessionsInjectCommand(sessionsCmd);
5174
5175
  registerSessionsExportCommand(sessionsCmd);
5175
5176
  registerSessionsRenderCommand(sessionsCmd);
5177
+ registerSessionsShareCommand(sessionsCmd);
5176
5178
  registerSessionsImportCommand(sessionsCmd);
5177
5179
  registerSessionsMigrateCommand(sessionsCmd);
5178
5180
  registerSessionsMigrationsCommand(sessionsCmd);
@@ -14,7 +14,7 @@ import { DEFAULT_BROWSER_PROFILE_NAME, createProfile, findFreeProfilePort, getCo
14
14
  import { DEFAULT_VIEWPORT } from '../lib/browser/devices.js';
15
15
  import { isInteractiveTerminal, isPromptCancelled } from './utils.js';
16
16
  import { defaultBrowserChoice } from './setup-preferences.js';
17
- const INSTALL_HINT = 'Install one of: Google Chrome, Brave, Microsoft Edge, Chromium, or Comet, then re-run `agents setup browser`.\n' +
17
+ const INSTALL_HINT = 'Install one of: Google Chrome, Brave, Microsoft Edge, Chromium, Comet, or Arc, then re-run `agents setup browser`.\n' +
18
18
  '(Safari and Firefox are not supported — agents browser drives over the Chrome DevTools Protocol.)';
19
19
  /**
20
20
  * Interactive browser setup. Returns true if a usable default profile exists
@@ -115,10 +115,10 @@ export async function maybePickBrowserProfile() {
115
115
  viewport: { width: DEFAULT_VIEWPORT.width, height: DEFAULT_VIEWPORT.height },
116
116
  };
117
117
  await createProfile(profile);
118
- // The same key `agents browser profiles set-default` writes (device-local).
118
+ // The same key `agents browser use` writes (device-local).
119
119
  setConfigValue('browser.profile', profile.name);
120
120
  console.log(chalk.green(`Browser: '${chosen.browserType}'`) +
121
- chalk.dim(` — profile "${profile.name}" is this machine's default (agents browser profiles set-default to change).`));
121
+ chalk.dim(` — profile "${profile.name}" is this machine's default (agents browser use to change).`));
122
122
  return true;
123
123
  }
124
124
  /**