@phnx-labs/agents-cli 1.22.51 → 1.22.52

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 (59) hide show
  1. package/CHANGELOG.md +92 -0
  2. package/dist/commands/attach.js +7 -0
  3. package/dist/commands/browser.js +118 -56
  4. package/dist/commands/daemon.d.ts +2 -0
  5. package/dist/commands/daemon.js +8 -4
  6. package/dist/commands/detach.js +1 -1
  7. package/dist/commands/focus.d.ts +1 -10
  8. package/dist/commands/focus.js +14 -79
  9. package/dist/commands/go.d.ts +26 -0
  10. package/dist/commands/go.js +63 -5
  11. package/dist/commands/monitors.js +1 -1
  12. package/dist/commands/repo.js +31 -3
  13. package/dist/commands/sessions-resume.d.ts +1 -0
  14. package/dist/commands/sessions-resume.js +13 -2
  15. package/dist/commands/sessions-stop.js +1 -1
  16. package/dist/commands/sessions.d.ts +23 -13
  17. package/dist/commands/sessions.js +40 -20
  18. package/dist/commands/setup-browser.d.ts +5 -2
  19. package/dist/commands/setup-browser.js +14 -29
  20. package/dist/commands/setup-preferences.d.ts +22 -3
  21. package/dist/commands/setup-preferences.js +25 -8
  22. package/dist/commands/share.js +12 -8
  23. package/dist/commands/status.js +5 -0
  24. package/dist/commands/sync.js +58 -2
  25. package/dist/commands/tmux.d.ts +8 -1
  26. package/dist/commands/tmux.js +167 -17
  27. package/dist/lib/browser/ipc.d.ts +44 -0
  28. package/dist/lib/browser/ipc.js +120 -8
  29. package/dist/lib/browser/profiles.d.ts +39 -17
  30. package/dist/lib/browser/profiles.js +51 -52
  31. package/dist/lib/browser/runtime-state.d.ts +4 -2
  32. package/dist/lib/browser/runtime-state.js +4 -2
  33. package/dist/lib/browser/service.js +4 -3
  34. package/dist/lib/channels/owner-forward.d.ts +88 -0
  35. package/dist/lib/channels/owner-forward.js +116 -0
  36. package/dist/lib/channels/owner-sink.js +7 -0
  37. package/dist/lib/exec.d.ts +8 -0
  38. package/dist/lib/exec.js +7 -0
  39. package/dist/lib/feed-broadcast.js +15 -1
  40. package/dist/lib/git.d.ts +93 -0
  41. package/dist/lib/git.js +232 -0
  42. package/dist/lib/monitors/remote.d.ts +18 -1
  43. package/dist/lib/monitors/remote.js +15 -2
  44. package/dist/lib/notify.d.ts +7 -0
  45. package/dist/lib/notify.js +15 -1
  46. package/dist/lib/session/local-tmux-attach.d.ts +69 -0
  47. package/dist/lib/session/local-tmux-attach.js +164 -0
  48. package/dist/lib/session/remote-active.d.ts +8 -0
  49. package/dist/lib/session/remote-active.js +1 -0
  50. package/dist/lib/share/publish.d.ts +8 -11
  51. package/dist/lib/share/publish.js +16 -20
  52. package/dist/lib/share/worker-template.js +99 -12
  53. package/dist/lib/sync-status.d.ts +17 -0
  54. package/dist/lib/sync-status.js +21 -2
  55. package/dist/lib/tmux/index.d.ts +1 -1
  56. package/dist/lib/tmux/index.js +1 -1
  57. package/dist/lib/tmux/session.d.ts +10 -0
  58. package/dist/lib/tmux/session.js +29 -0
  59. package/package.json +1 -1
@@ -22,6 +22,8 @@ export function formatSharePublishResult(result, json = false) {
22
22
  if (json)
23
23
  return JSON.stringify(result, null, 2);
24
24
  const lines = [chalk.green(result.url)];
25
+ if (result.slug)
26
+ lines.push(chalk.dim(` slug: ${result.slug}`));
25
27
  if (result.label) {
26
28
  const hint = result.labelSource === 'derived' ? chalk.dim(' (derived — pass --label to set one)') : '';
27
29
  lines.push(chalk.dim(` "${result.label}"`) + hint);
@@ -436,7 +438,7 @@ export function registerShareCommands(artifactsCmd) {
436
438
  .command('share')
437
439
  .description('Publish an HTML file to a shareable link — managed if signed in, otherwise your Cloudflare R2.')
438
440
  .argument('[file]', 'file to publish (HTML or any static asset)')
439
- .option('--slug <slug>', 'custom URL slug under your namespace (default: <feature>-<16hex>)')
441
+ .option('--slug <slug>', 'URL slug override (default: stable slug of the artifact title, then filename)')
440
442
  .option('--github-user <user>', 'GitHub username for the share namespace (default: resolved from gh/git config; ignored on the managed endpoint)')
441
443
  .option('--expire <spec>', "auto-expire (default 30d). e.g. 12h, 30d, 2026-08-01, or 'never'")
442
444
  .addOption(new Option('--visibility <level>', 'public | unlisted | me | org (default public). unlisted is a capability URL; me/org require a Phoenix session and are hidden from the gallery')
@@ -497,17 +499,17 @@ export function registerShareCommands(artifactsCmd) {
497
499
  # Hide from the public gallery (direct URL still works, noindex) and expire sooner
498
500
  agents artifacts share ./out/report.html --visibility unlisted --expire 12h
499
501
 
500
- # Permanent public page (opt out of the default 30d expiry)
501
- agents artifacts share ./out/landing.html --slug landing --expire never
502
+ # Permanent public page (the slug is derived from its title)
503
+ agents artifacts share ./out/landing.html --expire never
502
504
 
503
- # Custom slug, expiring in 7 days
505
+ # Optional slug override
504
506
  agents artifacts share ./out/report.html --slug q3-report --expire 7d
505
507
 
506
508
  # Human title + structured metadata, shown in the gallery and share list
507
509
  agents artifacts share ./out/plan.html --label "Q3 fleet plan" --meta kind=plan --meta ticket=RUSH-2683
508
510
 
509
- # Republish without keeping the previous version as a revision
510
- agents artifacts share ./out/plan.html --slug q3-report --no-revision
511
+ # Republish the same title to the same URL without retaining a revision
512
+ agents artifacts share ./out/plan.html --no-revision
511
513
  ${SHARE_DELETE_EXAMPLES}
512
514
  # One-time setup (or join an existing endpoint)
513
515
  agents artifacts setup
@@ -519,8 +521,10 @@ ${SHARE_DELETE_EXAMPLES}
519
521
  notes: `
520
522
  Signed-in users publish to the managed endpoint (share.agents-cli.sh/<handle>/…)
521
523
  with the Phoenix session — no Cloudflare account, bucket, or write token. The
522
- handle is the local-part of the signed-in email; the default slug is
523
- <readable>-<16hex>. Without a session, the existing BYO Cloudflare path still
524
+ handle is the local-part of the signed-in email. The default slug is derived
525
+ deterministically from the HTML <title> or Markdown frontmatter title, then
526
+ the filename, so republishing the same title updates the same URL. --slug is
527
+ an optional override. Without a session, the existing BYO Cloudflare path still
524
528
  applies (setup / join). HTML is stored as one object: local images are inlined
525
529
  and Chrome-saved file:// TOC links become in-page hashes.
526
530
 
@@ -75,6 +75,11 @@ export function registerStatusCommand(syncCmd) {
75
75
  else {
76
76
  console.log(` ${'.system repo'.padEnd(28)} ${chalk.green('up to date')}`);
77
77
  }
78
+ // A non-git / partial ~/.agents is its own drift state, not a per-agent
79
+ // "N missing" — surface it distinctly so the real problem isn't buried (PHNX-3301).
80
+ if (status.user.notGitRepo) {
81
+ console.log(` ${'~/.agents (user repo)'.padEnd(28)} ${chalk.yellow('not a git repo — will adopt on next `agents repo sync user`')}`);
82
+ }
78
83
  if (status.agents.length === 0) {
79
84
  console.log(chalk.gray(' (no installed agent versions)'));
80
85
  }
@@ -44,7 +44,7 @@ import { formatKeptProjectResources } from '../lib/project-resources.js';
44
44
  import { isInteractiveTerminal, isPromptCancelled } from './utils.js';
45
45
  import { runUmbrellaSync } from '../lib/sync-umbrella.js';
46
46
  import { addHostOption } from '../lib/hosts/option.js';
47
- import { syncRepoGit } from '../lib/git.js';
47
+ import { syncRepoGit, adoptUserRepoIfNeeded, recordUserRepoRemote, resolveUserRepoRemoteUrl } from '../lib/git.js';
48
48
  import { getSystemAgentsDir, getUserAgentsDir, getEnabledExtraRepos } from '../lib/state.js';
49
49
  import { registerStatusCommand } from './status.js';
50
50
  /** Emit one JSON object to stdout for `--json` callers / fleet fan-out. */
@@ -206,6 +206,37 @@ async function runRepoGitSync(repo, quiet, outLog, errLog, json = false) {
206
206
  }
207
207
  if (!quiet && !json)
208
208
  outLog(chalk.bold(`Syncing ${repo} repo…`) + chalk.gray(` (${target.dir})`));
209
+ // Self-heal a non-git / partial user checkout in place before the git sync
210
+ // (PHNX-3301) — otherwise syncRepoGit hard-fails with "Not a git repo" and the
211
+ // only fix is a destructive re-clone. Only the user repo adopts: system is
212
+ // cloned by setup, extras by `repo add`.
213
+ if (repo === 'user') {
214
+ const adopted = await adoptUserRepoIfNeeded(target.dir);
215
+ if (adopted && !adopted.success) {
216
+ const hint = adopted.needsUrl
217
+ ? ' — git-back it: agents repo pull user <git-url>'
218
+ : '';
219
+ if (json) {
220
+ emitJson({ ok: false, mode: 'repo-git', repo, error: `${adopted.error}${hint}` });
221
+ }
222
+ else {
223
+ errLog(chalk.red(`sync ${repo} failed: ${adopted.error}`));
224
+ if (adopted.needsUrl)
225
+ errLog(chalk.gray(' git-back it: agents repo pull user <git-url>'));
226
+ }
227
+ process.exitCode = 1;
228
+ return;
229
+ }
230
+ if (adopted?.success && !quiet && !json) {
231
+ outLog(chalk.green(` adopted ${repo} in place → ${adopted.commit} (${adopted.materialized} file(s) materialized${adopted.reconciledAgentsYaml ? ', agents.yaml reconciled' : ''})`));
232
+ if (adopted.localEdits.length > 0) {
233
+ outLog(chalk.yellow(` kept ${adopted.localEdits.length} local edit(s): ${adopted.localEdits.slice(0, 5).join(', ')}${adopted.localEdits.length > 5 ? ', …' : ''}`));
234
+ }
235
+ if (adopted.agentsYamlBackup) {
236
+ outLog(chalk.gray(` saved the previous agents.yaml to ${adopted.agentsYamlBackup}`));
237
+ }
238
+ }
239
+ }
209
240
  const result = await syncRepoGit(target.dir, { push: target.push });
210
241
  if (!result.success) {
211
242
  if (json) {
@@ -217,6 +248,14 @@ async function runRepoGitSync(repo, quiet, outLog, errLog, json = false) {
217
248
  process.exitCode = 1;
218
249
  return;
219
250
  }
251
+ // Record the resolved remote so a future partial box (lost .git) can adopt in
252
+ // place without the operator re-typing the URL (PHNX-3301). Before the --json
253
+ // early-return so the record is refreshed on every healthy sync, JSON or not.
254
+ if (repo === 'user') {
255
+ const u = resolveUserRepoRemoteUrl(target.dir);
256
+ if (u)
257
+ recordUserRepoRemote(target.dir, u);
258
+ }
220
259
  if (json) {
221
260
  emitJson({
222
261
  ok: true,
@@ -290,9 +329,26 @@ async function runInteractiveReconcile(opts, outLog, errLog) {
290
329
  const target = resolveRepoGitTarget(repo);
291
330
  if (!target)
292
331
  continue;
332
+ // Adopt a non-git / partial user checkout in place before pulling (PHNX-3301).
333
+ if (repo === 'user') {
334
+ const adopted = await adoptUserRepoIfNeeded(target.dir);
335
+ if (adopted?.success) {
336
+ outLog(chalk.gray(` adopted user in place → ${adopted.commit} (${adopted.materialized} file(s) materialized)`));
337
+ }
338
+ else if (adopted && !adopted.success) {
339
+ outLog(chalk.yellow(` ! user: ${adopted.error}${adopted.needsUrl ? ' — agents repo pull user <git-url>' : ''}`));
340
+ continue;
341
+ }
342
+ }
293
343
  const res = await syncRepoGit(target.dir, { push: false });
294
- if (res.success)
344
+ if (res.success) {
295
345
  outLog(chalk.gray(` pulled ${repo} → ${res.commit}`));
346
+ if (repo === 'user') {
347
+ const u = resolveUserRepoRemoteUrl(target.dir);
348
+ if (u)
349
+ recordUserRepoRemote(target.dir, u);
350
+ }
351
+ }
296
352
  else
297
353
  outLog(chalk.yellow(` ! ${repo}: ${(res.error ?? 'pull failed').split('\n')[0]}`));
298
354
  }
@@ -17,9 +17,16 @@
17
17
  * agents tmux send <name>[:pane] <keys> [--no-enter] [--raw]
18
18
  * agents tmux capture <name>[:pane] [--lines N] [--ansi]
19
19
  * agents tmux info <name> [--json]
20
- * agents tmux kill <name>
20
+ * agents tmux kill [name] no name → picker with live pane preview
21
21
  * agents tmux kill-all [--yes]
22
22
  */
23
23
  import type { Command } from 'commander';
24
+ import { type ListedSession } from '../lib/tmux/index.js';
24
25
  /** Register the `agents tmux` command tree. */
25
26
  export declare function registerTmuxCommands(program: Command): void;
27
+ /** Last useful line of a pane capture — what `tmux ls` shows so a name isn't a black box. */
28
+ export declare function tmuxScreenSnippet(raw: string, max?: number): string;
29
+ /** Last non-empty lines of a pane capture, for the kill-picker preview. */
30
+ export declare function tmuxScreenPreview(raw: string, maxLines?: number): string;
31
+ export declare function formatTmuxKillLabel(s: ListedSession, snippet: string, nowMs?: number): string;
32
+ export declare function formatTmuxKillPreview(s: ListedSession, raw: string, nowMs?: number): string;
@@ -17,14 +17,15 @@
17
17
  * agents tmux send <name>[:pane] <keys> [--no-enter] [--raw]
18
18
  * agents tmux capture <name>[:pane] [--lines N] [--ansi]
19
19
  * agents tmux info <name> [--json]
20
- * agents tmux kill <name>
20
+ * agents tmux kill [name] no name → picker with live pane preview
21
21
  * agents tmux kill-all [--yes]
22
22
  */
23
23
  import chalk from 'chalk';
24
- import { truncate } from '../lib/format.js';
24
+ import { truncate, isInteractiveTerminal, isPromptCancelled } from '../lib/format.js';
25
25
  import * as path from 'path';
26
26
  import { setHelpSections } from '../lib/help.js';
27
- import { assertTmuxAvailable, attachTmux, capturePane, createSession, ensureSessionHookRepaired, getDefaultSocketPath, getTmuxVersion, hasSession, isTmuxInstalled, isTmuxVersionSupported, killAll, killSession, listSessions, MIN_TMUX_VERSION, readSessionMeta, sendKeys, splitPane, TmuxCommandError, TmuxSessionError, TmuxUnavailableError, } from '../lib/tmux/index.js';
27
+ import { multiItemPicker } from '../lib/picker.js';
28
+ import { assertTmuxAvailable, attachTmux, capturePane, createSession, ensureSessionHookRepaired, getDefaultSocketPath, getTmuxVersion, hasSession, isTmuxInstalled, isTmuxVersionSupported, killAll, killSession, teardownIfAgentExited, listSessions, MIN_TMUX_VERSION, readSessionMeta, sendKeys, splitPane, TmuxCommandError, TmuxSessionError, TmuxUnavailableError, } from '../lib/tmux/index.js';
28
29
  /** Register the `agents tmux` command tree. */
29
30
  export function registerTmuxCommands(program) {
30
31
  const tmux = program
@@ -47,8 +48,9 @@ export function registerTmuxCommands(program) {
47
48
  # Send a slash command from a script
48
49
  agents tmux send claude-debug "/clear"
49
50
 
50
- # Clean up
51
+ # Clean up one by name, or pick from a list that shows each pane's last lines
51
52
  agents tmux kill claude-debug
53
+ agents tmux kill
52
54
  `,
53
55
  notes: `
54
56
  Storage:
@@ -145,7 +147,7 @@ export function registerTmuxCommands(program) {
145
147
  // ─── attach ─────────────────────────────────────────────────────────────────
146
148
  const attachCmd = tmux
147
149
  .command('attach <name>')
148
- .description('Attach to a running session. Replaces this shell with the tmux client until you detach (Ctrl-b d).')
150
+ .description('Attach to a running session. Replaces this shell with the tmux client until you detach (Ctrl-b d) or the agent exits (session is then torn down).')
149
151
  .option('--socket <path>', 'Use a custom socket (default: shared server)');
150
152
  attachCmd.action(async (name, opts) => {
151
153
  await guardTmux(async () => {
@@ -163,6 +165,11 @@ export function registerTmuxCommands(program) {
163
165
  // deleted; attach-time repair is what closes the gap now (RUSH-2435).
164
166
  await ensureSessionHookRepaired(name, socket);
165
167
  const code = await attachTmux({ socket, args: ['attach-session', '-t', `=${name}`] });
168
+ // The v6 pane-died hook only detach-clients when a client is attached
169
+ // (so runInTmux can read the exit status). Attach verbs must kill the
170
+ // husk themselves — otherwise `tmux ls` still lists the session after
171
+ // the agent exits (PHNX-3293).
172
+ await teardownIfAgentExited(name, socket);
166
173
  process.exit(code);
167
174
  });
168
175
  });
@@ -170,25 +177,34 @@ export function registerTmuxCommands(program) {
170
177
  const listCmd = tmux
171
178
  .command('list')
172
179
  .alias('ls')
173
- .description('List live tmux sessions on the shared server. Prunes stale meta files as a side effect.')
180
+ .description('List live tmux sessions on the shared server (name, age, last screen line). Prunes stale meta files as a side effect.')
174
181
  .option('--socket <path>', 'Use a custom socket (default: shared server)')
175
182
  .option('--json', 'Output as JSON');
176
183
  listCmd.action(async (opts) => {
177
184
  await guardTmux(async () => {
178
185
  const sessions = await listSessions({ socket: opts.socket });
179
- if (opts.json) {
180
- console.log(JSON.stringify(sessions));
181
- return;
182
- }
183
186
  if (sessions.length === 0) {
187
+ if (opts.json) {
188
+ console.log('[]');
189
+ return;
190
+ }
184
191
  console.log(chalk.gray('No tmux sessions.'));
185
192
  return;
186
193
  }
194
+ const screens = await captureScreens(sessions, opts.socket);
195
+ if (opts.json) {
196
+ console.log(JSON.stringify(sessions.map((s) => ({
197
+ ...s,
198
+ preview: tmuxScreenSnippet(screens.get(s.name) ?? ''),
199
+ }))));
200
+ return;
201
+ }
187
202
  for (const s of sessions) {
188
203
  const age = formatAge(Date.now() / 1000 - s.createdAtTmux);
189
- const cmd = s.meta?.cmd ? chalk.gray(` ${truncate(s.meta.cmd, 60)}`) : '';
190
204
  const attached = s.attached ? chalk.green(' [attached]') : '';
191
- console.log(` ${chalk.bold(s.name)} ${s.windows}w ${age}${attached}${cmd}`);
205
+ const snippet = tmuxScreenSnippet(screens.get(s.name) ?? '');
206
+ const preview = snippet ? chalk.gray(` ${snippet}`) : '';
207
+ console.log(` ${chalk.bold(s.name)} ${s.windows}w ${age}${attached}${preview}`);
192
208
  }
193
209
  });
194
210
  });
@@ -329,15 +345,35 @@ export function registerTmuxCommands(program) {
329
345
  });
330
346
  // ─── kill ───────────────────────────────────────────────────────────────────
331
347
  const killCmd = tmux
332
- .command('kill <name>')
333
- .description('Kill one tmux session. Idempotent — exits 0 even if the session was already gone.')
348
+ .command('kill [name]')
349
+ .description('Kill one tmux session, or pick from a list that shows each pane\'s last screen. Idempotent.')
334
350
  .option('--socket <path>', 'Use a custom socket (default: shared server)');
351
+ setHelpSections(killCmd, {
352
+ examples: `
353
+ # Kill by name
354
+ agents tmux kill ag-claude-04a1307c
355
+
356
+ # Pick from live panes — preview shows the last lines on screen
357
+ # (trust-folder stall, weekly-limit dialog, real work)
358
+ agents tmux kill
359
+ `,
360
+ notes: `
361
+ With no name, the picker captures each pane so you can tell a leaked
362
+ first-run dialog from an agent that is still working. Space multi-selects;
363
+ enter kills the checked rows (or the highlighted one if none are checked).
364
+ Ctrl-b d still keeps a live session; exiting the agent tears it down.
365
+ `,
366
+ });
335
367
  killCmd.action(async (name, opts) => {
336
368
  await guardTmux(async () => {
337
- const killed = await killSession(name, opts.socket);
338
- if (!killed) {
339
- console.log(chalk.gray(`No session "${name}" — nothing to do.`));
369
+ if (name) {
370
+ const killed = await killSession(name, opts.socket);
371
+ if (!killed) {
372
+ console.log(chalk.gray(`No session "${name}" — nothing to do.`));
373
+ }
374
+ return;
340
375
  }
376
+ await pickAndKillSessions(opts.socket);
341
377
  });
342
378
  });
343
379
  // ─── kill-all ───────────────────────────────────────────────────────────────
@@ -399,6 +435,120 @@ function collectLabel(value, acc) {
399
435
  acc[value.slice(0, eq)] = value.slice(eq + 1);
400
436
  return acc;
401
437
  }
438
+ /** Last useful line of a pane capture — what `tmux ls` shows so a name isn't a black box. */
439
+ export function tmuxScreenSnippet(raw, max = 72) {
440
+ const lines = raw
441
+ .split('\n')
442
+ .map((l) => l.replace(/\s+/g, ' ').trim())
443
+ .filter((l) => l.length > 0 && !/^[─═│┃┌┐└┘╰╯╭╮\s|-]+$/.test(l) && !/enter to confirm|esc to cancel|tab:next/i.test(l));
444
+ const tail = lines.slice(-8);
445
+ const preferred = [...tail].reverse().find((l) => /[?❯]|trust this folder|weekly limit|needs.you|waiting/i.test(l));
446
+ return truncate(preferred ?? tail[tail.length - 1] ?? '', max);
447
+ }
448
+ /** Last non-empty lines of a pane capture, for the kill-picker preview. */
449
+ export function tmuxScreenPreview(raw, maxLines = 16) {
450
+ return raw
451
+ .split('\n')
452
+ .map((l) => l.replace(/\s+$/, ''))
453
+ .filter((l) => l.length > 0)
454
+ .slice(-maxLines)
455
+ .join('\n');
456
+ }
457
+ export function formatTmuxKillLabel(s, snippet, nowMs = Date.now()) {
458
+ const age = formatAge(nowMs / 1000 - s.createdAtTmux);
459
+ const agent = s.meta?.labels?.agent;
460
+ const bits = [s.name, age];
461
+ if (agent)
462
+ bits.push(agent);
463
+ if (snippet)
464
+ bits.push(snippet);
465
+ return bits.join(' ');
466
+ }
467
+ export function formatTmuxKillPreview(s, raw, nowMs = Date.now()) {
468
+ const age = formatAge(nowMs / 1000 - s.createdAtTmux);
469
+ const agent = s.meta?.labels?.agent;
470
+ const header = [
471
+ chalk.bold(s.name),
472
+ agent ? chalk.cyan(agent) : '',
473
+ chalk.gray(age),
474
+ s.attached ? chalk.green('attached') : chalk.gray('detached'),
475
+ ].filter(Boolean).join(' ');
476
+ const meta = [
477
+ s.meta?.cwd ? `cwd ${s.meta.cwd}` : '',
478
+ s.meta?.cmd ? `cmd ${truncate(s.meta.cmd, 80)}` : '',
479
+ ].filter(Boolean).join('\n');
480
+ const body = tmuxScreenPreview(raw) || chalk.gray('(empty pane)');
481
+ return [header, meta, '', chalk.gray('── last screen ──'), body].filter((line) => line !== '').join('\n');
482
+ }
483
+ async function captureScreens(sessions, socket) {
484
+ const rows = await Promise.all(sessions.map(async (s) => {
485
+ try {
486
+ const raw = await capturePane({ name: s.name, socket, lines: 40 });
487
+ return [s.name, raw];
488
+ }
489
+ catch {
490
+ return [s.name, ''];
491
+ }
492
+ }));
493
+ return new Map(rows);
494
+ }
495
+ async function pickAndKillSessions(socket) {
496
+ if (!isInteractiveTerminal()) {
497
+ console.error(chalk.red('Pass a session name, or run from a TTY to pick from a list with pane previews.'));
498
+ console.error(chalk.gray(' agents tmux ls # names + last screen line'));
499
+ console.error(chalk.gray(' agents tmux kill <name>'));
500
+ process.exitCode = 1;
501
+ return;
502
+ }
503
+ const sessions = await listSessions({ socket });
504
+ if (sessions.length === 0) {
505
+ console.log(chalk.gray('No tmux sessions.'));
506
+ return;
507
+ }
508
+ const screens = await captureScreens(sessions, socket);
509
+ let chosen;
510
+ try {
511
+ chosen = await multiItemPicker({
512
+ message: 'Kill which tmux sessions?',
513
+ items: sessions,
514
+ filter: (q) => {
515
+ const needle = q.trim().toLowerCase();
516
+ if (!needle)
517
+ return sessions;
518
+ return sessions.filter((s) => {
519
+ const hay = [
520
+ s.name,
521
+ s.meta?.labels?.agent,
522
+ s.meta?.cwd,
523
+ s.meta?.cmd,
524
+ screens.get(s.name),
525
+ ].join(' ').toLowerCase();
526
+ return hay.includes(needle);
527
+ });
528
+ },
529
+ labelFor: (s) => formatTmuxKillLabel(s, tmuxScreenSnippet(screens.get(s.name) ?? '')),
530
+ keyFor: (s) => s.name,
531
+ buildPreview: (s) => formatTmuxKillPreview(s, screens.get(s.name) ?? ''),
532
+ pageSize: 12,
533
+ emptyMessage: 'No sessions match.',
534
+ enterHint: 'kill',
535
+ });
536
+ }
537
+ catch (err) {
538
+ if (isPromptCancelled(err))
539
+ return;
540
+ throw err;
541
+ }
542
+ if (!chosen || chosen.length === 0)
543
+ return;
544
+ for (const s of chosen) {
545
+ const killed = await killSession(s.name, socket);
546
+ if (killed)
547
+ console.log(chalk.gray(`Killed ${s.name}`));
548
+ else
549
+ console.log(chalk.gray(`No session "${s.name}" — already gone.`));
550
+ }
551
+ }
402
552
  function formatAge(secs) {
403
553
  if (secs < 60)
404
554
  return `${Math.max(0, Math.floor(secs))}s`;
@@ -12,6 +12,50 @@ export declare function getSocketPath(): string;
12
12
  /** Is the daemon reachable? A real connect probe on every platform — a socket
13
13
  * file existing on disk is not proof a daemon is listening on it. */
14
14
  export declare function isDaemonReachable(): Promise<boolean>;
15
+ /**
16
+ * Wait until the browser daemon is genuinely reachable, or throw.
17
+ *
18
+ * Re-probes across the whole window rather than latching on the first accept, so
19
+ * it survives an IPC-server restart that happens mid-wait (PHNX-3289): a restart
20
+ * just resets the consecutive-accept counter, and the loop keeps going until the
21
+ * daemon is *stably* up or the deadline passes. Bounded and fail-loud — a daemon
22
+ * that never comes up throws a message naming the endpoint and the budget, never
23
+ * a silent hang.
24
+ */
25
+ export declare function waitForSocket(_socketPath: string, timeoutMs?: number): Promise<void>;
26
+ /** Outcome of {@link resetBrowserDaemon} — what the wedge-recovery actually did. */
27
+ export interface BrowserDaemonResetResult {
28
+ /** The daemon was reachable before the reset and a stop was issued. */
29
+ wasRunning: boolean;
30
+ /** A leftover `browser.sock` file was unlinked (POSIX only). */
31
+ socketCleared: boolean;
32
+ }
33
+ /**
34
+ * Clear a wedged browser daemon so a subsequent `start` comes up clean
35
+ * (PHNX-3289). Stops the shared daemon (the same `stopDaemon` path
36
+ * `reconcileDaemonVersion` uses for a stale-version restart), waits for the IPC
37
+ * endpoint to stop accepting, then unlinks any stale `browser.sock` a
38
+ * hard-crashed daemon left behind — the file a fresh `start` would otherwise
39
+ * `unlink` blindly, racing whatever still holds it.
40
+ *
41
+ * Fails loud: if the endpoint is *still* reachable after the quiesce window, a
42
+ * live server is holding it and clearing the socket under it would orphan two
43
+ * servers on one path, so we throw rather than pretend the reset worked. The
44
+ * daemon auto-restarts on the next browser command.
45
+ */
46
+ export declare function resetBrowserDaemon(): Promise<BrowserDaemonResetResult>;
47
+ /**
48
+ * Remove a leftover browser socket FILE, but only when nothing is listening on
49
+ * it — re-probing liveness IMMEDIATELY before the unlink to close the TOCTOU
50
+ * window (PHNX-3289 review). Between {@link resetBrowserDaemon}'s quiesce loop
51
+ * deciding the endpoint was unreachable and this unlink, a concurrent
52
+ * `browser start` could bind a NEW listener on the same path; an unconditional
53
+ * unlink would then delete a LIVE daemon's socket — the exact two-servers orphan
54
+ * this function exists to prevent. A listener seen here means the wedge is already
55
+ * resolved (a fresh daemon owns the path), so its socket is left intact. Returns
56
+ * true only when a genuinely dead file was removed.
57
+ */
58
+ export declare function clearDeadSocketFile(endpoint: string, socketPath: string): Promise<boolean>;
15
59
  /**
16
60
  * One long-lived connection to the existing browser daemon. Requests are
17
61
  * serialized so the daemon's newline-delimited responses always map to the
@@ -69,6 +69,29 @@ const SOCKET_NAME = 'browser.sock';
69
69
  * coming near the grace window.
70
70
  */
71
71
  const IPC_CLOSE_TIMEOUT_MS = 1_500;
72
+ /**
73
+ * How long {@link waitForSocket} waits for the browser daemon to come up before
74
+ * failing loud (PHNX-3289).
75
+ *
76
+ * The old flat 6s ceiling was the wedge: the shared daemon's browser IPC server
77
+ * restarts (version reconcile, supervisor restart, a hard-crashed predecessor's
78
+ * successor claiming the socket), and a client that started its wait *during*
79
+ * one of those restart windows could burn the whole 6s and throw
80
+ * `Timeout waiting for browser daemon socket` on an otherwise-healthy daemon. A
81
+ * browser start/navigate then failed intermittently and self-healed on the next
82
+ * try. 15s comfortably spans a restart; the stable-probe requirement below is
83
+ * what keeps a socket that "appears and is immediately destroyed" (#556) from
84
+ * being mistaken for a ready daemon.
85
+ */
86
+ const SOCKET_WAIT_TIMEOUT_MS = 15_000;
87
+ /**
88
+ * Consecutive successful probes required before {@link waitForSocket} declares
89
+ * the daemon ready. A single accept can land in the sliver between a restarting
90
+ * server binding and tearing back down; requiring two accepts ~100ms apart means
91
+ * we only return once the daemon is *staying* up, so the caller's real request
92
+ * doesn't race a restart it happened to probe mid-flight.
93
+ */
94
+ const SOCKET_WAIT_STABLE_PROBES = 2;
72
95
  export class BrowserDaemonNotRunningError extends Error {
73
96
  constructor() {
74
97
  super(formatBrowserDaemonNotRunningError());
@@ -78,8 +101,8 @@ export class BrowserDaemonNotRunningError extends Error {
78
101
  export function formatBrowserDaemonNotRunningError() {
79
102
  return [
80
103
  'Browser daemon not running.',
81
- 'Start it with: agents browser start (auto-picks an installed browser)',
82
- 'Or pin a profile: agents browser start --profile <name>',
104
+ 'Start it with: agents browser start (uses this machine\'s configured default browser)',
105
+ 'Pick / pin a profile: agents browser use <name> (or: agents browser start --profile <name>)',
83
106
  'List profiles: agents browser profiles list',
84
107
  ].join('\n');
85
108
  }
@@ -117,15 +140,104 @@ function probeDaemon(endpoint, timeoutMs = 500) {
117
140
  export async function isDaemonReachable() {
118
141
  return probeDaemon(getIpcEndpoint());
119
142
  }
120
- async function waitForSocket(_socketPath, timeoutMs) {
143
+ /**
144
+ * Wait until the browser daemon is genuinely reachable, or throw.
145
+ *
146
+ * Re-probes across the whole window rather than latching on the first accept, so
147
+ * it survives an IPC-server restart that happens mid-wait (PHNX-3289): a restart
148
+ * just resets the consecutive-accept counter, and the loop keeps going until the
149
+ * daemon is *stably* up or the deadline passes. Bounded and fail-loud — a daemon
150
+ * that never comes up throws a message naming the endpoint and the budget, never
151
+ * a silent hang.
152
+ */
153
+ export async function waitForSocket(_socketPath, timeoutMs = SOCKET_WAIT_TIMEOUT_MS) {
121
154
  const endpoint = getIpcEndpoint();
122
155
  const deadline = Date.now() + timeoutMs;
156
+ let consecutive = 0;
123
157
  while (Date.now() < deadline) {
124
- if (await probeDaemon(endpoint))
125
- return;
158
+ if (await probeDaemon(endpoint)) {
159
+ consecutive += 1;
160
+ if (consecutive >= SOCKET_WAIT_STABLE_PROBES)
161
+ return;
162
+ }
163
+ else {
164
+ // A dropped probe means a restart (or the daemon isn't up yet) — start the
165
+ // stability count over rather than counting accepts from before the gap.
166
+ consecutive = 0;
167
+ }
168
+ await new Promise((resolve) => setTimeout(resolve, 100));
169
+ }
170
+ throw new Error(`Timeout waiting for browser daemon socket after ${Math.round(timeoutMs / 1000)}s (${endpoint}).`);
171
+ }
172
+ /** How long {@link resetBrowserDaemon} waits for the endpoint to go quiet after
173
+ * signalling a stop before it clears the socket and re-checks. Bounded so the
174
+ * command fails loud instead of hanging on a daemon that will not die. */
175
+ const DAEMON_RESET_QUIESCE_MS = 5_000;
176
+ /**
177
+ * Clear a wedged browser daemon so a subsequent `start` comes up clean
178
+ * (PHNX-3289). Stops the shared daemon (the same `stopDaemon` path
179
+ * `reconcileDaemonVersion` uses for a stale-version restart), waits for the IPC
180
+ * endpoint to stop accepting, then unlinks any stale `browser.sock` a
181
+ * hard-crashed daemon left behind — the file a fresh `start` would otherwise
182
+ * `unlink` blindly, racing whatever still holds it.
183
+ *
184
+ * Fails loud: if the endpoint is *still* reachable after the quiesce window, a
185
+ * live server is holding it and clearing the socket under it would orphan two
186
+ * servers on one path, so we throw rather than pretend the reset worked. The
187
+ * daemon auto-restarts on the next browser command.
188
+ */
189
+ export async function resetBrowserDaemon() {
190
+ const endpoint = getIpcEndpoint();
191
+ const wasRunning = await probeDaemon(endpoint);
192
+ stopDaemon();
193
+ // Wait for the listener to actually release the endpoint. We must decide
194
+ // reachability BEFORE touching the socket file: unlinking a path out from
195
+ // under a live server makes new connects ENOENT (so it would *look* cleared)
196
+ // while the server keeps running — the two-servers orphan the eviction
197
+ // protocol exists to prevent. So a still-reachable endpoint fails loud here,
198
+ // and only a genuinely dead one gets its stale file removed below.
199
+ const deadline = Date.now() + DAEMON_RESET_QUIESCE_MS;
200
+ let reachable = wasRunning;
201
+ while (reachable && Date.now() < deadline) {
126
202
  await new Promise((resolve) => setTimeout(resolve, 100));
203
+ reachable = await probeDaemon(endpoint);
204
+ }
205
+ if (reachable) {
206
+ throw new Error(actionable('Browser daemon is still reachable after stop — a live server is holding the socket.', `Endpoint: ${endpoint}`, 'Next: agents daemon status (find and stop the process holding it)'));
207
+ }
208
+ // Nothing is listening now — clear the leftover socket file a hard-crashed
209
+ // daemon left behind, so the next `start` binds clean instead of unlinking it
210
+ // blindly. (Named pipes vanish with their owning process, so Windows has no
211
+ // stale file to clear.)
212
+ const socketCleared = IS_WINDOWS ? false : await clearDeadSocketFile(endpoint, getSocketPath());
213
+ return { wasRunning, socketCleared };
214
+ }
215
+ /**
216
+ * Remove a leftover browser socket FILE, but only when nothing is listening on
217
+ * it — re-probing liveness IMMEDIATELY before the unlink to close the TOCTOU
218
+ * window (PHNX-3289 review). Between {@link resetBrowserDaemon}'s quiesce loop
219
+ * deciding the endpoint was unreachable and this unlink, a concurrent
220
+ * `browser start` could bind a NEW listener on the same path; an unconditional
221
+ * unlink would then delete a LIVE daemon's socket — the exact two-servers orphan
222
+ * this function exists to prevent. A listener seen here means the wedge is already
223
+ * resolved (a fresh daemon owns the path), so its socket is left intact. Returns
224
+ * true only when a genuinely dead file was removed.
225
+ */
226
+ export async function clearDeadSocketFile(endpoint, socketPath) {
227
+ if (!fs.existsSync(socketPath))
228
+ return false;
229
+ // A listener bound again since the quiesce loop → do not touch its socket.
230
+ if (await probeDaemon(endpoint))
231
+ return false;
232
+ try {
233
+ fs.unlinkSync(socketPath);
234
+ return true;
235
+ }
236
+ catch {
237
+ // Raced with a fresh start that already claimed it — the goal (no stale
238
+ // socket) still holds, so report cleared only when it is genuinely gone.
239
+ return !fs.existsSync(socketPath);
127
240
  }
128
- throw new Error('Timeout waiting for browser daemon socket');
129
241
  }
130
242
  /**
131
243
  * One long-lived connection to the existing browser daemon. Requests are
@@ -865,7 +977,7 @@ async function reconcileDaemonVersion(socketPath) {
865
977
  stopDaemon();
866
978
  startDaemon();
867
979
  if (!(await isDaemonReachable())) {
868
- await waitForSocket(socketPath, 6000);
980
+ await waitForSocket(socketPath);
869
981
  }
870
982
  await new Promise((r) => setTimeout(r, 300));
871
983
  }
@@ -968,7 +1080,7 @@ async function prepareIPC(action, opts) {
968
1080
  }
969
1081
  startDaemon();
970
1082
  if (!(await isDaemonReachable())) {
971
- await waitForSocket(socketPath, 6000);
1083
+ await waitForSocket(socketPath);
972
1084
  }
973
1085
  if (!(await isDaemonReachable())) {
974
1086
  throw new Error(actionable('Failed to start browser daemon.', `Log: ${getDaemonLogPath()}`, 'Next: agents doctor (checks for a second agents-cli install)'));