@sayknow-cli/utils 0.7.10 → 0.7.12

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 (49) hide show
  1. package/dist/types/broken-pipe.d.ts +4 -3
  2. package/dist/types/canonical-log-dir.d.ts +20 -0
  3. package/dist/types/cli-metadata.d.ts +33 -0
  4. package/dist/types/cli.d.ts +12 -0
  5. package/dist/types/crash-fingerprint.d.ts +82 -0
  6. package/dist/types/crash-journal.d.ts +84 -0
  7. package/dist/types/crash-redaction.d.ts +20 -0
  8. package/dist/types/dirs.d.ts +127 -36
  9. package/dist/types/env-file.d.ts +59 -0
  10. package/dist/types/env.d.ts +19 -1
  11. package/dist/types/error-classification.d.ts +12 -0
  12. package/dist/types/fetch-retry.d.ts +6 -3
  13. package/dist/types/header-value.d.ts +10 -0
  14. package/dist/types/index.d.ts +6 -0
  15. package/dist/types/logger.d.ts +22 -8
  16. package/dist/types/native-process.d.ts +12 -0
  17. package/dist/types/procmgr.d.ts +1 -35
  18. package/dist/types/safe-error.d.ts +20 -0
  19. package/dist/types/sanitize-text.d.ts +10 -0
  20. package/dist/types/shell-config.d.ts +58 -0
  21. package/dist/types/stream.d.ts +2 -1
  22. package/dist/types/which.d.ts +1 -1
  23. package/package.json +2 -2
  24. package/src/broken-pipe.ts +16 -4
  25. package/src/canonical-log-dir.ts +59 -0
  26. package/src/cli-metadata.ts +74 -0
  27. package/src/cli.ts +30 -2
  28. package/src/crash-fingerprint.ts +294 -0
  29. package/src/crash-journal.ts +292 -0
  30. package/src/crash-redaction.ts +558 -0
  31. package/src/dirs.ts +583 -165
  32. package/src/env-file.ts +326 -20
  33. package/src/env.ts +321 -35
  34. package/src/error-classification.ts +31 -0
  35. package/src/fetch-retry.ts +23 -10
  36. package/src/frontmatter.ts +28 -2
  37. package/src/glob.ts +13 -10
  38. package/src/header-value.ts +15 -0
  39. package/src/index.ts +6 -0
  40. package/src/logger.ts +252 -97
  41. package/src/native-process.ts +19 -0
  42. package/src/postmortem.ts +1 -55
  43. package/src/procmgr.ts +16 -212
  44. package/src/ptree.ts +3 -2
  45. package/src/safe-error.ts +40 -0
  46. package/src/sanitize-text.ts +14 -0
  47. package/src/shell-config.ts +329 -0
  48. package/src/stream.ts +32 -1
  49. package/src/which.ts +6 -5
package/src/dirs.ts CHANGED
@@ -5,83 +5,37 @@
5
5
  * config root and SKC_CODING_AGENT_DIR (legacy alias PI_CODING_AGENT_DIR) to
6
6
  * override the agent directory.
7
7
  *
8
- * On Linux, if XDG_DATA_HOME / XDG_STATE_HOME / XDG_CACHE_HOME environment
9
- * variables are set, paths are redirected to XDG-compliant locations under
10
- * $XDG_*_HOME/skc/. This requires running `skc config migrate` first to
11
- * move data to the new locations. No filesystem existence checks are performed
12
- * — if the env var is set, skc trusts that the migration has been done.
8
+ * On Linux and macOS, a category (data, state, cache) is redirected to
9
+ * $XDG_*_HOME/skc/ only when the trusted XDG_*_HOME variable is set, the
10
+ * $XDG_*_HOME/skc directory already exists (`skc config init-xdg` creates it),
11
+ * and the agent directory is the default. Otherwise paths stay under the config
12
+ * root. `init-xdg` only creates the directories; existing data is not moved.
13
13
  */
14
14
 
15
15
  import * as fs from "node:fs";
16
16
  import * as os from "node:os";
17
17
  import * as path from "node:path";
18
- import { engines, version } from "../package.json" with { type: "json" };
19
- import { parseEnvFile } from "./env-file";
18
+ import { resolveCanonicalLogsDir } from "./canonical-log-dir";
19
+ import { APP_NAME } from "./cli-metadata";
20
+ import { canonicalEnvKey, type ProjectEnvSnapshot, projectEnvSnapshot } from "./env-file";
20
21
 
21
- /** App name (e.g. "skc") */
22
- export const APP_NAME: string = "skc";
22
+ /**
23
+ * Entry metadata lives in the pure `cli-metadata` leaf so the actual CLI entry can
24
+ * import it without loading this module's load-time directory resolution. These
25
+ * re-exports keep the existing `@sayknow-cli/utils/dirs` surface unchanged.
26
+ */
27
+ export { APP_NAME, formatBunRuntimeError, MIN_BUN_VERSION, VERSION } from "./cli-metadata";
28
+ // The provenance snapshot and its key fold live in the leaf `env-file` module so
29
+ // `scripts/test-preload.ts` can share this exact logic without importing this
30
+ // module (whose load-time resolver construction would freeze state before the
31
+ // preload sets its isolation variables). Re-exported here because `env.ts` and
32
+ // `test/env-provenance.windows.test.ts` import `canonicalEnvKey` from this
33
+ // module's public surface.
34
+ export { canonicalEnvKey };
23
35
 
24
36
  /** Config directory name (e.g. ".skc") */
25
37
  export const CONFIG_DIR_NAME: string = ".skc";
26
38
 
27
- /** Version (e.g. "1.0.0") */
28
- export const VERSION: string = version;
29
-
30
- /** Minimum Bun version */
31
- export const MIN_BUN_VERSION: string = engines.bun.replace(/[^0-9.]/g, "");
32
-
33
- /**
34
- * Build the diagnostic shown when the Bun runtime executing `skc` is older
35
- * than {@link MIN_BUN_VERSION}. This is the most common Windows native-install
36
- * failure (issue #525): `bun install -g sayknow-cli` probes a recent Bun while
37
- * the `skc` launcher resolves an older Bun still on PATH. The message names the
38
- * exact detected runtime path and gives a platform-specific upgrade + PATH fix
39
- * instead of a bare `bun upgrade`.
40
- *
41
- * Pure and platform-parameterized so it can be unit-tested cross-platform.
42
- */
43
- export function formatBunRuntimeError(opts: {
44
- currentVersion: string;
45
- minVersion: string;
46
- execPath?: string;
47
- platform?: NodeJS.Platform;
48
- }): string {
49
- const platform = opts.platform ?? process.platform;
50
- const lines = [
51
- `error: ${APP_NAME} requires Bun >= ${opts.minVersion}, but the running Bun is v${opts.currentVersion}.`,
52
- ];
53
- if (opts.execPath) {
54
- lines.push(` detected Bun runtime: ${opts.execPath}`);
55
- }
56
- if (platform === "win32") {
57
- lines.push(
58
- "",
59
- "The 'skc' launcher is using an older Bun than the one used to install it.",
60
- "Upgrade Bun, then restart your terminal so PATH and the runtime refresh:",
61
- "",
62
- ' powershell -c "irm bun.sh/install.ps1|iex"',
63
- "",
64
- "After restarting the terminal, verify both versions match:",
65
- " bun --version",
66
- " skc --version",
67
- "",
68
- "If 'skc' still loads the old runtime, make sure %USERPROFILE%\\.bun\\bin is",
69
- "first on PATH and remove any stale Bun installs shadowing it.",
70
- );
71
- } else {
72
- lines.push(
73
- "",
74
- "Upgrade Bun, then restart your terminal:",
75
- " bun upgrade",
76
- "",
77
- "Then verify:",
78
- " bun --version",
79
- " skc --version",
80
- );
81
- }
82
- return `${lines.join("\n")}\n`;
83
- }
84
-
85
39
  // =============================================================================
86
40
  // Project directory
87
41
  // =============================================================================
@@ -90,7 +44,7 @@ export function formatBunRuntimeError(opts: {
90
44
  * On macOS, strip /private prefix only when both paths resolve to the same location.
91
45
  * This preserves aliases like /private/tmp -> /tmp without rewriting unrelated paths.
92
46
  */
93
- function standardizeMacOSPath(p: string): string {
47
+ export function standardizeMacOSPath(p: string): string {
94
48
  if (process.platform !== "darwin" || !p.startsWith("/private/")) return p;
95
49
  const stripped = p.slice("/private".length);
96
50
  try {
@@ -110,6 +64,25 @@ export function resolveEquivalentPath(inputPath: string): string {
110
64
  }
111
65
  }
112
66
 
67
+ /**
68
+ * Return a stable key for comparing existing filesystem paths.
69
+ *
70
+ * Windows path spelling is not a safe identity: most directories are
71
+ * case-insensitive, while directories can opt into case-sensitive names. Use
72
+ * the filesystem object's identity when it is available instead of folding
73
+ * path text. If the path does not exist or the filesystem has no usable inode,
74
+ * retain the resolved spelling so distinct case-sensitive paths never alias.
75
+ */
76
+ export function pathIdentityKey(inputPath: string): string {
77
+ const resolvedPath = resolveEquivalentPath(inputPath);
78
+ if (process.platform !== "win32") return resolvedPath;
79
+ try {
80
+ const stats = fs.statSync(resolvedPath, { bigint: true });
81
+ if (stats.ino !== 0n) return JSON.stringify(["win32-file-id", stats.dev.toString(), stats.ino.toString()]);
82
+ } catch {}
83
+ return resolvedPath;
84
+ }
85
+
113
86
  export function normalizePathForComparison(inputPath: string, platform: NodeJS.Platform = process.platform): string {
114
87
  const pathApi = platform === "win32" ? path.win32 : path;
115
88
  const resolvedPath = platform === process.platform ? resolveEquivalentPath(inputPath) : pathApi.resolve(inputPath);
@@ -145,8 +118,9 @@ export function getProjectDir(): string {
145
118
 
146
119
  /** Set the project directory. */
147
120
  export function setProjectDir(dir: string): void {
148
- projectDir = standardizeMacOSPath(path.resolve(dir));
149
- process.chdir(projectDir);
121
+ const resolved = standardizeMacOSPath(path.resolve(dir));
122
+ process.chdir(resolved);
123
+ projectDir = standardizeMacOSPath(process.cwd());
150
124
  }
151
125
 
152
126
  /**
@@ -169,9 +143,9 @@ function sanitizeConfigDirName(value: string | undefined): string | undefined {
169
143
  return trimmed;
170
144
  }
171
145
 
172
- /** Get the config directory name relative to home (e.g. ".skc" or PI_CONFIG_DIR override). */
173
146
  /**
174
- * Config-directory name, rejected when it comes from the caller's project `.env`.
147
+ * Resolve an environment value only when it is not supplied by the caller's
148
+ * project dotenv (or when the inherited value is observably distinct).
175
149
  *
176
150
  * The name is joined with the home directory to build the config root, and that
177
151
  * root plus the agent directory beneath it supply two of the `.env` files
@@ -181,28 +155,190 @@ function sanitizeConfigDirName(value: string | undefined): string | undefined {
181
155
  * recovering every endpoint and credential redirect the boundary rejects.
182
156
  *
183
157
  * `env.ts` imports this module, so the check cannot go through `$credentialEnv`;
184
- * it applies the same conservative ambiguity rule directly, matching how
185
- * `SKC_CODING_AGENT_DIR` is treated.
158
+ * it applies the same conservative ambiguity rule directly: a value that matches
159
+ * what the project `.env` sets is not honoured. An operator whose environment
160
+ * happens to carry the identical value loses the override, which is the same
161
+ * trade-off `resolveLiveCredentialEnvValue` already makes.
186
162
  */
187
- function trustedConfigDirName(name: "SKC_CONFIG_DIR" | "PI_CONFIG_DIR"): string | undefined {
163
+ function trustedValue(name: string, project: ProjectEnvSnapshot): string | undefined {
188
164
  const value = process.env[name];
189
165
  if (!value) return undefined;
190
- if (parseEnvFile(path.join(process.cwd(), ".env"))[name] === value) return undefined;
166
+ const key = canonicalEnvKey(name);
167
+ const projectValue = project.values[key];
168
+ if (projectValue !== undefined && (project.dynamic.has(key) || projectValue === value)) return undefined;
191
169
  return value;
192
170
  }
193
171
 
194
- export function getConfigDirName(): string {
195
- // Both guards apply: the value must come from a trusted source (not the
196
- // caller's project `.env`), and it must still be a single name that stays
197
- // beneath home once joined.
172
+ function resolveConfigDirName(project: ProjectEnvSnapshot): string {
198
173
  return (
199
- sanitizeConfigDirName(trustedConfigDirName("SKC_CONFIG_DIR")) ??
200
- sanitizeConfigDirName(trustedConfigDirName("PI_CONFIG_DIR")) ??
174
+ sanitizeConfigDirName(trustedValue("SKC_CONFIG_DIR", project)) ??
175
+ sanitizeConfigDirName(trustedValue("PI_CONFIG_DIR", project)) ??
201
176
  CONFIG_DIR_NAME
202
177
  );
203
178
  }
204
179
 
205
- /** Get the config agent directory name relative to home (e.g. ".skc/agent" or PI_CONFIG_DIR + "/agent"). */
180
+ /**
181
+ * A home directory is usable only when it is absolute and resolves to somewhere
182
+ * strictly below a filesystem root. A relative value would anchor user state
183
+ * beneath whatever the current directory happens to be, and a root would place
184
+ * it at `/.skc`.
185
+ *
186
+ * The root test normalizes first, because a root has many spellings: `/.`, `//`,
187
+ * `/foo/..` and `C:\x\..` are all roots that a raw string comparison against
188
+ * `path.parse(home).root` misses, and `path.join(home, ".skc")` would happily
189
+ * produce `/.skc` from every one of them.
190
+ *
191
+ * The **original spelling** is returned, never the normalized form. Provenance
192
+ * compares the declared dotenv value against this result, and both sides must
193
+ * stay in the same spelling: canonicalizing only this side would make
194
+ * `HOME=/tmp/base/../attacker` compare unequal to its own declaration and let a
195
+ * project-planted home through as if it were operator-supplied.
196
+ */
197
+ function usableHome(home: string | undefined): string | undefined {
198
+ if (!home || !path.isAbsolute(home)) return undefined;
199
+ const normalized = path.resolve(home);
200
+ return normalized === path.parse(normalized).root ? undefined : home;
201
+ }
202
+
203
+ /**
204
+ * The account home for the running uid, read through the operating system's own
205
+ * account database.
206
+ *
207
+ * On Linux this must go through NSS rather than parsing `/etc/passwd`: LDAP and
208
+ * SSSD accounts have no local passwd entry, and a direct file read would miss
209
+ * them and fall through to an environment-derived value. `getent passwd` is the
210
+ * NSS front end, so it resolves local and directory-backed accounts alike.
211
+ *
212
+ * Only an **environment-independent** result is memoized, and only on success.
213
+ * The cache is keyed by the effective account identity, not by process lifetime:
214
+ * a setuid or container identity transition must never reuse another uid's home.
215
+ * The NSS answer cannot change during one identity's process lifetime, so
216
+ * per-identity caching is safe.
217
+ * The `os.userInfo()` fallback is different: Bun derives `homedir` from `$HOME`,
218
+ * so caching it would freeze one side of the independence comparison in
219
+ * {@link resolveTrustedHome}. A planted home that was live at first resolution
220
+ * would stay cached, and once the runtime home moved it would no longer *equal*
221
+ * the runtime home -- passing the echo check and being promoted to independent
222
+ * evidence. Provenance is carried with the value so that can never happen.
223
+ */
224
+ type AccountHome = { home: string; envDerived: boolean };
225
+ type AccountIdentity = { key: string; uid: number };
226
+
227
+ const accountHomeCache = new Map<string, AccountHome>();
228
+
229
+ function accountIdentity(info: os.UserInfo<string>): AccountIdentity {
230
+ const uid = process.platform === "win32" ? info.uid : (process.geteuid?.() ?? info.uid);
231
+ return {
232
+ key: `${process.platform}:uid=${uid}:user=${info.username}`,
233
+ uid,
234
+ };
235
+ }
236
+
237
+ /** The uid's home field from the NSS account database, or undefined. */
238
+ function nssAccountHome(uid: number): string | undefined {
239
+ try {
240
+ // Spawned with an empty environment so nothing the caller controls (HOME,
241
+ // NSS module configuration, locale) can steer the answer.
242
+ const result = Bun.spawnSync({
243
+ cmd: ["getent", "passwd", String(uid)],
244
+ env: { PATH: "/usr/bin:/bin:/usr/sbin:/sbin", LC_ALL: "C" },
245
+ stdout: "pipe",
246
+ stderr: "ignore",
247
+ });
248
+ if (result.exitCode !== 0) return undefined;
249
+ // `getent` echoes passwd-format records; the home directory is field 6.
250
+ const line = new TextDecoder().decode(result.stdout).split("\n")[0];
251
+ return usableHome(line?.split(":")[5]);
252
+ } catch {
253
+ return undefined;
254
+ }
255
+ }
256
+
257
+ function accountHomeFromSystem(): AccountHome | undefined {
258
+ try {
259
+ const info = os.userInfo();
260
+ const identity = accountIdentity(info);
261
+ const cached = accountHomeCache.get(identity.key);
262
+ if (cached !== undefined) return cached;
263
+ if (process.platform === "linux") {
264
+ const nss = nssAccountHome(identity.uid);
265
+ if (nss !== undefined) {
266
+ // NSS is environment-independent and stable: safe to memoize.
267
+ const result = { home: nss, envDerived: false };
268
+ accountHomeCache.set(identity.key, result);
269
+ return result;
270
+ }
271
+ }
272
+ // `os.userInfo().homedir` is the portable path for macOS and Windows, and on
273
+ // Linux is reached only when NSS is unavailable. Bun derives it from `$HOME`,
274
+ // so it is re-read every time and never cached, and it is flagged so the
275
+ // caller can refuse to treat it as independent evidence.
276
+ const fallback = usableHome(info.homedir);
277
+ if (fallback !== undefined) return { home: fallback, envDerived: true };
278
+ } catch {
279
+ // Do not retain or consult a prior identity's result when the current
280
+ // identity cannot be observed. An unavailable uid is not evidence for any
281
+ // other uid and must fail closed instead of inheriting stale state.
282
+ return undefined;
283
+ }
284
+ return undefined;
285
+ }
286
+
287
+ /**
288
+ * Resolve the authoritative home for user-scope state.
289
+ *
290
+ * Two properties must hold together, and pinning either one alone breaks the
291
+ * other (issue #4761):
292
+ *
293
+ * 1. **Provenance.** Bun overlays a checkout's `.env` into `process.env` before
294
+ * any module runs, so a repository can plant HOME/USERPROFILE and redirect
295
+ * user state — including the `.env` files `$credentialEnv` treats as trusted.
296
+ * When the platform-authoritative variable is indistinguishable from the
297
+ * value the project dotenv declares, the OS account database wins instead.
298
+ * 2. **Call-time resolution.** The trusted home is *derived*, never snapshotted
299
+ * at module load. A resolution frozen at import silently loses every
300
+ * user-scope location whenever the runtime home is established or changed
301
+ * after this module initializes — which is exactly how user-scope skill and
302
+ * MCP discovery regressed.
303
+ *
304
+ * `os.homedir()` is the runtime candidate: on POSIX it reflects HOME, on Windows
305
+ * USERPROFILE, and it falls back to the account database on its own. Reading it
306
+ * per call is what makes the contract call-time; the provenance comparison above
307
+ * is what keeps an untrusted mutable home from being honored.
308
+ */
309
+ function resolveTrustedHome(project: ProjectEnvSnapshot): string {
310
+ const authoritativeHomeKey = process.platform === "win32" ? "USERPROFILE" : "HOME";
311
+ const declaredHomeKey = canonicalEnvKey(authoritativeHomeKey);
312
+ const declaredHome = project.values[declaredHomeKey];
313
+ // A relative or filesystem-root runtime home would anchor user state beneath
314
+ // the current directory (or at `/`), so it is not a usable candidate no matter
315
+ // how it was supplied. Validate it exactly as the account home is validated.
316
+ const runtimeHome = usableHome(os.homedir());
317
+ // Only the platform-authoritative variable can select the home. In particular,
318
+ // do not let the opposite platform variable (or a project dotenv value
319
+ // overlaid into it) redirect user state when this is absent.
320
+ const ambiguousHome =
321
+ declaredHome !== undefined && (project.dynamic.has(declaredHomeKey) || declaredHome === runtimeHome);
322
+ // The account lookup is consulted lazily. It can spawn the NSS front end, and
323
+ // this resolver runs on every directory access, so an unambiguous runtime home
324
+ // -- the ordinary CLI path -- must never pay for it.
325
+ if (!ambiguousHome && runtimeHome !== undefined) return runtimeHome;
326
+
327
+ const accountHome = accountHomeFromSystem();
328
+ if (ambiguousHome) {
329
+ // The account home is independent evidence only when it is not itself derived
330
+ // from the environment. An `os.userInfo()` fallback echoes `$HOME`, so a
331
+ // project-declared home would otherwise come back as its own justification.
332
+ // Fail closed: with no independent evidence the resolver yields a filesystem
333
+ // root, which `#homeAvailable` rejects, rather than honoring the declared
334
+ // home. Issue #4773 owns widening that fallback; do not weaken it here.
335
+ if (accountHome === undefined || accountHome.envDerived) return path.parse(process.cwd()).root;
336
+ return accountHome.home;
337
+ }
338
+ // No usable runtime home: fall back to whatever the account database reports.
339
+ if (accountHome !== undefined) return accountHome.home;
340
+ throw new Error("Unable to determine a trustworthy account home directory");
341
+ }
206
342
  export function getConfigAgentDirName(): string {
207
343
  return `${getConfigDirName()}/agent`;
208
344
  }
@@ -218,64 +354,158 @@ type XdgCategory = "data" | "state" | "cache";
218
354
  * variables are set, paths are redirected under $XDG_*_HOME/skc/. A new
219
355
  * instance is created whenever the agent directory changes, which naturally
220
356
  * invalidates all cached paths.
357
+ *
358
+ * The trusted home is re-derived on each access (see {@link resolveTrustedHome})
359
+ * and every cached path is rebuilt when it changes, so a home established or
360
+ * mocked after module load is honored without weakening the provenance rule.
221
361
  */
222
362
  class DirResolver {
223
- readonly configRoot: string;
224
- readonly agentDir: string;
363
+ configRoot: string;
364
+ agentDir: string;
365
+ readonly #projectEnv: ProjectEnvSnapshot;
366
+ #configDirName: string;
367
+ readonly #agentDirOverride: boolean;
368
+ #trustedHome: string;
369
+ /**
370
+ * Whether this resolver's agent directory may follow `$XDG_*_HOME`, decided
371
+ * once at construction and never re-derived from the path afterwards.
372
+ */
373
+ #xdgEligible: boolean;
225
374
 
226
375
  // Per-category base dirs. Without XDG, all three equal configRoot / agentDir.
227
376
  // With XDG on Linux, they point to $XDG_*_HOME/skc/.
228
- readonly #rootDirs: Record<XdgCategory, string>;
229
- readonly #agentDirs: Record<XdgCategory, string>;
377
+ #rootDirs: Record<XdgCategory, string>;
378
+ #defaultRootDirs: Record<XdgCategory, string>;
379
+ #agentDirs: Record<XdgCategory, string>;
230
380
 
231
381
  readonly #rootCache = new Map<string, string>();
382
+ readonly #defaultRootCache = new Map<string, string>();
232
383
  readonly #agentCache = new Map<string, string>();
233
384
 
234
- constructor(agentDirOverride?: string) {
235
- this.configRoot = path.join(os.homedir(), getConfigDirName());
385
+ constructor(agentDirOverride?: string, snapshot = projectEnvSnapshot()) {
386
+ this.#projectEnv = snapshot;
387
+ this.#configDirName = resolveConfigDirName(snapshot);
388
+ this.#trustedHome = resolveTrustedHome(snapshot);
389
+ this.configRoot = path.join(this.#trustedHome, this.#configDirName);
236
390
 
237
391
  const defaultAgent = path.join(this.configRoot, "agent");
392
+ this.#agentDirOverride = Boolean(agentDirOverride);
238
393
  this.agentDir = agentDirOverride ? path.resolve(agentDirOverride) : defaultAgent;
239
- const isDefault = this.agentDir === defaultAgent;
394
+ // An agent directory equal to the home-derived default *is* the default
395
+ // profile, XDG categories included, however it arrived.
396
+ //
397
+ // Deciding this from override state instead was tried and reverted: it is
398
+ // unobservably wrong. `setAgentDir()` exports `SKC_CODING_AGENT_DIR`, so a
399
+ // child process inherits the same value the parent set programmatically and
400
+ // cannot tell the two apart. Treating the inherited form as "not default"
401
+ // put parent and child on different storage lanes for one logical profile --
402
+ // the parent reading `$XDG_STATE_HOME/skc/python-gateway` while the child
403
+ // read `<agentDir>/python-gateway`. Splitting a live store in half is worse
404
+ // than the narrower complaint it was meant to answer.
405
+ const isDefault = normalizePathForComparison(this.agentDir) === normalizePathForComparison(defaultAgent);
406
+ // That decision is then *sticky*. Recomputing it later from path shape is
407
+ // what let a pinned agent directory silently change storage lane when a home
408
+ // refresh made it coincide with the new default: `getAgentDir()` looked
409
+ // unchanged while `agent.db` moved into `$XDG_DATA_HOME/skc`.
410
+ this.#xdgEligible = isDefault;
411
+
412
+ this.#rootDirs = { data: this.configRoot, state: this.configRoot, cache: this.configRoot };
413
+ this.#defaultRootDirs = { data: this.configRoot, state: this.configRoot, cache: this.configRoot };
414
+ this.#agentDirs = { data: this.agentDir, state: this.agentDir, cache: this.agentDir };
415
+ this.refreshCategoryDirs(snapshot, isDefault);
416
+ }
240
417
 
241
- // XDG is a Linux convention. On other platforms, or for non-default
242
- // profiles, all categories resolve to the legacy paths.
418
+ get profileAuthority(): "default" | "custom" {
419
+ return this.#xdgEligible ? "default" : "custom";
420
+ }
421
+
422
+ /**
423
+ * `isDefault` decides whether the agent directory may follow `$XDG_*_HOME`.
424
+ *
425
+ * It is always supplied by the caller and never defaulted: the only correct
426
+ * value is the construction-time decision held in `#xdgEligible`, and
427
+ * re-deriving it from path shape is exactly the bug that let a directory
428
+ * change storage lane when a home refresh made its path coincide with the
429
+ * new default.
430
+ */
431
+ private refreshCategoryDirs(snapshot: ProjectEnvSnapshot, isDefault: boolean): void {
243
432
  let xdgData: string | undefined;
244
433
  let xdgState: string | undefined;
245
434
  let xdgCache: string | undefined;
246
- if ((process.platform === "linux" || process.platform === "darwin") && isDefault) {
435
+ if (process.platform === "linux" || process.platform === "darwin") {
247
436
  const resolveIf = (envVar: string) => {
248
- const value = process.env[envVar];
249
- if (value) {
250
- try {
251
- const joined = path.join(value, APP_NAME);
252
- if (fs.existsSync(joined)) {
253
- return joined;
254
- }
255
- } catch {}
437
+ const value = trustedValue(envVar, snapshot);
438
+ if (!value) return undefined;
439
+ try {
440
+ const joined = path.join(value, APP_NAME);
441
+ return fs.existsSync(joined) ? joined : undefined;
442
+ } catch {
443
+ return undefined;
256
444
  }
257
- return undefined;
258
445
  };
259
446
  xdgData = resolveIf("XDG_DATA_HOME");
260
447
  xdgState = resolveIf("XDG_STATE_HOME");
261
448
  xdgCache = resolveIf("XDG_CACHE_HOME");
262
449
  }
263
-
264
- this.#rootDirs = {
450
+ this.#defaultRootDirs = {
265
451
  data: xdgData ?? this.configRoot,
266
452
  state: xdgState ?? this.configRoot,
267
453
  cache: xdgCache ?? this.configRoot,
268
454
  };
269
- // XDG flattens the agent/ prefix: ~/.skc/agent/sessions → $XDG_DATA_HOME/skc/sessions
455
+ this.#rootDirs = {
456
+ data: isDefault ? (xdgData ?? this.configRoot) : this.configRoot,
457
+ state: isDefault ? (xdgState ?? this.configRoot) : this.configRoot,
458
+ cache: isDefault ? (xdgCache ?? this.configRoot) : this.configRoot,
459
+ };
270
460
  this.#agentDirs = {
271
- data: xdgData ?? this.agentDir,
272
- state: xdgState ?? this.agentDir,
273
- cache: xdgCache ?? this.agentDir,
461
+ data: isDefault ? (xdgData ?? this.agentDir) : this.agentDir,
462
+ state: isDefault ? (xdgState ?? this.agentDir) : this.agentDir,
463
+ cache: isDefault ? (xdgCache ?? this.agentDir) : this.agentDir,
274
464
  };
275
465
  }
276
466
 
467
+ /**
468
+ * Re-derive the trusted home and the caller-supplied config-dir override
469
+ * without replacing the trust snapshot.
470
+ *
471
+ * Both inputs are call-time: the home comes from {@link resolveTrustedHome}
472
+ * (provenance-checked, never an import-time snapshot) and the config-dir name
473
+ * from the trusted-value rule. When either changes, the config root, the
474
+ * default agent dir, the XDG category dirs and both path caches are rebuilt
475
+ * so reads and writes cannot straddle two different homes.
476
+ */
477
+ refreshConfigDirOverride(): void {
478
+ const nextConfigDirName = resolveConfigDirName(this.#projectEnv);
479
+ const nextHome = resolveTrustedHome(this.#projectEnv);
480
+ if (nextConfigDirName === this.#configDirName && nextHome === this.#trustedHome) return;
481
+ const nextConfigRoot = path.join(nextHome, nextConfigDirName);
482
+ const nextAgentDir = this.#agentDirOverride ? this.agentDir : path.join(nextConfigRoot, "agent");
483
+ this.#trustedHome = nextHome;
484
+ this.#configDirName = nextConfigDirName;
485
+ this.configRoot = nextConfigRoot;
486
+ this.agentDir = nextAgentDir;
487
+ // Reuse the construction-time decision rather than re-deriving it, so an
488
+ // agent directory never changes storage lane just because a home refresh made
489
+ // its path coincide with (or diverge from) the new default.
490
+ this.refreshCategoryDirs(this.#projectEnv, this.#xdgEligible);
491
+ this.#rootCache.clear();
492
+ this.#defaultRootCache.clear();
493
+ this.#agentCache.clear();
494
+ }
495
+
496
+ /** Whether the resolved home is a real directory rather than a filesystem root. */
497
+ get #homeAvailable(): boolean {
498
+ return this.#trustedHome !== path.parse(this.#trustedHome).root;
499
+ }
500
+
501
+ isProjectEnvDeclaration(name: string): boolean {
502
+ return Object.hasOwn(this.#projectEnv.values, canonicalEnvKey(name));
503
+ }
504
+
277
505
  /** Config-root subdirectory, with optional XDG override. */
278
506
  rootSubdir(subdir: string, xdg?: XdgCategory): string {
507
+ this.refreshConfigDirOverride();
508
+ if (!this.#homeAvailable) throw new Error("User state is unavailable: no trustworthy home directory");
279
509
  const cached = this.#rootCache.get(subdir);
280
510
  if (cached) return cached;
281
511
  const base = xdg ? this.#rootDirs[xdg] : this.configRoot;
@@ -286,7 +516,9 @@ class DirResolver {
286
516
 
287
517
  /** Agent subdirectory, with optional XDG override. */
288
518
  agentSubdir(userAgentDir: string | undefined, subdir: string, xdg?: XdgCategory): string {
289
- if (!userAgentDir || userAgentDir === this.agentDir) {
519
+ this.refreshConfigDirOverride();
520
+ if (!this.#homeAvailable) throw new Error("User state is unavailable: no trustworthy home directory");
521
+ if (!userAgentDir || normalizePathForComparison(userAgentDir) === normalizePathForComparison(this.agentDir)) {
290
522
  const cached = this.#agentCache.get(subdir);
291
523
  if (cached) return cached;
292
524
  const base = xdg ? this.#agentDirs[xdg] : this.agentDir;
@@ -296,72 +528,137 @@ class DirResolver {
296
528
  }
297
529
  return path.join(userAgentDir, subdir);
298
530
  }
299
- }
300
531
 
301
- /**
302
- * Agent-directory override, rejected when it comes from the caller's project
303
- * `.env`.
304
- *
305
- * This directory selects the agent's own `.env`, which is one of the trusted
306
- * sources `$credentialEnv` consults. Bun loads `cwd/.env` into `process.env`
307
- * before any module runs, so a repository could otherwise point this at a
308
- * directory it ships and have its own `.env` treated as trusted — recovering
309
- * every redirect the credential boundary is meant to reject.
310
- *
311
- * `env.ts` imports this module, so the check cannot go through `$credentialEnv`;
312
- * it applies the same conservative ambiguity rule directly: a value that matches
313
- * what the project `.env` sets is not honoured. An operator whose environment
314
- * happens to carry the identical value loses the override, which is the same
315
- * trade-off `resolveLiveCredentialEnvValue` already makes.
316
- */
317
- function trustedAgentDirOverrideFor(name: "SKC_CODING_AGENT_DIR" | "PI_CODING_AGENT_DIR"): string | undefined {
318
- const value = process.env[name];
319
- if (!value) return undefined;
320
- if (parseEnvFile(path.join(process.cwd(), ".env"))[name] === value) return undefined;
321
- return value;
532
+ /** Config-root subdirectory for the resolver-owned default profile. */
533
+ rootSubdirForDefaultProfile(subdir: string, xdg?: XdgCategory): string {
534
+ this.refreshConfigDirOverride();
535
+ if (!this.#homeAvailable) throw new Error("User state is unavailable: no trustworthy home directory");
536
+ const cacheKey = `${xdg ?? "config"}:${subdir}`;
537
+ const cached = this.#defaultRootCache.get(cacheKey);
538
+ if (cached) return cached;
539
+ const base = xdg ? this.#defaultRootDirs[xdg] : this.configRoot;
540
+ const result = path.join(base, subdir);
541
+ this.#defaultRootCache.set(cacheKey, result);
542
+ return result;
543
+ }
544
+
545
+ get configDirName(): string {
546
+ return this.#configDirName;
547
+ }
548
+ get trustedHome(): string {
549
+ this.refreshConfigDirOverride();
550
+ return this.#trustedHome;
551
+ }
552
+ assertHomeAvailable(): void {
553
+ this.refreshConfigDirOverride();
554
+ if (!this.#homeAvailable) throw new Error("User state is unavailable: no trustworthy home directory");
555
+ }
556
+ get trustSnapshot(): ProjectEnvSnapshot {
557
+ return this.#projectEnv;
558
+ }
322
559
  }
323
560
 
561
+ const INITIAL_PROJECT_SNAPSHOT = projectEnvSnapshot();
562
+ const trustedAgentOverride =
563
+ trustedValue("SKC_CODING_AGENT_DIR", INITIAL_PROJECT_SNAPSHOT) ??
564
+ trustedValue("PI_CODING_AGENT_DIR", INITIAL_PROJECT_SNAPSHOT);
565
+ let dirs = new DirResolver(trustedAgentOverride, INITIAL_PROJECT_SNAPSHOT);
566
+
324
567
  /**
325
- * Both spellings are honoured, mirroring `getConfigDirName`.
326
- *
327
- * `PI_CODING_AGENT_DIR` is the legacy alias this module's own header documents,
328
- * and parts of the product already resolve it (`gc-runtime.ts:370`,
329
- * `deep-interview-runtime.ts:384`). Reading only the `SKC_` spelling here split
330
- * the agent directory in two: `skc gc` operated on the aliased directory while
331
- * everything reaching `getAgentDir()` stayed on the default.
568
+ * Project dotenv as captured when this module loaded.
569
+ * A later edit or delete of those files does not change the returned provenance.
332
570
  */
333
- function trustedAgentDirOverride(): string | undefined {
334
- return trustedAgentDirOverrideFor("SKC_CODING_AGENT_DIR") ?? trustedAgentDirOverrideFor("PI_CODING_AGENT_DIR");
571
+ export function loadedProjectEnvSnapshot(): ProjectEnvSnapshot {
572
+ const snapshot = dirs.trustSnapshot;
573
+ return { values: { ...snapshot.values }, dynamic: new Set(snapshot.dynamic) };
335
574
  }
336
575
 
337
- let dirs = new DirResolver(trustedAgentDirOverride());
338
-
339
- // Anchor home for the resolver. Captured at module load to stay stable across
340
- // test mocks of `os.homedir()`. `getPluginsDir(home)` compares against this so
341
- // production callers (`home === RESOLVER_HOME`) hit the XDG-aware resolver while
342
- // tests passing a temp HOME short-circuit to a deterministic path.
343
- const RESOLVER_HOME = os.homedir();
344
-
345
576
  // =============================================================================
346
577
  // Root directories
347
578
  // =============================================================================
348
579
 
349
580
  /** Get the config root directory (~/.skc). */
350
581
  export function getConfigRootDir(): string {
582
+ dirs.refreshConfigDirOverride();
583
+ dirs.assertHomeAvailable();
351
584
  return dirs.configRoot;
352
585
  }
353
586
 
354
- /** Set the coding agent directory. Creates a fresh resolver, invalidating all cached paths. */
587
+ /**
588
+ * The authoritative home for user-scope state.
589
+ *
590
+ * Provenance-checked and resolved at call time: a home established or changed
591
+ * after this module loaded is honored, while a home the project dotenv could
592
+ * have planted is rejected in favor of the OS account database. See
593
+ * {@link resolveTrustedHome}.
594
+ */
595
+ export function getTrustedHomeDir(): string {
596
+ dirs.assertHomeAvailable();
597
+ return dirs.trustedHome;
598
+ }
599
+
600
+ /** Trusted config root, resolved at call time; preserves the configured nested config-dir name. */
601
+ export function getTrustedConfigRootDir(): string {
602
+ dirs.refreshConfigDirOverride();
603
+ dirs.assertHomeAvailable();
604
+ return dirs.configRoot;
605
+ }
606
+
607
+ /**
608
+ * Set the coding agent directory. Creates a fresh resolver, invalidating all
609
+ * cached paths.
610
+ *
611
+ * This also exports `SKC_CODING_AGENT_DIR`, so child processes inherit the same
612
+ * selection and resolve the same storage lane.
613
+ */
355
614
  export function setAgentDir(dir: string): void {
356
- dirs = new DirResolver(dir);
615
+ dirs = new DirResolver(dir, dirs.trustSnapshot);
357
616
  process.env.SKC_CODING_AGENT_DIR = dir;
358
617
  }
359
618
 
619
+ /**
620
+ * Rebuild the resolver from the current trusted environment. Callers that
621
+ * temporarily used {@link setAgentDir} must first restore the original
622
+ * SKC_CODING_AGENT_DIR / PI_CODING_AGENT_DIR values, then call this function;
623
+ * an originally absent override remains absent and keeps following HOME.
624
+ */
625
+ export function resetAgentDirFromEnvironment(): void {
626
+ const snapshot = dirs.trustSnapshot;
627
+ const override = trustedValue("SKC_CODING_AGENT_DIR", snapshot) ?? trustedValue("PI_CODING_AGENT_DIR", snapshot);
628
+ dirs = new DirResolver(override, snapshot);
629
+ }
630
+
360
631
  /** Get the agent config directory (~/.skc/agent). */
361
632
  export function getAgentDir(): string {
633
+ dirs.refreshConfigDirOverride();
634
+ dirs.assertHomeAvailable();
362
635
  return dirs.agentDir;
363
636
  }
364
637
 
638
+ /** Resolver-owned profile classification, stable across HOME refreshes. */
639
+ export function getAgentProfileAuthority(): "default" | "custom" {
640
+ return dirs.profileAuthority;
641
+ }
642
+
643
+ export function getConfigDirName(): string {
644
+ dirs.refreshConfigDirOverride();
645
+ return dirs.configDirName;
646
+ }
647
+ /**
648
+ * Join a file under the provenance-checked agent directory, never the XDG
649
+ * state category. Automatic crash relay must not follow `XDG_STATE_HOME`:
650
+ * a checkout `.env` can set that variable and create `$XDG_STATE_HOME/skc`,
651
+ * which the ordinary state resolver would then treat as the crash store.
652
+ */
653
+ export function getTrustedAgentFile(filename: string): string {
654
+ return path.join(getAgentDir(), filename);
655
+ }
656
+
657
+ /** Whether the current checkout declares an environment key in its `.env`. */
658
+ export function isProjectEnvDeclaration(name: string): boolean {
659
+ return dirs.isProjectEnvDeclaration(name);
660
+ }
661
+
365
662
  /** Get the project-local config directory (.skc). */
366
663
  export function getProjectAgentDir(cwd: string = getProjectDir()): string {
367
664
  return path.join(cwd, CONFIG_DIR_NAME);
@@ -378,31 +675,98 @@ export function getReportsDir(): string {
378
675
 
379
676
  /** Get the logs directory (~/.skc/logs). */
380
677
  export function getLogsDir(): string {
381
- return dirs.rootSubdir("logs", "state");
678
+ const home = getTrustedHomeDir();
679
+ return resolveCanonicalLogsDir({
680
+ home,
681
+ env: {
682
+ SKC_CONFIG_DIR: process.env.SKC_CONFIG_DIR,
683
+ PI_CONFIG_DIR: process.env.PI_CONFIG_DIR,
684
+ XDG_STATE_HOME: process.env.XDG_STATE_HOME,
685
+ },
686
+ projectEnv: dirs.trustSnapshot,
687
+ xdgEligible: dirs.profileAuthority === "default",
688
+ pathExists: fs.existsSync,
689
+ });
690
+ }
691
+
692
+ /** Dated log file name, shared by the canonical and effective log paths so they cannot drift. */
693
+ function datedLogFileName(date: Date): string {
694
+ return `${APP_NAME}.${date.toISOString().slice(0, 10)}.log`;
382
695
  }
383
696
 
384
697
  /** Get the path to a dated log file (~/.skc/logs/skc.YYYY-MM-DD.log). */
385
698
  export function getLogPath(date = new Date()): string {
386
- return path.join(getLogsDir(), `${APP_NAME}.${date.toISOString().slice(0, 10)}.log`);
699
+ return path.join(getLogsDir(), datedLogFileName(date));
700
+ }
701
+
702
+ /**
703
+ * The logs directory the process actually reads and writes.
704
+ *
705
+ * A trusted `SKC_LOG_DIR` redirects the sink — the test preload pins it to a
706
+ * per-process temp directory so `bun test` stops appending fixture
707
+ * `level:error` records to the operator's shared log (issue #5618) — and
708
+ * everything else falls back to {@link getLogsDir}.
709
+ *
710
+ * The override is provenance-checked through the same {@link trustedValue} rule
711
+ * as the config and agent directories: Bun loads `cwd/.env` into `process.env`
712
+ * before any module runs, so a repository could otherwise redirect where the
713
+ * operator's production logs are written. A dynamic (`$`/backtick) declaration
714
+ * is rejected there too, which fails closed onto the canonical path.
715
+ *
716
+ * This is deliberately *not* folded into {@link getLogsDir}: that function's
717
+ * config-root semantics are pinned by tests, and the preload sets `SKC_LOG_DIR`
718
+ * for every test process, so folding the override in would move it under every
719
+ * test. Writers and readers must both come through here instead — the split
720
+ * between the two is what let the transport and the log readers disagree.
721
+ */
722
+ export function getEffectiveLogsDir(): string {
723
+ const override = trustedValue("SKC_LOG_DIR", dirs.trustSnapshot)?.trim();
724
+ return override || getLogsDir();
725
+ }
726
+
727
+ /** Get the dated log file under {@link getEffectiveLogsDir}. */
728
+ export function getEffectiveLogPath(date = new Date()): string {
729
+ return path.join(getEffectiveLogsDir(), datedLogFileName(date));
387
730
  }
388
731
 
389
732
  /**
390
733
  * Get the plugins directory (~/.skc/plugins or its XDG equivalent).
391
734
  *
392
735
  * No-arg form (production callers) goes through the XDG-aware DirResolver so
393
- * reads and writes always agree. The optional `home` parameter is for test
394
- * isolation: when it differs from `os.homedir()` it short-circuits the resolver
395
- * and returns `<home>/<configDir>/plugins` so tests with a temp HOME get a
396
- * deterministic path. Passing `os.homedir()` explicitly is identical to the
397
- * no-arg form — XDG semantics are preserved.
736
+ * reads and writes always agree. The optional `home` parameter names an explicit
737
+ * home: when it differs from the authoritative home resolved right now it
738
+ * short-circuits the resolver and returns `<home>/<configDir>/plugins`, giving
739
+ * callers that carry their own home (and tests with a temp HOME) a deterministic
740
+ * path. Passing the authoritative home explicitly is identical to the no-arg
741
+ * form — XDG semantics are preserved.
398
742
  */
399
743
  export function getPluginsDir(home?: string): string {
400
- if (home !== undefined && home !== RESOLVER_HOME) {
401
- return path.join(home, getConfigDirName(), "plugins");
744
+ if (home !== undefined) {
745
+ const explicitPath = () => path.join(home, resolveConfigDirName(dirs.trustSnapshot), "plugins");
746
+ try {
747
+ const canonical = (value: string): string => {
748
+ try {
749
+ return fs.realpathSync.native(value);
750
+ } catch {
751
+ return path.resolve(value);
752
+ }
753
+ };
754
+ if (canonical(home) !== canonical(dirs.trustedHome)) return explicitPath();
755
+ } catch {
756
+ // An explicit home is the caller's documented escape hatch. If the
757
+ // authoritative home is unavailable, do not let its fail-closed resolver
758
+ // prevent a caller-owned plugin path from being returned.
759
+ return explicitPath();
760
+ }
402
761
  }
403
762
  return dirs.rootSubdir("plugins", "data");
404
763
  }
405
764
 
765
+ /** Get the default-profile plugin directory without ambient profile authority. */
766
+ export function getDefaultPluginsDir(): string {
767
+ return dirs.rootSubdirForDefaultProfile("plugins", "data");
768
+ }
769
+
406
770
  /** Where npm installs packages (~/.skc/plugins/node_modules). */
407
771
  export function getPluginsNodeModules(): string {
408
772
  return path.join(getPluginsDir(), "node_modules");
@@ -473,6 +837,8 @@ export function getWorktreeDir(segment: string): string {
473
837
 
474
838
  /** Get the GPU cache path (~/.skc/gpu_cache.json). */
475
839
  export function getGpuCachePath(): string {
840
+ const defaultAgentDir = path.join(dirs.trustedHome, dirs.configDirName, "agent");
841
+ if (path.resolve(dirs.agentDir) !== path.resolve(defaultAgentDir)) return path.join(dirs.agentDir, "gpu_cache.json");
476
842
  return dirs.rootSubdir("gpu_cache.json", "cache");
477
843
  }
478
844
 
@@ -482,11 +848,16 @@ export function getGpuCachePath(): string {
482
848
  * cache file without touching the rest of the config root.
483
849
  */
484
850
  export function getGithubCacheDbPath(): string {
485
- const override = process.env.SKC_GITHUB_CACHE_DB;
851
+ const override = trustedValue("SKC_GITHUB_CACHE_DB", dirs.trustSnapshot)?.trim();
486
852
  if (override) return override;
487
853
  return dirs.rootSubdir(path.join("cache", "github-cache.db"), "cache");
488
854
  }
489
855
 
856
+ /** Get the durable tool-choice capability cache path. */
857
+ export function getToolChoiceCapabilityCachePath(): string {
858
+ return dirs.rootSubdir(path.join("cache", "tool-choice-capabilities.db"), "cache");
859
+ }
860
+
490
861
  /** Get the resolved Claude Code client-version cache path (~/.skc/cache/claude-code-version.json). */
491
862
  export function getClaudeCodeVersionCachePath(): string {
492
863
  return dirs.rootSubdir(path.join("cache", "claude-code-version.json"), "cache");
@@ -497,6 +868,11 @@ export function getNativesDir(): string {
497
868
  return dirs.rootSubdir("natives", "cache");
498
869
  }
499
870
 
871
+ /** Get the local tool-issue report database path (~/.skc/tool-issues.db). */
872
+ export function getToolIssuesDbPath(): string {
873
+ return dirs.rootSubdir("tool-issues.db", "data");
874
+ }
875
+
500
876
  /** Get the stats database path (~/.skc/stats.db). */
501
877
  export function getStatsDbPath(): string {
502
878
  return dirs.rootSubdir("stats.db", "data");
@@ -551,6 +927,16 @@ export function getBlobsDir(agentDir?: string): string {
551
927
  return dirs.agentSubdir(agentDir, "blobs", "data");
552
928
  }
553
929
 
930
+ /** Get the resident-text cache root for a profile agent directory. */
931
+ export function getResidentCacheRootDir(profileAgentDir: string): string {
932
+ return dirs.agentSubdir(profileAgentDir, "resident-cache", "cache");
933
+ }
934
+
935
+ /** Get the managed cold-history sidecar cache root for a profile agent directory. */
936
+ export function getSidecarCacheRootDir(profileAgentDir: string): string {
937
+ return dirs.agentSubdir(profileAgentDir, "sidecar-cache", "cache");
938
+ }
939
+
554
940
  /** Get the custom themes directory (~/.skc/agent/themes). */
555
941
  export function getCustomThemesDir(agentDir?: string): string {
556
942
  return dirs.agentSubdir(agentDir, "themes");
@@ -591,6 +977,31 @@ export function getCrashLogPath(agentDir?: string): string {
591
977
  return dirs.agentSubdir(agentDir, "skc-crash.log", "state");
592
978
  }
593
979
 
980
+ /** Get the crash event journal path (~/.skc/agent/skc-crash-events.jsonl). */
981
+ export function getCrashEventsPath(agentDir?: string): string {
982
+ return dirs.agentSubdir(agentDir, "skc-crash-events.jsonl", "state");
983
+ }
984
+
985
+ /** Get the compacted crash signature index path (~/.skc/agent/skc-crash-index.json). */
986
+ export function getCrashIndexPath(agentDir?: string): string {
987
+ return dirs.agentSubdir(agentDir, "skc-crash-index.json", "state");
988
+ }
989
+
990
+ /** Get the handled error log path (~/.skc/agent/skc-error.log). */
991
+ export function getHandledErrorLogPath(agentDir?: string): string {
992
+ return dirs.agentSubdir(agentDir, "skc-error.log", "state");
993
+ }
994
+
995
+ /** Get the handled error event journal path (~/.skc/agent/skc-error-events.jsonl). */
996
+ export function getHandledErrorEventsPath(agentDir?: string): string {
997
+ return dirs.agentSubdir(agentDir, "skc-error-events.jsonl", "state");
998
+ }
999
+
1000
+ /** Get the compacted handled error signature index path (~/.skc/agent/skc-error-index.json). */
1001
+ export function getHandledErrorIndexPath(agentDir?: string): string {
1002
+ return dirs.agentSubdir(agentDir, "skc-error-index.json", "state");
1003
+ }
1004
+
594
1005
  /** Get the debug log path (~/.skc/agent/skc-debug.log). */
595
1006
  export function getDebugLogPath(agentDir?: string): string {
596
1007
  return dirs.agentSubdir(agentDir, `${APP_NAME}-debug.log`, "state");
@@ -619,18 +1030,25 @@ export function getProjectPluginOverridesPath(cwd: string = getProjectDir()): st
619
1030
  // MCP config paths
620
1031
  // =============================================================================
621
1032
 
622
- /** Get the primary MCP config file path (first candidate). */
623
- export function getMCPConfigPath(scope: "user" | "project", cwd: string = getProjectDir()): string {
1033
+ /**
1034
+ * Get the primary MCP config file path (first candidate).
1035
+ *
1036
+ * User scope lives in the agent directory, so a profile override
1037
+ * (`--agent-dir`, `SKC_CODING_AGENT_DIR`, `setAgentDir()`) moves it. Pass
1038
+ * `agentDir` to resolve the scope of a session whose agent directory differs
1039
+ * from the process-wide one.
1040
+ */
1041
+ export function getMCPConfigPath(scope: "user" | "project", cwd: string = getProjectDir(), agentDir?: string): string {
624
1042
  if (scope === "user") {
625
- return path.join(getAgentDir(), "mcp.json");
1043
+ return path.join(agentDir ?? getAgentDir(), "mcp.json");
626
1044
  }
627
1045
  return path.join(getProjectAgentDir(cwd), "mcp.json");
628
1046
  }
629
1047
 
630
1048
  /** Get the SSH config file path. */
631
- export function getSSHConfigPath(scope: "user" | "project", cwd: string = getProjectDir()): string {
1049
+ export function getSSHConfigPath(scope: "user" | "project", cwd: string = getProjectDir(), agentDir?: string): string {
632
1050
  if (scope === "user") {
633
- return path.join(getAgentDir(), "ssh.json");
1051
+ return path.join(agentDir ?? getAgentDir(), "ssh.json");
634
1052
  }
635
1053
  return path.join(getProjectAgentDir(cwd), "ssh.json");
636
1054
  }