@phnx-labs/agents-cli 1.20.42 → 1.20.44

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 (47) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/README.md +4 -3
  3. package/dist/commands/exec.js +46 -8
  4. package/dist/commands/hosts.js +14 -9
  5. package/dist/commands/logs.d.ts +4 -0
  6. package/dist/commands/logs.js +19 -13
  7. package/dist/commands/routines.d.ts +6 -0
  8. package/dist/commands/routines.js +70 -12
  9. package/dist/commands/sessions.d.ts +6 -5
  10. package/dist/commands/sessions.js +50 -22
  11. package/dist/commands/teams.js +43 -5
  12. package/dist/lib/browser/chrome.d.ts +22 -0
  13. package/dist/lib/browser/chrome.js +53 -13
  14. package/dist/lib/browser/service.js +13 -0
  15. package/dist/lib/daemon.js +34 -9
  16. package/dist/lib/exec.d.ts +15 -0
  17. package/dist/lib/exec.js +83 -6
  18. package/dist/lib/hosts/dispatch.d.ts +5 -0
  19. package/dist/lib/hosts/dispatch.js +4 -0
  20. package/dist/lib/hosts/logs.d.ts +14 -5
  21. package/dist/lib/hosts/logs.js +39 -13
  22. package/dist/lib/hosts/session-index.js +1 -0
  23. package/dist/lib/hosts/tasks.d.ts +15 -0
  24. package/dist/lib/hosts/tasks.js +16 -0
  25. package/dist/lib/redact.js +1 -0
  26. package/dist/lib/rotate.d.ts +11 -6
  27. package/dist/lib/rotate.js +25 -11
  28. package/dist/lib/session/active.d.ts +8 -0
  29. package/dist/lib/session/active.js +17 -1
  30. package/dist/lib/session/db.d.ts +11 -0
  31. package/dist/lib/session/db.js +84 -19
  32. package/dist/lib/session/discover.js +5 -1
  33. package/dist/lib/session/remote.d.ts +4 -6
  34. package/dist/lib/session/remote.js +5 -12
  35. package/dist/lib/session/run-names.d.ts +32 -0
  36. package/dist/lib/session/run-names.js +63 -0
  37. package/dist/lib/session/types.d.ts +8 -0
  38. package/dist/lib/shims.d.ts +1 -1
  39. package/dist/lib/shims.js +17 -3
  40. package/dist/lib/teams/agents.js +16 -7
  41. package/dist/lib/tmux/session.d.ts +40 -0
  42. package/dist/lib/tmux/session.js +92 -0
  43. package/dist/lib/usage.d.ts +5 -3
  44. package/dist/lib/usage.js +5 -3
  45. package/dist/lib/versions.d.ts +54 -1
  46. package/dist/lib/versions.js +138 -1
  47. package/package.json +1 -1
@@ -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
- const header = ws.key === '__cloud__'
585
- ? chalk.magenta.bold('cloud')
586
- : ws.key === '__unknown__'
587
- ? chalk.gray.bold('unknown')
588
- : chalk.cyan.bold(shortCwd(ws.key));
589
- const wsSessions = [...ws.windows.flatMap(w => w.sessions), ...ws.flat];
590
- const tally = groupTally(wsSessions);
591
- console.log(`${base}${header} ${chalk.gray(`(${ws.total})`)}${tally ? chalk.gray(` ${tally}`) : ''}`);
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(base + ' ' + winHeader);
616
+ console.log(rowBase + winHeader);
598
617
  for (const s of win.sessions)
599
- printActiveRow(s, base + ' ');
618
+ printActiveRow(s, rowBase + ' ');
600
619
  }
601
620
  for (const s of ws.flat)
602
- printActiveRow(s, base + ' ');
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
- const marker = mg.isLocal ? chalk.cyan(' ') : chalk.gray('▸ ');
609
- const name = mg.isLocal ? chalk.bold.cyan(mg.machine) : chalk.bold(mg.machine);
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
- const machineWord = grouped.machines.length === 1 ? 'machine' : 'machines';
747
- console.log(chalk.gray(`\n${sessions.length} active (${parts.join(', ')}) across ${grouped.machines.length} ${machineWord}.`));
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's full transcript to stdout — the non-follow view behind
1262
- * `agents logs <sessionId>`. Reuses the same markdown renderer as
1263
- * `agents sessions <id> --markdown`.
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, 'markdown', {});
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"
@@ -11,9 +11,10 @@ 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';
19
20
  import { teamPicker, printTeamTable } from './teams-picker.js';
@@ -1651,8 +1652,9 @@ export function registerTeamsCommands(program) {
1651
1652
  teams
1652
1653
  .command('logs [teammate]')
1653
1654
  .alias('log')
1654
- .description("Read a teammate's raw log output. Accepts positional name, --teammate <name>, UUID, or UUID prefix.")
1655
- .option('-n, --tail <n>', 'Show only the last N lines instead of the full log')
1655
+ .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.")
1656
+ .option('-n, --tail <n>', 'Show the last N lines of raw stdout instead of the concise summary')
1657
+ .option('-m, --full', 'Show the full raw stdout log instead of the concise summary')
1656
1658
  .option('--team <team>', 'Disambiguate when the same name appears in multiple teams')
1657
1659
  .option('--teammate <name>', 'Teammate name (alias for the positional arg; useful for scripts)')
1658
1660
  .action(async (ref, opts) => {
@@ -1682,14 +1684,28 @@ export function registerTeamsCommands(program) {
1682
1684
  }
1683
1685
  agentId = resolved.agentId;
1684
1686
  }
1687
+ // Concise by default: a teammate's agentId IS its agent session id (passed
1688
+ // as --session-id at launch), so render the same summary digest as
1689
+ // `agents sessions <id>`. --full / -n <lines> opt into the raw stdout.log.
1690
+ if (!opts.full && !opts.tail) {
1691
+ const all = await discoverSessions({ all: true, limit: 5000 });
1692
+ const matches = resolveSessionById(all, agentId);
1693
+ if (matches.length > 0) {
1694
+ await renderSessionLog(matches[0], 'summary');
1695
+ return;
1696
+ }
1697
+ // No resolvable session (e.g. a non-Claude teammate) — fall through to a
1698
+ // bounded tail of raw stdout rather than dumping the whole file.
1699
+ }
1685
1700
  const logPath = path.join(base, agentId, 'stdout.log');
1686
1701
  try {
1687
1702
  const content = await fs.readFile(logPath, 'utf-8');
1688
- if (!opts.tail) {
1703
+ if (opts.full) {
1689
1704
  process.stdout.write(content);
1690
1705
  return;
1691
1706
  }
1692
- const n = Math.max(1, parseInt(opts.tail, 10) || 50);
1707
+ // Default tail size keeps an un-resolvable teammate's glance bounded too.
1708
+ const n = opts.tail ? Math.max(1, parseInt(opts.tail, 10) || 50) : 40;
1693
1709
  const lines = content.split('\n');
1694
1710
  process.stdout.write(lines.slice(-n).join('\n'));
1695
1711
  }
@@ -1705,6 +1721,28 @@ export function registerTeamsCommands(program) {
1705
1721
  .option('--json', 'Output machine-readable JSON')
1706
1722
  .action(async (opts) => {
1707
1723
  const info = checkAllClis();
1724
+ // Deep integrity probe. `checkAllClis` reports presence (shim + stub guard),
1725
+ // but a GUTTED native binary (JS wrapper present, platform binary missing —
1726
+ // the codex/kimi optional-dep partial-extract failure) still passes that. So
1727
+ // actually launch the resolved default version and, if it won't run, flip the
1728
+ // agent to not-installed with a repair hint — otherwise doctor says "ready"
1729
+ // and the teammate ENOENTs at spawn. Parallel; win32 is treated as healthy by
1730
+ // verifyInstalledBinaryLaunches.
1731
+ await Promise.all(Object.entries(info).map(async ([name, entry]) => {
1732
+ if (!entry.installed)
1733
+ return;
1734
+ const agent = name;
1735
+ const version = resolveVersion(agent);
1736
+ if (!version)
1737
+ return;
1738
+ const health = await verifyInstalledBinaryLaunches(agent, version);
1739
+ if (!health.ok) {
1740
+ entry.installed = false;
1741
+ entry.path = null;
1742
+ entry.error = `${AGENTS[agent]?.cliCommand ?? name}@${version} is installed but its binary won't launch`
1743
+ + `${health.detail ? ` (${health.detail})` : ''}. Repair: agents add ${agent}@${version}`;
1744
+ }
1745
+ }));
1708
1746
  // Advisory enrichment only. Sign-in detection is UNRELIABLE, so it never
1709
1747
  // changes the authoritative installed/ready column — it annotates. And an
1710
1748
  // agent that is actually running in a team is treated as signed in
@@ -36,6 +36,28 @@ export declare function getRunningChromeInfo(profileName: string): {
36
36
  pid: number;
37
37
  port: number;
38
38
  } | null;
39
+ /**
40
+ * Prepare `<userDataDir>/Default/Preferences` before launch.
41
+ *
42
+ * Two concerns, one write:
43
+ * - First launch (file absent): stamp the agents-cli profile name so
44
+ * Chromium's UI shows "<profile>" instead of its default "Person 1".
45
+ * Cosmetic; existing files keep whatever Chrome wrote in the meantime.
46
+ * - Every launch (when `persistSessionCookies`): pin
47
+ * `session.restore_on_startup: 1` ("continue where you left off").
48
+ * Chromium purges memory-only session cookies at startup UNLESS this
49
+ * preference says the session will be restored — it keys the purge off
50
+ * the pref, not off tabs actually reopening. Sites like idealista issue
51
+ * login cookies with `expires=-1`, so without this every browser restart
52
+ * silently logs the profile out. The visible tab-restore side effect is
53
+ * suppressed separately via `--no-startup-window` (see launchBrowser).
54
+ *
55
+ * Runs only while the browser is down (called before spawn), so Chromium
56
+ * can't overwrite the patch on exit. Best-effort: a malformed existing file
57
+ * is left untouched (Chromium recovers its own state better than we can),
58
+ * and any I/O hiccup is silently ignored.
59
+ */
60
+ export declare function ensureProfilePreferences(userDataDir: string, profileName: string, persistSessionCookies: boolean): void;
39
61
  /**
40
62
  * Is a TCP port currently bound? `lsof` on POSIX, `netstat -ano` on Windows
41
63
  * (lsof doesn't exist there). Returns false on any tooling error so port
@@ -212,11 +212,11 @@ isElectron = false) {
212
212
  const runtimeDir = getProfileRuntimeDir(profileName);
213
213
  const userDataDir = path.join(runtimeDir, 'chrome-data');
214
214
  fs.mkdirSync(userDataDir, { recursive: true });
215
- // First-launch seed: stamp the user-data-dir's Default/Preferences with
216
- // the agents-cli profile name so Chromium's UI shows "<profile>" instead
217
- // of its default "Person 1". Done only when the file doesn't exist
218
- // subsequent launches inherit whatever Chrome wrote in the meantime.
219
- seedDefaultProfileName(userDataDir, profileName);
215
+ // Pre-launch Preferences pass: first-launch profile-name stamp, plus (for
216
+ // real browsers, not Electron apps) the session-cookie persistence pin.
217
+ // Electron apps manage their own storage and don't read Chromium's
218
+ // `session.*` prefs, so they get the name stamp only.
219
+ ensureProfilePreferences(userDataDir, profileName, !isElectron);
220
220
  // Chromium on macOS coordinates instances via the SingletonLock file
221
221
  // *inside* each user-data-dir. Direct binary spawn with a fresh
222
222
  // --user-data-dir creates a fully independent process — the user's
@@ -242,6 +242,14 @@ isElectron = false) {
242
242
  // remote-debugging transport is active. That property is the loudest
243
243
  // signal Cloudflare Turnstile, hCaptcha, and similar checks read.
244
244
  '--disable-blink-features=AutomationControlled',
245
+ // Companion to `session.restore_on_startup: 1` (see
246
+ // ensureProfilePreferences): the pref keeps session cookies alive across
247
+ // restarts, but on its own it would also reopen last session's tabs at
248
+ // startup. Suppressing the startup window leaves restore nothing to fill —
249
+ // cookies survive, no ghost tabs — and the task flow creates its own tab
250
+ // over CDP anyway. Electron apps need their window to appear (the CDP
251
+ // driver binds to it), so they skip the flag.
252
+ ...(isElectron ? [] : ['--no-startup-window']),
245
253
  ...(options.headless ? ['--headless=new'] : []),
246
254
  `--window-size=${viewport.width},${viewport.height}`,
247
255
  ...(viewport.x !== undefined && viewport.y !== undefined
@@ -325,20 +333,52 @@ export function getRunningChromeInfo(profileName) {
325
333
  return { pid: rt.pid, port: rt.port };
326
334
  }
327
335
  /**
328
- * Stamp `<userDataDir>/Default/Preferences` with our profile name so
329
- * Chrome's UI labels the window with the agents-cli name rather than the
330
- * default "Person 1". Only writes when the file is absent (first launch).
331
- * Best-effort: any I/O hiccup is silently ignored; missing the rename is
332
- * cosmetic, not functional.
336
+ * Prepare `<userDataDir>/Default/Preferences` before launch.
337
+ *
338
+ * Two concerns, one write:
339
+ * - First launch (file absent): stamp the agents-cli profile name so
340
+ * Chromium's UI shows "<profile>" instead of its default "Person 1".
341
+ * Cosmetic; existing files keep whatever Chrome wrote in the meantime.
342
+ * - Every launch (when `persistSessionCookies`): pin
343
+ * `session.restore_on_startup: 1` ("continue where you left off").
344
+ * Chromium purges memory-only session cookies at startup UNLESS this
345
+ * preference says the session will be restored — it keys the purge off
346
+ * the pref, not off tabs actually reopening. Sites like idealista issue
347
+ * login cookies with `expires=-1`, so without this every browser restart
348
+ * silently logs the profile out. The visible tab-restore side effect is
349
+ * suppressed separately via `--no-startup-window` (see launchBrowser).
350
+ *
351
+ * Runs only while the browser is down (called before spawn), so Chromium
352
+ * can't overwrite the patch on exit. Best-effort: a malformed existing file
353
+ * is left untouched (Chromium recovers its own state better than we can),
354
+ * and any I/O hiccup is silently ignored.
333
355
  */
334
- function seedDefaultProfileName(userDataDir, profileName) {
356
+ export function ensureProfilePreferences(userDataDir, profileName, persistSessionCookies) {
335
357
  const defaultDir = path.join(userDataDir, 'Default');
336
358
  const prefsPath = path.join(defaultDir, 'Preferences');
337
- if (fs.existsSync(prefsPath))
359
+ let prefs;
360
+ try {
361
+ prefs = JSON.parse(fs.readFileSync(prefsPath, 'utf8'));
362
+ if (typeof prefs !== 'object' || prefs === null)
363
+ return; // not ours to fix
364
+ }
365
+ catch (err) {
366
+ if (err?.code !== 'ENOENT')
367
+ return; // unreadable/malformed: leave alone
368
+ }
369
+ const firstLaunch = prefs === undefined;
370
+ if (firstLaunch)
371
+ prefs = { profile: { name: profileName } };
372
+ let dirty = firstLaunch;
373
+ if (persistSessionCookies && prefs.session?.restore_on_startup !== 1) {
374
+ prefs.session = { ...prefs.session, restore_on_startup: 1 };
375
+ dirty = true;
376
+ }
377
+ if (!dirty)
338
378
  return;
339
379
  try {
340
380
  fs.mkdirSync(defaultDir, { recursive: true });
341
- fs.writeFileSync(prefsPath, JSON.stringify({ profile: { name: profileName } }));
381
+ fs.writeFileSync(prefsPath, JSON.stringify(prefs));
342
382
  }
343
383
  catch { /* not critical */ }
344
384
  }
@@ -310,6 +310,19 @@ export class BrowserService {
310
310
  conn = await this.connectProfile(effectiveProfile, resolved.target);
311
311
  this.connections.set(composite, conn);
312
312
  }
313
+ // Browsers launch with --no-startup-window (session-cookie persistence,
314
+ // see launchBrowser), so a bare `start` with no --url would otherwise
315
+ // leave the user staring at a process with zero windows. Recreate the
316
+ // old startup-window affordance: if no page target exists, open a blank
317
+ // one. Deliberately NOT registered on the task — the startup window
318
+ // never was either, and tasks track only tabs they created.
319
+ if (!opts.url && !conn.electron) {
320
+ const { targetInfos } = (await conn.cdp.send('Target.getTargets'));
321
+ if (!targetInfos.some((t) => t.type === 'page')) {
322
+ await conn.cdp.send('Target.createTarget', { url: 'about:blank' });
323
+ this.invalidateTargetCache(conn);
324
+ }
325
+ }
313
326
  const task = {
314
327
  id: taskId,
315
328
  name: taskName,
@@ -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
  };
@@ -80,6 +80,13 @@ export interface ExecOptions {
80
80
  addDirs?: string[];
81
81
  timeout?: string;
82
82
  sessionId?: string;
83
+ /**
84
+ * Durable `agents run --name <slug>` handle. Exported to the agent's env as
85
+ * `AGENT_SESSION_NAME` (companion to `AGENT_SESSION_ID`) and, when a session
86
+ * id is known at launch, recorded in the run-name index so `agents sessions
87
+ * <name>` resolves the run. Absent for unnamed runs — no behavior change.
88
+ */
89
+ name?: string;
83
90
  /**
84
91
  * Resume the conversation named by `sessionId` using the agent's NATIVE resume
85
92
  * form (claude `--resume`, codex `resume`) instead of the default `--session-id`
@@ -265,6 +272,14 @@ export declare function shouldWrapInTmux(ctx: TmuxWrapContext): boolean;
265
272
  * (`BASH_FUNC_*%%`) can't make `env` choke.
266
273
  */
267
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;
268
283
  /** Exit code spawnAgent resolves with when a run is killed for crossing a budget cap. */
269
284
  export declare const BUDGET_KILL_EXIT_CODE = 7;
270
285
  /**
package/dist/lib/exec.js CHANGED
@@ -17,6 +17,7 @@ import { maybeRotate, createTimer, redactPrompt, redactArgs } from './events.js'
17
17
  import { sanitizeProcessEnv } from './secrets/bundles.js';
18
18
  import { getShimsDir } from './state.js';
19
19
  import { writePidSessionEntry, extractSessionIdArg } from './session/pid-registry.js';
20
+ import { recordRunName } from './session/run-names.js';
20
21
  import { mailboxDir, isValidMailboxId } from './mailbox.js';
21
22
  import { composeWin32CommandLine } from './platform/index.js';
22
23
  import { isTmuxInstalled } from './tmux/binary.js';
@@ -257,6 +258,12 @@ export function buildExecEnv(options) {
257
258
  if (options.sessionId && isValidMailboxId(options.sessionId)) {
258
259
  result.AGENTS_MAILBOX_DIR = mailboxDir(options.sessionId);
259
260
  }
261
+ // Export the run's durable name (companion to AGENT_SESSION_ID) so a
262
+ // SessionStart hook / the agent can associate its transcript with the handle
263
+ // the user gave the run. Only set when --name was passed.
264
+ if (options.name) {
265
+ result.AGENT_SESSION_NAME = options.name;
266
+ }
260
267
  return {
261
268
  ...result,
262
269
  ...options.env,
@@ -508,7 +515,13 @@ export function buildExecCommand(options) {
508
515
  cmd[0] = absPath;
509
516
  }
510
517
  else {
511
- cmd[0] = versionedName;
518
+ // No versioned shim on disk. Prefer the version's REAL launch binary
519
+ // (node_modules/.bin/<cli>) over the bare `<cli>@<version>` name — that
520
+ // literal is not on PATH and spawns as ENOENT (the `kimi@0.19.2` failure).
521
+ // Fall back to the literal only if the binary is absent (the run path's
522
+ // ensureAgentRunnable normally repairs/creates the alias before we reach here).
523
+ const realBinary = options.agent ? getBinaryPath(options.agent, options.version) : undefined;
524
+ cmd[0] = realBinary && fs.existsSync(realBinary) ? realBinary : versionedName;
512
525
  }
513
526
  }
514
527
  // Add reasoning effort flags (before mode flags for codex -c positioning)
@@ -807,6 +820,21 @@ export function buildTmuxAgentCommand(executable, args, env) {
807
820
  const agentCmd = [executable, ...args].map(shellQuote).join(' ');
808
821
  return `exec env ${envPrefix} ${agentCmd}`;
809
822
  }
823
+ /**
824
+ * Trim a raw `tmux capture-pane` dump to its last `maxLines` non-empty lines
825
+ * (right-stripping each). Used by runInTmux to recap a fast-failed agent's
826
+ * output into the caller's shell so a launch crash (e.g. a gutted install that
827
+ * dies with ENOENT the instant it spawns) isn't swallowed by the bare
828
+ * `[detached]` the pane-died hook otherwise leaves behind.
829
+ */
830
+ export function formatPaneTail(raw, maxLines = 30) {
831
+ return raw
832
+ .split('\n')
833
+ .map(l => l.replace(/\s+$/, ''))
834
+ .filter(l => l.length > 0)
835
+ .slice(-maxLines)
836
+ .join('\n');
837
+ }
810
838
  /**
811
839
  * Run an interactive agent inside a detached tmux session on the shared socket,
812
840
  * attach the current TTY, and propagate the wrapped agent's exit code.
@@ -827,7 +855,7 @@ export function buildTmuxAgentCommand(executable, args, env) {
827
855
  * (Ctrl-b d) — return 0 and LEAVE the session for `agents focus` to re-attach.
828
856
  */
829
857
  async function runInTmux(options, executable, args) {
830
- const { createSession, killSession, paneExitStatus, setSessionHook, slugifyName } = await import('./tmux/session.js');
858
+ const { createSession, killSession, paneExitStatus, setSessionHook, slugifyName, agentPaneDiedHook, markSessionHookSchema } = await import('./tmux/session.js');
831
859
  const { getDefaultSocketPath } = await import('./tmux/paths.js');
832
860
  const { attachTmux, runTmux } = await import('./tmux/binary.js');
833
861
  const socket = getDefaultSocketPath();
@@ -848,7 +876,10 @@ async function runInTmux(options, executable, args) {
848
876
  // that split in place instead of detaching everyone (the pane-died hook runs
849
877
  // in the dead pane's context, so bare `kill-pane` targets it). Without the
850
878
  // guard, exiting any split kicked the user clean out of tmux.
851
- await setSessionHook(name, 'pane-died', `if -F '#{==:#{hook_pane},${pane}}' 'detach-client -s =${name}' 'kill-pane'`, socket);
879
+ await setSessionHook(name, 'pane-died', agentPaneDiedHook(name, pane), socket);
880
+ // Stamp the schema marker so the daemon reconcile (which retrofits older
881
+ // sessions) recognizes this one as already current and skips it.
882
+ await markSessionHookSchema(name, socket);
852
883
  // Record the agent's OS pid (the pane leaf, thanks to `exec`) WITH its tmux
853
884
  // pane so the active-scan attributes it exactly and shows the %pane.
854
885
  let panePid = 0;
@@ -866,14 +897,54 @@ async function runInTmux(options, executable, args) {
866
897
  startedAtMs: Date.now(),
867
898
  });
868
899
  }
900
+ // Recap a dead pane's tail into THIS shell's stderr. The pane-died hook
901
+ // detaches the client the instant the agent exits, so a fast failure (a
902
+ // gutted install that dies with ENOENT, a bad flag, a crash on startup) would
903
+ // otherwise leave only a bare `[detached]` with no clue why. Must run BEFORE
904
+ // killSession — capture-pane needs the session still alive (remain-on-exit
905
+ // keeps the dead pane readable until we tear it down). Best-effort throughout.
906
+ const surfacePaneFailure = async (status, headline) => {
907
+ if (!pane)
908
+ return;
909
+ let tail = '';
910
+ try {
911
+ const r = await runTmux({ socket, args: ['capture-pane', '-p', '-t', pane, '-S', '-200'], throwOnError: false });
912
+ if (r.code === 0)
913
+ tail = formatPaneTail(r.stdout);
914
+ }
915
+ catch { /* best-effort — a missing pane just means no recap */ }
916
+ const RED = '\x1b[31m', GRAY = '\x1b[90m', OFF = '\x1b[0m';
917
+ process.stderr.write(`\n${RED}agents: ${headline} (exit ${status ?? 1}).${OFF}\n`);
918
+ if (tail) {
919
+ process.stderr.write(`${GRAY} ── last output from ${options.agent} ──${OFF}\n`);
920
+ process.stderr.write(tail.replace(/^/gm, ' ') + '\n');
921
+ process.stderr.write(`${GRAY} ${'─'.repeat(30)}${OFF}\n`);
922
+ }
923
+ process.stderr.write(`${GRAY} Tip: re-run with --no-tmux to launch the agent directly and see its full output.${OFF}\n\n`);
924
+ };
869
925
  // The agent could exit before we attach (fast failure). Don't attach to an
870
- // already-dead pane — read its status directly and tear down.
926
+ // already-dead pane — surface its output + status directly and tear down.
871
927
  const before = pane ? await paneExitStatus(pane, socket) : { dead: false };
872
- if (!before.dead) {
873
- await attachTmux({ socket, args: ['attach-session', '-t', name] });
928
+ if (before.dead) {
929
+ // Only recap a FAILURE. A clean (0) exit before we attached is a successful
930
+ // quick run, not a crash — a red banner there would be spurious (mirrors the
931
+ // post-attach guard below).
932
+ if ((before.status ?? 0) !== 0) {
933
+ await surfacePaneFailure(before.status, `${options.agent} exited before it could start`);
934
+ }
935
+ await killSession(name, socket).catch(() => { });
936
+ return { exitCode: before.status ?? 0, stderr: '' };
874
937
  }
938
+ await attachTmux({ socket, args: ['attach-session', '-t', name] });
875
939
  const after = pane ? await paneExitStatus(pane, socket) : { dead: false };
876
940
  if (after.dead) {
941
+ // Nonzero exit after attach → the agent crashed rather than the user
942
+ // detaching cleanly (a clean detach leaves the pane ALIVE, handled below).
943
+ // The pane-died hook may have yanked the view before the error was readable,
944
+ // so recap it into the shell. A clean (0) exit stays quiet — nothing to say.
945
+ if ((after.status ?? 0) !== 0) {
946
+ await surfacePaneFailure(after.status, `${options.agent} exited`);
947
+ }
877
948
  await killSession(name, socket).catch(() => { });
878
949
  return { exitCode: after.status ?? 0, stderr: '' };
879
950
  }
@@ -900,6 +971,12 @@ async function spawnAgent(options) {
900
971
  if (options.agent === 'claude' && !options.resume && !options.sessionId) {
901
972
  options = { ...options, sessionId: randomUUID() };
902
973
  }
974
+ // Record the run's --name against its session id (when both are known at
975
+ // launch) so `agents sessions <name>` resolves it. Best-effort; unnamed runs
976
+ // and agents whose id isn't known up front simply skip this.
977
+ if (options.name && options.sessionId) {
978
+ recordRunName({ sessionId: options.sessionId, name: options.name, agent: options.agent, cwd: options.cwd });
979
+ }
903
980
  const cmd = buildExecCommand(options);
904
981
  const [executable, ...args] = cmd;
905
982
  const timeoutMs = options.timeout ? parseTimeout(options.timeout) : undefined;
@@ -27,6 +27,11 @@ export interface DispatchOptions {
27
27
  * resumable by id. Mutually exclusive with `resume`.
28
28
  */
29
29
  sessionId?: string;
30
+ /**
31
+ * Durable `--name <slug>` handle, forwarded to the remote `agents run` and
32
+ * recorded on the local task so `agents hosts logs/ps <name>` resolve it.
33
+ */
34
+ name?: string;
30
35
  /** Resume an existing session on the host by id (via `agents run --resume`). */
31
36
  resume?: string;
32
37
  /** Stream progress and block until completion (default true). */
@@ -62,6 +62,7 @@ async function launchDetached(host, target, opts) {
62
62
  prompt: opts.promptLabel,
63
63
  pid: Number.isFinite(pid) ? pid : undefined,
64
64
  sessionId: opts.sessionId,
65
+ name: opts.name,
65
66
  remoteLog,
66
67
  remoteExit,
67
68
  status: 'running',
@@ -96,6 +97,8 @@ export function buildRunForwardedArgs(opts) {
96
97
  args.push('--mode', opts.mode);
97
98
  if (opts.model)
98
99
  args.push('--model', opts.model);
100
+ if (opts.name)
101
+ args.push('--name', opts.name);
99
102
  if (opts.resume)
100
103
  args.push('--resume', opts.resume);
101
104
  else if (opts.sessionId)
@@ -115,6 +118,7 @@ export async function dispatchToHost(host, opts) {
115
118
  timeoutMs: opts.timeoutMs,
116
119
  agentLabel: opts.agent,
117
120
  promptLabel: opts.prompt,
121
+ name: opts.name,
118
122
  // On resume the remote session keeps its existing id; record that id so the
119
123
  // task stays mapped to the same session.
120
124
  sessionId: opts.resume ?? opts.sessionId,