diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3d7f15f..9e24466 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,6 +21,7 @@ jobs: uses: actions/setup-node@v4 with: node-version: "22" + registry-url: https://registry.npmjs.org cache: npm - name: Install dependencies diff --git a/package-lock.json b/package-lock.json index ce39a1d..182f6dc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@carboncode/cli", - "version": "0.1.2", + "version": "0.1.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@carboncode/cli", - "version": "0.1.2", + "version": "0.1.5", "license": "MIT", "dependencies": { "cli-highlight": "^2.1.11", @@ -2225,7 +2225,7 @@ }, "node_modules/@types/ws": { "version": "8.18.1", - "resolved": "https://registry.npmmirror.com/@types/ws/-/ws-8.18.1.tgz", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", "dev": true, "license": "MIT", @@ -6891,7 +6891,7 @@ }, "node_modules/ws": { "version": "8.20.1", - "resolved": "https://registry.npmmirror.com/ws/-/ws-8.20.1.tgz", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.1.tgz", "integrity": "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==", "license": "MIT", "engines": { diff --git a/package.json b/package.json index 4aeaec7..7be76a3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@carboncode/cli", - "version": "0.1.2", + "version": "0.1.5", "description": "Chinese-first DeepSeek-powered terminal coding agent for personal developer workflows.", "type": "module", "bin": { diff --git a/src/cli/heap-limit-launch.ts b/src/cli/heap-limit-launch.ts index 5a83d88..543e0b1 100644 --- a/src/cli/heap-limit-launch.ts +++ b/src/cli/heap-limit-launch.ts @@ -3,20 +3,30 @@ import { spawnSync } from "node:child_process"; import { totalmem } from "node:os"; import { getHeapStatistics } from "node:v8"; -import { RX_HEAP_REEXEC_ENV, decideHeapTargetMb } from "./heap-limit.js"; +import { + HEAP_REEXEC_ENV, + LEGACY_HEAP_REEXEC_ENV, + decideHeapTargetMb, + isHeapReexecEnvSet, +} from "./heap-limit.js"; const target = decideHeapTargetMb({ currentLimitMb: Math.floor(getHeapStatistics().heap_size_limit / 1024 / 1024), totalMemMb: Math.floor(totalmem() / 1024 / 1024), nodeOptions: process.env.NODE_OPTIONS ?? "", execArgv: process.execArgv, - alreadyReexec: process.env[RX_HEAP_REEXEC_ENV] === "1", + alreadyReexec: isHeapReexecEnvSet(), }); if (target !== null) { const existing = process.env.NODE_OPTIONS ?? ""; const nextOptions = `${existing} --max-old-space-size=${target}`.trim(); - const childEnv = { ...process.env, NODE_OPTIONS: nextOptions, [RX_HEAP_REEXEC_ENV]: "1" }; + const childEnv = { + ...process.env, + NODE_OPTIONS: nextOptions, + [HEAP_REEXEC_ENV]: "1", + [LEGACY_HEAP_REEXEC_ENV]: "1", + }; const result = spawnSync(process.execPath, process.argv.slice(1), { env: childEnv, stdio: "inherit", diff --git a/src/cli/heap-limit.ts b/src/cli/heap-limit.ts index 6fe7457..4c8d216 100644 --- a/src/cli/heap-limit.ts +++ b/src/cli/heap-limit.ts @@ -8,7 +8,12 @@ export const TARGET_HEAP_MB_FLOOR = 2048; export const HEAP_HEADROOM_MB = 64; /** Set on the spawned child so we don't re-exec recursively if Node ignores our flag. */ -export const RX_HEAP_REEXEC_ENV = "REASONIX_HEAP_REEXEC"; +export const HEAP_REEXEC_ENV = "CARBONCODE_HEAP_REEXEC"; +export const LEGACY_HEAP_REEXEC_ENV = "REASONIX_HEAP_REEXEC"; + +export function isHeapReexecEnvSet(env: NodeJS.ProcessEnv = process.env): boolean { + return env[HEAP_REEXEC_ENV] === "1" || env[LEGACY_HEAP_REEXEC_ENV] === "1"; +} export interface HeapCheckInputs { currentLimitMb: number; diff --git a/src/server/api/health.ts b/src/server/api/health.ts index 6583d11..d43fcea 100644 --- a/src/server/api/health.ts +++ b/src/server/api/health.ts @@ -1,9 +1,7 @@ import { existsSync, readdirSync, statSync } from "node:fs"; -import { homedir } from "node:os"; import { join } from "node:path"; -import { listSessions } from "../../memory/session.js"; import { VERSION } from "../../version.js"; -import type { DashboardContext } from "../context.js"; +import { type DashboardContext, resolveCarboncodeHome } from "../context.js"; import type { ApiResult } from "../router.js"; interface DirStat { @@ -56,6 +54,17 @@ function dirSize(path: string): DirStat { return { path, exists: true, fileCount, totalBytes }; } +function countSessionFiles(path: string): number { + if (!existsSync(path)) return 0; + try { + return readdirSync(path).filter( + (file) => file.endsWith(".jsonl") && !file.endsWith(".events.jsonl"), + ).length; + } catch { + return 0; + } +} + export async function handleHealth( method: string, _rest: string[], @@ -65,8 +74,7 @@ export async function handleHealth( if (method !== "GET") { return { status: 405, body: { error: "GET only" } }; } - const home = homedir(); - const carboncodeHome = join(home, ".carboncode"); + const carboncodeHome = resolveCarboncodeHome(ctx.configPath); const sessionsStat = dirSize(join(carboncodeHome, "sessions")); const memoryStat = dirSize(join(carboncodeHome, "memory")); @@ -81,8 +89,6 @@ export async function handleHealth( } } - const sessions = listSessions(); - return { status: 200, body: { @@ -91,7 +97,7 @@ export async function handleHealth( carboncodeHome, sessions: { path: sessionsStat.path, - count: sessions.length, + count: countSessionFiles(sessionsStat.path), totalBytes: sessionsStat.totalBytes, }, memory: { diff --git a/src/server/api/memory.ts b/src/server/api/memory.ts index e244ce5..28871d0 100644 --- a/src/server/api/memory.ts +++ b/src/server/api/memory.ts @@ -10,26 +10,25 @@ import { unlinkSync, writeFileSync, } from "node:fs"; -import { homedir } from "node:os"; import { basename, dirname, join, resolve as resolvePath } from "node:path"; import { PROJECT_MEMORY_FILE, findProjectMemoryPath, resolveProjectMemoryWritePath, } from "../../memory/project.js"; -import type { DashboardContext } from "../context.js"; +import { type DashboardContext, resolveCarboncodeHome } from "../context.js"; import type { ApiResult } from "../router.js"; function projectHash(rootDir: string): string { return createHash("sha1").update(resolvePath(rootDir)).digest("hex").slice(0, 16); } -function globalMemoryDir(): string { - return join(homedir(), ".carboncode", "memory", "global"); +function globalMemoryDir(carboncodeHome: string): string { + return join(carboncodeHome, "memory", "global"); } -function projectMemoryDir(rootDir: string): string { - return join(homedir(), ".carboncode", "memory", projectHash(rootDir)); +function projectMemoryDir(carboncodeHome: string, rootDir: string): string { + return join(carboncodeHome, "memory", projectHash(rootDir)); } interface WriteBody { @@ -74,8 +73,9 @@ export async function handleMemory( ctx: DashboardContext, ): Promise { const cwd = ctx.getCurrentCwd?.(); - const globalDir = globalMemoryDir(); - const projectMemDir = cwd ? projectMemoryDir(cwd) : ""; + const carboncodeHome = resolveCarboncodeHome(ctx.configPath); + const globalDir = globalMemoryDir(carboncodeHome); + const projectMemDir = cwd ? projectMemoryDir(carboncodeHome, cwd) : ""; if (method === "GET" && rest.length === 0) { const existingProjectMemory = cwd ? findProjectMemoryPath(cwd) : null; diff --git a/src/server/context.ts b/src/server/context.ts index 83fa7c8..50faefa 100644 --- a/src/server/context.ts +++ b/src/server/context.ts @@ -1,11 +1,20 @@ /** Callbacks (not refs) so endpoints read live loop state per request, not a frozen closure. */ +import { homedir } from "node:os"; +import { basename, dirname, join } from "node:path"; import type { McpServerSummary } from "../cli/ui/slash/types.js"; import type { EditMode } from "../config.js"; import type { CacheFirstLoop } from "../loop.js"; import type { ToolRegistry } from "../tools.js"; import type { JobRegistry } from "../tools/jobs.js"; +export function resolveCarboncodeHome(configPath: string): string { + if (!configPath.trim()) return join(homedir(), ".carboncode"); + const configDir = dirname(configPath); + if (basename(configDir).toLowerCase() === ".carboncode") return configDir; + return join(configDir, ".carboncode"); +} + export interface DashboardContext { /** Caller resolves via `defaultConfigPath()`; module deliberately avoids `homedir()` so tests can redirect. */ configPath: string; diff --git a/tests/cli-bare-routing.test.ts b/tests/cli-bare-routing.test.ts index 8c99a98..0e51170 100644 --- a/tests/cli-bare-routing.test.ts +++ b/tests/cli-bare-routing.test.ts @@ -4,6 +4,7 @@ import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "nod import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { HEAP_REEXEC_ENV } from "../src/cli/heap-limit.js"; import { writeConfig } from "../src/config.js"; const codeCommand = vi.fn(async () => {}); @@ -25,6 +26,7 @@ describe("bare CLI routing", () => { let cwd: string; const origHome = process.env.HOME; const origUserProfile = process.env.USERPROFILE; + const origHeapReexec = process.env[HEAP_REEXEC_ENV]; const origArgv = process.argv; const origCwd = process.cwd(); let stderr: ReturnType; @@ -38,6 +40,7 @@ describe("bare CLI routing", () => { cwd = realpathSync(mkdtempSync(join(tmpdir(), "carboncode-cli-cwd-"))); process.env.HOME = home; process.env.USERPROFILE = home; + process.env[HEAP_REEXEC_ENV] = "1"; process.chdir(cwd); codeCommand.mockClear(); chatCommand.mockClear(); @@ -63,6 +66,11 @@ describe("bare CLI routing", () => { } else { process.env.USERPROFILE = origUserProfile; } + if (origHeapReexec === undefined) { + delete process.env[HEAP_REEXEC_ENV]; + } else { + process.env[HEAP_REEXEC_ENV] = origHeapReexec; + } }); it("routes bare carboncode to code mode rooted at cwd", async () => { diff --git a/tests/heap-limit.test.ts b/tests/heap-limit.test.ts index 717233a..1df0aff 100644 --- a/tests/heap-limit.test.ts +++ b/tests/heap-limit.test.ts @@ -1,9 +1,12 @@ import { describe, expect, it } from "vitest"; import { HEAP_HEADROOM_MB, + HEAP_REEXEC_ENV, + LEGACY_HEAP_REEXEC_ENV, TARGET_HEAP_MB_CEILING, TARGET_HEAP_MB_FLOOR, decideHeapTargetMb, + isHeapReexecEnvSet, } from "../src/cli/heap-limit.js"; describe("decideHeapTargetMb (issue #1011)", () => { @@ -73,7 +76,7 @@ describe("decideHeapTargetMb (issue #1011)", () => { ).toBeNull(); }); - it("returns null after a successful re-exec (REASONIX_HEAP_REEXEC=1 set)", () => { + it("returns null after a successful re-exec", () => { expect( decideHeapTargetMb({ ...base, @@ -84,6 +87,14 @@ describe("decideHeapTargetMb (issue #1011)", () => { ).toBeNull(); }); + it("recognizes Carbon and legacy heap re-exec env markers", () => { + expect(isHeapReexecEnvSet({ [HEAP_REEXEC_ENV]: "1" })).toBe(true); + expect(isHeapReexecEnvSet({ [LEGACY_HEAP_REEXEC_ENV]: "1" })).toBe(true); + expect(isHeapReexecEnvSet({ [HEAP_REEXEC_ENV]: "0", [LEGACY_HEAP_REEXEC_ENV]: "0" })).toBe( + false, + ); + }); + it("does NOT re-exec when current limit is already at the target", () => { expect( decideHeapTargetMb({ diff --git a/tests/helpers/codex-parity-harness.ts b/tests/helpers/codex-parity-harness.ts index 67e127c..2bb03c6 100644 --- a/tests/helpers/codex-parity-harness.ts +++ b/tests/helpers/codex-parity-harness.ts @@ -139,6 +139,7 @@ function runCommand( const child = spawn(command, [...args], { cwd, env: { ...process.env, CI: "1" }, + shell: process.platform === "win32", stdio: ["ignore", "pipe", "pipe"], }); const chunks: Buffer[] = [];