@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.
- package/CHANGELOG.md +989 -0
- package/README.md +36 -10
- package/dist/bin/agents +0 -0
- package/dist/commands/apply.js +21 -3
- package/dist/commands/attach.d.ts +10 -0
- package/dist/commands/attach.js +41 -0
- package/dist/commands/computer.js +3 -3
- package/dist/commands/detach-core.d.ts +46 -0
- package/dist/commands/detach-core.js +61 -0
- package/dist/commands/detach.d.ts +5 -0
- package/dist/commands/detach.js +171 -0
- package/dist/commands/doctor.js +31 -1
- package/dist/commands/exec.js +31 -3
- package/dist/commands/export.d.ts +11 -0
- package/dist/commands/export.js +215 -0
- package/dist/commands/feed.d.ts +7 -1
- package/dist/commands/feed.js +47 -4
- package/dist/commands/go.d.ts +9 -2
- package/dist/commands/go.js +16 -6
- package/dist/commands/harness.d.ts +14 -0
- package/dist/commands/harness.js +145 -0
- package/dist/commands/import.js +87 -9
- package/dist/commands/inspect.js +5 -2
- package/dist/commands/profiles.d.ts +20 -0
- package/dist/commands/profiles.js +68 -21
- package/dist/commands/repo.js +14 -0
- package/dist/commands/routines.js +15 -2
- package/dist/commands/secrets.js +44 -21
- package/dist/commands/sessions-browser.js +1 -1
- package/dist/commands/sessions-export.js +19 -5
- package/dist/commands/sessions-migrate.d.ts +29 -0
- package/dist/commands/sessions-migrate.js +596 -0
- package/dist/commands/sessions-picker.js +16 -1
- package/dist/commands/sessions.d.ts +36 -13
- package/dist/commands/sessions.js +210 -40
- package/dist/commands/setup-computer.js +2 -2
- package/dist/commands/setup.js +6 -1
- package/dist/commands/ssh.js +71 -35
- package/dist/commands/status.js +10 -2
- package/dist/commands/versions.js +51 -11
- package/dist/commands/view.d.ts +5 -0
- package/dist/commands/view.js +99 -31
- package/dist/commands/watchdog.d.ts +7 -2
- package/dist/commands/watchdog.js +73 -57
- package/dist/index.js +59 -7
- package/dist/lib/activity.d.ts +14 -4
- package/dist/lib/activity.js +17 -5
- package/dist/lib/actor.d.ts +50 -0
- package/dist/lib/actor.js +166 -0
- package/dist/lib/agent-spec/provider.js +2 -1
- package/dist/lib/agent-spec/resolve.js +19 -5
- package/dist/lib/agent-spec/types.d.ts +9 -1
- package/dist/lib/agents.d.ts +56 -0
- package/dist/lib/agents.js +133 -7
- package/dist/lib/cloud/codex.d.ts +2 -0
- package/dist/lib/cloud/codex.js +14 -3
- package/dist/lib/cloud/session-index.d.ts +32 -0
- package/dist/lib/cloud/session-index.js +58 -0
- package/dist/lib/cloud/store.d.ts +7 -0
- package/dist/lib/cloud/store.js +25 -0
- package/dist/lib/config-transfer.d.ts +16 -0
- package/dist/lib/config-transfer.js +60 -0
- package/dist/lib/daemon.js +18 -2
- package/dist/lib/devices/connect.d.ts +15 -1
- package/dist/lib/devices/connect.js +15 -1
- package/dist/lib/devices/fleet.d.ts +21 -0
- package/dist/lib/devices/fleet.js +26 -0
- package/dist/lib/devices/health-report.d.ts +38 -0
- package/dist/lib/devices/health-report.js +214 -0
- package/dist/lib/devices/health.js +4 -1
- package/dist/lib/devices/reachability.d.ts +31 -0
- package/dist/lib/devices/reachability.js +40 -0
- package/dist/lib/devices/registry.d.ts +33 -0
- package/dist/lib/devices/registry.js +37 -0
- package/dist/lib/devices/resolve-target.d.ts +15 -27
- package/dist/lib/devices/resolve-target.js +63 -102
- package/dist/lib/devices/sync.d.ts +18 -0
- package/dist/lib/devices/sync.js +23 -1
- package/dist/lib/devices/tailscale.d.ts +3 -0
- package/dist/lib/devices/tailscale.js +1 -0
- package/dist/lib/events.d.ts +1 -1
- package/dist/lib/exec.d.ts +66 -0
- package/dist/lib/exec.js +195 -6
- package/dist/lib/export.d.ts +72 -0
- package/dist/lib/export.js +269 -0
- package/dist/lib/feed-post.d.ts +63 -0
- package/dist/lib/feed-post.js +204 -0
- package/dist/lib/fleet/apply.d.ts +32 -2
- package/dist/lib/fleet/apply.js +97 -10
- package/dist/lib/fleet/types.d.ts +11 -0
- package/dist/lib/fs-walk.d.ts +13 -0
- package/dist/lib/fs-walk.js +16 -7
- package/dist/lib/hooks.d.ts +1 -0
- package/dist/lib/hooks.js +156 -0
- package/dist/lib/hosts/dispatch.d.ts +7 -0
- package/dist/lib/hosts/dispatch.js +2 -0
- package/dist/lib/hosts/passthrough.js +2 -0
- package/dist/lib/hosts/reconnect.d.ts +78 -0
- package/dist/lib/hosts/reconnect.js +127 -0
- package/dist/lib/hosts/registry.d.ts +75 -14
- package/dist/lib/hosts/registry.js +205 -30
- package/dist/lib/hosts/remote-cmd.js +1 -0
- package/dist/lib/hosts/run-target.d.ts +13 -5
- package/dist/lib/hosts/run-target.js +26 -10
- package/dist/lib/hosts/session-index.d.ts +18 -1
- package/dist/lib/hosts/session-index.js +37 -2
- package/dist/lib/hosts/session-marker.d.ts +33 -0
- package/dist/lib/hosts/session-marker.js +51 -0
- package/dist/lib/hosts/tasks.d.ts +8 -4
- package/dist/lib/import.d.ts +25 -0
- package/dist/lib/import.js +107 -2
- package/dist/lib/isolation-boundary-report.d.ts +10 -0
- package/dist/lib/isolation-boundary-report.js +35 -0
- package/dist/lib/menubar/install-menubar.d.ts +20 -0
- package/dist/lib/menubar/install-menubar.js +45 -1
- package/dist/lib/migrate.d.ts +16 -0
- package/dist/lib/migrate.js +36 -0
- package/dist/lib/models.d.ts +27 -0
- package/dist/lib/models.js +54 -1
- package/dist/lib/overdue.d.ts +7 -2
- package/dist/lib/overdue.js +18 -11
- package/dist/lib/picker.js +4 -1
- package/dist/lib/platform/process.d.ts +17 -0
- package/dist/lib/platform/process.js +70 -0
- package/dist/lib/profiles-presets.js +9 -7
- package/dist/lib/profiles.d.ts +31 -0
- package/dist/lib/profiles.js +70 -0
- package/dist/lib/project-resources.js +20 -5
- package/dist/lib/pty-server.d.ts +2 -10
- package/dist/lib/pty-server.js +4 -38
- package/dist/lib/registry.d.ts +1 -1
- package/dist/lib/registry.js +48 -8
- package/dist/lib/rotate.d.ts +18 -0
- package/dist/lib/rotate.js +28 -0
- package/dist/lib/routines.d.ts +26 -0
- package/dist/lib/routines.js +19 -2
- package/dist/lib/runner.js +104 -7
- package/dist/lib/secrets/agent.d.ts +66 -1
- package/dist/lib/secrets/agent.js +154 -90
- package/dist/lib/secrets/bundles.d.ts +34 -0
- package/dist/lib/secrets/bundles.js +179 -19
- package/dist/lib/secrets/index.d.ts +15 -1
- package/dist/lib/secrets/index.js +21 -3
- package/dist/lib/secrets/rc-hygiene.d.ts +55 -0
- package/dist/lib/secrets/rc-hygiene.js +154 -0
- package/dist/lib/secrets/remote.d.ts +7 -4
- package/dist/lib/secrets/remote.js +7 -4
- package/dist/lib/secrets/sync-commands.d.ts +21 -0
- package/dist/lib/secrets/sync-commands.js +21 -0
- package/dist/lib/self-update.d.ts +23 -2
- package/dist/lib/self-update.js +86 -5
- package/dist/lib/session/active.d.ts +114 -7
- package/dist/lib/session/active.js +323 -52
- package/dist/lib/session/cloud.js +2 -1
- package/dist/lib/session/db.d.ts +48 -0
- package/dist/lib/session/db.js +238 -14
- package/dist/lib/session/detached.d.ts +30 -0
- package/dist/lib/session/detached.js +92 -0
- package/dist/lib/session/discover.d.ts +465 -2
- package/dist/lib/session/discover.js +1648 -468
- package/dist/lib/session/fork.js +2 -1
- package/dist/lib/session/hook-sessions.d.ts +43 -0
- package/dist/lib/session/hook-sessions.js +135 -0
- package/dist/lib/session/linear.d.ts +6 -0
- package/dist/lib/session/linear.js +55 -0
- package/dist/lib/session/migrate-targets.d.ts +65 -0
- package/dist/lib/session/migrate-targets.js +94 -0
- package/dist/lib/session/migrations.d.ts +37 -0
- package/dist/lib/session/migrations.js +60 -0
- package/dist/lib/session/parse.d.ts +18 -0
- package/dist/lib/session/parse.js +130 -30
- package/dist/lib/session/pid-registry.d.ts +34 -2
- package/dist/lib/session/pid-registry.js +49 -2
- package/dist/lib/session/render.d.ts +7 -2
- package/dist/lib/session/render.js +22 -10
- package/dist/lib/session/short-id.d.ts +17 -0
- package/dist/lib/session/short-id.js +20 -0
- package/dist/lib/session/state.js +6 -2
- package/dist/lib/shims.d.ts +27 -1
- package/dist/lib/shims.js +88 -6
- package/dist/lib/sqlite.d.ts +3 -2
- package/dist/lib/sqlite.js +27 -4
- package/dist/lib/staleness/detectors/commands.js +1 -1
- package/dist/lib/staleness/detectors/workflows.js +13 -2
- package/dist/lib/staleness/writers/commands.js +7 -7
- package/dist/lib/staleness/writers/workflows.d.ts +4 -2
- package/dist/lib/startup/command-registry.d.ts +2 -0
- package/dist/lib/startup/command-registry.js +5 -0
- package/dist/lib/state.d.ts +1 -1
- package/dist/lib/state.js +15 -36
- package/dist/lib/teams/agents.d.ts +19 -10
- package/dist/lib/teams/agents.js +40 -39
- package/dist/lib/types.d.ts +48 -2
- package/dist/lib/uninstall.js +1 -45
- package/dist/lib/usage.d.ts +33 -1
- package/dist/lib/usage.js +172 -12
- package/dist/lib/versions.d.ts +22 -2
- package/dist/lib/versions.js +98 -10
- package/dist/lib/watchdog/log.d.ts +43 -0
- package/dist/lib/watchdog/log.js +69 -0
- package/dist/lib/watchdog/routine.d.ts +44 -0
- package/dist/lib/watchdog/routine.js +69 -0
- package/dist/lib/watchdog/runner.d.ts +51 -7
- package/dist/lib/watchdog/runner.js +239 -64
- package/dist/lib/watchdog/watchdog.d.ts +1 -1
- package/dist/lib/watchdog/watchdog.js +31 -16
- package/dist/lib/workflows.d.ts +16 -0
- package/dist/lib/workflows.js +110 -1
- 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
|
|
670
|
-
//
|
|
671
|
-
//
|
|
672
|
-
|
|
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
|
-
|
|
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
|
+
}
|