@phnx-labs/agents-cli 1.22.51 → 1.22.53

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 (117) hide show
  1. package/CHANGELOG.md +238 -0
  2. package/README.md +1 -1
  3. package/dist/commands/accounts.js +1 -1
  4. package/dist/commands/attach.js +7 -0
  5. package/dist/commands/browser.js +118 -56
  6. package/dist/commands/daemon.d.ts +2 -0
  7. package/dist/commands/daemon.js +8 -4
  8. package/dist/commands/detach.js +1 -1
  9. package/dist/commands/exec.js +16 -9
  10. package/dist/commands/fleet-capture.js +7 -0
  11. package/dist/commands/focus.d.ts +1 -10
  12. package/dist/commands/focus.js +16 -79
  13. package/dist/commands/go.d.ts +26 -0
  14. package/dist/commands/go.js +65 -6
  15. package/dist/commands/monitors.js +1 -1
  16. package/dist/commands/repo.js +31 -3
  17. package/dist/commands/sessions-inject.js +8 -3
  18. package/dist/commands/sessions-picker.js +2 -1
  19. package/dist/commands/sessions-resume.d.ts +1 -0
  20. package/dist/commands/sessions-resume.js +13 -2
  21. package/dist/commands/sessions-stop.js +1 -1
  22. package/dist/commands/sessions.d.ts +23 -13
  23. package/dist/commands/sessions.js +69 -39
  24. package/dist/commands/setup-browser.d.ts +5 -2
  25. package/dist/commands/setup-browser.js +14 -29
  26. package/dist/commands/setup-preferences.d.ts +22 -3
  27. package/dist/commands/setup-preferences.js +25 -8
  28. package/dist/commands/share.js +12 -8
  29. package/dist/commands/ssh.js +35 -12
  30. package/dist/commands/status.js +5 -0
  31. package/dist/commands/sync.js +102 -2
  32. package/dist/commands/tmux.d.ts +8 -1
  33. package/dist/commands/tmux.js +167 -17
  34. package/dist/lib/account-registry.d.ts +15 -5
  35. package/dist/lib/account-registry.js +150 -50
  36. package/dist/lib/answer-router.js +2 -1
  37. package/dist/lib/browser/ipc.d.ts +44 -0
  38. package/dist/lib/browser/ipc.js +120 -8
  39. package/dist/lib/browser/profiles.d.ts +57 -17
  40. package/dist/lib/browser/profiles.js +77 -53
  41. package/dist/lib/browser/registry.d.ts +44 -14
  42. package/dist/lib/browser/registry.js +141 -45
  43. package/dist/lib/browser/runtime-state.d.ts +4 -2
  44. package/dist/lib/browser/runtime-state.js +4 -2
  45. package/dist/lib/browser/service.js +4 -3
  46. package/dist/lib/channels/owner-forward.d.ts +88 -0
  47. package/dist/lib/channels/owner-forward.js +116 -0
  48. package/dist/lib/channels/owner-sink.js +7 -0
  49. package/dist/lib/daemon/runner.js +10 -2
  50. package/dist/lib/device-config.js +3 -2
  51. package/dist/lib/devices/config-migration.js +147 -1
  52. package/dist/lib/devices/device-docs.d.ts +35 -0
  53. package/dist/lib/devices/device-docs.js +163 -0
  54. package/dist/lib/devices/discovery-policy.d.ts +14 -2
  55. package/dist/lib/devices/discovery-policy.js +31 -21
  56. package/dist/lib/devices/registry.d.ts +11 -5
  57. package/dist/lib/devices/registry.js +46 -18
  58. package/dist/lib/exec.d.ts +66 -28
  59. package/dist/lib/exec.js +71 -26
  60. package/dist/lib/feed/feed.d.ts +10 -2
  61. package/dist/lib/feed/feed.js +12 -1
  62. package/dist/lib/feed-broadcast.js +15 -1
  63. package/dist/lib/git.d.ts +93 -0
  64. package/dist/lib/git.js +232 -0
  65. package/dist/lib/hosts/dispatch.d.ts +4 -3
  66. package/dist/lib/hosts/dispatch.js +12 -8
  67. package/dist/lib/hosts/providers/local.d.ts +9 -3
  68. package/dist/lib/hosts/providers/local.js +23 -12
  69. package/dist/lib/hosts/reconnect.d.ts +7 -4
  70. package/dist/lib/hosts/reconnect.js +29 -25
  71. package/dist/lib/hosts/registry.js +4 -1
  72. package/dist/lib/hosts/remote-os.js +3 -1
  73. package/dist/lib/monitors/remote.d.ts +18 -1
  74. package/dist/lib/monitors/remote.js +15 -2
  75. package/dist/lib/notify.d.ts +7 -0
  76. package/dist/lib/notify.js +15 -1
  77. package/dist/lib/session/active.d.ts +10 -1
  78. package/dist/lib/session/active.js +7 -1
  79. package/dist/lib/session/actor-sidecar.d.ts +7 -0
  80. package/dist/lib/session/actor-sidecar.js +2 -0
  81. package/dist/lib/session/db.d.ts +1 -1
  82. package/dist/lib/session/db.js +39 -3
  83. package/dist/lib/session/discover.js +7 -12
  84. package/dist/lib/session/live-metadata.js +1 -0
  85. package/dist/lib/session/local-tmux-attach.d.ts +69 -0
  86. package/dist/lib/session/local-tmux-attach.js +164 -0
  87. package/dist/lib/session/pid-registry.d.ts +7 -0
  88. package/dist/lib/session/prompt.d.ts +15 -0
  89. package/dist/lib/session/prompt.js +21 -0
  90. package/dist/lib/session/remote-active.d.ts +8 -0
  91. package/dist/lib/session/remote-active.js +1 -0
  92. package/dist/lib/session/types.d.ts +17 -0
  93. package/dist/lib/session/types.js +10 -0
  94. package/dist/lib/share/publish.d.ts +8 -11
  95. package/dist/lib/share/publish.js +16 -20
  96. package/dist/lib/share/worker-template.js +104 -12
  97. package/dist/lib/state.d.ts +8 -0
  98. package/dist/lib/state.js +143 -11
  99. package/dist/lib/sync-status.d.ts +17 -0
  100. package/dist/lib/sync-status.js +21 -2
  101. package/dist/lib/terminal/resolve.d.ts +7 -0
  102. package/dist/lib/terminal/resolve.js +41 -2
  103. package/dist/lib/tmux/index.d.ts +1 -1
  104. package/dist/lib/tmux/index.js +1 -1
  105. package/dist/lib/tmux/session.d.ts +10 -0
  106. package/dist/lib/tmux/session.js +29 -0
  107. package/dist/lib/traces/insights.d.ts +67 -0
  108. package/dist/lib/traces/insights.js +178 -0
  109. package/dist/lib/traces/phenotype.d.ts +67 -0
  110. package/dist/lib/traces/phenotype.js +437 -0
  111. package/dist/lib/traces/segments.d.ts +133 -0
  112. package/dist/lib/traces/segments.js +301 -0
  113. package/dist/lib/traces/sync.d.ts +33 -0
  114. package/dist/lib/traces/sync.js +11 -2
  115. package/dist/lib/types.d.ts +47 -1
  116. package/dist/lib/watchdog/runner.js +18 -4
  117. package/package.json +1 -1
@@ -1136,7 +1136,7 @@ function registerDevicesCommands(program) {
1136
1136
  registerFleetApplyAlias(devicesCmd);
1137
1137
  devicesCmd
1138
1138
  .command('register <name>')
1139
- .description('Register a discovered node and sync the approval through agents.yaml fleet.discovery.')
1139
+ .description("Register a discovered node and record the approval in this box's device doc (fleet.discovery), unioned fleet-wide.")
1140
1140
  .action(async (name) => {
1141
1141
  try {
1142
1142
  const nodes = parseTailscaleStatus(tailscaleStatusJson());
@@ -1145,11 +1145,20 @@ function registerDevicesCommands(program) {
1145
1145
  console.error(chalk.red(`'${name}' is not a current tailscale node. See 'agents devices sync'.`));
1146
1146
  process.exit(1);
1147
1147
  }
1148
- await removeIgnored(name); // a re-registered node is no longer dismissed
1148
+ await removeIgnored(name); // clear THIS box's dismissal, if any
1149
1149
  const d = await upsertDevice(name, nodeToDeviceInput(node));
1150
1150
  setDeviceDiscoveryStatus(name, 'approved');
1151
1151
  clearPendingSentinel(name); // drop the notification immediately
1152
1152
  console.log(chalk.green(`Registered '${name}'`) + chalk.gray(` (${d.platform})`));
1153
+ // A dismissal on another box still wins the union (ignored beats
1154
+ // approved), so approving here does not un-dismiss it fleet-wide — say so
1155
+ // rather than imply the node is now discoverable everywhere (PHNX-3315).
1156
+ const otherDismissals = loadIgnoredEntries().filter((e) => e.name === name);
1157
+ if (otherDismissals.length > 0 || getDeviceDiscoveryStatus(name) === 'ignored') {
1158
+ const boxes = [...new Set(otherDismissals.map((e) => e.ignoredOn))].sort().join(', ') || 'another box';
1159
+ console.error(chalk.yellow(`Note: '${name}' is still dismissed on: ${boxes}, so it stays ignored fleet-wide`) +
1160
+ chalk.gray(` until you run \`agents devices unignore ${name}\` there.`));
1161
+ }
1153
1162
  }
1154
1163
  catch (err) {
1155
1164
  console.error(chalk.red(err.message));
@@ -1158,7 +1167,7 @@ function registerDevicesCommands(program) {
1158
1167
  });
1159
1168
  devicesCmd
1160
1169
  .command('ignore <name>')
1161
- .description('Dismiss a node and sync the decision through agents.yaml fleet.discovery (also removes it locally).')
1170
+ .description("Dismiss a node and record the decision in this box's device doc (fleet.ignored), unioned fleet-wide (also removes it locally).")
1162
1171
  .action(async (name) => {
1163
1172
  try {
1164
1173
  await removeDevice(name);
@@ -1176,13 +1185,25 @@ function registerDevicesCommands(program) {
1176
1185
  .command('unignore <name>')
1177
1186
  .description('Undo `ignore`: allow a node to be discovered and registered again.')
1178
1187
  .action(async (name) => {
1179
- const portableWasIgnored = getDeviceDiscoveryStatus(name) === 'ignored';
1180
- const ok = await removeIgnored(name);
1181
- if (!ok && !portableWasIgnored) {
1188
+ const wasIgnored = getDeviceDiscoveryStatus(name) === 'ignored' || loadIgnoredEntries().some((e) => e.name === name);
1189
+ // A box can edit only its OWN device doc (PHNX-3315), so this clears just
1190
+ // this box's dismissal/approval decision.
1191
+ await removeIgnored(name);
1192
+ setDeviceDiscoveryStatus(name, undefined);
1193
+ // Re-evaluate the cross-box union AFTER clearing our slice: another box's
1194
+ // dismissal keeps the node ignored fleet-wide (ignored beats approved), so
1195
+ // never report a false "no longer ignoring" — name the box(es) to clear.
1196
+ const remaining = loadIgnoredEntries().filter((e) => e.name === name);
1197
+ if (remaining.length > 0 || getDeviceDiscoveryStatus(name) === 'ignored') {
1198
+ const boxes = [...new Set(remaining.map((e) => e.ignoredOn))].sort().join(', ') || 'another box';
1199
+ console.error(chalk.yellow(`Cleared this box's decision, but '${name}' is still dismissed on: ${boxes}.`) +
1200
+ chalk.gray(`\nRun \`agents devices unignore ${name}\` there too to clear it fleet-wide.`));
1201
+ return;
1202
+ }
1203
+ if (!wasIgnored) {
1182
1204
  console.error(chalk.gray(`'${name}' was not ignored.`));
1183
1205
  return;
1184
1206
  }
1185
- setDeviceDiscoveryStatus(name, undefined);
1186
1207
  console.log(chalk.green(`No longer ignoring '${name}'`) + chalk.gray(' — run `agents devices sync` to register it.'));
1187
1208
  });
1188
1209
  const ignoredCmd = devicesCmd
@@ -1219,11 +1240,13 @@ function registerDevicesCommands(program) {
1219
1240
  agents devices unignore old-laptop # undo a dismissal
1220
1241
  `,
1221
1242
  notes: `
1222
- Dismissals live in the tracked central agents.yaml (fleet.ignored) and
1223
- sync with 'agents repo push/pull', so a node dismissed on one box stays
1224
- dismissed everywhere. An ignored node is not a device — it never enters
1225
- the registry, so 'agents devices list' never shows it; this command is
1226
- where dismissals are visible.
1243
+ Dismissals live in each box's tracked device doc
1244
+ (devices/<host>/agents.yaml fleet.ignored) and sync with 'agents repo
1245
+ push/pull'; the effective list is their cross-box union, so a node
1246
+ dismissed on one box stays dismissed everywhere without the boxes ever
1247
+ rewriting one shared file. An ignored node is not a device — it never
1248
+ enters the registry, so 'agents devices list' never shows it; this command
1249
+ is where dismissals are visible.
1227
1250
  `,
1228
1251
  });
1229
1252
  // ─── devices config (unified settings surface) ────────────────────────────
@@ -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
  }
@@ -35,6 +35,8 @@ import { Option } from 'commander';
35
35
  import chalk from 'chalk';
36
36
  import { resolveSyncPassphraseFromEnv } from '../lib/secrets/sync-passphrase.js';
37
37
  import { agentLabel, resolveAgentName, MANAGED_AGENT_IDS, isAgentHardDeprecated, hardDeprecationError } from '../lib/agents.js';
38
+ import { autoEvictCentralBrowserProfiles } from '../lib/browser/registry.js';
39
+ import { shouldAutoClaimCentralProfile } from '../lib/browser/profiles.js';
38
40
  import { isVersionInstalled, syncResourcesToVersion, parseAgentSpec, resolveVersion, resolveVersionAlias, listInstalledVersions, healDanglingVersionPointers, getAvailableResources, getActuallySyncedResources, getProjectOnlyResources, getNewResources, hasNewResources, promptResourceSelection, promptNewResourceSelection, buildSelection, mergeRepoScopedSelections, listRepoNames, getVersionHomePath, } from '../lib/installations/versions.js';
39
41
  import { capableAgents } from '../lib/capabilities.js';
40
42
  import { parseHookManifest, registerHooksToSettings } from '../lib/hooks/install.js';
@@ -44,7 +46,7 @@ import { formatKeptProjectResources } from '../lib/project-resources.js';
44
46
  import { isInteractiveTerminal, isPromptCancelled } from './utils.js';
45
47
  import { runUmbrellaSync } from '../lib/sync-umbrella.js';
46
48
  import { addHostOption } from '../lib/hosts/option.js';
47
- import { syncRepoGit } from '../lib/git.js';
49
+ import { syncRepoGit, adoptUserRepoIfNeeded, recordUserRepoRemote, resolveUserRepoRemoteUrl } from '../lib/git.js';
48
50
  import { getSystemAgentsDir, getUserAgentsDir, getEnabledExtraRepos } from '../lib/state.js';
49
51
  import { registerStatusCommand } from './status.js';
50
52
  /** Emit one JSON object to stdout for `--json` callers / fleet fan-out. */
@@ -206,6 +208,37 @@ async function runRepoGitSync(repo, quiet, outLog, errLog, json = false) {
206
208
  }
207
209
  if (!quiet && !json)
208
210
  outLog(chalk.bold(`Syncing ${repo} repo…`) + chalk.gray(` (${target.dir})`));
211
+ // Self-heal a non-git / partial user checkout in place before the git sync
212
+ // (PHNX-3301) — otherwise syncRepoGit hard-fails with "Not a git repo" and the
213
+ // only fix is a destructive re-clone. Only the user repo adopts: system is
214
+ // cloned by setup, extras by `repo add`.
215
+ if (repo === 'user') {
216
+ const adopted = await adoptUserRepoIfNeeded(target.dir);
217
+ if (adopted && !adopted.success) {
218
+ const hint = adopted.needsUrl
219
+ ? ' — git-back it: agents repo pull user <git-url>'
220
+ : '';
221
+ if (json) {
222
+ emitJson({ ok: false, mode: 'repo-git', repo, error: `${adopted.error}${hint}` });
223
+ }
224
+ else {
225
+ errLog(chalk.red(`sync ${repo} failed: ${adopted.error}`));
226
+ if (adopted.needsUrl)
227
+ errLog(chalk.gray(' git-back it: agents repo pull user <git-url>'));
228
+ }
229
+ process.exitCode = 1;
230
+ return;
231
+ }
232
+ if (adopted?.success && !quiet && !json) {
233
+ outLog(chalk.green(` adopted ${repo} in place → ${adopted.commit} (${adopted.materialized} file(s) materialized${adopted.reconciledAgentsYaml ? ', agents.yaml reconciled' : ''})`));
234
+ if (adopted.localEdits.length > 0) {
235
+ outLog(chalk.yellow(` kept ${adopted.localEdits.length} local edit(s): ${adopted.localEdits.slice(0, 5).join(', ')}${adopted.localEdits.length > 5 ? ', …' : ''}`));
236
+ }
237
+ if (adopted.agentsYamlBackup) {
238
+ outLog(chalk.gray(` saved the previous agents.yaml to ${adopted.agentsYamlBackup}`));
239
+ }
240
+ }
241
+ }
209
242
  const result = await syncRepoGit(target.dir, { push: target.push });
210
243
  if (!result.success) {
211
244
  if (json) {
@@ -217,6 +250,14 @@ async function runRepoGitSync(repo, quiet, outLog, errLog, json = false) {
217
250
  process.exitCode = 1;
218
251
  return;
219
252
  }
253
+ // Record the resolved remote so a future partial box (lost .git) can adopt in
254
+ // place without the operator re-typing the URL (PHNX-3301). Before the --json
255
+ // early-return so the record is refreshed on every healthy sync, JSON or not.
256
+ if (repo === 'user') {
257
+ const u = resolveUserRepoRemoteUrl(target.dir);
258
+ if (u)
259
+ recordUserRepoRemote(target.dir, u);
260
+ }
220
261
  if (json) {
221
262
  emitJson({
222
263
  ok: true,
@@ -290,12 +331,35 @@ async function runInteractiveReconcile(opts, outLog, errLog) {
290
331
  const target = resolveRepoGitTarget(repo);
291
332
  if (!target)
292
333
  continue;
334
+ // Adopt a non-git / partial user checkout in place before pulling (PHNX-3301).
335
+ if (repo === 'user') {
336
+ const adopted = await adoptUserRepoIfNeeded(target.dir);
337
+ if (adopted?.success) {
338
+ outLog(chalk.gray(` adopted user in place → ${adopted.commit} (${adopted.materialized} file(s) materialized)`));
339
+ }
340
+ else if (adopted && !adopted.success) {
341
+ outLog(chalk.yellow(` ! user: ${adopted.error}${adopted.needsUrl ? ' — agents repo pull user <git-url>' : ''}`));
342
+ continue;
343
+ }
344
+ }
293
345
  const res = await syncRepoGit(target.dir, { push: false });
294
- if (res.success)
346
+ if (res.success) {
295
347
  outLog(chalk.gray(` pulled ${repo} → ${res.commit}`));
348
+ if (repo === 'user') {
349
+ const u = resolveUserRepoRemoteUrl(target.dir);
350
+ if (u)
351
+ recordUserRepoRemote(target.dir, u);
352
+ }
353
+ }
296
354
  else
297
355
  outLog(chalk.yellow(` ! ${repo}: ${(res.error ?? 'pull failed').split('\n')[0]}`));
298
356
  }
357
+ // Drain the legacy central `browser:` tombstone now that the repos are pulled.
358
+ // Running AFTER the pull is load-bearing: it acts on a view of central that
359
+ // already reflects peers' drains, so it never re-claims a profile another box
360
+ // already migrated (which would flip that profile's kind identity->fungible).
361
+ // The interactive path is always non-quiet, non-json.
362
+ evictCentralBrowserProfilesForSync(false, false, outLog, errLog);
299
363
  // 2. One selection spanning the chosen repos.
300
364
  const selection = mergeRepoScopedSelections(repos, cwd);
301
365
  const hasResources = selection.memory === 'all' || Object.entries(selection).some(([kind, v]) => kind !== 'memory' && Array.isArray(v) && v.length > 0);
@@ -318,6 +382,36 @@ async function runInteractiveReconcile(opts, outLog, errLog) {
318
382
  }
319
383
  }
320
384
  }
385
+ /**
386
+ * Drain the legacy central `browser:` tombstone during `agents sync` (PHNX-3315).
387
+ * New profiles write the per-device doc, but profiles created before the
388
+ * device-scoped store lingered in the shared top-level `agents.yaml` and churned
389
+ * every fleet pull until someone ran `agents browser profiles claim` by hand.
390
+ * Fold the ones THIS box can host into its device doc — host-gated, and the
391
+ * selection is computed under the meta lock (see autoEvictCentralBrowserProfiles)
392
+ * so it never races itself. Callers MUST invoke this AFTER the repo pull: acting
393
+ * on a pre-pull view of central risks re-claiming a profile a peer already
394
+ * drained, which would flip its kind identity->fungible. Non-fatal so a hiccup
395
+ * can never wedge the sync.
396
+ */
397
+ function evictCentralBrowserProfilesForSync(quiet, json, outLog, errLog) {
398
+ try {
399
+ // Auto-claim ONLY remote (ssh://) tombstones — they are fungible by design,
400
+ // so a concurrent cross-machine double-claim is harmless. Local/cdp profiles
401
+ // have no per-machine ownership signal and are left central for an explicit
402
+ // `agents browser profiles claim` (PHNX-3315 review). See
403
+ // shouldAutoClaimCentralProfile.
404
+ const result = autoEvictCentralBrowserProfiles(shouldAutoClaimCentralProfile);
405
+ if (!quiet && !json && result.claimed.length > 0) {
406
+ outLog(chalk.gray(` Claimed ${result.claimed.length} central browser profile(s) into this device: ${result.claimed.join(', ')}`));
407
+ }
408
+ }
409
+ catch (err) {
410
+ if (!quiet && !json) {
411
+ errLog(chalk.yellow(` ! browser profile eviction skipped: ${err.message}`));
412
+ }
413
+ }
414
+ }
321
415
  /**
322
416
  * The umbrella verb: bare `agents sync` (no agent) makes this machine current.
323
417
  * Resolves the flags + a secrets passphrase (env-only for now; tokenized auth
@@ -359,6 +453,12 @@ async function runUmbrella(opts, quiet, outLog, errLog, json = false) {
359
453
  log: (msg) => { if (!quiet && !json)
360
454
  outLog(chalk.gray(` ${msg}`)); },
361
455
  });
456
+ // Drain the legacy central `browser:` tombstone AFTER the umbrella pull, so
457
+ // we act on central as converged by this sync rather than a stale pre-pull
458
+ // copy that could re-claim a profile a peer already migrated. Skipped under
459
+ // --cloud (fetch-only, no local reconcile).
460
+ if (!opts.cloud)
461
+ evictCentralBrowserProfilesForSync(quiet, json, outLog, errLog);
362
462
  if (json) {
363
463
  emitJson({
364
464
  // A refused resource is not a clean sync (RUSH-2700).
@@ -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`;
@@ -34,7 +34,15 @@ export type UnifiedAccount = (CredentialAccount & {
34
34
  export declare function accountRegistryPath(base?: string): string;
35
35
  export declare function readAccountRegistry(base?: string): AccountRegistryDocument;
36
36
  export declare function findAccount(name: string, doc?: AccountRegistryDocument): CredentialAccount | null;
37
- export declare function listNativeAccounts(meta: Pick<Meta, 'accounts'>): NativeAccount[];
37
+ /**
38
+ * Effective native accounts: the fleet-shared central store (version-scoped
39
+ * identities) merged with THIS box's own device doc (device-scoped identities).
40
+ * A native login is machine-local — its home follows its scope (PHNX-3315), so
41
+ * a `scope:'device'` identity is read from this box's device doc and never the
42
+ * shared central agents.yaml (which is where its email/identityKey PII used to
43
+ * accumulate). On an id collision the device slice wins.
44
+ */
45
+ export declare function listNativeAccounts(meta: Pick<Meta, 'accounts' | 'deviceAccounts'>): NativeAccount[];
38
46
  /**
39
47
  * Resolve one account by name or id across both stores, native first.
40
48
  *
@@ -46,15 +54,17 @@ export declare function listNativeAccounts(meta: Pick<Meta, 'accounts'>): Native
46
54
  * running before the body, so callers that only need a native lookup must be
47
55
  * able to omit it.
48
56
  */
49
- export declare function findUnifiedAccount(nameOrId: string, meta: Pick<Meta, 'accounts'>, doc?: AccountRegistryDocument): UnifiedAccount | null;
57
+ export declare function findUnifiedAccount(nameOrId: string, meta: Pick<Meta, 'accounts' | 'deviceAccounts'>, doc?: AccountRegistryDocument): UnifiedAccount | null;
50
58
  export declare function addNativeAccount(name: string, agent: AgentId, identityKey: string, identityLabel: string | undefined, scope: 'version' | 'device'): NativeAccount;
51
59
  /** Create or replace the version-independent label for one native identity. */
52
60
  export declare function labelNativeAccount(agent: AgentId, identityKey: string, identityLabel: string | undefined, label: string | undefined, scope: 'version' | 'device'): NativeAccount;
53
61
  export declare function bindAccount(nameOrId: string, target: string): UnifiedAccount;
54
62
  export declare function unbindAccount(nameOrId: string, target: string): void;
55
- export declare function accountBindings(accountId: string, meta: Pick<Meta, 'accounts'>): string[];
63
+ /** Every target bound to `accountId`: this box's device-doc bindings merged over
64
+ * the fleet-shared central bindings (PHNX-3315). */
65
+ export declare function accountBindings(accountId: string, meta: Pick<Meta, 'accounts' | 'deviceAccounts'>): string[];
56
66
  /** Explicit selection wins over a configured per-harness default. */
57
- export declare function resolveAccountSelection(explicit: string | undefined, agent: AgentId, meta: Pick<Meta, 'accounts'>, opts?: {
67
+ export declare function resolveAccountSelection(explicit: string | undefined, agent: AgentId, meta: Pick<Meta, 'accounts' | 'deviceAccounts'>, opts?: {
58
68
  useDefault?: boolean;
59
69
  target?: string;
60
70
  }): string | undefined;
@@ -100,7 +110,7 @@ export type SpawnAccount = {
100
110
  * A native account bound to a different harness than the one being launched
101
111
  * fails loudly (EXEC-ACCOUNT-4). Returns null when nothing is selected.
102
112
  */
103
- export declare function resolveSpawnAccount(explicit: string | undefined, agent: AgentId, version: string | undefined, meta: Pick<Meta, 'accounts'>, opts?: {
113
+ export declare function resolveSpawnAccount(explicit: string | undefined, agent: AgentId, version: string | undefined, meta: Pick<Meta, 'accounts' | 'deviceAccounts'>, opts?: {
104
114
  useDefault?: boolean;
105
115
  provider?: string;
106
116
  base?: string;