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
@@ -0,0 +1,131 @@
1
+ /**
2
+ * Keep Claude transcripts resumable when the Talon home moves.
3
+ *
4
+ * Claude Code files a session's transcript under
5
+ * `~/.claude/projects/<slug>/`, where the slug IS the absolute cwd with
6
+ * every non-alphanumeric character turned into `-`. Talon's cwd is
7
+ * `<TALON_HOME>/workspace`, so moving the home — `/home/bun/.talon` to
8
+ * `/data/.talon` when a container switches to the single data root, or a
9
+ * new TALON_HOME on a host — changes the slug, and every stored session
10
+ * id then points at a transcript Claude can no longer find.
11
+ *
12
+ * This copies the old slug directories' files under the new slug. It
13
+ * never overwrites a file that already exists there and never moves or
14
+ * deletes the originals: the old directories stay as they were.
15
+ */
16
+
17
+ import { constants } from "node:fs";
18
+ import { copyFile, mkdir, readdir } from "node:fs/promises";
19
+ import { join } from "node:path";
20
+ import { relocateClaudeSlug } from "../backup/sources/relocate.js";
21
+ import { claudeProjectSlug, sessionCwds } from "../backup/sources/sessions.js";
22
+
23
+ export type RelinkResult = {
24
+ /** The old slug directory, left in place. */
25
+ from: string;
26
+ /** The directory the current home's sessions are looked up in. */
27
+ to: string;
28
+ /** Files copied (existing ones are skipped, never replaced). */
29
+ copied: number;
30
+ };
31
+
32
+ function matchesSlugOf(name: string, home: string): boolean {
33
+ return sessionCwds(home)
34
+ .map(claudeProjectSlug)
35
+ .some((slug) => name === slug || name.startsWith(`${slug}-`));
36
+ }
37
+
38
+ /** Copy `src` into `dst` recursively; existing files win. Returns files copied. */
39
+ export async function mergeCopy(src: string, dst: string): Promise<number> {
40
+ await mkdir(dst, { recursive: true });
41
+ let copied = 0;
42
+ for (const entry of await readdir(src, { withFileTypes: true })) {
43
+ const from = join(src, entry.name);
44
+ const to = join(dst, entry.name);
45
+ if (entry.isDirectory()) {
46
+ copied += await mergeCopy(from, to);
47
+ } else if (entry.isFile()) {
48
+ try {
49
+ await copyFile(from, to, constants.COPYFILE_EXCL);
50
+ copied++;
51
+ } catch (err) {
52
+ if ((err as NodeJS.ErrnoException).code !== "EEXIST") throw err;
53
+ }
54
+ }
55
+ }
56
+ return copied;
57
+ }
58
+
59
+ async function listDir(dir: string): Promise<string[]> {
60
+ try {
61
+ return (await readdir(dir, { withFileTypes: true }))
62
+ .filter((e) => e.isDirectory())
63
+ .map((e) => e.name)
64
+ .sort();
65
+ } catch {
66
+ return [];
67
+ }
68
+ }
69
+
70
+ /**
71
+ * Old-slug directories to carry over: every project dir, in any of the
72
+ * `sources` projects directories, that belongs to one of `previousHomes`
73
+ * and not to `home`.
74
+ */
75
+ async function findOldSlugDirs(input: {
76
+ home: string;
77
+ previousHomes: readonly string[];
78
+ sources: readonly string[];
79
+ }): Promise<{ from: string; name: string; previousHome: string }[]> {
80
+ const found: { from: string; name: string; previousHome: string }[] = [];
81
+ for (const dir of new Set(input.sources)) {
82
+ const names = await listDir(dir);
83
+ for (const previousHome of new Set(input.previousHomes)) {
84
+ if (previousHome === input.home) continue;
85
+ for (const name of names) {
86
+ if (!matchesSlugOf(name, previousHome)) continue;
87
+ if (matchesSlugOf(name, input.home)) continue;
88
+ found.push({ from: join(dir, name), name, previousHome });
89
+ }
90
+ }
91
+ }
92
+ return found;
93
+ }
94
+
95
+ /**
96
+ * Copy transcripts filed under a previous home's slug to the current
97
+ * home's slug. `projectsDir` is where the current Claude looks;
98
+ * `extraSources` are other projects directories to read from (an old
99
+ * `~/.claude` still mounted during a migration). Sources in `skip` were
100
+ * carried over on an earlier boot. A directory that fails to copy is
101
+ * reported through `onError` and left for the next boot.
102
+ */
103
+ export async function relinkClaudeProjects(input: {
104
+ home: string;
105
+ projectsDir: string;
106
+ previousHomes: readonly string[];
107
+ extraSources?: readonly string[];
108
+ skip?: ReadonlySet<string>;
109
+ onError?: (from: string, err: unknown) => void;
110
+ }): Promise<RelinkResult[]> {
111
+ const olds = await findOldSlugDirs({
112
+ home: input.home,
113
+ previousHomes: input.previousHomes,
114
+ sources: [input.projectsDir, ...(input.extraSources ?? [])],
115
+ });
116
+ const results: RelinkResult[] = [];
117
+ for (const { from, name, previousHome } of olds) {
118
+ if (input.skip?.has(from)) continue;
119
+ const to = join(
120
+ input.projectsDir,
121
+ relocateClaudeSlug(name, previousHome, input.home),
122
+ );
123
+ if (to === from) continue;
124
+ try {
125
+ results.push({ from, to, copied: await mergeCopy(from, to) });
126
+ } catch (err) {
127
+ input.onError?.(from, err);
128
+ }
129
+ }
130
+ return results;
131
+ }
@@ -0,0 +1,170 @@
1
+ /**
2
+ * Storage layout at boot: keep Claude transcripts reachable across a
3
+ * Talon-home move, and (in a container) warn when a backend's session
4
+ * store is not on a persistent volume. See docs/docker.md.
5
+ *
6
+ * Neither step throws: both are advisory, and a boot must never fail
7
+ * because a check could not read a directory.
8
+ */
9
+
10
+ import { readFile, mkdir } from "node:fs/promises";
11
+ import { homedir } from "node:os";
12
+ import { dirname, join } from "node:path";
13
+ import writeFileAtomic from "write-file-atomic";
14
+ import { claudeProjectsDir } from "../backup/sources/sessions.js";
15
+ import { raiseAlert } from "../frontend-runtime/alerts.js";
16
+ import { dirs } from "../../util/paths.js";
17
+ import { log, logWarn } from "../../util/log.js";
18
+ import { relinkClaudeProjects, type RelinkResult } from "./claude-relink.js";
19
+ import { readMountTable, type MountEntry } from "./mounts.js";
20
+ import {
21
+ describeFindings,
22
+ findEphemeralStores,
23
+ inContainer,
24
+ type StorageFinding,
25
+ } from "./persistence.js";
26
+
27
+ type Env = Readonly<Record<string, string | undefined>>;
28
+
29
+ /**
30
+ * Talon homes the published image has used. In a container either may
31
+ * hold the transcripts of a deployment that moved to the other.
32
+ */
33
+ const CONTAINER_HOMES = ["/home/bun/.talon", "/data/.talon"] as const;
34
+ /** The pre-/data image's HOME, where an old `~/.claude` may still be mounted. */
35
+ const LEGACY_USER_HOME = "/home/bun";
36
+
37
+ /** What the last boot recorded: the home it ran in, dirs already copied. */
38
+ type RelinkState = { home: string; merged: string[] };
39
+
40
+ async function readState(file: string): Promise<RelinkState | null> {
41
+ try {
42
+ const raw = JSON.parse(await readFile(file, "utf8")) as unknown;
43
+ if (!raw || typeof raw !== "object") return null;
44
+ const { home, merged } = raw as Record<string, unknown>;
45
+ if (typeof home !== "string") return null;
46
+ return {
47
+ home,
48
+ merged: Array.isArray(merged)
49
+ ? merged.filter((m): m is string => typeof m === "string")
50
+ : [],
51
+ };
52
+ } catch {
53
+ return null;
54
+ }
55
+ }
56
+
57
+ /**
58
+ * Carry Claude transcripts over from a previous Talon home's project slug.
59
+ * The previous home comes from the state file (which travels with a copied
60
+ * `.talon`, so it names the old path after a migration) and, in a
61
+ * container, from the image's known homes.
62
+ */
63
+ export async function relinkAfterHomeMove(opts: {
64
+ home: string;
65
+ userHome: string;
66
+ env: Env;
67
+ container: boolean;
68
+ stateFile: string;
69
+ }): Promise<RelinkResult[]> {
70
+ const state = await readState(opts.stateFile);
71
+ const previousHomes = [
72
+ ...(state ? [state.home] : []),
73
+ ...(opts.container ? CONTAINER_HOMES : []),
74
+ ].filter((h) => h !== opts.home);
75
+ const projectsDir = claudeProjectsDir(opts.userHome, opts.env);
76
+ const legacyProjects = join(LEGACY_USER_HOME, ".claude", "projects");
77
+ const results = await relinkClaudeProjects({
78
+ home: opts.home,
79
+ projectsDir,
80
+ previousHomes,
81
+ extraSources:
82
+ opts.container && legacyProjects !== projectsDir ? [legacyProjects] : [],
83
+ skip: new Set(state?.merged ?? []),
84
+ onError: (from, err) =>
85
+ logWarn(
86
+ "sessions",
87
+ `Could not copy Claude transcripts from ${from}: ${String(err)} (will retry next boot)`,
88
+ ),
89
+ });
90
+ for (const r of results) {
91
+ log(
92
+ "sessions",
93
+ `Talon home moved: copied ${r.copied} Claude transcript file(s) from ${r.from} to ${r.to} (the original is kept)`,
94
+ );
95
+ }
96
+ if (state?.home !== opts.home || results.length > 0) {
97
+ const merged = [...(state?.merged ?? []), ...results.map((r) => r.from)];
98
+ await mkdir(dirname(opts.stateFile), { recursive: true });
99
+ await writeFileAtomic(
100
+ opts.stateFile,
101
+ JSON.stringify({ home: opts.home, merged: [...new Set(merged)] }) + "\n",
102
+ { mode: 0o600 },
103
+ );
104
+ }
105
+ return results;
106
+ }
107
+
108
+ /**
109
+ * In a container, raise one admin alert listing every store that would
110
+ * not survive a recreate. Returns the findings (empty outside a container
111
+ * or when the check is switched off with TALON_STORAGE_CHECK=0).
112
+ */
113
+ export function checkContainerStorage(opts: {
114
+ talonHome: string;
115
+ userHome: string;
116
+ env: Env;
117
+ config: Record<string, unknown>;
118
+ container: boolean;
119
+ mounts?: readonly MountEntry[];
120
+ raise?: typeof raiseAlert;
121
+ }): StorageFinding[] {
122
+ if (!opts.container || opts.env.TALON_STORAGE_CHECK === "0") return [];
123
+ const findings = findEphemeralStores({
124
+ ...opts,
125
+ mounts: opts.mounts ?? readMountTable(),
126
+ });
127
+ if (findings.length === 0) return [];
128
+ // Losing only Claude's account file costs a re-onboarding; losing a
129
+ // session store costs every conversation.
130
+ const onlyAccountFile = findings.every((f) =>
131
+ f.path.endsWith(".claude.json"),
132
+ );
133
+ (opts.raise ?? raiseAlert)(
134
+ "storage.ephemeral",
135
+ describeFindings(findings, opts.env),
136
+ { severity: onlyAccountFile ? "warn" : "error" },
137
+ );
138
+ return findings;
139
+ }
140
+
141
+ /** Both steps, with this process's paths. Never throws. */
142
+ export async function runStorageLayoutChecks(
143
+ config: Record<string, unknown>,
144
+ ): Promise<void> {
145
+ const env = process.env;
146
+ const container = inContainer(env);
147
+ const userHome = homedir();
148
+ try {
149
+ await relinkAfterHomeMove({
150
+ home: dirs.root,
151
+ userHome,
152
+ env,
153
+ container,
154
+ stateFile: join(dirs.data, "claude-relink.json"),
155
+ });
156
+ } catch (err) {
157
+ logWarn("sessions", `Claude transcript relink failed: ${String(err)}`);
158
+ }
159
+ try {
160
+ checkContainerStorage({
161
+ talonHome: dirs.root,
162
+ userHome,
163
+ env,
164
+ config,
165
+ container,
166
+ });
167
+ } catch (err) {
168
+ logWarn("bot", `Container storage check failed: ${String(err)}`);
169
+ }
170
+ }
@@ -0,0 +1,129 @@
1
+ /**
2
+ * Which filesystem a path lives on, read from /proc/self/mountinfo.
3
+ *
4
+ * Inside a container the root filesystem is the image's writable layer:
5
+ * whatever is written there is gone the moment the container is recreated
6
+ * (an image update, a NAS app redeploy). Only bind mounts and named
7
+ * volumes outlive it. This module answers "is this path on one of those?"
8
+ * without shelling out to `mountpoint` or `df`.
9
+ */
10
+
11
+ import { existsSync, readFileSync, realpathSync } from "node:fs";
12
+ import { dirname, join, relative, sep } from "node:path";
13
+
14
+ /** One line of /proc/self/mountinfo, the fields this module needs. */
15
+ export type MountEntry = {
16
+ /** Where it is mounted, in this mount namespace. */
17
+ mountPoint: string;
18
+ /** The directory of the source filesystem that is mounted there. */
19
+ root: string;
20
+ fsType: string;
21
+ source: string;
22
+ };
23
+
24
+ /** Where a path's data lives, as far as surviving a container recreate goes. */
25
+ export type Persistence =
26
+ | "persistent"
27
+ /** The container's own writable layer (or a tmpfs): lost on recreate. */
28
+ | "ephemeral"
29
+ /** An anonymous Docker volume: lost when the container is removed. */
30
+ | "anonymous";
31
+
32
+ /** mountinfo escapes space, tab, newline and backslash as octal. */
33
+ function unescape(field: string): string {
34
+ return field.replace(/\\([0-7]{3})/g, (_, oct: string) =>
35
+ String.fromCharCode(parseInt(oct, 8)),
36
+ );
37
+ }
38
+
39
+ /**
40
+ * Parse mountinfo text (proc(5)): `id parent maj:min root mountpoint opts
41
+ * [optional…] - fstype source superopts`. Malformed lines are skipped.
42
+ */
43
+ export function parseMountInfo(text: string): MountEntry[] {
44
+ const out: MountEntry[] = [];
45
+ for (const line of text.split("\n")) {
46
+ const fields = line.trim().split(" ");
47
+ const dash = fields.indexOf("-");
48
+ if (fields.length < 6 || dash < 6 || dash + 2 >= fields.length) continue;
49
+ out.push({
50
+ root: unescape(fields[3]),
51
+ mountPoint: unescape(fields[4]),
52
+ fsType: fields[dash + 1],
53
+ source: unescape(fields[dash + 2]),
54
+ });
55
+ }
56
+ return out;
57
+ }
58
+
59
+ function covers(mountPoint: string, path: string): boolean {
60
+ if (mountPoint === "/") return true;
61
+ return path === mountPoint || path.startsWith(mountPoint + "/");
62
+ }
63
+
64
+ /**
65
+ * The mount a path is on: the longest covering mount point, and among
66
+ * equal ones the last listed (a later mount on the same point shadows the
67
+ * earlier one).
68
+ */
69
+ export function mountFor(
70
+ path: string,
71
+ mounts: readonly MountEntry[],
72
+ ): MountEntry | undefined {
73
+ let best: MountEntry | undefined;
74
+ for (const m of mounts) {
75
+ if (!covers(m.mountPoint, path)) continue;
76
+ if (!best || m.mountPoint.length >= best.mountPoint.length) best = m;
77
+ }
78
+ return best;
79
+ }
80
+
81
+ /** Docker names anonymous volumes by a 64-hex id; named ones by name. */
82
+ const ANONYMOUS_VOLUME = /\/volumes\/[0-9a-f]{64}\/_data(\/|$)/;
83
+
84
+ /** Classify the mount a path is on. A missing mount counts as ephemeral. */
85
+ export function classifyMount(mount: MountEntry | undefined): Persistence {
86
+ if (!mount || mount.mountPoint === "/" || mount.fsType === "tmpfs")
87
+ return "ephemeral";
88
+ if (ANONYMOUS_VOLUME.test(mount.root)) return "anonymous";
89
+ return "persistent";
90
+ }
91
+
92
+ /**
93
+ * The real path `path` resolves to once created: symlinks in the part that
94
+ * exists are followed (a `~/.claude` symlinked onto a volume is on that
95
+ * volume), the part that doesn't is appended as-is.
96
+ */
97
+ function resolveExisting(path: string): string {
98
+ let head = path;
99
+ const tail: string[] = [];
100
+ while (!existsSync(head)) {
101
+ const parent = dirname(head);
102
+ if (parent === head) return path;
103
+ tail.unshift(relative(parent, head));
104
+ head = parent;
105
+ }
106
+ try {
107
+ return tail.length ? join(realpathSync(head), ...tail) : realpathSync(head);
108
+ } catch {
109
+ return path;
110
+ }
111
+ }
112
+
113
+ /** Read this process's mount table; empty when there is none (not Linux). */
114
+ export function readMountTable(file = "/proc/self/mountinfo"): MountEntry[] {
115
+ try {
116
+ return parseMountInfo(readFileSync(file, "utf8"));
117
+ } catch {
118
+ return [];
119
+ }
120
+ }
121
+
122
+ /** Whether `path` would survive the container being recreated. */
123
+ export function persistenceOf(
124
+ path: string,
125
+ mounts: readonly MountEntry[],
126
+ ): Persistence {
127
+ const real = resolveExisting(path).split(sep).join("/");
128
+ return classifyMount(mountFor(real, mounts));
129
+ }
@@ -0,0 +1,132 @@
1
+ /**
2
+ * Boot check: in a container, is every backend's session store on a
3
+ * volume that outlives the container?
4
+ *
5
+ * The image keeps everything under one data root (HOME=/data). A setup
6
+ * that maps only part of it — commonly just `~/.talon` in a NAS "custom
7
+ * app" — runs fine until the first image update, which silently takes
8
+ * every Claude/Codex/OpenCode transcript with it: the database still
9
+ * names those sessions, but nothing is left to resume. This check turns
10
+ * that into one admin alert at boot, naming each path and the fix.
11
+ */
12
+
13
+ import { existsSync } from "node:fs";
14
+ import { join } from "node:path";
15
+ import { backendStoreDirs } from "../backup/sources/sessions.js";
16
+ import { persistenceOf, type MountEntry, type Persistence } from "./mounts.js";
17
+
18
+ export type StorageFinding = {
19
+ path: string;
20
+ /** What the operator loses with it. */
21
+ label: string;
22
+ persistence: Exclude<Persistence, "persistent">;
23
+ };
24
+
25
+ type Env = Readonly<Record<string, string | undefined>>;
26
+
27
+ /** Whether this process runs inside a container (Docker, Podman, the image). */
28
+ export function inContainer(
29
+ env: Env,
30
+ exists: (path: string) => boolean = existsSync,
31
+ ): boolean {
32
+ return (
33
+ env.TALON_CONTAINER === "1" ||
34
+ exists("/.dockerenv") ||
35
+ exists("/run/.containerenv")
36
+ );
37
+ }
38
+
39
+ const LABELS: Record<string, string> = {
40
+ claude: "Claude Code transcripts and sign-in",
41
+ codex: "Codex sessions and sign-in",
42
+ opencode: "OpenCode sessions",
43
+ kilo: "Kilo sessions",
44
+ agy: "Antigravity sign-in and conversations",
45
+ };
46
+
47
+ /** Every path whose loss costs the operator something, with what it holds. */
48
+ function storagePaths(input: {
49
+ talonHome: string;
50
+ userHome: string;
51
+ env: Env;
52
+ config: Record<string, unknown>;
53
+ }): { path: string; label: string }[] {
54
+ const paths = [
55
+ {
56
+ path: input.talonHome,
57
+ label: "Talon home: config, chat history, memory, keys",
58
+ },
59
+ ];
60
+ for (const store of backendStoreDirs(
61
+ input.userHome,
62
+ input.env,
63
+ input.config,
64
+ )) {
65
+ paths.push({
66
+ path: store.path,
67
+ label: LABELS[store.backend] ?? `${store.backend} sessions`,
68
+ });
69
+ }
70
+ // Claude Code keeps its account/onboarding state next to ~/.claude,
71
+ // not inside it — unless CLAUDE_CONFIG_DIR moves both.
72
+ if (!input.env.CLAUDE_CONFIG_DIR?.trim()) {
73
+ paths.push({
74
+ path: join(input.userHome, ".claude.json"),
75
+ label: "Claude Code account state",
76
+ });
77
+ }
78
+ return paths;
79
+ }
80
+
81
+ /**
82
+ * The stores that would not survive a container recreate. Empty when the
83
+ * mount table is unknown: no table, no claim either way.
84
+ */
85
+ export function findEphemeralStores(input: {
86
+ talonHome: string;
87
+ userHome: string;
88
+ env: Env;
89
+ config: Record<string, unknown>;
90
+ mounts: readonly MountEntry[];
91
+ }): StorageFinding[] {
92
+ if (input.mounts.length === 0) return [];
93
+ const findings: StorageFinding[] = [];
94
+ const seen = new Set<string>();
95
+ for (const { path, label } of storagePaths(input)) {
96
+ if (seen.has(path)) continue;
97
+ seen.add(path);
98
+ const persistence = persistenceOf(path, input.mounts);
99
+ if (persistence !== "persistent")
100
+ findings.push({ path, label, persistence });
101
+ }
102
+ return findings;
103
+ }
104
+
105
+ const WHERE: Record<StorageFinding["persistence"], string> = {
106
+ ephemeral: "container filesystem, lost on every image update",
107
+ anonymous: "anonymous Docker volume, lost when the container is removed",
108
+ };
109
+
110
+ /** The admin alert text for a set of findings. */
111
+ export function describeFindings(
112
+ findings: readonly StorageFinding[],
113
+ env: Env,
114
+ ): string {
115
+ const lines = findings.map(
116
+ (f) => `• ${f.path}: ${f.label} (${WHERE[f.persistence]})`,
117
+ );
118
+ const legacy =
119
+ env.TALON_LAYOUT === "legacy"
120
+ ? "\nThis container runs the old /home/bun layout (only some paths mounted)."
121
+ : "";
122
+ return (
123
+ "Some of Talon's state is not on a persistent volume and will be lost " +
124
+ "when this container is recreated:\n" +
125
+ lines.join("\n") +
126
+ legacy +
127
+ "\n\nFix: mount one volume at /data and run with HOME=/data (the image " +
128
+ "default), after copying the paths above into it. Nothing is moved for " +
129
+ 'you. Steps: docs/docker.md, "Upgrading from the old layout".' +
130
+ "\nSilence this check with TALON_STORAGE_CHECK=0."
131
+ );
132
+ }
@@ -180,7 +180,13 @@ function storageLines(f: ReportFormatter, status: BackupStatus): string[] {
180
180
  if (policy) {
181
181
  lines.push(
182
182
  `${f.bold("Retention:")} newest ${policy.keepLocal} kept here, ` +
183
- `${policy.keepRemote} per target · pinned ones are never pruned`,
183
+ `${policy.keepRemote} per target` +
184
+ (policy.keepDaily ? ` · 1/day for ${policy.keepDaily}d` : "") +
185
+ (policy.keepWeekly ? ` · 1/week for ${policy.keepWeekly}w` : "") +
186
+ (policy.keepCheckpoints
187
+ ? ` · ${policy.keepCheckpoints} checkpoints`
188
+ : "") +
189
+ ` · pinned ones and the last verified are never pruned`,
184
190
  );
185
191
  lines.push(
186
192
  policy.encrypted
@@ -21,7 +21,6 @@
21
21
  */
22
22
 
23
23
  import { existsSync } from "node:fs";
24
- import { homedir } from "node:os";
25
24
  import { dirname, join, resolve } from "node:path";
26
25
  import type { DoctorCheck } from "../../core/doctor/index.js";
27
26
  import {
@@ -41,6 +40,7 @@ import {
41
40
  type ProvisionState,
42
41
  } from "../../core/plugin/provision.js";
43
42
  import { dirs, files } from "../../util/paths.js";
43
+ import { userHome } from "../../util/fs-path.js";
44
44
 
45
45
  /**
46
46
  * The mempalace version Talon installs and reconciles the managed venv
@@ -89,7 +89,7 @@ export interface MempalaceSection {
89
89
  */
90
90
  export function resolveMempalacePaths(
91
91
  section: MempalaceSection | undefined,
92
- home = homedir(),
92
+ home = userHome(),
93
93
  ): { pythonPath: string; palacePath: string } {
94
94
  return {
95
95
  pythonPath: resolve(
@@ -166,7 +166,7 @@ export async function provisionMempalace(
166
166
  const exec = deps.exec ?? runStep;
167
167
  const platform = deps.platform ?? process.platform;
168
168
  const now = deps.now ?? Date.now;
169
- const home = deps.home ?? homedir();
169
+ const home = deps.home ?? userHome();
170
170
  const pathExists = deps.pathExists ?? existsSync;
171
171
  const target = section.version ?? MEMPALACE_PINNED_VERSION;
172
172
 
@@ -606,7 +606,7 @@ export async function inspectMempalace(
606
606
  ): Promise<DoctorCheck[]> {
607
607
  const exec = deps.exec ?? runStep;
608
608
  const pathExists = deps.pathExists ?? existsSync;
609
- const home = deps.home ?? homedir();
609
+ const home = deps.home ?? userHome();
610
610
  const { pythonPath: python } = resolveMempalacePaths(section, home);
611
611
  const pin = section.version ?? MEMPALACE_PINNED_VERSION;
612
612
  const managed =
@@ -14,7 +14,6 @@
14
14
  */
15
15
 
16
16
  import { existsSync, readFileSync, readdirSync } from "node:fs";
17
- import { homedir } from "node:os";
18
17
  import { dirname, join, resolve } from "node:path";
19
18
  import type { DoctorCheck } from "../../core/doctor/index.js";
20
19
  import {
@@ -34,6 +33,7 @@ import {
34
33
  bundledPlaywrightVersion,
35
34
  couplingError,
36
35
  } from "./version-coupling.js";
36
+ import { userHome } from "../../util/fs-path.js";
37
37
 
38
38
  /** Engines whose builds Playwright manages (vs system channels). */
39
39
  const MANAGED_ENGINES = new Set(["chromium", "firefox", "webkit"]);
@@ -173,7 +173,7 @@ function browserPresent(
173
173
  ): boolean {
174
174
  const root = browsersRoot(
175
175
  deps.platform ?? process.platform,
176
- deps.home ?? homedir(),
176
+ deps.home ?? userHome(),
177
177
  deps.env ?? process.env,
178
178
  );
179
179
  const present = new Set((deps.listDir ?? safeListDir)(root));