@phnx-labs/agents-cli 1.20.73 → 1.20.76

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 (209) hide show
  1. package/CHANGELOG.md +989 -0
  2. package/README.md +36 -10
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/apply.js +21 -3
  5. package/dist/commands/attach.d.ts +10 -0
  6. package/dist/commands/attach.js +41 -0
  7. package/dist/commands/computer.js +3 -3
  8. package/dist/commands/detach-core.d.ts +46 -0
  9. package/dist/commands/detach-core.js +61 -0
  10. package/dist/commands/detach.d.ts +5 -0
  11. package/dist/commands/detach.js +171 -0
  12. package/dist/commands/doctor.js +31 -1
  13. package/dist/commands/exec.js +31 -3
  14. package/dist/commands/export.d.ts +11 -0
  15. package/dist/commands/export.js +215 -0
  16. package/dist/commands/feed.d.ts +7 -1
  17. package/dist/commands/feed.js +47 -4
  18. package/dist/commands/go.d.ts +9 -2
  19. package/dist/commands/go.js +16 -6
  20. package/dist/commands/harness.d.ts +14 -0
  21. package/dist/commands/harness.js +145 -0
  22. package/dist/commands/import.js +87 -9
  23. package/dist/commands/inspect.js +5 -2
  24. package/dist/commands/profiles.d.ts +20 -0
  25. package/dist/commands/profiles.js +68 -21
  26. package/dist/commands/repo.js +14 -0
  27. package/dist/commands/routines.js +15 -2
  28. package/dist/commands/secrets.js +44 -21
  29. package/dist/commands/sessions-browser.js +1 -1
  30. package/dist/commands/sessions-export.js +19 -5
  31. package/dist/commands/sessions-migrate.d.ts +29 -0
  32. package/dist/commands/sessions-migrate.js +596 -0
  33. package/dist/commands/sessions-picker.js +16 -1
  34. package/dist/commands/sessions.d.ts +36 -13
  35. package/dist/commands/sessions.js +210 -40
  36. package/dist/commands/setup-computer.js +2 -2
  37. package/dist/commands/setup.js +6 -1
  38. package/dist/commands/ssh.js +71 -35
  39. package/dist/commands/status.js +10 -2
  40. package/dist/commands/versions.js +51 -11
  41. package/dist/commands/view.d.ts +5 -0
  42. package/dist/commands/view.js +99 -31
  43. package/dist/commands/watchdog.d.ts +7 -2
  44. package/dist/commands/watchdog.js +73 -57
  45. package/dist/index.js +59 -7
  46. package/dist/lib/activity.d.ts +14 -4
  47. package/dist/lib/activity.js +17 -5
  48. package/dist/lib/actor.d.ts +50 -0
  49. package/dist/lib/actor.js +166 -0
  50. package/dist/lib/agent-spec/provider.js +2 -1
  51. package/dist/lib/agent-spec/resolve.js +19 -5
  52. package/dist/lib/agent-spec/types.d.ts +9 -1
  53. package/dist/lib/agents.d.ts +56 -0
  54. package/dist/lib/agents.js +133 -7
  55. package/dist/lib/cloud/codex.d.ts +2 -0
  56. package/dist/lib/cloud/codex.js +14 -3
  57. package/dist/lib/cloud/session-index.d.ts +32 -0
  58. package/dist/lib/cloud/session-index.js +58 -0
  59. package/dist/lib/cloud/store.d.ts +7 -0
  60. package/dist/lib/cloud/store.js +25 -0
  61. package/dist/lib/config-transfer.d.ts +16 -0
  62. package/dist/lib/config-transfer.js +60 -0
  63. package/dist/lib/daemon.js +18 -2
  64. package/dist/lib/devices/connect.d.ts +15 -1
  65. package/dist/lib/devices/connect.js +15 -1
  66. package/dist/lib/devices/fleet.d.ts +21 -0
  67. package/dist/lib/devices/fleet.js +26 -0
  68. package/dist/lib/devices/health-report.d.ts +38 -0
  69. package/dist/lib/devices/health-report.js +214 -0
  70. package/dist/lib/devices/health.js +4 -1
  71. package/dist/lib/devices/reachability.d.ts +31 -0
  72. package/dist/lib/devices/reachability.js +40 -0
  73. package/dist/lib/devices/registry.d.ts +33 -0
  74. package/dist/lib/devices/registry.js +37 -0
  75. package/dist/lib/devices/resolve-target.d.ts +15 -27
  76. package/dist/lib/devices/resolve-target.js +63 -102
  77. package/dist/lib/devices/sync.d.ts +18 -0
  78. package/dist/lib/devices/sync.js +23 -1
  79. package/dist/lib/devices/tailscale.d.ts +3 -0
  80. package/dist/lib/devices/tailscale.js +1 -0
  81. package/dist/lib/events.d.ts +1 -1
  82. package/dist/lib/exec.d.ts +66 -0
  83. package/dist/lib/exec.js +195 -6
  84. package/dist/lib/export.d.ts +72 -0
  85. package/dist/lib/export.js +269 -0
  86. package/dist/lib/feed-post.d.ts +63 -0
  87. package/dist/lib/feed-post.js +204 -0
  88. package/dist/lib/fleet/apply.d.ts +32 -2
  89. package/dist/lib/fleet/apply.js +97 -10
  90. package/dist/lib/fleet/types.d.ts +11 -0
  91. package/dist/lib/fs-walk.d.ts +13 -0
  92. package/dist/lib/fs-walk.js +16 -7
  93. package/dist/lib/hooks.d.ts +1 -0
  94. package/dist/lib/hooks.js +156 -0
  95. package/dist/lib/hosts/dispatch.d.ts +7 -0
  96. package/dist/lib/hosts/dispatch.js +2 -0
  97. package/dist/lib/hosts/passthrough.js +2 -0
  98. package/dist/lib/hosts/reconnect.d.ts +78 -0
  99. package/dist/lib/hosts/reconnect.js +127 -0
  100. package/dist/lib/hosts/registry.d.ts +75 -14
  101. package/dist/lib/hosts/registry.js +205 -30
  102. package/dist/lib/hosts/remote-cmd.js +1 -0
  103. package/dist/lib/hosts/run-target.d.ts +13 -5
  104. package/dist/lib/hosts/run-target.js +26 -10
  105. package/dist/lib/hosts/session-index.d.ts +18 -1
  106. package/dist/lib/hosts/session-index.js +37 -2
  107. package/dist/lib/hosts/session-marker.d.ts +33 -0
  108. package/dist/lib/hosts/session-marker.js +51 -0
  109. package/dist/lib/hosts/tasks.d.ts +8 -4
  110. package/dist/lib/import.d.ts +25 -0
  111. package/dist/lib/import.js +107 -2
  112. package/dist/lib/isolation-boundary-report.d.ts +10 -0
  113. package/dist/lib/isolation-boundary-report.js +35 -0
  114. package/dist/lib/menubar/install-menubar.d.ts +20 -0
  115. package/dist/lib/menubar/install-menubar.js +45 -1
  116. package/dist/lib/migrate.d.ts +16 -0
  117. package/dist/lib/migrate.js +36 -0
  118. package/dist/lib/models.d.ts +27 -0
  119. package/dist/lib/models.js +54 -1
  120. package/dist/lib/overdue.d.ts +7 -2
  121. package/dist/lib/overdue.js +18 -11
  122. package/dist/lib/picker.js +4 -1
  123. package/dist/lib/platform/process.d.ts +17 -0
  124. package/dist/lib/platform/process.js +70 -0
  125. package/dist/lib/profiles-presets.js +9 -7
  126. package/dist/lib/profiles.d.ts +31 -0
  127. package/dist/lib/profiles.js +70 -0
  128. package/dist/lib/project-resources.js +20 -5
  129. package/dist/lib/pty-server.d.ts +2 -10
  130. package/dist/lib/pty-server.js +4 -38
  131. package/dist/lib/registry.d.ts +1 -1
  132. package/dist/lib/registry.js +48 -8
  133. package/dist/lib/rotate.d.ts +18 -0
  134. package/dist/lib/rotate.js +28 -0
  135. package/dist/lib/routines.d.ts +26 -0
  136. package/dist/lib/routines.js +19 -2
  137. package/dist/lib/runner.js +104 -7
  138. package/dist/lib/secrets/agent.d.ts +66 -1
  139. package/dist/lib/secrets/agent.js +154 -90
  140. package/dist/lib/secrets/bundles.d.ts +34 -0
  141. package/dist/lib/secrets/bundles.js +179 -19
  142. package/dist/lib/secrets/index.d.ts +15 -1
  143. package/dist/lib/secrets/index.js +21 -3
  144. package/dist/lib/secrets/rc-hygiene.d.ts +55 -0
  145. package/dist/lib/secrets/rc-hygiene.js +154 -0
  146. package/dist/lib/secrets/remote.d.ts +7 -4
  147. package/dist/lib/secrets/remote.js +7 -4
  148. package/dist/lib/secrets/sync-commands.d.ts +21 -0
  149. package/dist/lib/secrets/sync-commands.js +21 -0
  150. package/dist/lib/self-update.d.ts +23 -2
  151. package/dist/lib/self-update.js +86 -5
  152. package/dist/lib/session/active.d.ts +114 -7
  153. package/dist/lib/session/active.js +323 -52
  154. package/dist/lib/session/cloud.js +2 -1
  155. package/dist/lib/session/db.d.ts +48 -0
  156. package/dist/lib/session/db.js +238 -14
  157. package/dist/lib/session/detached.d.ts +30 -0
  158. package/dist/lib/session/detached.js +92 -0
  159. package/dist/lib/session/discover.d.ts +465 -2
  160. package/dist/lib/session/discover.js +1648 -468
  161. package/dist/lib/session/fork.js +2 -1
  162. package/dist/lib/session/hook-sessions.d.ts +43 -0
  163. package/dist/lib/session/hook-sessions.js +135 -0
  164. package/dist/lib/session/linear.d.ts +6 -0
  165. package/dist/lib/session/linear.js +55 -0
  166. package/dist/lib/session/migrate-targets.d.ts +65 -0
  167. package/dist/lib/session/migrate-targets.js +94 -0
  168. package/dist/lib/session/migrations.d.ts +37 -0
  169. package/dist/lib/session/migrations.js +60 -0
  170. package/dist/lib/session/parse.d.ts +18 -0
  171. package/dist/lib/session/parse.js +130 -30
  172. package/dist/lib/session/pid-registry.d.ts +34 -2
  173. package/dist/lib/session/pid-registry.js +49 -2
  174. package/dist/lib/session/render.d.ts +7 -2
  175. package/dist/lib/session/render.js +22 -10
  176. package/dist/lib/session/short-id.d.ts +17 -0
  177. package/dist/lib/session/short-id.js +20 -0
  178. package/dist/lib/session/state.js +6 -2
  179. package/dist/lib/shims.d.ts +27 -1
  180. package/dist/lib/shims.js +88 -6
  181. package/dist/lib/sqlite.d.ts +3 -2
  182. package/dist/lib/sqlite.js +27 -4
  183. package/dist/lib/staleness/detectors/commands.js +1 -1
  184. package/dist/lib/staleness/detectors/workflows.js +13 -2
  185. package/dist/lib/staleness/writers/commands.js +7 -7
  186. package/dist/lib/staleness/writers/workflows.d.ts +4 -2
  187. package/dist/lib/startup/command-registry.d.ts +2 -0
  188. package/dist/lib/startup/command-registry.js +5 -0
  189. package/dist/lib/state.d.ts +1 -1
  190. package/dist/lib/state.js +15 -36
  191. package/dist/lib/teams/agents.d.ts +19 -10
  192. package/dist/lib/teams/agents.js +40 -39
  193. package/dist/lib/types.d.ts +48 -2
  194. package/dist/lib/uninstall.js +1 -45
  195. package/dist/lib/usage.d.ts +33 -1
  196. package/dist/lib/usage.js +172 -12
  197. package/dist/lib/versions.d.ts +22 -2
  198. package/dist/lib/versions.js +98 -10
  199. package/dist/lib/watchdog/log.d.ts +43 -0
  200. package/dist/lib/watchdog/log.js +69 -0
  201. package/dist/lib/watchdog/routine.d.ts +44 -0
  202. package/dist/lib/watchdog/routine.js +69 -0
  203. package/dist/lib/watchdog/runner.d.ts +51 -7
  204. package/dist/lib/watchdog/runner.js +239 -64
  205. package/dist/lib/watchdog/watchdog.d.ts +1 -1
  206. package/dist/lib/watchdog/watchdog.js +31 -16
  207. package/dist/lib/workflows.d.ts +16 -0
  208. package/dist/lib/workflows.js +110 -1
  209. package/package.json +1 -1
package/dist/lib/exec.js CHANGED
@@ -15,10 +15,13 @@ import { getBinaryPath, getVersionHomePath, isVersionInstalled, resolveVersion }
15
15
  import { resolveModel, buildReasoningFlags } from './models.js';
16
16
  import { maybeRotate, createTimer, redactPrompt, redactArgs } from './events.js';
17
17
  import { sanitizeProcessEnv } from './secrets/bundles.js';
18
+ import { resolveActor, actorEnv } from './actor.js';
18
19
  import { getShimsDir, getHistoryDir } from './state.js';
19
20
  import { resolveCodexHome } from './codex-home.js';
20
21
  import { readCodexConfiguredModel } from './shims.js';
21
22
  import { writePidSessionEntry, extractSessionIdArg } from './session/pid-registry.js';
23
+ import { loadHookSessionIndex, resolveHookSessionId } from './session/hook-sessions.js';
24
+ import { sessionIdMarkerLine } from './hosts/session-marker.js';
22
25
  import { recordRunName } from './session/run-names.js';
23
26
  import { mailboxDir, isValidMailboxId } from './mailbox.js';
24
27
  import { composeWin32CommandLine } from './platform/index.js';
@@ -312,14 +315,29 @@ export function buildExecEnv(options) {
312
315
  // iterations share one inbox.
313
316
  if (options.sessionId && isValidMailboxId(options.sessionId)) {
314
317
  result.AGENTS_MAILBOX_DIR = mailboxDir(options.sessionId);
318
+ // Full session id for agent-callable tools (`agents feed post`, etc.).
319
+ result.AGENT_SESSION_ID = options.sessionId;
320
+ result.AGENTS_SESSION_ID = options.sessionId;
315
321
  }
316
322
  result.AGENTS_RUNTIME = resolveInteractive(options) ? 'terminal' : 'headless';
323
+ // So activity / feed posts stamp the right harness without re-detecting.
324
+ if (options.agent) {
325
+ result.AGENTS_AGENT_NAME = options.agent;
326
+ }
327
+ if (options.cwd) {
328
+ result.AGENTS_CWD = options.cwd;
329
+ }
317
330
  // Export the run's durable name (companion to AGENT_SESSION_ID) so a
318
331
  // SessionStart hook / the agent can associate its transcript with the handle
319
332
  // the user gave the run. Only set when --name was passed.
320
333
  if (options.name) {
321
334
  result.AGENT_SESSION_NAME = options.name;
322
335
  }
336
+ // Actor provenance -- who initiated this run. Rides the env so the whole spawn
337
+ // tree shares one actor, and (for a resolved human) so the agent's own git
338
+ // commits are credited to the person instead of the shared account. options.env
339
+ // (spread last) overrides any of these keys a caller sets explicitly.
340
+ Object.assign(result, actorEnv(resolveActor()));
323
341
  return {
324
342
  ...result,
325
343
  ...options.env,
@@ -666,10 +684,16 @@ export function buildExecCommand(options) {
666
684
  else {
667
685
  cmd.push(...modeFlags);
668
686
  }
669
- // Add print/headless flags only when a prompt is provided. Without a prompt
670
- // the caller wants an interactive REPL -- passing --print would immediately
671
- // wait on stdin and never render the TUI.
672
- if (!interactive && options.headless && template.printFlags) {
687
+ // Add print/headless flags whenever the run RESOLVED headless -- gate on the
688
+ // resolved state (`!interactive`), not the raw `--headless` flag. Headless is
689
+ // inferred from prompt presence (resolveInteractive), and `--headless` defaults
690
+ // to `false` at the CLI layer, so gating on `options.headless` skipped these
691
+ // flags for a bare `agents run <agent> "prompt"`. For antigravity that meant
692
+ // `agy <prompt>` with no `--print`, launching the TUI and dying on `/dev/tty`
693
+ // in any non-terminal shell. `!interactive` is true for both an explicit
694
+ // `--headless` and a prompt-inferred headless run; an interactive run (no
695
+ // prompt, or `--interactive`) still skips them so the CLI opens its REPL.
696
+ if (!interactive && template.printFlags) {
673
697
  cmd.push(...template.printFlags);
674
698
  }
675
699
  // Resume vs. create. With `resume`, emit the agent's NATIVE resume reference:
@@ -842,9 +866,15 @@ export async function execShimPassthrough(agent, rawArgs, cwd, pinnedVersion) {
842
866
  }
843
867
  // The only flag the bash shim injects (codex); everything else is transparent.
844
868
  const launchArgs = agent === 'codex' ? ['-c', 'check_for_update_on_startup=false'] : [];
869
+ // Mint a launch id and export it as AGENT_LAUNCH_ID so the agent's SessionStart
870
+ // hook records the same id — the join key that maps this launch to its exact
871
+ // session even though the recorded pid here is the cmd.exe wrapper (Windows) or
872
+ // a shell, while the hook runs under the agent descendant. This is the primary
873
+ // attribution path on Windows (no lsof), so the launchId join matters most here.
874
+ const launchId = randomUUID();
845
875
  // mode/effort are required by ExecOptions but unused by buildExecEnv (which only
846
876
  // derives the per-version config-dir env); pass the agent's default to satisfy the type.
847
- const env = buildExecEnv({ agent, version, cwd, mode: defaultModeFor(agent), effort: 'auto' });
877
+ const env = buildExecEnv({ agent, version, cwd, mode: defaultModeFor(agent), effort: 'auto', env: { AGENT_LAUNCH_ID: launchId } });
848
878
  const { command, args, shell } = resolveShimSpawn(process.platform, binary, [...launchArgs, ...rawArgs]);
849
879
  return new Promise((resolve) => {
850
880
  const child = spawn(command, args, { cwd, stdio: 'inherit', env, shell });
@@ -860,6 +890,8 @@ export async function execShimPassthrough(agent, rawArgs, cwd, pinnedVersion) {
860
890
  agent,
861
891
  sessionId: extractSessionIdArg(rawArgs),
862
892
  cwd,
893
+ launchId,
894
+ terminalId: process.env.AGENT_TERMINAL_ID,
863
895
  startedAtMs: Date.now(),
864
896
  });
865
897
  }
@@ -1008,6 +1040,12 @@ async function runInTmux(options, executable, args) {
1008
1040
  agent: options.agent,
1009
1041
  sessionId: options.sessionId,
1010
1042
  cwd,
1043
+ // spawnAgent injected AGENT_LAUNCH_ID into options.env before delegating
1044
+ // here; record the same id so the hook (running under the pane-leaf agent
1045
+ // pid) reconciles by launchId. This pane's pid usually IS the agent pid,
1046
+ // but the launchId join is robust even when it isn't.
1047
+ launchId: options.env?.AGENT_LAUNCH_ID,
1048
+ terminalId: process.env.AGENT_TERMINAL_ID,
1011
1049
  tmuxPane: pane,
1012
1050
  startedAtMs: Date.now(),
1013
1051
  });
@@ -1066,6 +1104,36 @@ async function runInTmux(options, executable, args) {
1066
1104
  // Pane still alive → the user detached; keep the session for `agents focus`.
1067
1105
  return { exitCode: 0, stderr: '', stdout: '' };
1068
1106
  }
1107
+ /**
1108
+ * Print the run's resolved session id as a one-line stdout sentinel so a `--host`
1109
+ * launcher can relate the remote-created session back to itself (see the
1110
+ * `emitSessionId` option and hosts/session-marker.ts).
1111
+ *
1112
+ * For Claude the id was forced up front (`options.sessionId`, wired as
1113
+ * `--session-id`), so it's authoritative with no lookup. Every other agent coined
1114
+ * its OWN id, which its SessionStart hook recorded under this run's launchId — the
1115
+ * exact join key `agents sessions --active` uses — so we read it back from the hook
1116
+ * index by launchId (falling back to the child pid the hook may have recorded
1117
+ * under). Nothing to emit when the hook hasn't landed an id (hookless harness):
1118
+ * the launcher simply keeps the un-mapped task, never a fabricated id.
1119
+ */
1120
+ function emitResolvedSessionId(options, launchId, childPid) {
1121
+ let sessionId = options.sessionId;
1122
+ if (!sessionId) {
1123
+ try {
1124
+ sessionId = resolveHookSessionId(loadHookSessionIndex(), {
1125
+ pid: childPid ?? 0,
1126
+ kind: options.agent,
1127
+ launchId,
1128
+ });
1129
+ }
1130
+ catch {
1131
+ /* hook index unreadable — emit nothing rather than a guess */
1132
+ }
1133
+ }
1134
+ if (sessionId)
1135
+ process.stdout.write(sessionIdMarkerLine(sessionId));
1136
+ }
1069
1137
  /**
1070
1138
  * Spawn an agent process and return its exit code plus a tee'd copy of stderr.
1071
1139
  *
@@ -1104,7 +1172,17 @@ async function spawnAgent(options) {
1104
1172
  // timeout. Spend is recorded to the shared ledger in the close handler. The
1105
1173
  // watcher is dormant (and zero-cost) when no caps are configured.
1106
1174
  const cwd = options.cwd || process.cwd();
1107
- const runId = randomUUID();
1175
+ // Mint the launch id once. It doubles as the budget watcher's run id AND is
1176
+ // exported to the child as AGENT_LAUNCH_ID, so the agent's SessionStart hook
1177
+ // records the SAME id in its own state file (terminals/sessions/<pid>.json).
1178
+ // That id is the join key that reconciles this launch's pid-registry entry
1179
+ // with the hook's authoritative session id even when the hook runs under a
1180
+ // different pid (tmux pane leaf / cmd.exe wrapper) — see pid-registry.ts and
1181
+ // session/hook-sessions.ts. Injected into options.env so every downstream env
1182
+ // build (the bare spawn below AND the tmux env prefix in runInTmux) carries it.
1183
+ const launchId = randomUUID();
1184
+ const runId = launchId;
1185
+ options = { ...options, env: { ...options.env, AGENT_LAUNCH_ID: launchId } };
1108
1186
  const watcherState = await setupBudgetWatcher(options, cwd, runId);
1109
1187
  maybeRotate();
1110
1188
  const timer = createTimer('agent.run', {
@@ -1178,6 +1256,8 @@ async function spawnAgent(options) {
1178
1256
  agent: options.agent,
1179
1257
  sessionId: options.sessionId,
1180
1258
  cwd: options.cwd || process.cwd(),
1259
+ launchId,
1260
+ terminalId: process.env.AGENT_TERMINAL_ID,
1181
1261
  tmuxPane: process.env.TMUX_PANE,
1182
1262
  startedAtMs: Date.now(),
1183
1263
  });
@@ -1264,6 +1344,12 @@ async function spawnAgent(options) {
1264
1344
  // Budget kill resolves with a DISTINCT non-zero exit so CI/headless and
1265
1345
  // teams/cloud can tell a budget termination apart from a normal failure.
1266
1346
  const exitCode = budgetKilled ? BUDGET_KILL_EXIT_CODE : (code ?? 0);
1347
+ // Relate the session id back to a `--host` launcher (see the emitSessionId
1348
+ // doc). Claude's id is the one we forced; every other agent coined its own,
1349
+ // which the SessionStart hook recorded under this run's launchId — resolve
1350
+ // and print it as a one-line sentinel that rides the followed log home.
1351
+ if (options.emitSessionId)
1352
+ emitResolvedSessionId(options, launchId, child.pid);
1267
1353
  timer.end({ exitCode, status: budgetKilled ? 'budget_killed' : code === 0 ? 'success' : 'failed' });
1268
1354
  resolve({ exitCode, stderr: stderrBuffer, stdout: stdoutTail });
1269
1355
  });
@@ -1358,6 +1444,109 @@ export const RATE_LIMIT_PATTERNS = [
1358
1444
  export function detectRateLimit(text) {
1359
1445
  return RATE_LIMIT_PATTERNS.some(pattern => pattern.test(text));
1360
1446
  }
1447
+ /**
1448
+ * Patterns that indicate an authentication failure — the agent is logged out,
1449
+ * its token was revoked, or the session expired. These are the user-visible
1450
+ * strings a logged-out agent surfaces (observed across the routine-run corpus).
1451
+ * Unlike a rate limit, an auth failure is NOT self-healing by failover — every
1452
+ * chain entry on the same account fails identically — so it is classified
1453
+ * separately and never triggers a fallback attempt.
1454
+ *
1455
+ * The bare `401` is deliberately paired with an auth keyword: a plain "401" can
1456
+ * appear in legitimate output (an HTTP-status table, a log line), so it only
1457
+ * counts when it co-occurs with OAuth/authentication/credentials/Unauthorized.
1458
+ */
1459
+ export const AUTH_FAILURE_PATTERNS = [
1460
+ /OAuth (?:access token has been revoked|session expired)/i,
1461
+ /(?:Please run|run) \/login/i,
1462
+ /\bNot logged in\b/i,
1463
+ /Invalid authentication credentials/i,
1464
+ /Failed to authenticate/i,
1465
+ /organization has (?:disabled|revoked) .*(?:subscription|access)/i,
1466
+ /401\b[^\n]*(?:OAuth|authenticat|credential|Unauthorized)/i,
1467
+ ];
1468
+ /**
1469
+ * Return true if the text contains any known authentication-failure indicator.
1470
+ * Agent-agnostic: matches the user-visible error string wherever it surfaces
1471
+ * (stdout tail, a captured error message, a plain-text agent's output).
1472
+ */
1473
+ export function detectAuthFailure(text) {
1474
+ return AUTH_FAILURE_PATTERNS.some(pattern => pattern.test(text));
1475
+ }
1476
+ /**
1477
+ * Return true if a stream-json log carries the structural markers of an auth
1478
+ * failure. This is the authoritative signal for Claude: a logged-out run emits
1479
+ * {"type":"system","subtype":"api_retry","error":"authentication_failed",…}
1480
+ * {"type":"assistant",…,"error":"authentication_failed"}
1481
+ * {"type":"result","is_error":true,"result":"Failed to authenticate…"}
1482
+ * Note `terminal_reason` is "completed" on such a run, so exit-code / terminal-
1483
+ * reason logic can never catch it — the `error:"authentication_failed"` marker
1484
+ * and the `result`+`is_error` text are the reliable signals.
1485
+ *
1486
+ * Gated on the Claude stream-json shape; other agents don't emit these fields,
1487
+ * so callers pass their agent and this returns false for non-claude.
1488
+ */
1489
+ export function detectAuthFailureEvent(logText, agent) {
1490
+ if (agent !== 'claude')
1491
+ return false;
1492
+ const lines = logText.split('\n');
1493
+ for (const line of lines) {
1494
+ const trimmed = line.trim();
1495
+ if (!trimmed || trimmed[0] !== '{')
1496
+ continue;
1497
+ let parsed;
1498
+ try {
1499
+ parsed = JSON.parse(trimmed);
1500
+ }
1501
+ catch {
1502
+ continue;
1503
+ }
1504
+ if (parsed?.error === 'authentication_failed')
1505
+ return true;
1506
+ if (parsed?.type === 'result' &&
1507
+ parsed?.is_error === true &&
1508
+ typeof parsed?.result === 'string' &&
1509
+ detectAuthFailure(parsed.result)) {
1510
+ return true;
1511
+ }
1512
+ }
1513
+ return false;
1514
+ }
1515
+ /**
1516
+ * Return the first human-readable auth-failure phrase found in the text, for use
1517
+ * as a stable, short run `errorMessage` reason. Falls back to null when the only
1518
+ * signal was the structural `error:"authentication_failed"` marker with no
1519
+ * user-visible string.
1520
+ */
1521
+ export function authFailureReason(text) {
1522
+ for (const pattern of AUTH_FAILURE_PATTERNS) {
1523
+ const m = text.match(pattern);
1524
+ if (m)
1525
+ return m[0];
1526
+ }
1527
+ return null;
1528
+ }
1529
+ /**
1530
+ * Decide whether a run's stream-json log is an authentication failure. The
1531
+ * single source of truth for both the foreground and detached run paths.
1532
+ *
1533
+ * The structural marker (`detectAuthFailureEvent`, gated on `is_error:true`) is
1534
+ * authoritative and safe on ANY exit — it catches a logged-out Claude that exits
1535
+ * 0 (its `result` event carries `is_error:true` while `terminal_reason` is
1536
+ * "completed"). The raw user-visible string (`detectAuthFailure`) is only
1537
+ * consulted when the process actually FAILED, as a fallback for a run that died
1538
+ * mid-stream before emitting a `result` event. That gate is what prevents a
1539
+ * genuinely-completed run whose output merely *mentions* an auth phrase (e.g. a
1540
+ * routine documenting a login flow) from being misclassified and having its
1541
+ * legitimate report suppressed.
1542
+ */
1543
+ export function isAuthFailureFromLog(logText, agent, opts) {
1544
+ if (detectAuthFailureEvent(logText, agent))
1545
+ return true;
1546
+ if (opts.processFailed && detectAuthFailure(logText))
1547
+ return true;
1548
+ return false;
1549
+ }
1361
1550
  /**
1362
1551
  * Build the prompt handed to the fallback agent when the primary was stopped
1363
1552
  * mid-task by a rate limit.
@@ -0,0 +1,72 @@
1
+ import type { AgentId } from './types.js';
2
+ export type ExportMode = 'merge' | 'replace' | 'staged';
3
+ /** Filename of the provenance receipt written into the agent's config dir. */
4
+ export declare const RECEIPT_NAME = ".agents-cli-export.json";
5
+ /** Suffix for the incoming copy of a file the user already has. */
6
+ export declare const CONFLICT_SUFFIX = ".from-agents-cli";
7
+ /** Why an export cannot proceed. Each maps to a distinct user-facing remedy. */
8
+ export type ExportBlocker = {
9
+ kind: 'not-installed';
10
+ } | {
11
+ kind: 'not-isolated';
12
+ } | {
13
+ kind: 'no-config';
14
+ source: string;
15
+ } | {
16
+ kind: 'dest-adopted';
17
+ realPath: string;
18
+ adoptedVersion: string;
19
+ };
20
+ /** What the destination `~/.<agent>` currently is. */
21
+ export type DestKind = 'absent' | 'real-dir' | 'foreign-symlink';
22
+ /** One file the export would place, relative to the config dir root. */
23
+ export interface ExportEntry {
24
+ /** Path relative to the config dir, e.g. `prompts/review.md`. */
25
+ rel: string;
26
+ /** Absolute source file inside the isolated home. */
27
+ source: string;
28
+ /** Absolute path this file will be written to. */
29
+ target: string;
30
+ /**
31
+ * Set when the user already has `rel`. `target` then points at the
32
+ * `.from-agents-cli` sibling, and `existing` is the untouched original.
33
+ */
34
+ existing?: string;
35
+ }
36
+ export interface ExportPlan {
37
+ agent: AgentId;
38
+ version: string;
39
+ mode: ExportMode;
40
+ /** The isolated config dir, e.g. `<versionDir>/home/.codex`. */
41
+ source: string;
42
+ /** The user's real config dir, e.g. `~/.codex`. */
43
+ dest: string;
44
+ destKind: DestKind;
45
+ /** Files with no counterpart in `dest` — written in place. */
46
+ writes: ExportEntry[];
47
+ /** Files the user already has — written alongside, never over. */
48
+ conflicts: ExportEntry[];
49
+ /** replace mode only: where `dest` gets moved first. */
50
+ backupPath: string | null;
51
+ /** staged mode only: the subdirectory receiving the whole tree. */
52
+ stagedPath: string | null;
53
+ receiptPath: string;
54
+ blocker: ExportBlocker | null;
55
+ }
56
+ export interface ExportResult {
57
+ exported: boolean;
58
+ dest: string;
59
+ written: string[];
60
+ conflicts: Array<{
61
+ path: string;
62
+ theirs: string;
63
+ }>;
64
+ backupPath: string | null;
65
+ stagedPath: string | null;
66
+ receiptPath: string | null;
67
+ errors: string[];
68
+ }
69
+ /** Build a read-only plan. Performs no mutations. */
70
+ export declare function planExport(agent: AgentId, version: string, timestamp: number, mode?: ExportMode): ExportPlan;
71
+ /** Execute a plan from {@link planExport}. */
72
+ export declare function executeExport(plan: ExportPlan, timestamp: number): ExportResult;
@@ -0,0 +1,269 @@
1
+ /**
2
+ * `agents export` — copy an ISOLATED install's config back out to the user's real
3
+ * `~/.<agent>`, so they can keep their settings and delete agents-cli entirely.
4
+ *
5
+ * Isolation is currently a one-way door: `agents add <agent>@<v> --isolated` builds a
6
+ * self-contained home under the version dir, and nothing ever brings that work back.
7
+ * Reversibility is what makes a sandbox safe to try.
8
+ *
9
+ * Three modes, because "export" means different things depending on which config you
10
+ * consider authoritative:
11
+ *
12
+ * merge (default) — additive. Copy only paths the user doesn't already have. A
13
+ * collision is NOT silently skipped: the incoming file is written
14
+ * beside theirs as `<name>.from-agents-cli` so they can diff and
15
+ * take the parts they want. Nothing of theirs is ever modified.
16
+ * replace — the isolated config becomes `~/.<agent>`; theirs moves to
17
+ * `backups/<agent>/<ts>`. For when you've been living in the
18
+ * sandbox and want to promote it wholesale.
19
+ * staged — write the whole tree to `~/.<agent>/.agents-export-<ts>/` and
20
+ * activate nothing. For inspecting first.
21
+ *
22
+ * Every mode leaves a receipt at `~/.<agent>/.agents-cli-export.json` recording what
23
+ * came from the export. That is what makes provenance answerable ("which of these
24
+ * files are mine?") and the whole operation reversible.
25
+ *
26
+ * Deliberately NOT implemented: key-level merging of file *contents*. The TOML
27
+ * library here does not preserve comments across parse+stringify, so auto-merging a
28
+ * `config.toml` would silently delete the user's comments. Handing over both files
29
+ * and a diff is honest; rewriting their config behind a "merge" flag is not.
30
+ *
31
+ * Split into a read-only {@link planExport} and a mutating {@link executeExport} so
32
+ * `--dry-run` and the real run share one code path, mirroring `lib/uninstall.ts`.
33
+ */
34
+ import * as fs from 'fs';
35
+ import * as path from 'path';
36
+ import { AGENTS } from './agents.js';
37
+ import { getAgentConfigPath, getConfigSymlinkVersion } from './shims.js';
38
+ import { getVersionHomePath, isVersionIsolated, isVersionInstalled } from './versions.js';
39
+ import { getUserAgentsDir, getBackupsDir } from './state.js';
40
+ import { copyDirStrippingAgentsSymlinks, moveDirCrossDevice } from './config-transfer.js';
41
+ /** Filename of the provenance receipt written into the agent's config dir. */
42
+ export const RECEIPT_NAME = '.agents-cli-export.json';
43
+ /** Suffix for the incoming copy of a file the user already has. */
44
+ export const CONFLICT_SUFFIX = '.from-agents-cli';
45
+ /** The config dir's basename (`.codex`), used to locate it inside a version home. */
46
+ function configBasename(agent) {
47
+ return path.basename(AGENTS[agent].configDir);
48
+ }
49
+ /**
50
+ * Every regular file under `root`, as paths relative to it. Symlinks pointing back
51
+ * into `~/.agents` are omitted for the same reason `copyDirStrippingAgentsSymlinks`
52
+ * drops them: they dangle the moment `~/.agents` is disposed, and an export that
53
+ * leaves dangling links is not an export.
54
+ */
55
+ function walkFiles(root, agentsDir, rel = '') {
56
+ const inside = agentsDir + path.sep;
57
+ const out = [];
58
+ let entries;
59
+ try {
60
+ entries = fs.readdirSync(path.join(root, rel), { withFileTypes: true });
61
+ }
62
+ catch {
63
+ return out;
64
+ }
65
+ for (const entry of entries) {
66
+ const childRel = rel ? path.join(rel, entry.name) : entry.name;
67
+ const abs = path.join(root, childRel);
68
+ if (entry.isSymbolicLink()) {
69
+ try {
70
+ const tgt = path.resolve(path.dirname(abs), fs.readlinkSync(abs));
71
+ if (tgt === agentsDir || tgt.startsWith(inside))
72
+ continue;
73
+ }
74
+ catch {
75
+ continue;
76
+ }
77
+ out.push(childRel);
78
+ continue;
79
+ }
80
+ if (entry.isDirectory()) {
81
+ out.push(...walkFiles(root, agentsDir, childRel));
82
+ continue;
83
+ }
84
+ out.push(childRel);
85
+ }
86
+ return out;
87
+ }
88
+ /** Build a read-only plan. Performs no mutations. */
89
+ export function planExport(agent, version, timestamp, mode = 'merge') {
90
+ const source = path.join(getVersionHomePath(agent, version), configBasename(agent));
91
+ const dest = getAgentConfigPath(agent);
92
+ const base = {
93
+ agent,
94
+ version,
95
+ mode,
96
+ source,
97
+ dest,
98
+ destKind: 'absent',
99
+ writes: [],
100
+ conflicts: [],
101
+ backupPath: null,
102
+ stagedPath: null,
103
+ receiptPath: path.join(dest, RECEIPT_NAME),
104
+ };
105
+ if (!isVersionInstalled(agent, version))
106
+ return { ...base, blocker: { kind: 'not-installed' } };
107
+ if (!isVersionIsolated(agent, version))
108
+ return { ...base, blocker: { kind: 'not-isolated' } };
109
+ if (!fs.existsSync(source))
110
+ return { ...base, blocker: { kind: 'no-config', source } };
111
+ // Refuse when `~/.<agent>` is a symlink agents-cli owns: the agent has a normal
112
+ // install that adopted it, so writing there would land inside a version home
113
+ // rather than in the user's real config, and silently mutate that other install.
114
+ const adopted = getConfigSymlinkVersion(agent);
115
+ if (adopted !== null) {
116
+ return { ...base, blocker: { kind: 'dest-adopted', realPath: dest, adoptedVersion: adopted } };
117
+ }
118
+ let destKind = 'absent';
119
+ try {
120
+ destKind = fs.lstatSync(dest).isSymbolicLink() ? 'foreign-symlink' : 'real-dir';
121
+ }
122
+ catch {
123
+ destKind = 'absent';
124
+ }
125
+ const agentsDir = getUserAgentsDir();
126
+ const rels = walkFiles(source, agentsDir).sort();
127
+ if (mode === 'staged') {
128
+ const stagedPath = path.join(dest, `.agents-export-${timestamp}`);
129
+ return {
130
+ ...base,
131
+ destKind,
132
+ stagedPath,
133
+ writes: rels.map((rel) => ({
134
+ rel,
135
+ source: path.join(source, rel),
136
+ target: path.join(stagedPath, rel),
137
+ })),
138
+ blocker: null,
139
+ };
140
+ }
141
+ if (mode === 'replace') {
142
+ return {
143
+ ...base,
144
+ destKind,
145
+ backupPath: destKind === 'real-dir' ? path.join(getBackupsDir(), agent, String(timestamp)) : null,
146
+ writes: rels.map((rel) => ({
147
+ rel,
148
+ source: path.join(source, rel),
149
+ target: path.join(dest, rel),
150
+ })),
151
+ blocker: null,
152
+ };
153
+ }
154
+ // merge: additive. Anything the user already has becomes a conflict written
155
+ // alongside, so their file is never the thing that changes.
156
+ const writes = [];
157
+ const conflicts = [];
158
+ for (const rel of rels) {
159
+ const target = path.join(dest, rel);
160
+ let exists = false;
161
+ try {
162
+ fs.lstatSync(target);
163
+ exists = true;
164
+ }
165
+ catch {
166
+ exists = false;
167
+ }
168
+ if (exists) {
169
+ conflicts.push({
170
+ rel,
171
+ source: path.join(source, rel),
172
+ target: target + CONFLICT_SUFFIX,
173
+ existing: target,
174
+ });
175
+ }
176
+ else {
177
+ writes.push({ rel, source: path.join(source, rel), target });
178
+ }
179
+ }
180
+ return { ...base, destKind, writes, conflicts, blocker: null };
181
+ }
182
+ /** Copy one file, creating parent dirs. Symlinks are recreated, not followed. */
183
+ function placeFile(entry) {
184
+ fs.mkdirSync(path.dirname(entry.target), { recursive: true });
185
+ const st = fs.lstatSync(entry.source);
186
+ if (st.isSymbolicLink()) {
187
+ const link = fs.readlinkSync(entry.source);
188
+ try {
189
+ fs.unlinkSync(entry.target);
190
+ }
191
+ catch {
192
+ /* nothing there */
193
+ }
194
+ fs.symlinkSync(link, entry.target);
195
+ return;
196
+ }
197
+ fs.copyFileSync(entry.source, entry.target);
198
+ }
199
+ function writeReceipt(plan, result, timestamp) {
200
+ const receipt = {
201
+ exportedAt: new Date(timestamp).toISOString(),
202
+ from: `${plan.agent}@${plan.version} (isolated)`,
203
+ mode: plan.mode,
204
+ written: result.written,
205
+ conflicts: result.conflicts,
206
+ ...(result.backupPath ? { backupPath: result.backupPath } : {}),
207
+ ...(result.stagedPath ? { stagedAt: result.stagedPath } : {}),
208
+ note: plan.mode === 'merge'
209
+ ? `Files under "written" came from this export. Paths under "conflicts" already existed and were NOT modified — the incoming version sits beside yours with the ${CONFLICT_SUFFIX} suffix.`
210
+ : plan.mode === 'replace'
211
+ ? 'This config was replaced wholesale by the export; the previous one is at backupPath.'
212
+ : 'Nothing was activated — the exported tree sits under stagedAt.',
213
+ };
214
+ fs.mkdirSync(path.dirname(plan.receiptPath), { recursive: true });
215
+ fs.writeFileSync(plan.receiptPath, JSON.stringify(receipt, null, 2) + '\n');
216
+ }
217
+ /** Execute a plan from {@link planExport}. */
218
+ export function executeExport(plan, timestamp) {
219
+ const result = {
220
+ exported: false,
221
+ dest: plan.dest,
222
+ written: [],
223
+ conflicts: [],
224
+ backupPath: null,
225
+ stagedPath: null,
226
+ receiptPath: null,
227
+ errors: [],
228
+ };
229
+ if (plan.blocker) {
230
+ result.errors.push(`refusing to export: ${plan.blocker.kind}`);
231
+ return result;
232
+ }
233
+ try {
234
+ if (plan.mode === 'replace') {
235
+ if (plan.backupPath) {
236
+ fs.mkdirSync(path.dirname(plan.backupPath), { recursive: true });
237
+ moveDirCrossDevice(plan.dest, plan.backupPath);
238
+ result.backupPath = plan.backupPath;
239
+ }
240
+ else if (plan.destKind === 'foreign-symlink') {
241
+ // Not ours to back up, but it must go or cpSync would write through it.
242
+ fs.unlinkSync(plan.dest);
243
+ }
244
+ copyDirStrippingAgentsSymlinks(plan.source, plan.dest, getUserAgentsDir());
245
+ result.written = plan.writes.map((w) => w.rel);
246
+ }
247
+ else {
248
+ // merge and staged both place individual files; neither disturbs anything
249
+ // the user already has.
250
+ for (const entry of plan.writes) {
251
+ placeFile(entry);
252
+ result.written.push(entry.rel);
253
+ }
254
+ for (const entry of plan.conflicts) {
255
+ placeFile(entry);
256
+ result.conflicts.push({ path: entry.rel, theirs: entry.target });
257
+ }
258
+ if (plan.stagedPath)
259
+ result.stagedPath = plan.stagedPath;
260
+ }
261
+ writeReceipt(plan, result, timestamp);
262
+ result.receiptPath = plan.receiptPath;
263
+ result.exported = true;
264
+ }
265
+ catch (err) {
266
+ result.errors.push(err instanceof Error ? err.message : String(err));
267
+ }
268
+ return result;
269
+ }