@golba98/codexa 1.0.5 → 1.0.7

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.
@@ -9,11 +9,16 @@ export interface UpdateCheckCache {
9
9
  updateAvailable: boolean;
10
10
  }
11
11
 
12
- const CACHE_FILE = join(homedir(), ".codexa-update-check.json");
12
+ // Resolved per call (not at module load) so HOME changes — e.g. test isolation —
13
+ // are honored. See the same pattern in src/core/models/providerModelCache.ts.
14
+ export function getUpdateCheckCacheFilePath(): string {
15
+ const home = process.env.USERPROFILE ?? process.env.HOME ?? homedir();
16
+ return join(home, ".codexa-update-check.json");
17
+ }
13
18
 
14
- export function loadUpdateCheckCache(): UpdateCheckCache | null {
19
+ export function loadUpdateCheckCache(filePath = getUpdateCheckCacheFilePath()): UpdateCheckCache | null {
15
20
  try {
16
- const text = readFileSync(CACHE_FILE, "utf-8");
21
+ const text = readFileSync(filePath, "utf-8");
17
22
  const data = JSON.parse(text) as Record<string, unknown>;
18
23
  if (typeof data.lastChecked !== "number") return null;
19
24
  if (typeof data.currentVersion !== "string") return null;
@@ -28,12 +33,12 @@ export function loadUpdateCheckCache(): UpdateCheckCache | null {
28
33
  }
29
34
  }
30
35
 
31
- export function saveUpdateCheckCache(cache: UpdateCheckCache): void {
36
+ export function saveUpdateCheckCache(cache: UpdateCheckCache, filePath = getUpdateCheckCacheFilePath()): void {
32
37
  try {
33
- mkdirSync(dirname(CACHE_FILE), { recursive: true });
34
- const tmp = `${CACHE_FILE}.tmp`;
38
+ mkdirSync(dirname(filePath), { recursive: true });
39
+ const tmp = `${filePath}.tmp`;
35
40
  writeFileSync(tmp, JSON.stringify(cache, null, 2), "utf-8");
36
- renameSync(tmp, CACHE_FILE);
41
+ renameSync(tmp, filePath);
37
42
  } catch {
38
43
  // Best-effort — never crash on cache write failure.
39
44
  }
@@ -43,6 +48,11 @@ function stripV(v: string): string {
43
48
  return v.startsWith("v") ? v.slice(1) : v;
44
49
  }
45
50
 
51
+ /** Returns true when a cache entry was created by the running Codexa version. */
52
+ export function isCacheForRunningVersion(cache: UpdateCheckCache, runningVersion: string): boolean {
53
+ return stripV(cache.currentVersion) === stripV(runningVersion);
54
+ }
55
+
46
56
  /**
47
57
  * Returns true only when the cache is still usable:
48
58
  * - `runningVersion` matches the version the cache was written for (version-mismatch = stale)
@@ -54,7 +64,7 @@ export function isCacheValid(
54
64
  runningVersion?: string,
55
65
  ): boolean {
56
66
  if (runningVersion !== undefined) {
57
- if (stripV(cache.currentVersion) !== stripV(runningVersion)) {
67
+ if (!isCacheForRunningVersion(cache, runningVersion)) {
58
68
  return false;
59
69
  }
60
70
  }
@@ -1,5 +1,6 @@
1
1
  import { appendFileSync, mkdirSync } from "fs";
2
- import { dirname, join } from "path";
2
+ import { dirname } from "path";
3
+ import { resolveCodexaDebugLogPath } from "../workspace/appData.js";
3
4
 
4
5
  type ModelStateDebugDetails = Record<string, unknown>;
5
6
 
@@ -12,7 +13,7 @@ export function isModelStateDebugEnabled(): boolean {
12
13
  export function getModelStateDebugLogPath(): string {
13
14
  return process.env.CODEXA_RENDER_DEBUG_FILE?.trim()
14
15
  || process.env.CODEXA_DEBUG_MODEL_STATE_LOG?.trim()
15
- || join(process.cwd(), ".codexa", "debug", "render-status.log");
16
+ || resolveCodexaDebugLogPath();
16
17
  }
17
18
 
18
19
  export function traceModelStateDebug(event: string, details: ModelStateDebugDetails = {}): void {
@@ -46,30 +46,18 @@ function readCacheFile(cacheFile: string): ProviderModelCacheFile | null {
46
46
  }
47
47
  }
48
48
 
49
- // A model id is passed straight to a provider CLI, so it must be a bare token.
50
- // Discovery scrapes CLI output, and a buggy build can bake an ANSI escape into
51
- // an id (e.g. "claude-fable-5\x1b[1m"). Reject rather than repair: a cached id
52
- // we cannot vouch for is dropped so discovery re-probes for a clean one.
53
- const VALID_MODEL_ID = /^[A-Za-z0-9._:\/-]+$/;
54
-
55
- function isUsableModel(model: unknown): model is ProviderModel {
56
- if (typeof model !== "object" || model === null) {
57
- return false;
58
- }
59
- const candidate = model as ProviderModel;
60
- return typeof candidate.id === "string"
61
- && typeof candidate.modelId === "string"
62
- && typeof candidate.label === "string"
63
- && VALID_MODEL_ID.test(candidate.id)
64
- && VALID_MODEL_ID.test(candidate.modelId);
65
- }
66
-
67
49
  function isValidEntry(entry: unknown): entry is CachedProviderModels {
68
50
  if (typeof entry !== "object" || entry === null) {
69
51
  return false;
70
52
  }
71
53
  const candidate = entry as CachedProviderModels;
72
- return typeof candidate.discoveredAt === "number" && Array.isArray(candidate.models);
54
+ return typeof candidate.discoveredAt === "number"
55
+ && Array.isArray(candidate.models)
56
+ && candidate.models.every((model) =>
57
+ typeof model === "object" && model !== null
58
+ && typeof model.id === "string"
59
+ && typeof model.modelId === "string"
60
+ && typeof model.label === "string");
73
61
  }
74
62
 
75
63
  export function loadCachedProviderModels(
@@ -78,14 +66,10 @@ export function loadCachedProviderModels(
78
66
  ): CachedProviderModels | null {
79
67
  const cache = readCacheFile(cacheFile);
80
68
  const entry = cache?.providers?.[providerId];
81
- if (!entry || !isValidEntry(entry)) {
82
- return null;
83
- }
84
- const models = entry.models.filter(isUsableModel);
85
- if (models.length === 0) {
69
+ if (!entry || !isValidEntry(entry) || entry.models.length === 0) {
86
70
  return null;
87
71
  }
88
- return { discoveredAt: entry.discoveredAt, models };
72
+ return entry;
89
73
  }
90
74
 
91
75
  export function saveCachedProviderModels(
@@ -1,6 +1,7 @@
1
1
  import { appendFileSync, mkdirSync } from "fs";
2
- import { dirname, join } from "path";
3
- import { useEffect, useRef } from "react";
2
+ import { dirname } from "path";
3
+ import { useEffect, useRef } from "react";
4
+ import { resolveCodexaDebugLogPath } from "../workspace/appData.js";
4
5
 
5
6
  type DebugEnv = Record<string, string | undefined>;
6
7
 
@@ -11,7 +12,7 @@ let renderTraceEnabled = false;
11
12
  let lifecycleEnabled = false;
12
13
  let flickerEnabled = false;
13
14
  let plainActionsEnabled = false;
14
- let logPath = join(process.cwd(), ".codexa", "debug", "render-status.log");
15
+ let logPath = resolveCodexaDebugLogPath();
15
16
  let sessionId = `${Date.now()}-${process.pid}`;
16
17
  const counters = new Map<string, number>();
17
18
 
@@ -29,7 +30,7 @@ function configureFromEnv(env: DebugEnv = process.env): void {
29
30
  lifecycleEnabled = env["CODEXA_DEBUG_LIFECYCLE"] === "1";
30
31
  flickerEnabled = env["CODEXA_DEBUG_FLICKER"] === "1";
31
32
  plainActionsEnabled = env["CODEXA_DEBUG_PLAIN_ACTIONS"] === "1";
32
- logPath = env["CODEXA_RENDER_DEBUG_FILE"]?.trim() || join(process.cwd(), ".codexa", "debug", "render-status.log");
33
+ logPath = env["CODEXA_RENDER_DEBUG_FILE"]?.trim() || resolveCodexaDebugLogPath(env);
33
34
  sessionId = `${Date.now()}-${process.pid}`;
34
35
  configured = true;
35
36
  }
@@ -39,7 +39,7 @@ export function buildProviderLaunchSpec(provider: ProviderConfig, cwd: string):
39
39
  if (!provider.enabled) {
40
40
  return {
41
41
  status: "disabled",
42
- message: `${provider.displayName} is disabled. Configure a command in .codexa/providers.json before launching it.`,
42
+ message: `${provider.displayName} is disabled. Configure a command in Codexa's provider settings before launching it.`,
43
43
  };
44
44
  }
45
45
 
@@ -1,7 +1,8 @@
1
1
  import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "fs";
2
2
  import { dirname, join } from "path";
3
- import { DEFAULT_MODEL } from "../../config/settings.js";
4
- import { normalizeWorkspaceRoot } from "../workspace/workspaceRoot.js";
3
+ import { DEFAULT_MODEL } from "../../config/settings.js";
4
+ import { resolveCodexaWorkspaceDataDir } from "../workspace/appData.js";
5
+ import { normalizeWorkspaceRoot } from "../workspace/workspaceRoot.js";
5
6
  import { isKnownProviderId } from "./registry.js";
6
7
  import { getDefaultRouteModel, getProviderRuntime, isProviderRouteConfigured, isProviderRoutableInCodexa } from "../providerRuntime/registry.js";
7
8
  import { normalizeGeminiModelId } from "../providerRuntime/models.js";
@@ -17,8 +18,12 @@ const DEPRECATED_ANTIGRAVITY_PROVIDER_ID = "antigravity";
17
18
  const DEPRECATED_ANTIGRAVITY_BACKENDS = new Set(["antigravity-cli-auth", "agy"]);
18
19
  const DEPRECATED_GOOGLE_PROVIDER_ID = "google";
19
20
 
20
- export function getProviderWorkspaceConfigFile(workspaceRoot: string): string {
21
- return join(normalizeWorkspaceRoot(workspaceRoot), ".codexa", "providers.json");
21
+ export function getProviderWorkspaceConfigFile(workspaceRoot: string): string {
22
+ return join(resolveCodexaWorkspaceDataDir(normalizeWorkspaceRoot(workspaceRoot)), "providers.json");
23
+ }
24
+
25
+ export function getLegacyProviderWorkspaceConfigFile(workspaceRoot: string): string {
26
+ return join(normalizeWorkspaceRoot(workspaceRoot), ".codexa", "providers.json");
22
27
  }
23
28
 
24
29
  function isRecord(value: unknown): value is Record<string, unknown> {
@@ -345,15 +350,24 @@ export function serializeProviderWorkspaceConfig(config: ProviderWorkspaceConfig
345
350
  };
346
351
  }
347
352
 
348
- export function loadProviderWorkspaceConfig(workspaceRoot: string): ProviderWorkspaceConfig {
349
- const filePath = getProviderWorkspaceConfigFile(workspaceRoot);
350
- if (!existsSync(filePath)) return {};
351
- try {
352
- return parseProviderWorkspaceConfig(JSON.parse(readFileSync(filePath, "utf-8")));
353
- } catch {
354
- return {};
355
- }
356
- }
353
+ export function loadProviderWorkspaceConfig(workspaceRoot: string): ProviderWorkspaceConfig {
354
+ const filePath = getProviderWorkspaceConfigFile(workspaceRoot);
355
+ if (existsSync(filePath)) {
356
+ try {
357
+ return parseProviderWorkspaceConfig(JSON.parse(readFileSync(filePath, "utf-8")));
358
+ } catch {
359
+ return {};
360
+ }
361
+ }
362
+
363
+ const legacyFilePath = getLegacyProviderWorkspaceConfigFile(workspaceRoot);
364
+ if (!existsSync(legacyFilePath)) return {};
365
+ try {
366
+ return parseProviderWorkspaceConfig(JSON.parse(readFileSync(legacyFilePath, "utf-8")));
367
+ } catch {
368
+ return {};
369
+ }
370
+ }
357
371
 
358
372
  export function saveProviderWorkspaceConfig(workspaceRoot: string, config: ProviderWorkspaceConfig): void {
359
373
  const filePath = getProviderWorkspaceConfigFile(workspaceRoot);
@@ -165,7 +165,18 @@ export function getAgyModelSelector(
165
165
  }
166
166
 
167
167
  export function getAntigravityModelLabel(modelId: string): string {
168
- return getActiveAgyModels().find((m) => m.id === modelId)?.label ?? modelId;
168
+ const discovered = getActiveAgyModels().find((m) => m.id === modelId || m.modelId === modelId);
169
+ if (discovered) return discovered.label;
170
+
171
+ const normalizedId = migrateAntigravityLegacyModelId(modelId).modelId;
172
+ const knownLabels: Record<string, string> = {
173
+ "gemini-3.5-flash": "Gemini 3.5 Flash",
174
+ "gemini-3.1-pro": "Gemini 3.1 Pro",
175
+ "claude-sonnet-4.6-thinking": "Claude Sonnet 4.6 (Thinking)",
176
+ "claude-opus-4.6-thinking": "Claude Opus 4.6 (Thinking)",
177
+ "gpt-oss-120b-medium": "GPT-OSS 120B",
178
+ };
179
+ return knownLabels[normalizedId] ?? modelId;
169
180
  }
170
181
 
171
182
  // ---------------------------------------------------------------------------
@@ -199,17 +199,21 @@ export function resolveActiveProviderRoute(options: {
199
199
  route.modelId = resolveGeminiModelId(route.modelSelection);
200
200
  } else if (route.providerId === "google") {
201
201
  route.modelId = normalizeGeminiModelId(route.modelId);
202
- } else if (route.providerId === "anthropic") {
203
- const discovery = discoverProviderModels("anthropic");
202
+ } else if (route.providerId === "anthropic") {
203
+ const discovery = discoverProviderModels("anthropic");
204
204
  const stillAvailable = discovery.models.some((model) =>
205
205
  model.modelId === route.modelId ||
206
206
  model.id === route.modelId ||
207
207
  model.canonicalId === route.modelId
208
208
  );
209
- const hasNonFallbackModels = discovery.models.some((model) => model.source !== "fallback");
210
- if (discovery.status === "ready" && hasNonFallbackModels && discovery.models.length > 0 && !stillAvailable) {
211
- route.modelId = discovery.models[0]!.modelId;
212
- }
209
+ const hasNonFallbackModels = discovery.models.some((model) => model.source !== "fallback");
210
+ // Preserve explicit versioned Claude IDs even when the current discovery
211
+ // output does not advertise that historical model. Short aliases are the
212
+ // values that need migration to the first currently discovered model.
213
+ const isKnownShortAlias = ANTHROPIC_FALLBACK_MODELS.some((model) => model.modelId === route.modelId);
214
+ if (discovery.status === "ready" && hasNonFallbackModels && discovery.models.length > 0 && !stillAvailable && isKnownShortAlias) {
215
+ route.modelId = discovery.models[0]!.modelId;
216
+ }
213
217
  } else if (route.providerId === "local") {
214
218
  const discovery = discoverProviderModels("local");
215
219
  const selectedModel = typeof discovery.diagnostics?.selectedModel === "string"
@@ -74,16 +74,16 @@ export async function importExternalFile(
74
74
  return destPath;
75
75
  }
76
76
 
77
- export function rewritePromptWithImportedPaths(
78
- prompt: string,
79
- replacements: Array<{ rawPath: string; workspaceRelativePath: string }>,
80
- ): string {
81
- let result = prompt;
82
- for (const { rawPath, workspaceRelativePath } of replacements) {
83
- const needsQuotes = /\s/.test(workspaceRelativePath);
84
- const quotedReplacement = needsQuotes
85
- ? `"${workspaceRelativePath}"`
86
- : workspaceRelativePath;
77
+ export function rewritePromptWithImportedPaths(
78
+ prompt: string,
79
+ replacements: Array<{ rawPath: string; replacementPath: string }>,
80
+ ): string {
81
+ let result = prompt;
82
+ for (const { rawPath, replacementPath } of replacements) {
83
+ const needsQuotes = /\s/.test(replacementPath);
84
+ const quotedReplacement = needsQuotes
85
+ ? `"${replacementPath}"`
86
+ : replacementPath;
87
87
  // Replace quoted forms first so the unquoted pass doesn't double-replace
88
88
  result = result.split(`"${rawPath}"`).join(quotedReplacement);
89
89
  result = result.split(`'${rawPath}'`).join(quotedReplacement);
@@ -1,5 +1,6 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
+ import { resolveCodexaDebugLogPath } from "../workspace/appData.js";
3
4
  import { isTerminalResizing } from "./terminalControl.js";
4
5
 
5
6
  export interface FrameLockOptions {
@@ -28,7 +29,7 @@ export function wrapStdoutWithFrameLock({
28
29
  if (env.CODEXA_RENDER_DEBUG === "1") {
29
30
  try {
30
31
  const logPath = env.CODEXA_RENDER_DEBUG_FILE?.trim()
31
- || path.join(process.cwd(), ".codexa", "debug", "render-status.log");
32
+ || resolveCodexaDebugLogPath(env);
32
33
  const logDir = path.dirname(logPath);
33
34
  if (!fs.existsSync(logDir)) {
34
35
  fs.mkdirSync(logDir, { recursive: true });
@@ -1,7 +1,8 @@
1
1
  import { APP_NAME, type TerminalTitleMode, formatTerminalTitlePath } from "../../config/settings.js";
2
2
  import { appendFileSync, mkdirSync } from "fs";
3
- import { dirname, join } from "path";
3
+ import { dirname } from "path";
4
4
  import * as renderDebug from "../perf/renderDebug.js";
5
+ import { resolveCodexaDebugLogPath } from "../workspace/appData.js";
5
6
 
6
7
  export const DEFAULT_TERMINAL_TITLE = APP_NAME;
7
8
 
@@ -52,7 +53,7 @@ function findIncompleteOscTitleStart(text: string): number {
52
53
 
53
54
  const TERMINAL_TITLE_DEBUG_LOG_PATH = process.env["CODEXA_TERMINAL_TITLE_DEBUG_FILE"]?.trim()
54
55
  || process.env["CODEXA_RENDER_DEBUG_FILE"]?.trim()
55
- || join(process.cwd(), ".codexa", "debug", "render-status.log");
56
+ || resolveCodexaDebugLogPath();
56
57
 
57
58
  let terminalTitleLifecycleState = "unknown";
58
59
 
@@ -0,0 +1,119 @@
1
+ import type { ChildProcess } from "child_process";
2
+ import {
3
+ runCommand,
4
+ runShellCommand,
5
+ type CommandResult,
6
+ type CommandSpec,
7
+ type CommandStreamHandlers,
8
+ } from "../process/CommandRunner.js";
9
+ import { CODEXA_NPM_PACKAGE } from "./updateCheck.js";
10
+
11
+ export type GlobalPackageManager = "npm" | "pnpm" | "yarn" | "bun";
12
+
13
+ const UPDATE_TIMEOUT_MS = 300_000;
14
+
15
+ const PACKAGE_SPEC = `${CODEXA_NPM_PACKAGE}@latest`;
16
+
17
+ // Yarn Classic only — Yarn Berry (v2+) removed `yarn global`, but Berry installs
18
+ // don't produce the global launcher paths we detect, so Classic is the only case.
19
+ const UPDATE_ARGV: Record<GlobalPackageManager, readonly string[]> = {
20
+ npm: ["npm", "install", "-g", PACKAGE_SPEC],
21
+ pnpm: ["pnpm", "add", "-g", PACKAGE_SPEC],
22
+ yarn: ["yarn", "global", "add", PACKAGE_SPEC],
23
+ bun: ["bun", "add", "-g", PACKAGE_SPEC],
24
+ };
25
+
26
+ /**
27
+ * Infers which package manager owns the global Codexa install from the
28
+ * launcher script location (CODEXA_LAUNCHER_SCRIPT, set by bin/codexa.js).
29
+ */
30
+ export function detectGlobalPackageManager(
31
+ env: NodeJS.ProcessEnv = process.env,
32
+ launcherPathOverride?: string,
33
+ ): GlobalPackageManager {
34
+ const launcherPath = launcherPathOverride ?? env.CODEXA_LAUNCHER_SCRIPT ?? process.argv[1] ?? "";
35
+ const normalized = launcherPath.toLowerCase().replace(/\\/g, "/");
36
+ if (!normalized) return "npm";
37
+
38
+ if (normalized.includes("pnpm")) return "pnpm";
39
+ if (normalized.includes("/.bun/") || normalized.includes("/bun/install/global/")) return "bun";
40
+ if (normalized.includes("/.yarn/") || normalized.includes("/yarn/") || normalized.includes(".config/yarn")) {
41
+ return "yarn";
42
+ }
43
+ return "npm";
44
+ }
45
+
46
+ export function getUpdateCommand(pm: GlobalPackageManager): {
47
+ displayCommand: string;
48
+ argv: readonly string[];
49
+ } {
50
+ const argv = UPDATE_ARGV[pm];
51
+ return { displayCommand: argv.join(" "), argv };
52
+ }
53
+
54
+ export interface RunUpdateCommandDeps {
55
+ platform?: NodeJS.Platform;
56
+ cwd?: string;
57
+ runCommandFn?: (
58
+ spec: CommandSpec,
59
+ handlers?: CommandStreamHandlers,
60
+ ) => { child: ChildProcess; result: Promise<CommandResult>; cancel: () => void };
61
+ runShellCommandFn?: (
62
+ command: string,
63
+ options: Pick<CommandSpec, "cwd" | "env" | "timeoutMs">,
64
+ handlers?: CommandStreamHandlers,
65
+ ) => { child: ChildProcess; result: Promise<CommandResult>; cancel: () => void };
66
+ }
67
+
68
+ /**
69
+ * Runs the global update command for the detected package manager.
70
+ * POSIX spawns the argv directly; Windows routes the constant, whitelisted
71
+ * command string through cmd.exe so .cmd shims (npm.cmd, pnpm.cmd) resolve —
72
+ * spawning them without a shell throws EINVAL on Node >= 18.20.
73
+ */
74
+ export function runUpdateCommand(
75
+ pm: GlobalPackageManager,
76
+ handlers: CommandStreamHandlers = {},
77
+ deps: RunUpdateCommandDeps = {},
78
+ ): { result: Promise<CommandResult>; cancel: () => void } {
79
+ const platform = deps.platform ?? process.platform;
80
+ const cwd = deps.cwd ?? process.cwd();
81
+ const { displayCommand, argv } = getUpdateCommand(pm);
82
+
83
+ if (platform === "win32") {
84
+ const runShell = deps.runShellCommandFn ?? runShellCommand;
85
+ const { result, cancel } = runShell(displayCommand, { cwd, timeoutMs: UPDATE_TIMEOUT_MS }, handlers);
86
+ return { result, cancel };
87
+ }
88
+
89
+ const run = deps.runCommandFn ?? runCommand;
90
+ const { result, cancel } = run(
91
+ { executable: argv[0]!, args: [...argv.slice(1)], cwd, timeoutMs: UPDATE_TIMEOUT_MS },
92
+ handlers,
93
+ );
94
+ return { result, cancel };
95
+ }
96
+
97
+ const PERMISSION_STDERR_RE = /EACCES|EPERM|permission denied/i;
98
+
99
+ /** npm reports EACCES via stderr with a nonzero exit, not as a spawn error. */
100
+ export function isPermissionError(result: CommandResult): boolean {
101
+ if (result.errorCode === "EACCES" || result.errorCode === "EPERM") return true;
102
+ return result.exitCode !== 0 && PERMISSION_STDERR_RE.test(result.stderr);
103
+ }
104
+
105
+ export function formatPermissionGuidance(pm: GlobalPackageManager): string {
106
+ const { displayCommand } = getUpdateCommand(pm);
107
+ const lines = [
108
+ `${pm} could not write to its global package directory, so the update was not installed.`,
109
+ ];
110
+ if (pm === "npm") {
111
+ lines.push(
112
+ "Check where global packages are installed with `npm config get prefix` and make sure that directory is writable by your user, or switch to a user-writable prefix (for example `npm config set prefix ~/.npm-global`, then add its bin directory to PATH).",
113
+ );
114
+ } else {
115
+ lines.push(`Make sure the ${pm} global package directory is writable by your user.`);
116
+ }
117
+ lines.push(`You can also run the command manually in a terminal with the right permissions: ${displayCommand}`);
118
+ return lines.join("\n");
119
+ }
@@ -154,7 +154,10 @@ export async function checkForUpdates(
154
154
  }
155
155
  }
156
156
 
157
- export function formatUpdateInstructions(result: UpdateCheckResult | null): string {
157
+ export function formatUpdateInstructions(
158
+ result: UpdateCheckResult | null,
159
+ updateCommand: string = CODEXA_UPDATE_COMMAND,
160
+ ): string {
158
161
  const current = result?.currentVersion ?? APP_VERSION;
159
162
  const latest = result?.latestVersion ?? "unknown";
160
163
 
@@ -166,21 +169,24 @@ export function formatUpdateInstructions(result: UpdateCheckResult | null): stri
166
169
  ].join("\n");
167
170
  }
168
171
 
169
- let statusLine: string;
170
- if (result?.status === "update-available" && result.latestVersion) {
171
- statusLine = `Update available: Codexa ${formatVersionLabel(result.latestVersion)}`;
172
- } else if (result?.status === "up-to-date") {
173
- statusLine = "Already up to date.";
174
- } else {
175
- statusLine = "Status unknown — could not reach npm registry.";
172
+ if (result?.status === "up-to-date") {
173
+ return [
174
+ "Codexa is up to date.",
175
+ `Current installed version: ${current}`,
176
+ `npm latest version: ${latest}`,
177
+ ].join("\n");
176
178
  }
177
179
 
180
+ const statusLine = result?.status === "update-available" && result.latestVersion
181
+ ? `Update available: Codexa ${formatVersionLabel(result.latestVersion)}`
182
+ : "Status unknown — could not reach npm registry.";
183
+
178
184
  return [
179
185
  `Current installed version: ${current}`,
180
186
  `npm latest version: ${latest}`,
181
187
  `Status: ${statusLine}`,
182
188
  "",
183
- `Run: ${CODEXA_UPDATE_COMMAND}`,
189
+ `Run: ${updateCommand}`,
184
190
  ].join("\n");
185
191
  }
186
192
 
@@ -0,0 +1,45 @@
1
+ import { createHash } from "node:crypto";
2
+ import { homedir } from "node:os";
3
+ import { isAbsolute, join, normalize } from "node:path";
4
+
5
+ type Platform = "win32" | "darwin" | "linux" | string;
6
+ type Environment = Record<string, string | undefined>;
7
+
8
+ export function resolveCodexaDataDir(
9
+ platformOverride?: Platform,
10
+ env: Environment = process.env,
11
+ home = homedir(),
12
+ ): string {
13
+ const configuredDir = env["CODEXA_DATA_DIR"]?.trim();
14
+ if (configuredDir) return configuredDir;
15
+
16
+ const platform = platformOverride ?? process.platform;
17
+ if (platform === "win32") {
18
+ return join(env["LOCALAPPDATA"]?.trim() || env["APPDATA"]?.trim() || join(home, "AppData", "Local"), "Codexa");
19
+ }
20
+ if (platform === "darwin") {
21
+ return join(home, "Library", "Application Support", "Codexa");
22
+ }
23
+
24
+ return join(env["XDG_DATA_HOME"]?.trim() || join(home, ".local", "share"), "codexa");
25
+ }
26
+
27
+ export function workspaceStorageKey(workspaceRoot: string): string {
28
+ return createHash("sha256").update(workspaceRoot).digest("hex").slice(0, 16);
29
+ }
30
+
31
+ export function resolveCodexaWorkspaceDataDir(workspaceRoot: string): string {
32
+ return join(resolveCodexaDataDir(), "workspaces", workspaceStorageKey(workspaceRoot));
33
+ }
34
+
35
+ export function resolveCodexaAttachmentDir(workspaceRoot: string, configuredDir: string): string {
36
+ if (isAbsolute(configuredDir)) return configuredDir;
37
+
38
+ const normalized = configuredDir.trim().replace(/\\/g, "/").replace(/^\.codexa\/?/, "") || "attachments";
39
+ const safeRelativeDir = normalize(normalized).replace(/^(\.\.([/\\]|$))+/, "") || "attachments";
40
+ return join(resolveCodexaWorkspaceDataDir(workspaceRoot), safeRelativeDir);
41
+ }
42
+
43
+ export function resolveCodexaDebugLogPath(env: Environment = process.env): string {
44
+ return join(resolveCodexaDataDir(undefined, env), "debug", "render-status.log");
45
+ }
@@ -1,9 +1,10 @@
1
1
  import { createHash } from "crypto";
2
2
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
3
- import { homedir } from "os";
4
- import { join } from "path";
5
- import { isNoiseLine } from "../providers/codexTranscript.js";
6
- import { sanitizeTerminalOutput } from "../terminal/terminalSanitize.js";
3
+ import { homedir } from "os";
4
+ import { join } from "path";
5
+ import { isNoiseLine } from "../providers/codexTranscript.js";
6
+ import { sanitizeTerminalOutput } from "../terminal/terminalSanitize.js";
7
+ import { resolveCodexaDataDir } from "./appData.js";
7
8
 
8
9
  type Platform = "win32" | "darwin" | "linux" | string;
9
10
 
@@ -91,15 +92,8 @@ export function resolvePlanDir(platformOverride?: Platform): string {
91
92
  return join(homedir(), "AppData", "Local", "Codexa", "plans");
92
93
  }
93
94
 
94
- if (platform === "darwin") {
95
- return join(homedir(), "Library", "Application Support", "Codexa", "plans");
96
- }
97
-
98
- // Linux and other Unix-like
99
- const xdgDataHome = process.env["XDG_DATA_HOME"];
100
- if (xdgDataHome) return join(xdgDataHome, "codexa", "plans");
101
- return join(homedir(), ".local", "share", "codexa", "plans");
102
- }
95
+ return join(resolveCodexaDataDir(platform), "plans");
96
+ }
103
97
 
104
98
  // SHA-256 of the workspace path ensures filename uniqueness across projects with the same name.
105
99
  function workspaceHash(workspaceRoot: string): string {