talon-agent 5.26.3 → 5.26.5

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 (35) hide show
  1. package/package.json +1 -1
  2. package/src/app.ts +8 -0
  3. package/src/backend/agy/auth.ts +2 -2
  4. package/src/backend/agy/mcp/config.ts +3 -3
  5. package/src/backend/claude-sdk/usage/plan-usage.ts +2 -2
  6. package/src/backend/codex/discovery.ts +2 -2
  7. package/src/backend/codex/plan-usage.ts +2 -2
  8. package/src/backend/codex/token-usage.ts +2 -2
  9. package/src/backend/remote-server/sessions.ts +82 -13
  10. package/src/backend/runtime/turn/handle-retry.ts +6 -5
  11. package/src/cli/commands/backup.ts +7 -3
  12. package/src/core/auth/status.ts +3 -3
  13. package/src/core/backup/archive/verify.ts +53 -0
  14. package/src/core/backup/plan.ts +7 -4
  15. package/src/core/backup/restore.ts +3 -3
  16. package/src/core/backup/retention/policy.ts +195 -0
  17. package/src/core/backup/scheduler.ts +10 -4
  18. package/src/core/backup/snapshot.ts +6 -2
  19. package/src/core/backup/sources/sessions.ts +29 -0
  20. package/src/core/backup/status.ts +7 -0
  21. package/src/core/backup/store.ts +20 -25
  22. package/src/core/backup/types.ts +14 -0
  23. package/src/core/backup/upload.ts +37 -20
  24. package/src/core/config/index.ts +26 -1
  25. package/src/core/engine/gateway-actions/native/params.ts +3 -3
  26. package/src/core/errors.ts +13 -2
  27. package/src/core/layout/claude-relink.ts +131 -0
  28. package/src/core/layout/index.ts +170 -0
  29. package/src/core/layout/mounts.ts +129 -0
  30. package/src/core/layout/persistence.ts +132 -0
  31. package/src/frontend/presentation/backup-panel.ts +7 -1
  32. package/src/plugins/mempalace/provision.ts +4 -4
  33. package/src/plugins/playwright/provision.ts +2 -2
  34. package/src/util/fs-path.ts +103 -3
  35. package/src/util/paths.ts +6 -2
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "talon-agent",
3
- "version": "5.26.3",
3
+ "version": "5.26.5",
4
4
  "description": "Multi-frontend AI agent with full tool access, streaming, cron jobs, and plugin system",
5
5
  "author": "The Falconry",
6
6
  "license": "Apache-2.0",
package/src/app.ts CHANGED
@@ -212,6 +212,14 @@ const { config } = await withConfigGuard(() =>
212
212
  ),
213
213
  );
214
214
 
215
+ // Before any backend resumes a session: carry Claude transcripts over a
216
+ // Talon-home move (their project slug is the cwd), and in a container
217
+ // alert when a backend's session store isn't on a persistent volume.
218
+ await bootPhase("storage layout", async () => {
219
+ const { runStorageLayoutChecks } = await import("./core/layout/index.js");
220
+ await runStorageLayoutChecks(config as unknown as Record<string, unknown>);
221
+ });
222
+
215
223
  // Record this process as the daemon. The gateway port is appended once
216
224
  // the gateway binds (it may fall back from the default on EADDRINUSE).
217
225
  const bootedAt = new Date().toISOString();
@@ -14,16 +14,16 @@
14
14
  * the host.
15
15
  */
16
16
 
17
- import { homedir } from "node:os";
18
17
  import { join } from "node:path";
19
18
  import { readFileSync } from "node:fs";
19
+ import { userHome } from "../../util/fs-path.js";
20
20
 
21
21
  /** Where the CLI caches its OAuth token. Env override for tests. */
22
22
  function agyTokenPath(override?: string): string {
23
23
  return (
24
24
  override ||
25
25
  process.env.TALON_AGY_TOKEN_FILE ||
26
- join(homedir(), ".gemini", "antigravity-cli", "antigravity-oauth-token")
26
+ join(userHome(), ".gemini", "antigravity-cli", "antigravity-oauth-token")
27
27
  );
28
28
  }
29
29
 
@@ -28,7 +28,6 @@
28
28
  * touch the real files.
29
29
  */
30
30
 
31
- import { homedir } from "node:os";
32
31
  import { join, dirname } from "node:path";
33
32
  import { randomBytes } from "node:crypto";
34
33
  import {
@@ -47,6 +46,7 @@ import { frontendsForChat } from "../../runtime/frontends.js";
47
46
  import { logWarn } from "../../../util/log.js";
48
47
  import { gatewayAuthHeaders } from "../../../core/engine/gateway-auth.js";
49
48
  import { AGY_MCP_PREFIX } from "../constants.js";
49
+ import { userHome } from "../../../util/fs-path.js";
50
50
 
51
51
  // ── Paths ───────────────────────────────────────────────────────────────────
52
52
 
@@ -55,7 +55,7 @@ function agyMcpConfigPath(override?: string): string {
55
55
  return (
56
56
  override ||
57
57
  process.env.TALON_AGY_MCP_CONFIG ||
58
- join(homedir(), ".gemini", "config", "mcp_config.json")
58
+ join(userHome(), ".gemini", "config", "mcp_config.json")
59
59
  );
60
60
  }
61
61
 
@@ -64,7 +64,7 @@ function agyMcpSnapshotDir(override?: string): string {
64
64
  return (
65
65
  override ||
66
66
  process.env.TALON_AGY_MCP_SNAPSHOT_DIR ||
67
- join(homedir(), ".gemini", "antigravity-cli", "mcp")
67
+ join(userHome(), ".gemini", "antigravity-cli", "mcp")
68
68
  );
69
69
  }
70
70
 
@@ -13,13 +13,13 @@
13
13
  */
14
14
 
15
15
  import { readFile } from "node:fs/promises";
16
- import { homedir } from "node:os";
17
16
  import { join } from "node:path";
18
17
  import { logWarn } from "../../../util/log.js";
19
18
  import type {
20
19
  PlanUsage,
21
20
  PlanWindow,
22
21
  } from "../../../core/agent-runtime/capabilities.js";
22
+ import { userHome } from "../../../util/fs-path.js";
23
23
 
24
24
  // `cedar_ember=1` asks the endpoint to include banked limit resets (the
25
25
  // claude.ai "Reset for free" grants); `skip_spend=1` drops the spend block we
@@ -38,7 +38,7 @@ let inFlight: Promise<PlanUsage | undefined> | undefined;
38
38
  function credentialsPath(): string {
39
39
  const configDir = process.env.CLAUDE_CONFIG_DIR?.trim();
40
40
  return join(
41
- configDir && configDir.length > 0 ? configDir : join(homedir(), ".claude"),
41
+ configDir && configDir.length > 0 ? configDir : join(userHome(), ".claude"),
42
42
  ".credentials.json",
43
43
  );
44
44
  }
@@ -39,12 +39,12 @@
39
39
  */
40
40
 
41
41
  import { promises as fs } from "node:fs";
42
- import { homedir } from "node:os";
43
42
  import { join } from "node:path";
44
43
  import { log, logDebug } from "../../util/log.js";
45
44
  import { getState } from "./state.js";
46
45
  import type { CodexAuthInfo } from "./auth.js";
47
46
  import { normalizeReasoningLevels } from "../../core/models/reasoning-levels.js";
47
+ import { userHome } from "../../util/fs-path.js";
48
48
 
49
49
  /** Shape of one entry returned by OpenAI's `/v1/models`. Sparse — only `id` is reliably present. */
50
50
  interface OpenAiModelEntry {
@@ -99,7 +99,7 @@ export function getCodexCachePath(): string {
99
99
  const home = process.env.CODEX_HOME?.trim();
100
100
  return home && home.length > 0
101
101
  ? join(home, "models_cache.json")
102
- : join(homedir(), ".codex", "models_cache.json");
102
+ : join(userHome(), ".codex", "models_cache.json");
103
103
  }
104
104
 
105
105
  /** Default soft timeout when callers await an in-flight discovery. */
@@ -12,13 +12,13 @@
12
12
  */
13
13
 
14
14
  import { readFile, stat } from "node:fs/promises";
15
- import { homedir } from "node:os";
16
15
  import { join } from "node:path";
17
16
  import { logWarn } from "../../util/log.js";
18
17
  import type {
19
18
  PlanUsage,
20
19
  PlanWindow,
21
20
  } from "../../core/agent-runtime/capabilities.js";
21
+ import { userHome } from "../../util/fs-path.js";
22
22
 
23
23
  const USAGE_ENDPOINT = "https://chatgpt.com/backend-api/wham/usage";
24
24
  const REQUEST_TIMEOUT_MS = 5_000;
@@ -37,7 +37,7 @@ function authPath(): string {
37
37
  const home = process.env.CODEX_HOME?.trim();
38
38
  return home && home.length > 0
39
39
  ? join(home, "auth.json")
40
- : join(homedir(), ".codex", "auth.json");
40
+ : join(userHome(), ".codex", "auth.json");
41
41
  }
42
42
 
43
43
  interface CodexAuth {
@@ -29,8 +29,8 @@
29
29
  */
30
30
 
31
31
  import { readFile, readdir } from "node:fs/promises";
32
- import { homedir } from "node:os";
33
32
  import { join } from "node:path";
33
+ import { userHome } from "../../util/fs-path.js";
34
34
 
35
35
  export interface CodexLastTokenUsage {
36
36
  /** Last API call's prompt size = current context fill in tokens. */
@@ -120,7 +120,7 @@ export interface CodexRolloutSnapshot {
120
120
  * Codex CLI resolves its data directory.
121
121
  */
122
122
  function codexHome(): string {
123
- return process.env.CODEX_HOME ?? join(homedir(), ".codex");
123
+ return process.env.CODEX_HOME ?? join(userHome(), ".codex");
124
124
  }
125
125
 
126
126
  /**
@@ -15,6 +15,7 @@ import {
15
15
  setSessionId,
16
16
  } from "../../storage/sessions.js";
17
17
  import { log, logWarn } from "../../util/log.js";
18
+ import { TalonError } from "../../core/errors.js";
18
19
  import type { RemoteAgentClient, RemotePermissionRule } from "./client.js";
19
20
  import type { RemoteServerState } from "./state.js";
20
21
  import {
@@ -73,32 +74,100 @@ export function buildPermissionRuleset(chatId: string): RemotePermissionRule[] {
73
74
  ];
74
75
  }
75
76
 
77
+ /** Pull an HTTP status out of whatever shape the SDK client threw. */
78
+ function errorStatus(err: unknown): number | undefined {
79
+ if (typeof err !== "object" || err === null) return undefined;
80
+ const e = err as Record<string, unknown>;
81
+ const cause = e.cause as Record<string, unknown> | undefined;
82
+ const response = e.response as Record<string, unknown> | undefined;
83
+ for (const candidate of [
84
+ e.status,
85
+ e.statusCode,
86
+ response?.status,
87
+ cause?.status,
88
+ ]) {
89
+ if (typeof candidate === "number") return candidate;
90
+ }
91
+ return undefined;
92
+ }
93
+
94
+ /** The error's `name`, from the thrown object or the parsed body behind it. */
95
+ function errorNames(err: unknown): string[] {
96
+ if (typeof err !== "object" || err === null) return [];
97
+ const e = err as Record<string, unknown>;
98
+ const cause = e.cause as Record<string, unknown> | undefined;
99
+ const body = (cause?.body ?? e.body) as Record<string, unknown> | undefined;
100
+ return [e.name, body?.name, (e.data as Record<string, unknown>)?.name].filter(
101
+ (name): name is string => typeof name === "string",
102
+ );
103
+ }
104
+
105
+ /**
106
+ * True only when the server definitely says the session does not exist:
107
+ * an HTTP 404, or the server's `NotFoundError`. Everything else — a
108
+ * refused connection while the server is still starting, a 5xx, a
109
+ * timeout, a body that would not parse — is not proof the session is
110
+ * gone, and must not cost the chat its session.
111
+ */
112
+ export function isRemoteSessionNotFound(err: unknown): boolean {
113
+ if (errorStatus(err) === 404) return true;
114
+ return errorNames(err).includes("NotFoundError");
115
+ }
116
+
117
+ /** Waits between `session.get` attempts on a transient failure. */
118
+ const RESUME_RETRY_DELAYS_MS: readonly number[] = [500, 1_500];
119
+
76
120
  /**
77
121
  * Ensure a session exists for this chat on the remote agent server.
78
122
  *
79
123
  * Resumes the stored session id if `session.get` confirms it's still
80
- * alive. If the stored id is stale (any failure from `session.get`),
81
- * resets local state and creates a fresh session, returning the new
82
- * id. The fresh session is created with Talon's standard permission
83
- * ruleset (see {@link buildPermissionRuleset}).
124
+ * alive. Only a definite not-found (see {@link isRemoteSessionNotFound})
125
+ * resets the chat — archiving the old id — and creates a fresh session
126
+ * with Talon's standard permission ruleset (see
127
+ * {@link buildPermissionRuleset}). Any other failure is retried a couple
128
+ * of times and then fails the turn with the session left in place: a
129
+ * server that is still starting after an update must not wipe every
130
+ * chat's session mapping.
84
131
  */
85
132
  export async function ensureRemoteSession<TClient extends RemoteAgentClient>(
86
133
  client: TClient,
87
134
  state: RemoteServerState<TClient>,
88
135
  chatId: string,
136
+ retryDelaysMs: readonly number[] = RESUME_RETRY_DELAYS_MS,
89
137
  ): Promise<string> {
90
138
  const session = getSession(chatId);
91
139
 
92
140
  if (session.sessionId) {
93
- try {
94
- await client.session.get({ sessionID: session.sessionId });
95
- return session.sessionId;
96
- } catch {
97
- logWarn(
98
- "agent",
99
- `[${chatId}] Session ${session.sessionId} expired, creating new`,
100
- );
101
- resetSession(chatId);
141
+ const sessionId = session.sessionId;
142
+ for (let attempt = 0; ; attempt++) {
143
+ try {
144
+ await client.session.get({ sessionID: sessionId });
145
+ return sessionId;
146
+ } catch (err) {
147
+ if (isRemoteSessionNotFound(err)) {
148
+ logWarn(
149
+ "agent",
150
+ `[${chatId}] ${state.label} session ${sessionId} not found on the server, creating new`,
151
+ );
152
+ resetSession(chatId, "remote_session_not_found");
153
+ break;
154
+ }
155
+ const delay = retryDelaysMs[attempt];
156
+ const detail = err instanceof Error ? err.message : String(err);
157
+ if (delay === undefined) {
158
+ throw new TalonError(
159
+ `Could not check ${state.label} session ${sessionId} (${detail}); ` +
160
+ `kept it — try again once the server is up`,
161
+ { reason: "network", retryable: true, cause: err },
162
+ );
163
+ }
164
+ logWarn(
165
+ "agent",
166
+ `[${chatId}] ${state.label} session check failed (${detail}); ` +
167
+ `retrying in ${delay}ms, session kept`,
168
+ );
169
+ await new Promise((resolve) => setTimeout(resolve, delay));
170
+ }
102
171
  }
103
172
  }
104
173
 
@@ -111,7 +111,8 @@ export interface ApplyRetryDecisionResult {
111
111
  *
112
112
  * Side effects (when a retry fires):
113
113
  * - `incrementCounter('errors.<reason>')` exactly once per call.
114
- * - `resetSession(chatId)` before recursion.
114
+ * - `resetSession(chatId, reason)` before recursion (the old id is
115
+ * archived under that reason).
115
116
  * - For `fallback_model`: the fallback model id is spread into the
116
117
  * recursion's params (`params.model` outranks chat settings).
117
118
  *
@@ -156,7 +157,7 @@ export async function applyRetryDecision(
156
157
  "agent",
157
158
  `[${chatId}] ${prefix}${decision.reason}, resetting ${resetNoun} and retrying`,
158
159
  );
159
- resetSession(chatId);
160
+ resetSession(chatId, decision.reason);
160
161
  return { retry: await recurseWithRetried(params), classified };
161
162
  }
162
163
 
@@ -165,7 +166,7 @@ export async function applyRetryDecision(
165
166
  "agent",
166
167
  `[${chatId}] ${classified.reason}, falling back to ${decision.fallbackModelId}`,
167
168
  );
168
- resetSession(chatId);
169
+ resetSession(chatId, `fallback_model:${decision.fallbackModelId}`);
169
170
  return {
170
171
  retry: await recurseWithRetried({
171
172
  ...params,
@@ -270,7 +271,7 @@ export async function* applyRetryDecisionStream(
270
271
  "agent",
271
272
  `[${chatId}] ${prefix}${decision.reason}, resetting ${resetNoun} and retrying`,
272
273
  );
273
- resetSession(chatId);
274
+ resetSession(chatId, decision.reason);
274
275
  yield* buildRetryStream();
275
276
  return { retried: true, classified };
276
277
  }
@@ -280,7 +281,7 @@ export async function* applyRetryDecisionStream(
280
281
  "agent",
281
282
  `[${chatId}] ${classified.reason}, falling back to ${decision.fallbackModelId}`,
282
283
  );
283
- resetSession(chatId);
284
+ resetSession(chatId, `fallback_model:${decision.fallbackModelId}`);
284
285
  yield* buildRetryStream(decision.fallbackModelId);
285
286
  return { retried: true, classified };
286
287
  }
@@ -46,6 +46,10 @@ import {
46
46
  import { resolveBackupSettings } from "../../core/backup/plan.js";
47
47
  import { buildSnapshot } from "../../core/backup/snapshot.js";
48
48
  import { pruneLocal, reconcileIndex } from "../../core/backup/store.js";
49
+ import {
50
+ describeRetention,
51
+ localRetention,
52
+ } from "../../core/backup/retention/policy.js";
49
53
  import { generatePassphraseFile } from "../../core/backup/passphrase.js";
50
54
  import { dirs } from "../../util/paths.js";
51
55
  import { fetchGateway } from "../daemon-api.js";
@@ -170,7 +174,7 @@ async function backupNow(flags: Flags): Promise<void> {
170
174
  pinned: pin,
171
175
  settings,
172
176
  });
173
- await pruneLocal(settings.keepLocal);
177
+ await pruneLocal(localRetention(settings));
174
178
  console.log(
175
179
  ` ${pc.green("●")} ${manifest.id} — ${manifest.parts.length} part(s), ` +
176
180
  `${formatBytes(manifest.sizeBytes)}\n`,
@@ -246,10 +250,10 @@ async function backupPin(id: string, pinned: boolean): Promise<void> {
246
250
 
247
251
  async function backupPrune(): Promise<void> {
248
252
  const settings = resolveBackupSettings(loadConfig().backup);
249
- const removed = await pruneLocal(settings.keepLocal);
253
+ const removed = await pruneLocal(localRetention(settings));
250
254
  console.log(
251
255
  removed.length === 0
252
- ? ` ${pc.dim(`Nothing to prune (keepLocal=${settings.keepLocal}).`)}\n`
256
+ ? ` ${pc.dim(`Nothing to prune (${describeRetention(localRetention(settings))}).`)}\n`
253
257
  : ` ${pc.green("●")} Pruned ${removed.length}: ${removed.join(", ")}\n`,
254
258
  );
255
259
  }
@@ -13,9 +13,9 @@
13
13
  */
14
14
 
15
15
  import { readFile, stat } from "node:fs/promises";
16
- import { homedir } from "node:os";
17
16
  import { join } from "node:path";
18
17
  import { logWarn } from "../../util/log.js";
18
+ import { userHome } from "../../util/fs-path.js";
19
19
 
20
20
  export type AuthProvider = "claude" | "codex";
21
21
 
@@ -44,14 +44,14 @@ export function claudeCredentialsPath(
44
44
  ): string {
45
45
  const configDir = env.CLAUDE_CONFIG_DIR?.trim();
46
46
  return join(
47
- configDir ? configDir : join(homedir(), ".claude"),
47
+ configDir ? configDir : join(userHome(), ".claude"),
48
48
  ".credentials.json",
49
49
  );
50
50
  }
51
51
 
52
52
  export function codexAuthPath(env: NodeJS.ProcessEnv = process.env): string {
53
53
  const home = env.CODEX_HOME?.trim();
54
- return join(home ? home : join(homedir(), ".codex"), "auth.json");
54
+ return join(home ? home : join(userHome(), ".codex"), "auth.json");
55
55
  }
56
56
 
57
57
  /** Providers the backends reported as dead since boot (cleared on login). */
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Read-back verification of a freshly written snapshot.
3
+ *
4
+ * A digest taken off the bytes on their way to disk says what was sent,
5
+ * not what landed. Before a snapshot is allowed to count as good, every
6
+ * part is read back from disk and re-hashed, and an encrypted part is
7
+ * decrypted end to end (and discarded) so a broken encryptor cannot
8
+ * produce a snapshot nobody can open. The result is `verifiedAt` in the
9
+ * manifest, and retention never prunes the newest snapshot that has one
10
+ * (see retention/policy.ts).
11
+ *
12
+ * Runs before the manifest is signed, so `verifiedAt` is covered by the
13
+ * MAC like every other field written at build time.
14
+ */
15
+
16
+ import { join } from "node:path";
17
+ import { TalonError } from "../../errors.js";
18
+ import { isEncryptedFile, verifyDecryptable } from "./crypt.js";
19
+ import { sha256File } from "./digest.js";
20
+ import type { SnapshotPart } from "../types.js";
21
+
22
+ /**
23
+ * Throws a TalonError naming the first part that does not read back.
24
+ * Returns the time verification finished.
25
+ */
26
+ export async function verifyWrittenParts(
27
+ dir: string,
28
+ parts: readonly SnapshotPart[],
29
+ passphrase: string | null,
30
+ now: () => number = Date.now,
31
+ ): Promise<number> {
32
+ for (const part of parts) {
33
+ const path = join(dir, part.name);
34
+ const actual = await sha256File(path);
35
+ if (actual !== part.sha256) {
36
+ throw new TalonError(
37
+ `Part ${part.name} does not read back (sha256 mismatch)`,
38
+ { reason: "unknown" },
39
+ );
40
+ }
41
+ if (passphrase && (await isEncryptedFile(path))) {
42
+ try {
43
+ await verifyDecryptable(path, passphrase);
44
+ } catch (err) {
45
+ throw new TalonError(
46
+ `Part ${part.name} cannot be decrypted after writing: ${err instanceof Error ? err.message : String(err)}`,
47
+ { reason: "unknown", cause: err },
48
+ );
49
+ }
50
+ }
51
+ }
52
+ return now();
53
+ }
@@ -21,8 +21,8 @@
21
21
 
22
22
  import { lstat, readdir, readlink } from "node:fs/promises";
23
23
  import { isAbsolute, join, resolve, sep } from "node:path";
24
- import { homedir } from "node:os";
25
24
  import type { BackupSettings } from "./types.js";
25
+ import { userHome } from "../../util/fs-path.js";
26
26
 
27
27
  /**
28
28
  * The workspace is mostly machine-generated bulk (uploads, media, build
@@ -51,6 +51,9 @@ export const DEFAULT_BACKUP_SETTINGS = {
51
51
  intervalHours: 6,
52
52
  keepLocal: 12,
53
53
  keepRemote: 30,
54
+ keepDaily: 7,
55
+ keepWeekly: 4,
56
+ keepCheckpoints: 10,
54
57
  includePalace: true,
55
58
  loginSessions: "local",
56
59
  includeSessions: true,
@@ -216,9 +219,9 @@ export function workspaceRoots(patterns: readonly string[]): string[] {
216
219
  /** Expand a leading `~` and resolve against the home directory. */
217
220
  export function expandUserPath(path: string): string {
218
221
  const trimmed = path.trim();
219
- if (trimmed === "~") return homedir();
220
- if (trimmed.startsWith("~/")) return resolve(homedir(), trimmed.slice(2));
221
- return isAbsolute(trimmed) ? resolve(trimmed) : resolve(homedir(), trimmed);
222
+ if (trimmed === "~") return userHome();
223
+ if (trimmed.startsWith("~/")) return resolve(userHome(), trimmed.slice(2));
224
+ return isAbsolute(trimmed) ? resolve(trimmed) : resolve(userHome(), trimmed);
222
225
  }
223
226
 
224
227
  /** One member the builder will hand to the tar writer. */
@@ -36,7 +36,6 @@ import {
36
36
  unlink,
37
37
  copyFile,
38
38
  } from "node:fs/promises";
39
- import { homedir } from "node:os";
40
39
  import { dirname, join, resolve } from "node:path";
41
40
  import writeFileAtomic from "write-file-atomic";
42
41
  import { dirs } from "../../util/paths.js";
@@ -73,6 +72,7 @@ import {
73
72
  } from "./store.js";
74
73
  import type { BackupTarget } from "./targets.js";
75
74
  import type { BackupSettings, Manifest, SnapshotPart } from "./types.js";
75
+ import { userHome } from "../../util/fs-path.js";
76
76
 
77
77
  /** A staged request older than this is stale and ignored. */
78
78
  export const RESTORE_PENDING_MAX_AGE_MS = 10 * 60_000;
@@ -571,7 +571,7 @@ export async function restoreSnapshot(
571
571
  const realHome = options.home === undefined;
572
572
  const cloneTarget: CloneTarget = {
573
573
  home,
574
- userHome: options.userHome ?? homedir(),
574
+ userHome: options.userHome ?? userHome(),
575
575
  env: options.env ?? (realHome ? process.env : {}),
576
576
  };
577
577
  const missing = await missingParts(manifest, home);
@@ -597,7 +597,7 @@ export async function restoreSnapshot(
597
597
  pinned: true,
598
598
  settings: options.settings,
599
599
  home,
600
- userHome: options.userHome ?? (realHome ? homedir() : null),
600
+ userHome: options.userHome ?? (realHome ? userHome() : null),
601
601
  env: cloneTarget.env,
602
602
  });
603
603
  checkpointId = checkpoint.id;