diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json index a8a02a1..b0d7d8e 100644 --- a/.codex-plugin/plugin.json +++ b/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "cc", - "version": "1.0.6", + "version": "1.0.7", "description": "Claude Code Plugin for Codex. Delegate code reviews, investigations, and tracked tasks to Claude Code from inside Codex.", "author": { "name": "Sendbird, Inc.", diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1933459..ee7026e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,7 +7,30 @@ on: pull_request: jobs: - ci: + core-cross-platform: + name: Core checks (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: + - windows-latest + - macos-latest + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6 + with: + node-version: 24 + cache: npm + - run: npm ci + - run: npm run check:version-sync + - run: npm run check:changelog + - run: npm run lint + - run: npm run typecheck + - run: npm run test:cross-platform + + linux-full: + name: Full CI (ubuntu-latest) runs-on: ubuntu-latest steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 diff --git a/CHANGELOG.md b/CHANGELOG.md index 5ac7f50..ecec9f2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## v1.0.7 + +- Add GitHub CI coverage across Windows, macOS, and Linux, with a portable cross-platform test suite plus Linux-only full integration/E2E coverage. +- Harden background routing by validating `parentThreadId`, combining reserved-job and session-routing metadata into one helper, and making background review/rescue explicitly use built-in forwarding subagents rather than direct detached companion processes. +- Stop exposing managed job log paths through user/model-facing status and result surfaces while keeping on-disk logs for debugging. +- Make installed skill-path materialization consistent for both staged installs and direct local-checkout installs, and centralize installer path helpers for reuse. +- Switch sandbox temp-dir settings from a hardcoded `/tmp` path to the OS temp directory so the runtime configuration stays valid off Linux. + ## v1.0.6 - Restore parent-session ownership for built-in background rescue/review runs so resume candidates, plain `$cc:status`, and no-argument `$cc:result` stay aligned after nested child sessions run. diff --git a/README.md b/README.md index 5284345..3d05b03 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,7 @@ That includes: - Session-scoped tracked jobs with status, result, and cancel commands - Background completion nudges that steer you to the right `$cc:result ` - An optional stop-time review gate +- GitHub CI coverage on Windows, macOS, and Linux It follows the shape of [openai/codex-plugin-cc](https://github.com/openai/codex-plugin-cc) but runs in the opposite direction. @@ -53,6 +54,9 @@ That's the entire install. It: - Enables `codex_hooks = true` - Installs lifecycle, review-gate, and unread-result hooks +On Windows, prefer the `npx` path above. The shell-script installer below is POSIX-only. +The maintained CI matrix now covers Windows, macOS, and Linux. The `npx` install path is the cross-platform path we test on every release. + > **Prerequisites:** Node.js 18+, Codex with hook support, and `claude` CLI installed and authenticated. > If you don't have the Claude CLI yet: > ```bash diff --git a/package-lock.json b/package-lock.json index 7af434c..235602a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "cc-plugin-codex", - "version": "1.0.6", + "version": "1.0.7", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "cc-plugin-codex", - "version": "1.0.6", + "version": "1.0.7", "license": "Apache-2.0", "devDependencies": { "@eslint/js": "^10.0.1", diff --git a/package.json b/package.json index 843555f..4733d91 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "cc-plugin-codex", - "version": "1.0.6", + "version": "1.0.7", "description": "Claude Code Plugin for Codex by Sendbird", "type": "module", "author": { @@ -51,6 +51,7 @@ "prepack": "npm run check:version-sync && npm run check:changelog", "setup:git-hooks": "node scripts/setup-git-hooks.mjs", "test": "node --test tests/*.test.mjs", + "test:cross-platform": "node --test tests/args.test.mjs tests/changelog.test.mjs tests/claude-cli.test.mjs tests/fs.test.mjs tests/prompts.test.mjs tests/render.test.mjs tests/sandbox-modes.test.mjs tests/skills-contracts.test.mjs tests/structured-output.test.mjs tests/version-sync.test.mjs", "test:integration": "node --test tests/integration/*.test.mjs", "test:e2e": "node --test tests/e2e/*.test.mjs", "uninstall:codex": "node scripts/installer-cli.mjs uninstall", diff --git a/scripts/claude-companion.mjs b/scripts/claude-companion.mjs index 5500a1d..784b485 100644 --- a/scripts/claude-companion.mjs +++ b/scripts/claude-companion.mjs @@ -118,6 +118,8 @@ function printUsage() { " node scripts/claude-companion.mjs status [job-id] [--all] [--json]", " node scripts/claude-companion.mjs result [job-id] [--json]", " node scripts/claude-companion.mjs cancel [job-id] [--json]", + " node scripts/claude-companion.mjs session-routing-context [--cwd ] [--json]", + " node scripts/claude-companion.mjs background-routing-context --kind [--cwd ] [--json]", " node scripts/claude-companion.mjs task-resume-candidate [--json]", " node scripts/claude-companion.mjs task-reserve-job [--json]", " node scripts/claude-companion.mjs review-reserve-job [--json]" @@ -131,7 +133,7 @@ function printUsage() { function outputResult(value, asJson) { if (asJson) { - console.log(JSON.stringify(value, null, 2)); + console.log(JSON.stringify(value, redactOutputReplacer, 2)); } else { process.stdout.write(typeof value === "string" ? value : JSON.stringify(value, null, 2)); } @@ -141,6 +143,13 @@ function outputCommandResult(payload, rendered, asJson) { outputResult(asJson ? payload : rendered, asJson); } +function redactOutputReplacer(key, value) { + if (key === "logFile") { + return undefined; + } + return value; +} + // --------------------------------------------------------------------------- // Normalization // --------------------------------------------------------------------------- @@ -165,24 +174,58 @@ function resolveExplicitJobId(value, workspaceRoot) { if (value == null || String(value).trim() === "") { return null; } - const explicitJobId = sanitizeId(String(value).trim(), "job ID"); - if (readStoredJob(workspaceRoot, explicitJobId)) { - throw new Error(`Claude Code job id ${explicitJobId} already exists.`); + const explicitJobId = String(value).trim(); + if (explicitJobId.startsWith("--")) { + throw new Error(`Invalid job ID: ${explicitJobId}`); } - if (!fs.existsSync(resolveReservedJobFile(workspaceRoot, explicitJobId))) { + const safeJobId = sanitizeId(explicitJobId, "job ID"); + if (readStoredJob(workspaceRoot, safeJobId)) { + throw new Error(`Claude Code job id ${safeJobId} already exists.`); + } + if (!fs.existsSync(resolveReservedJobFile(workspaceRoot, safeJobId))) { throw new Error( - `Claude Code job id ${explicitJobId} is not reserved. Reserve one with the companion reserve-job helper before reusing it.` + `Claude Code job id ${safeJobId} is not reserved. Reserve one with the companion reserve-job helper before reusing it.` ); } - return explicitJobId; + return safeJobId; } function resolveOwnerSessionId(value) { const trimmed = value == null ? "" : String(value).trim(); if (!trimmed) return null; + if (trimmed.startsWith("--")) { + throw new Error(`Invalid session ID: ${trimmed}`); + } return sanitizeId(trimmed, "session ID"); } +function resolveParentThreadId() { + const threadId = String(process.env.CODEX_THREAD_ID ?? "").trim(); + if (!threadId) { + return null; + } + if (threadId.startsWith("--")) { + return null; + } + try { + return sanitizeId(threadId, "parent thread ID"); + } catch { + return null; + } +} + +function buildSessionRoutingContext(cwd) { + const workspaceRoot = resolveWorkspaceRoot(cwd); + return { + workspaceRoot, + ownerSessionId: + resolveOwnerSessionId( + process.env[SESSION_ID_ENV] ?? getCurrentSession(workspaceRoot) ?? null + ), + parentThreadId: resolveParentThreadId(), + }; +} + function alignCurrentSessionToOwner(workspaceRoot, ownerSessionId) { if (!ownerSessionId) { return; @@ -1492,8 +1535,9 @@ function handleTaskResumeCandidate(argv) { }); const cwd = resolveCommandCwd(options); - const workspaceRoot = resolveCommandWorkspace(options); - const sessionId = process.env[SESSION_ID_ENV] ?? getCurrentSession(workspaceRoot) ?? null; + const routing = buildSessionRoutingContext(cwd); + const workspaceRoot = routing.workspaceRoot; + const sessionId = routing.ownerSessionId; const jobs = sortJobsNewestFirst(listJobs(workspaceRoot)); const candidate = sessionId == null @@ -1531,6 +1575,45 @@ function handleTaskResumeCandidate(argv) { outputCommandResult(payload, rendered, options.json); } +function handleSessionRoutingContext(argv) { + const { options } = parseCommandInput(argv, { + valueOptions: ["cwd"], + booleanOptions: ["json"], + }); + + const cwd = resolveCommandCwd(options); + const payload = buildSessionRoutingContext(cwd); + const rendered = + `Owner session: ${payload.ownerSessionId ?? "(none)"}\n` + + `Parent thread: ${payload.parentThreadId ?? "(none)"}\n`; + outputCommandResult(payload, rendered, options.json); +} + +function handleBackgroundRoutingContext(argv) { + const { options } = parseCommandInput(argv, { + valueOptions: ["cwd", "kind"], + booleanOptions: ["json"], + }); + + const kind = String(options.kind ?? "").trim().toLowerCase(); + const prefix = kind === "review" ? "review" : kind === "task" ? "task" : null; + if (!prefix) { + throw new Error("background-routing-context requires --kind review or --kind task."); + } + + const cwd = resolveCommandCwd(options); + const workspaceRoot = resolveCommandWorkspace({ cwd }); + const payload = { + ...buildSessionRoutingContext(cwd), + jobId: reserveUniqueJobId(workspaceRoot, prefix, prefix), + }; + const rendered = + `Job: ${payload.jobId}\n` + + `Owner session: ${payload.ownerSessionId ?? "(none)"}\n` + + `Parent thread: ${payload.parentThreadId ?? "(none)"}\n`; + outputCommandResult(payload, rendered, options.json); +} + function handleReserveJob(argv, prefix) { const { options } = parseCommandInput(argv, { valueOptions: ["cwd"], @@ -1670,6 +1753,12 @@ async function main() { case "result": handleResult(argv); break; + case "session-routing-context": + handleSessionRoutingContext(argv); + break; + case "background-routing-context": + handleBackgroundRoutingContext(argv); + break; case "task-resume-candidate": handleTaskResumeCandidate(argv); break; diff --git a/scripts/installer-cli.mjs b/scripts/installer-cli.mjs index c654405..ecc37cf 100755 --- a/scripts/installer-cli.mjs +++ b/scripts/installer-cli.mjs @@ -11,7 +11,8 @@ import path from "node:path"; import process from "node:process"; import { fileURLToPath } from "node:url"; import { spawnSync } from "node:child_process"; -import { resolveCodexHome } from "./lib/codex-paths.mjs"; +import { samePath, resolveCodexHome } from "./lib/codex-paths.mjs"; +import { materializeInstalledSkillPaths } from "./lib/installed-skill-paths.mjs"; const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url)); const PACKAGE_ROOT = path.resolve(SCRIPT_DIR, ".."); @@ -55,10 +56,6 @@ function parseArgs(argv) { return { command }; } -function samePath(a, b) { - return path.resolve(a) === path.resolve(b); -} - function ensureEmptyDir(dirPath) { fs.rmSync(dirPath, { recursive: true, force: true }); fs.mkdirSync(dirPath, { recursive: true }); @@ -97,6 +94,7 @@ function stageInstall(sourceRoot, installDir) { try { copyDistribution(sourceRoot, stagingDir); + materializeInstalledSkillPaths(stagingDir, installDir); fs.rmSync(installDir, { recursive: true, force: true }); fs.renameSync(stagingDir, installDir); } catch (error) { @@ -113,6 +111,7 @@ function runLocalInstaller(installDir, command) { ...process.env, HOME: process.env.HOME || os.homedir(), USERPROFILE: process.env.USERPROFILE || process.env.HOME || os.homedir(), + CC_PLUGIN_CODEX_SKILLS_MATERIALIZED: "1", }, }); diff --git a/scripts/lib/args.mjs b/scripts/lib/args.mjs index cd1a95b..d2536a8 100644 --- a/scripts/lib/args.mjs +++ b/scripts/lib/args.mjs @@ -39,7 +39,16 @@ export function parseArgs(argv, config = {}) { if (valueOptions.has(key)) { const nextValue = inlineValue ?? argv[index + 1]; - if (nextValue === undefined) { + if ( + nextValue === undefined || + (inlineValue === undefined && + isRecognizedOptionToken( + nextValue, + valueOptions, + booleanOptions, + aliasMap + )) + ) { throw new Error(`Missing value for --${rawKey}`); } options[key] = nextValue; @@ -63,7 +72,15 @@ export function parseArgs(argv, config = {}) { if (valueOptions.has(key)) { const nextValue = argv[index + 1]; - if (nextValue === undefined) { + if ( + nextValue === undefined || + isRecognizedOptionToken( + nextValue, + valueOptions, + booleanOptions, + aliasMap + ) + ) { throw new Error(`Missing value for -${shortKey}`); } options[key] = nextValue; @@ -77,6 +94,22 @@ export function parseArgs(argv, config = {}) { return { options, positionals }; } +function isRecognizedOptionToken(token, valueOptions, booleanOptions, aliasMap) { + if (!token || token === "-" || token === "--" || !token.startsWith("-")) { + return token === "--"; + } + + if (token.startsWith("--")) { + const [rawKey] = token.slice(2).split("=", 2); + const key = aliasMap[rawKey] ?? rawKey; + return valueOptions.has(key) || booleanOptions.has(key); + } + + const shortKey = token.slice(1); + const key = aliasMap[shortKey] ?? shortKey; + return valueOptions.has(key) || booleanOptions.has(key); +} + export function splitRawArgumentString(raw) { const tokens = []; let current = ""; diff --git a/scripts/lib/claude-cli.mjs b/scripts/lib/claude-cli.mjs index 8f95f9d..3cff9f7 100644 --- a/scripts/lib/claude-cli.mjs +++ b/scripts/lib/claude-cli.mjs @@ -10,8 +10,9 @@ import { spawn, spawnSync } from "node:child_process"; import { randomBytes } from "node:crypto"; import fs from "node:fs"; +import os from "node:os"; import path from "node:path"; -import { resolvePluginRuntimeRoot } from "./codex-paths.mjs"; +import { normalizePathSlashes, resolvePluginRuntimeRoot } from "./codex-paths.mjs"; import { getProcessIdentity, validateProcessIdentity } from "./process.mjs"; const CLAUDE_BIN = "claude"; @@ -20,6 +21,7 @@ export const MAX_STREAM_PARSER_PARSE_ERRORS = 50; export const MAX_STREAM_PARSER_TOOL_USES = 256; export const MAX_STREAM_PARSER_TOUCHED_FILES = 256; export const MAX_STDERR_BYTES = 64 * 1024; +export const SANDBOX_TEMP_DIR = normalizePathSlashes(path.resolve(os.tmpdir())); function pushBoundedTail(list, value, maxEntries) { list.push(value); @@ -371,7 +373,7 @@ export const SANDBOX_READ_ONLY_TOOLS = [ * Sandbox presets matching Codex sandbox modes. * * read-only: no writes at all, no network from Bash. - * workspace-write: Bash can write to cwd + /tmp only, no network from Bash. + * workspace-write: Bash can write to cwd + OS temp dir only, no network from Bash. * All tools allowed (no allowedTools restriction). */ export const SANDBOX_SETTINGS = { @@ -380,7 +382,7 @@ export const SANDBOX_SETTINGS = { enabled: true, autoAllowBashIfSandboxed: true, filesystem: { - allowWrite: ["/tmp"], + allowWrite: [SANDBOX_TEMP_DIR], }, network: { allowedDomains: [], @@ -392,7 +394,7 @@ export const SANDBOX_SETTINGS = { enabled: true, autoAllowBashIfSandboxed: true, filesystem: { - allowWrite: [".", "/tmp"], + allowWrite: [".", SANDBOX_TEMP_DIR], }, network: { allowedDomains: [], diff --git a/scripts/lib/codex-paths.mjs b/scripts/lib/codex-paths.mjs index c1c69d7..773e782 100644 --- a/scripts/lib/codex-paths.mjs +++ b/scripts/lib/codex-paths.mjs @@ -8,6 +8,18 @@ import path from "node:path"; export const PLUGIN_DATA_NAMESPACE = "cc"; export const LEGACY_PLUGIN_DATA_NAMESPACES = ["claude-code"]; +export function normalizePathSlashes(value) { + return value.replace(/\\/g, "/"); +} + +export function samePath(a, b, platform = process.platform) { + const left = path.resolve(a); + const right = path.resolve(b); + return platform === "win32" + ? left.toLowerCase() === right.toLowerCase() + : left === right; +} + export function resolveCodexHome() { return process.env.CODEX_HOME || path.join(os.homedir(), ".codex"); } diff --git a/scripts/lib/installed-skill-paths.mjs b/scripts/lib/installed-skill-paths.mjs new file mode 100644 index 0000000..405163f --- /dev/null +++ b/scripts/lib/installed-skill-paths.mjs @@ -0,0 +1,31 @@ +/** + * Copyright 2026 Sendbird, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ +import fs from "node:fs"; +import path from "node:path"; + +import { normalizePathSlashes } from "./codex-paths.mjs"; + +export function materializeInstalledSkillPaths(skillTreeRoot, installedRoot = skillTreeRoot) { + const normalizedPluginRoot = normalizePathSlashes(installedRoot); + const skillsRoot = path.join(skillTreeRoot, "skills"); + if (!fs.existsSync(skillsRoot)) { + return; + } + + for (const skillName of fs.readdirSync(skillsRoot, { withFileTypes: true })) { + if (!skillName.isDirectory()) { + continue; + } + const skillPath = path.join(skillsRoot, skillName.name, "SKILL.md"); + if (!fs.existsSync(skillPath)) { + continue; + } + const original = fs.readFileSync(skillPath, "utf8"); + const rewritten = original.replaceAll("", normalizedPluginRoot); + if (rewritten !== original) { + fs.writeFileSync(skillPath, rewritten, "utf8"); + } + } +} diff --git a/scripts/lib/managed-global-integration.mjs b/scripts/lib/managed-global-integration.mjs index c29ea16..9148f98 100644 --- a/scripts/lib/managed-global-integration.mjs +++ b/scripts/lib/managed-global-integration.mjs @@ -6,7 +6,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import { resolveCodexHome } from "./codex-paths.mjs"; +import { normalizePathSlashes, resolveCodexHome } from "./codex-paths.mjs"; const MARKETPLACE_NAME = "local-plugins"; const PLUGIN_NAME = "cc"; @@ -51,10 +51,6 @@ function writeText(filePath, content) { fs.writeFileSync(filePath, content, "utf8"); } -function normalizePathSlashes(value) { - return value.replace(/\\/g, "/"); -} - function removeIfEmpty(dirPath) { if (!fs.existsSync(dirPath)) { return; diff --git a/scripts/lib/render.mjs b/scripts/lib/render.mjs index dee8f66..26a0738 100644 --- a/scripts/lib/render.mjs +++ b/scripts/lib/render.mjs @@ -135,11 +135,6 @@ function formatMarkdownLink(label, target) { return `[${label}](${target})`; } -function formatLogFileLink(logFile) { - if (!logFile) return null; - return formatMarkdownLink(path.basename(logFile), logFile); -} - function formatJobTimestamp(value) { return typeof value === "string" && value.trim() ? value.trim() : ""; } @@ -330,8 +325,6 @@ export function renderJobStatusReport(job) { } const resumeCmd = formatClaudeResumeCommand(job); if (resumeCmd) pushKeyValueTableRow(lines, "Resume", `\`${resumeCmd}\``, { raw: true }); - const logLink = formatLogFileLink(job.logFile); - if (logLink) pushKeyValueTableRow(lines, "Log", logLink, { raw: true }); if (job.status === "queued" || job.status === "running") { pushKeyValueTableRow(lines, "Cancel", `\`${formatClaudeSkillCommand("cancel", job.id)}\``, { raw: true }); } else { diff --git a/scripts/lib/tracked-jobs.mjs b/scripts/lib/tracked-jobs.mjs index beeb93f..19d825b 100644 --- a/scripts/lib/tracked-jobs.mjs +++ b/scripts/lib/tracked-jobs.mjs @@ -12,6 +12,7 @@ import fs from "node:fs"; import process from "node:process"; +import { terminateProcessTree } from "./process.mjs"; import { nowIso, ensureStateDir, getCurrentSession, patchJob, resolveJobLogFile, writeJobFile, cleanupOldJobs, transitionJob } from "./state.mjs"; export { nowIso }; @@ -235,7 +236,7 @@ export async function runTrackedJob(job, runner, options = {}) { ); if (!transition.transitioned) { // Job already left running state (cancel won the race) — kill the child immediately - try { process.kill(-pid, "SIGTERM"); } catch {} + try { terminateProcessTree(pid); } catch {} return; } }; diff --git a/scripts/local-plugin-install.mjs b/scripts/local-plugin-install.mjs index 9748c13..10afbee 100644 --- a/scripts/local-plugin-install.mjs +++ b/scripts/local-plugin-install.mjs @@ -12,7 +12,8 @@ import process from "node:process"; import { spawnSync } from "node:child_process"; import { fileURLToPath } from "node:url"; import { callCodexAppServer } from "./lib/codex-app-server.mjs"; -import { resolveCodexHome } from "./lib/codex-paths.mjs"; +import { normalizePathSlashes, resolveCodexHome, samePath } from "./lib/codex-paths.mjs"; +import { materializeInstalledSkillPaths } from "./lib/installed-skill-paths.mjs"; import { cleanupManagedGlobalIntegrations, resolveManagedMarketplacePluginPath, @@ -30,6 +31,7 @@ const MARKETPLACE_FILE = path.join(HOME_DIR, ".agents", "plugins", "marketplace. const CODEX_CONFIG_FILE = path.join(CODEX_HOME, "config.toml"); const CODEX_SKILLS_DIR = path.join(CODEX_HOME, "skills"); const CODEX_PROMPTS_DIR = path.join(CODEX_HOME, "prompts"); +const INSTALLED_PLUGIN_ROOT = path.join(CODEX_HOME, "plugins", PLUGIN_NAME); const PLUGIN_CONFIG_HEADER = `[plugins."${PLUGIN_NAME}@${MARKETPLACE_NAME}"]`; const EXPORTED_SKILLS = [ "review", @@ -97,10 +99,6 @@ function normalizeTrailingNewline(text) { return `${text.replace(/\s*$/, "")}\n`; } -function normalizePathSlashes(value) { - return value.replace(/\\/g, "/"); -} - function formatWrapperName(skillName) { return `${PLUGIN_NAME}-${skillName}`; } @@ -142,6 +140,7 @@ function rewriteSkillFrontmatter(markdown, skillName) { function rewriteSkillBody(markdown, pluginRoot) { const normalizedPluginRoot = normalizePathSlashes(pluginRoot); return markdown + .replaceAll("", normalizedPluginRoot) .replace( "Resolve `` as two directories above this skill file. The companion entrypoint is:", "Use the companion entrypoint at:" @@ -497,6 +496,12 @@ async function uninstallPluginThroughCodex() { } export async function install(pluginRoot, skipHookInstall) { + if ( + samePath(pluginRoot, INSTALLED_PLUGIN_ROOT) && + process.env.CC_PLUGIN_CODEX_SKILLS_MATERIALIZED !== "1" + ) { + materializeInstalledSkillPaths(pluginRoot); + } upsertMarketplaceEntry(pluginRoot); configureCodexHooks(); let usedFallback = false; diff --git a/skills/adversarial-review/SKILL.md b/skills/adversarial-review/SKILL.md index ae6d87a..49be6b4 100644 --- a/skills/adversarial-review/SKILL.md +++ b/skills/adversarial-review/SKILL.md @@ -7,8 +7,8 @@ description: 'Run a design-challenging Claude Code review of local git changes i Use this skill when the user wants Claude Code to challenge the implementation approach, design choices, assumptions, or tradeoffs in this repository. -Resolve `` as two directories above this skill file. The companion entrypoint is: -`node "/scripts/claude-companion.mjs" adversarial-review ...` +Do not derive the companion path from this skill file or any cache directory. Always run the installed copy: +`node "/scripts/claude-companion.mjs" adversarial-review ...` Supported arguments: `--wait`, `--background`, `--base `, `--scope auto|working-tree|branch`, `--model `, plus optional focus text after the flags @@ -49,19 +49,21 @@ Argument handling: Foreground flow: - Run: - `node "/scripts/claude-companion.mjs" adversarial-review --view-state on-success ` + `node "/scripts/claude-companion.mjs" adversarial-review --view-state on-success ` - Present the companion stdout faithfully. - Do not fix anything mentioned in the review output. Background flow: - For background adversarial review, use Codex's built-in `default` subagent instead of a detached background shell command. -- Before spawning the built-in child, reserve a review job id by running: - `node "/scripts/claude-companion.mjs" review-reserve-job --json` +- Never satisfy background adversarial review by running the companion command itself with shell backgrounding such as `&`, `nohup`, detached `spawn`, or any equivalent direct background process launch. +- Background here means "spawn the forwarding child via `spawn_agent` and do not wait in the parent turn." The companion adversarial-review command inside that child still runs once, in the foreground, inside the child thread. +- Before spawning the built-in child, capture the review job id plus routing context in one call: + `node "/scripts/claude-companion.mjs" background-routing-context --kind review --json` - If that helper returns a non-empty `jobId`, pass it into the companion command as an internal `--job-id ` routing flag. -- Add an internal `--owner-session-id ` routing flag when spawning the built-in child so the tracked review job stays visible in the parent Codex session's plain `$cc:status`. -- If the built-in review is running in background, the parent should first capture its own thread id by running: - `node -e "process.stdout.write(process.env.CODEX_THREAD_ID || '')"` -- If that command returns a non-empty thread id, pass it into the child prompt as the parent thread id for one-shot completion notification. +- If that helper returns a non-empty `ownerSessionId`, include `--owner-session-id ` in the companion command. +- If it returns an empty `ownerSessionId`, omit `--owner-session-id` entirely. Never leave an empty placeholder such as `--owner-session-id --job-id`. +- If that helper returns a non-empty `parentThreadId`, pass it into the child prompt as the parent thread id for one-shot completion notification. +- If it returns an empty `parentThreadId`, omit the notification path instead of emitting a blank thread-id placeholder. - Spawn exactly one transient forwarding child through `spawn_agent` with: - `agent_type: "default"` - `fork_context: false` @@ -76,14 +78,17 @@ Background flow: - The built-in child must be a pure forwarder. It should: - run exactly one shell command - execute: - `node "/scripts/claude-companion.mjs" adversarial-review --view-state defer ` - - include `--owner-session-id ` so background review jobs stay attached to the parent session + `node "/scripts/claude-companion.mjs" adversarial-review --view-state defer ` + - include `--owner-session-id ` only when the parent resolved a non-empty owner session id - include `--job-id ` when the parent reserved one + - never leave an empty routing placeholder such as `--owner-session-id --job-id` - return only that command's stdout exactly, with no added commentary - ignore stderr progress chatter such as `[cc] ...` lines and preserve only the final stdout-equivalent result text - not inspect the repo or perform the review itself - if a parent thread id is available, allow one extra `send_input` call after a successful shell result and before finishing + - the child prompt must mention the tool name `send_input` literally; do not replace it with a vague instruction like "send a message to the parent" - that `send_input` call must target the provided parent thread id, must happen at most once, and must not run on failure paths + - that `send_input` call should use the exact tool shape `send_input({ target: , message: })` with no extra prose payload - if the parent provided a non-empty parent thread id, do not silently drop the completion notification path from the child prompt - if a reserved review job id is available, use this exact notification message: `Background Claude Code adversarial review finished. Open it with $cc:result .` diff --git a/skills/cancel/SKILL.md b/skills/cancel/SKILL.md index afb30c4..766fb01 100644 --- a/skills/cancel/SKILL.md +++ b/skills/cancel/SKILL.md @@ -7,8 +7,8 @@ description: 'Cancel an active tracked Claude Code job in this repository. Args: Use this skill when the user wants to stop an active Claude Code job in this repository. -Resolve `` as two directories above this skill file, then run: -`node "/scripts/claude-companion.mjs" cancel $ARGUMENTS` +Do not derive the companion path from this skill file or any cache directory. Always run the installed copy: +`node "/scripts/claude-companion.mjs" cancel $ARGUMENTS` Supported arguments: `[job-id]` diff --git a/skills/rescue/SKILL.md b/skills/rescue/SKILL.md index e9ce7fa..ee3dd72 100644 --- a/skills/rescue/SKILL.md +++ b/skills/rescue/SKILL.md @@ -12,8 +12,8 @@ Foreground rescue responses must be that subagent's output verbatim. Use this skill when the user wants Claude Code to investigate, implement, or continue substantial work in this repository. -Resolve `` as two directories above this skill file. The companion entrypoint is: -`node "/scripts/claude-companion.mjs" task ...` +Do not derive the companion path from this skill file or any cache directory. Always run the installed copy: +`node "/scripts/claude-companion.mjs" task ...` Raw slash-command arguments: `$ARGUMENTS` @@ -38,7 +38,7 @@ Main-thread routing rules: - Default to `--write` unless the user explicitly wants read-only behavior or only review, diagnosis, or research without edits. - If `--resume` or `--resume-last` is present, continue the latest tracked Claude Code task. If `--fresh` is present, start a new task. - If none of `--resume`, `--resume-last`, or `--fresh` is present, first run: - `node "/scripts/claude-companion.mjs" task-resume-candidate --json` + `node "/scripts/claude-companion.mjs" task-resume-candidate --json` - If that helper reports `available: true`, ask the user once whether to continue the current Claude Code thread or start a new one. - Use exactly these two choices: - `Continue current Claude Code thread` @@ -53,6 +53,7 @@ Main-thread routing rules: Subagent launch: - By default, use Codex's `spawn_agent` tool with `agent_type: "default"`. +- Never satisfy background rescue by launching `claude-companion.mjs task` itself as a detached shell process. Do not use `&`, `nohup`, detached `spawn`, or any equivalent direct background process launch from the parent. - If a legacy request still includes `--builtin-agent`, treat it as a compatibility alias for the default built-in path. It should not change behavior. - Prefer `fork_context: false` for the built-in rescue child. The parent should pass a self-contained forwarding message instead of replaying the full parent thread by default. - Only consider `fork_context: true` as a last resort for a short follow-up where essential context truly cannot be summarized. Avoid it for large or long-lived threads because it can exhaust the child context window. @@ -64,18 +65,17 @@ Subagent launch: - Remove `--background` and `--wait` before spawning the subagent. Those flags control only whether the main thread waits on the subagent. - Pass only the routing and task arguments that actually belong to `claude-companion.mjs task`. - If the free-text task begins with `/`, preserve it verbatim in the spawned subagent request. Do not strip the slash or rewrite it into a local Codex command. -- Add an internal `--owner-session-id ` routing flag when spawning the subagent so tracked Claude Code jobs stay attached to the user-facing parent session for `$cc:status` / `$cc:result`. -- If the rescue is running in background through the built-in path and parent wake-up is desired, the parent should first reserve a task job id by running: - `node "/scripts/claude-companion.mjs" task-reserve-job --json` +- Before spawning the built-in child, capture the task job id plus routing context in one call: + `node "/scripts/claude-companion.mjs" background-routing-context --kind task --json` +- If that helper returns a non-empty `ownerSessionId`, include `--owner-session-id ` in the companion command so tracked Claude Code jobs stay attached to the user-facing parent session for `$cc:status` / `$cc:result`. +- If it returns an empty `ownerSessionId`, omit `--owner-session-id` entirely. Never leave an empty routing placeholder such as `--owner-session-id --job-id`. - If that helper returns a non-empty `jobId`, pass it into the companion command as an internal `--job-id ` routing flag. - Add an internal companion routing flag that reflects whether the user will see this result in the current turn: - Foreground rescue must add `--view-state on-success` - Background rescue must add `--view-state defer` - Any user-supplied `--model` flag is for the Claude companion only and must be forwarded unchanged to `task`. -- If the rescue is running in background through the built-in path, the parent should first capture its own thread id by running: - `node -e "process.stdout.write(process.env.CODEX_THREAD_ID || '')"` -- If that command returns a non-empty thread id, pass it into the child prompt as the parent thread id for one-shot completion notification. -- If it returns empty output or fails, continue without parent wake-up instead of blocking the rescue. +- If that helper returns a non-empty `parentThreadId`, pass it into the child prompt as the parent thread id for one-shot completion notification. +- If it returns an empty `parentThreadId`, continue without parent wake-up instead of blocking the rescue. - This parent wake-up attempt is now the default for background built-in rescue on persistent Codex/Desktop threads. It is still best-effort and should silently degrade on one-shot `codex exec` runs. - For the built-in rescue path, the parent thread owns prompt shaping. The built-in child should stay a pure executor. - If the built-in rescue request is vague, chatty, or a follow-up, the parent may tighten only the task text before composing the exact companion command. @@ -109,7 +109,7 @@ Subagent launch: - single quotes, backticks, or XML-style blocks such as `` / `` - long concrete requests where inline shell quoting would be brittle - When using a prompt file, preserve the exact resolved task text byte-for-byte in that file and point the companion command at that file with an absolute `--prompt-file` path. -- Prefer a temporary path outside the repository checkout, for example under `/tmp`, so rescue prompt staging does not dirty the repo. +- Prefer a temporary path outside the repository checkout, for example under the OS temp directory such as `/tmp` on POSIX systems, so rescue prompt staging does not dirty the repo. - Materialize that prompt file with a normal file-write tool or other structured write path. Do not try to generate it by re-embedding the long task text inside another fragile one-line shell string. - If the user is not satisfied with a built-in rescue result, the parent should treat the next rescue request as a follow-up and prefer `--resume` or `--resume-last` with a short delta instruction when a resumable Claude Code session exists. - The built-in rescue path must use a compact strict forwarding message. It must: @@ -118,7 +118,9 @@ Subagent launch: - for foreground rescue only, tell the child to return that command's stdout text exactly, with no preamble, summary, code fence, trimming, normalization, or punctuation changes - tell the child to ignore stderr progress chatter such as `[cc] ...` lines and preserve only the stdout-equivalent final result text - if a parent thread id is provided for experimental background notification, allow one extra `send_input` call after a successful shell result and before finishing + - the child prompt must mention the tool name `send_input` literally; do not replace it with a vague instruction like "send a message to the parent" - that `send_input` call must target the provided parent thread id, must happen at most once, and must not run on failure paths + - that `send_input` call should use the exact tool shape `send_input({ target: , message: })` with no extra prose payload - if the parent provided a non-empty parent thread id, do not silently drop the completion notification path from the child prompt - that `send_input` message should use a short user-facing template that steers the parent toward explicit result retrieval instead of inlining the raw result - if a reserved companion job id is available, use this exact high-level shape for the notification message: diff --git a/skills/result/SKILL.md b/skills/result/SKILL.md index 7eede76..c8cabfa 100644 --- a/skills/result/SKILL.md +++ b/skills/result/SKILL.md @@ -7,8 +7,8 @@ description: 'Show the stored final output for a finished Claude Code job in thi Use this skill when the user wants the stored final output for a finished Claude Code job. -Resolve `` as two directories above this skill file, then run: -`node "/scripts/claude-companion.mjs" result $ARGUMENTS` +Do not derive the companion path from this skill file or any cache directory. Always run the installed copy: +`node "/scripts/claude-companion.mjs" result $ARGUMENTS` Supported arguments: `[job-id]` diff --git a/skills/review/SKILL.md b/skills/review/SKILL.md index 83515a0..17bf918 100644 --- a/skills/review/SKILL.md +++ b/skills/review/SKILL.md @@ -7,8 +7,8 @@ description: 'Run a standard Claude Code review of local git changes in this rep Use this skill when the user wants Claude Code to review the current working tree or a branch diff in this repository. -Resolve `` as two directories above this skill file. The companion entrypoint is: -`node "/scripts/claude-companion.mjs" review ...` +Do not derive the companion path from this skill file or any cache directory. Always run the installed copy: +`node "/scripts/claude-companion.mjs" review ...` Supported arguments: `--wait`, `--background`, `--base `, `--scope auto|working-tree|branch`, `--model ` @@ -47,19 +47,21 @@ Argument handling: Foreground flow: - Run: - `node "/scripts/claude-companion.mjs" review --view-state on-success ` + `node "/scripts/claude-companion.mjs" review --view-state on-success ` - Present the companion stdout faithfully. - Do not fix anything mentioned in the review output. Background flow: - For background review, use Codex's built-in `default` subagent instead of a detached background shell command. -- Before spawning the built-in child, reserve a review job id by running: - `node "/scripts/claude-companion.mjs" review-reserve-job --json` +- Never satisfy background review by running the companion command itself with shell backgrounding such as `&`, `nohup`, detached `spawn`, or any equivalent direct background process launch. +- Background here means "spawn the forwarding child via `spawn_agent` and do not wait in the parent turn." The companion review command inside that child still runs once, in the foreground, inside the child thread. +- Before spawning the built-in child, capture the review job id plus routing context in one call: + `node "/scripts/claude-companion.mjs" background-routing-context --kind review --json` - If that helper returns a non-empty `jobId`, pass it into the companion command as an internal `--job-id ` routing flag. -- Add an internal `--owner-session-id ` routing flag when spawning the built-in child so the tracked review job stays visible in the parent Codex session's plain `$cc:status`. -- If the built-in review is running in background, the parent should first capture its own thread id by running: - `node -e "process.stdout.write(process.env.CODEX_THREAD_ID || '')"` -- If that command returns a non-empty thread id, pass it into the child prompt as the parent thread id for one-shot completion notification. +- If that helper returns a non-empty `ownerSessionId`, include `--owner-session-id ` in the companion command. +- If it returns an empty `ownerSessionId`, omit `--owner-session-id` entirely. Never leave an empty placeholder such as `--owner-session-id --job-id`. +- If that helper returns a non-empty `parentThreadId`, pass it into the child prompt as the parent thread id for one-shot completion notification. +- If it returns an empty `parentThreadId`, omit the notification path instead of emitting a blank thread-id placeholder. - Spawn exactly one transient forwarding child through `spawn_agent` with: - `agent_type: "default"` - `fork_context: false` @@ -74,14 +76,17 @@ Background flow: - The built-in child must be a pure forwarder. It should: - run exactly one shell command - execute: - `node "/scripts/claude-companion.mjs" review --view-state defer ` - - include `--owner-session-id ` so background review jobs stay attached to the parent session + `node "/scripts/claude-companion.mjs" review --view-state defer ` + - include `--owner-session-id ` only when the parent resolved a non-empty owner session id - include `--job-id ` when the parent reserved one + - never leave an empty routing placeholder such as `--owner-session-id --job-id` - return only that command's stdout exactly, with no added commentary - ignore stderr progress chatter such as `[cc] ...` lines and preserve only the final stdout-equivalent result text - not inspect the repo or perform the review itself - if a parent thread id is available, allow one extra `send_input` call after a successful shell result and before finishing + - the child prompt must mention the tool name `send_input` literally; do not replace it with a vague instruction like "send a message to the parent" - that `send_input` call must target the provided parent thread id, must happen at most once, and must not run on failure paths + - that `send_input` call should use the exact tool shape `send_input({ target: , message: })` with no extra prose payload - if the parent provided a non-empty parent thread id, do not silently drop the completion notification path from the child prompt - if a reserved review job id is available, use this exact notification message: `Background Claude Code review finished. Open it with $cc:result .` diff --git a/skills/setup/SKILL.md b/skills/setup/SKILL.md index d0c125e..42c2d43 100644 --- a/skills/setup/SKILL.md +++ b/skills/setup/SKILL.md @@ -7,7 +7,7 @@ description: 'Check whether Claude Code CLI is ready in this environment and opt Use this skill when the user wants to verify Claude Code readiness or toggle the review gate. -Resolve `` as two directories above this skill file. +Do not derive the companion path from this skill file or any cache directory. Always run the installed copy under ``. Supported arguments: - `--enable-review-gate` @@ -15,15 +15,15 @@ Supported arguments: Workflow: - First run the machine-readable probe: - `node "/scripts/claude-companion.mjs" setup --json $ARGUMENTS` + `node "/scripts/claude-companion.mjs" setup --json $ARGUMENTS` - If it reports that Claude Code is unavailable and `npm` is available, ask whether to install Claude Code now. - If the user agrees, run `npm install -g @anthropic-ai/claude-code` and rerun setup. - If Claude Code is already installed or `npm` is unavailable, do not ask about installation. - If setup reports missing hooks, run: - `node "/scripts/install-hooks.mjs"` + `node "/scripts/install-hooks.mjs"` - After hook installation, rerun the final setup command so the user sees the repaired state immediately. - After the decision flow is complete, run the final user-facing command without `--json`: - `node "/scripts/claude-companion.mjs" setup $ARGUMENTS` + `node "/scripts/claude-companion.mjs" setup $ARGUMENTS` Output: - Present the final non-JSON setup output exactly as returned by the companion. diff --git a/skills/status/SKILL.md b/skills/status/SKILL.md index e32282f..ef910c2 100644 --- a/skills/status/SKILL.md +++ b/skills/status/SKILL.md @@ -7,8 +7,8 @@ description: 'Show active or recent Claude Code jobs in this repository, or deta Use this skill when the user wants the current state of Claude Code jobs in this repository. -Resolve `` as two directories above this skill file, then run: -`node "/scripts/claude-companion.mjs" status $ARGUMENTS` +Do not derive the companion path from this skill file or any cache directory. Always run the installed copy: +`node "/scripts/claude-companion.mjs" status $ARGUMENTS` Supported arguments: `[job-id]`, `--wait`, `--timeout-ms `, `--poll-interval-ms `, `--all` diff --git a/tests/args.test.mjs b/tests/args.test.mjs index 8ff05eb..2b78162 100644 --- a/tests/args.test.mjs +++ b/tests/args.test.mjs @@ -68,6 +68,18 @@ describe("parseArgs", () => { /Missing value for --output/ ); }); + + it("throws when the next token is another recognized option", () => { + assert.throws( + () => parseArgs(["--output", "--model", "sonnet"], config), + /Missing value for --output/ + ); + }); + + it("accepts an unknown --token as a literal value", () => { + const result = parseArgs(["--output", "--unknown-flag"], config); + assert.equal(result.options.output, "--unknown-flag"); + }); }); describe("short options", () => { @@ -93,6 +105,13 @@ describe("parseArgs", () => { /Missing value for -o/ ); }); + + it("throws when the next short token is another recognized option", () => { + assert.throws( + () => parseArgs(["-o", "-v"], config), + /Missing value for -o/ + ); + }); }); describe("alias mapping", () => { diff --git a/tests/claude-cli.test.mjs b/tests/claude-cli.test.mjs index 8c63bea..5aea6f1 100644 --- a/tests/claude-cli.test.mjs +++ b/tests/claude-cli.test.mjs @@ -16,6 +16,7 @@ import { VALID_EFFORTS, SANDBOX_READ_ONLY_BASH_TOOLS, SANDBOX_READ_ONLY_TOOLS, + SANDBOX_TEMP_DIR, SANDBOX_SETTINGS, MAX_STREAM_PARSER_UNKNOWN_EVENTS, MAX_STREAM_PARSER_PARSE_ERRORS, @@ -668,17 +669,17 @@ describe("SANDBOX_SETTINGS", () => { assert.ok("workspace-write" in SANDBOX_SETTINGS); }); - it("read-only enables sandbox with allowWrite ['/tmp']", () => { + it("read-only enables sandbox with allowWrite [SANDBOX_TEMP_DIR]", () => { const s = SANDBOX_SETTINGS["read-only"].sandbox; assert.equal(s.enabled, true); - assert.deepEqual(s.filesystem.allowWrite, ["/tmp"]); + assert.deepEqual(s.filesystem.allowWrite, [SANDBOX_TEMP_DIR]); assert.deepEqual(s.network.allowedDomains, []); }); - it("workspace-write enables sandbox with allowWrite ['.', '/tmp']", () => { + it("workspace-write enables sandbox with allowWrite ['.', SANDBOX_TEMP_DIR]", () => { const s = SANDBOX_SETTINGS["workspace-write"].sandbox; assert.equal(s.enabled, true); - assert.deepEqual(s.filesystem.allowWrite, [".", "/tmp"]); + assert.deepEqual(s.filesystem.allowWrite, [".", SANDBOX_TEMP_DIR]); assert.deepEqual(s.network.allowedDomains, []); }); }); diff --git a/tests/e2e/codex-skills-e2e.test.mjs b/tests/e2e/codex-skills-e2e.test.mjs index 3b326b3..c68736b 100644 --- a/tests/e2e/codex-skills-e2e.test.mjs +++ b/tests/e2e/codex-skills-e2e.test.mjs @@ -1715,8 +1715,9 @@ describe("Codex direct-skill E2E", () => { userRequest, skillTitle: "Claude Code Review", expectedParentNeedles: [ - "review-reserve-job --json", - "--owner-session-id ", + "background-routing-context --kind review --json", + "--owner-session-id ", + "Never satisfy background review by running the companion command itself with shell backgrounding", "allow one extra `send_input` call after a successful shell result", "must target the provided parent thread id", "do not silently drop the completion notification path from the child prompt", @@ -1858,8 +1859,9 @@ describe("Codex direct-skill E2E", () => { userRequest, skillTitle: "Claude Code Adversarial Review", expectedParentNeedles: [ - "review-reserve-job --json", - "--owner-session-id ", + "background-routing-context --kind review --json", + "--owner-session-id ", + "Never satisfy background adversarial review by running the companion command itself with shell backgrounding", "allow one extra `send_input` call after a successful shell result", "must target the provided parent thread id", "do not silently drop the completion notification path from the child prompt", diff --git a/tests/fs.test.mjs b/tests/fs.test.mjs index c6fc987..c2c1d1c 100644 --- a/tests/fs.test.mjs +++ b/tests/fs.test.mjs @@ -8,6 +8,7 @@ import assert from "node:assert/strict"; import { isProbablyText, } from "../scripts/lib/fs.mjs"; +import { samePath } from "../scripts/lib/codex-paths.mjs"; // --------------------------------------------------------------------------- // isProbablyText @@ -47,3 +48,17 @@ describe("isProbablyText", () => { assert.equal(isProbablyText(textPart), true); }); }); + +describe("samePath", () => { + it("matches identical resolved paths", () => { + assert.equal(samePath("/tmp/example", "/tmp/example"), true); + }); + + it("normalizes dot segments before comparison", () => { + assert.equal(samePath("/tmp/example/../example", "/tmp/example"), true); + }); + + it("treats Windows path casing as equivalent on win32", () => { + assert.equal(samePath("C:\\Users\\Jin\\Repo", "c:\\users\\jin\\repo", "win32"), true); + }); +}); diff --git a/tests/installer-cli.test.mjs b/tests/installer-cli.test.mjs index c7dc501..c5e63ef 100644 --- a/tests/installer-cli.test.mjs +++ b/tests/installer-cli.test.mjs @@ -97,6 +97,26 @@ function runInstaller(command, homeDir, sourceRoot, extraEnv = {}) { return result; } +function runLocalPluginInstaller(command, pluginRoot, homeDir, extraEnv = {}) { + const result = spawnSync( + process.execPath, + [path.join(pluginRoot, "scripts", "local-plugin-install.mjs"), command, "--plugin-root", pluginRoot], + { + cwd: pluginRoot, + env: { + ...process.env, + HOME: homeDir, + USERPROFILE: homeDir, + ...extraEnv, + }, + encoding: "utf8", + } + ); + + assert.equal(result.status, 0, result.stderr || result.stdout); + return result; +} + function createFakeCodex(homeDir, codexHome = path.join(homeDir, ".codex")) { const scriptPath = makeTempHelper("fake-codex-app-server"); const logPath = path.join(codexHome, "fake-codex-requests.log"); @@ -574,11 +594,18 @@ describe("installer-cli", () => { const hooksFile = path.join(homeDir, ".codex", "hooks.json"); const fallbackSkillPath = path.join(homeDir, ".codex", "skills", "cc-review", "SKILL.md"); const fallbackPromptPath = path.join(homeDir, ".codex", "prompts", "cc-review.md"); + const installedReviewSkill = path.join(installDir, "skills", "review", "SKILL.md"); + const cachedReviewSkill = path.join(cacheDir, "skills", "review", "SKILL.md"); + const normalizedInstallDir = installDir.replace(/\\/g, "/"); assert.ok(fs.existsSync(path.join(installDir, "scripts", "installer-cli.mjs"))); assert.ok(fs.existsSync(path.join(cacheDir, "skills", "review", "SKILL.md"))); assert.ok(!fs.existsSync(fallbackSkillPath)); assert.ok(!fs.existsSync(fallbackPromptPath)); + assert.ok(fs.readFileSync(installedReviewSkill, "utf8").includes(normalizedInstallDir)); + assert.doesNotMatch(fs.readFileSync(installedReviewSkill, "utf8"), //i); + assert.ok(fs.readFileSync(cachedReviewSkill, "utf8").includes(normalizedInstallDir)); + assert.doesNotMatch(fs.readFileSync(cachedReviewSkill, "utf8"), //i); const marketplace = JSON.parse(fs.readFileSync(marketplaceFile, "utf8")); assert.equal(marketplace.plugins[0].name, "cc"); @@ -602,6 +629,22 @@ describe("installer-cli", () => { ); }); + it("materializes installed skill paths for a direct local checkout install", () => { + const homeDir = makeTempHome(); + const installDir = path.join(homeDir, ".codex", "plugins", "cc"); + const fakeCodex = createFakeCodex(homeDir); + copyFixture(installDir); + + runLocalPluginInstaller("install", installDir, homeDir, fakeCodex.env); + + const installedReviewSkill = path.join(installDir, "skills", "review", "SKILL.md"); + const skillText = fs.readFileSync(installedReviewSkill, "utf8"); + const normalizedInstallDir = installDir.replace(/\\/g, "/"); + + assert.ok(skillText.includes(normalizedInstallDir)); + assert.doesNotMatch(skillText, //i); + }); + it("installs successfully when CODEX_HOME is outside the user's home directory", () => { const homeDir = makeTempHome(); const codexHome = fs.mkdtempSync(path.join(os.tmpdir(), "cc-external-codex-home-")); diff --git a/tests/integration/claude-companion.test.mjs b/tests/integration/claude-companion.test.mjs index 3308549..3c7ab50 100644 --- a/tests/integration/claude-companion.test.mjs +++ b/tests/integration/claude-companion.test.mjs @@ -977,6 +977,105 @@ describe("claude-companion integration", () => { } }); + it("rejects a missing owner session id before the next routing flag is consumed", () => { + const testEnv = createTestEnvironment(); + + try { + setupGitWorkspace(testEnv.workspaceDir); + seedWorkingTreeDiff(testEnv.workspaceDir); + + const result = runCompanionExpectFailure( + [ + "review", + "--cwd", + testEnv.workspaceDir, + "--background", + "--json", + "--scope", + "working-tree", + "--owner-session-id", + "--job-id", + "review-bad-owner-session", + ], + { env: testEnv.env } + ); + + assert.match(result.stderr, /Missing value for --owner-session-id/); + } finally { + cleanupTestEnvironment(testEnv); + } + }); + + it("reports session routing context from env and current-session marker", () => { + const testEnv = createTestEnvironment(); + + try { + writeCurrentSessionMarker(testEnv, "marker-session"); + const payload = runCompanionJson( + ["session-routing-context", "--cwd", testEnv.workspaceDir, "--json"], + { + env: { + ...testEnv.env, + [SESSION_ID_ENV]: "env-session", + CODEX_THREAD_ID: "thread-123", + }, + } + ); + + assert.equal(payload.ownerSessionId, "env-session"); + assert.equal(payload.parentThreadId, "thread-123"); + assert.equal(payload.workspaceRoot, testEnv.workspaceDir); + } finally { + cleanupTestEnvironment(testEnv); + } + }); + + it("drops invalid parent thread ids from session routing context", () => { + const testEnv = createTestEnvironment(); + + try { + const payload = runCompanionJson( + ["session-routing-context", "--cwd", testEnv.workspaceDir, "--json"], + { + env: { + ...testEnv.env, + [SESSION_ID_ENV]: "env-session", + CODEX_THREAD_ID: "--bad-thread-id", + }, + } + ); + + assert.equal(payload.ownerSessionId, "env-session"); + assert.equal(payload.parentThreadId, null); + } finally { + cleanupTestEnvironment(testEnv); + } + }); + + it("reports background routing context with a reserved review job id", () => { + const testEnv = createTestEnvironment(); + + try { + writeCurrentSessionMarker(testEnv, "marker-session"); + const payload = runCompanionJson( + ["background-routing-context", "--kind", "review", "--cwd", testEnv.workspaceDir, "--json"], + { + env: { + ...testEnv.env, + [SESSION_ID_ENV]: "env-session", + CODEX_THREAD_ID: "thread-123", + }, + } + ); + + assert.equal(payload.ownerSessionId, "env-session"); + assert.equal(payload.parentThreadId, "thread-123"); + assert.match(payload.jobId, /^review-/); + } finally { + cleanupTestEnvironment(testEnv); + } + }); + it("keeps completed resume candidates session-scoped and ignores active tasks", async () => { const testEnv = createTestEnvironment(); const sessionAEnv = { @@ -1146,6 +1245,47 @@ describe("claude-companion integration", () => { } }); + it("does not expose managed log paths in JSON-facing commands", async () => { + const testEnv = createTestEnvironment(); + const sessionEnv = { + ...testEnv.env, + [SESSION_ID_ENV]: "session-redacted-log", + }; + + try { + const launch = await runCompanionAsyncJson( + [ + "task", + "--cwd", + testEnv.workspaceDir, + "--background", + "--json", + "redacted-log delay=40", + ], + { env: sessionEnv } + ); + + assert.equal("logFile" in launch, false, "background launch payload should not expose logFile"); + + await waitForTerminalStatus(testEnv, launch.jobId, sessionEnv); + + const statusPayload = runCompanionJson( + ["status", "--cwd", testEnv.workspaceDir, launch.jobId, "--json"], + { env: sessionEnv } + ); + assert.equal("logFile" in statusPayload.job, false, "status --json should not expose logFile"); + + const resultPayload = runCompanionJson( + ["result", "--cwd", testEnv.workspaceDir, launch.jobId, "--json"], + { env: sessionEnv } + ); + assert.equal("logFile" in resultPayload.job, false, "result --json should not expose logFile on job"); + assert.equal("logFile" in (resultPayload.storedJob ?? {}), false, "result --json should not expose logFile on stored job"); + } finally { + cleanupTestEnvironment(testEnv); + } + }); + it("records PID identity for background worker jobs before they start running", async () => { const testEnv = createTestEnvironment(); const sessionEnv = { diff --git a/tests/job-control.test.mjs b/tests/job-control.test.mjs index a3f52c5..6f246dd 100644 --- a/tests/job-control.test.mjs +++ b/tests/job-control.test.mjs @@ -8,6 +8,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { spawnSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; import { sortJobsNewestFirst, @@ -26,10 +27,7 @@ import { resolveJobLogFile, } from "../scripts/lib/state.mjs"; -const PROJECT_CWD = path.resolve( - new URL(".", import.meta.url).pathname, - ".." -); +const PROJECT_CWD = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); function createTempGitRepo() { const repoDir = fs.mkdtempSync(path.join(os.tmpdir(), "jc-session-")); diff --git a/tests/prompts.test.mjs b/tests/prompts.test.mjs index 372f4a8..14a8661 100644 --- a/tests/prompts.test.mjs +++ b/tests/prompts.test.mjs @@ -7,9 +7,13 @@ import assert from "node:assert/strict"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; +import { fileURLToPath } from "node:url"; import { loadPromptTemplate, interpolateTemplate } from "../scripts/lib/prompts.mjs"; +const TESTS_DIR = path.dirname(fileURLToPath(import.meta.url)); +const PROJECT_ROOT = path.resolve(TESTS_DIR, ".."); + // --------------------------------------------------------------------------- // interpolateTemplate // --------------------------------------------------------------------------- @@ -111,14 +115,12 @@ describe("loadPromptTemplate", () => { }); it("works with the actual project prompts directory", () => { - const projectRoot = path.resolve(new URL(".", import.meta.url).pathname, ".."); - // Check if any prompt files exist - const promptDir = path.join(projectRoot, "prompts"); + const promptDir = path.join(PROJECT_ROOT, "prompts"); if (fs.existsSync(promptDir)) { const files = fs.readdirSync(promptDir).filter((f) => f.endsWith(".md")); if (files.length > 0) { const name = files[0].replace(".md", ""); - const content = loadPromptTemplate(projectRoot, name); + const content = loadPromptTemplate(PROJECT_ROOT, name); assert.ok(typeof content === "string"); assert.ok(content.length > 0); } @@ -126,8 +128,7 @@ describe("loadPromptTemplate", () => { }); it("keeps the stop-review-gate prompt aligned to Codex wording", () => { - const projectRoot = path.resolve(new URL(".", import.meta.url).pathname, ".."); - const content = loadPromptTemplate(projectRoot, "stop-review-gate"); + const content = loadPromptTemplate(PROJECT_ROOT, "stop-review-gate"); assert.match(content, /previous Codex turn/); assert.match(content, /\{\{PREVIOUS_RESPONSE_BLOCK\}\}/); assert.match(content, /untrusted model output/i); @@ -136,8 +137,7 @@ describe("loadPromptTemplate", () => { }); it("frames adversarial-review prompt inputs as untrusted data", () => { - const projectRoot = path.resolve(new URL(".", import.meta.url).pathname, ".."); - const content = loadPromptTemplate(projectRoot, "adversarial-review"); + const content = loadPromptTemplate(PROJECT_ROOT, "adversarial-review"); assert.match(content, /untrusted user input/i); assert.match(content, /untrusted repository data/i); assert.match(content, //); diff --git a/tests/render.test.mjs b/tests/render.test.mjs index e5efcb4..7c881eb 100644 --- a/tests/render.test.mjs +++ b/tests/render.test.mjs @@ -458,7 +458,6 @@ describe("renderJobStatusReport", () => { duration: "1m", sessionId: "owner-sess", threadId: "claude-sess", - logFile: "/tmp/log.txt", }; const output = renderJobStatusReport(job); assert.ok(output.includes("# Claude Code Job Status")); @@ -469,7 +468,6 @@ describe("renderJobStatusReport", () => { assert.ok(output.includes("| Started | 2026-04-02T19:00:00.000Z |")); assert.ok(output.includes("| Ended | 2026-04-02T19:01:00.000Z |")); assert.ok(output.includes("| Duration | 1m |")); - assert.ok(output.includes("| Log | [log.txt](/tmp/log.txt) |")); assert.ok(output.includes("| Result | `$cc:result j1` |")); assert.ok(output.includes("| Claude Code session | `claude-sess` |")); assert.ok(output.includes("| Owning Codex session | `owner-sess` |")); @@ -484,7 +482,6 @@ describe("renderJobStatusReport", () => { kindLabel: "review", startedAt: "2026-04-02T19:00:00.000Z", elapsed: "5s", - logFile: "/tmp/run.log", }; const output = renderJobStatusReport(job); assert.ok(output.includes("| Elapsed | 5s |")); diff --git a/tests/sandbox-modes.test.mjs b/tests/sandbox-modes.test.mjs index 2a782d3..6b44a25 100644 --- a/tests/sandbox-modes.test.mjs +++ b/tests/sandbox-modes.test.mjs @@ -12,6 +12,7 @@ import { buildArgs, SANDBOX_READ_ONLY_BASH_TOOLS, SANDBOX_READ_ONLY_TOOLS, + SANDBOX_TEMP_DIR, SANDBOX_SETTINGS, createSandboxSettings, cleanupSandboxSettings, @@ -201,19 +202,19 @@ describe("sandbox settings lifecycle", () => { // --------------------------------------------------------------------------- describe("sandbox settings content", () => { - it("read-only: sandbox enabled, allowWrite tmp only, no network", () => { + it("read-only: sandbox enabled, allowWrite temp dir only, no network", () => { const s = SANDBOX_SETTINGS["read-only"]; assert.equal(s.sandbox.enabled, true); assert.equal(s.sandbox.autoAllowBashIfSandboxed, true); - assert.deepEqual(s.sandbox.filesystem.allowWrite, ["/tmp"]); + assert.deepEqual(s.sandbox.filesystem.allowWrite, [SANDBOX_TEMP_DIR]); assert.deepEqual(s.sandbox.network.allowedDomains, []); }); - it("workspace-write: sandbox enabled, allowWrite cwd+tmp, no network", () => { + it("workspace-write: sandbox enabled, allowWrite cwd+temp dir, no network", () => { const s = SANDBOX_SETTINGS["workspace-write"]; assert.equal(s.sandbox.enabled, true); assert.equal(s.sandbox.autoAllowBashIfSandboxed, true); - assert.deepEqual(s.sandbox.filesystem.allowWrite, [".", "/tmp"]); + assert.deepEqual(s.sandbox.filesystem.allowWrite, [".", SANDBOX_TEMP_DIR]); assert.deepEqual(s.sandbox.network.allowedDomains, []); }); diff --git a/tests/skills-contracts.test.mjs b/tests/skills-contracts.test.mjs index da5451c..1af481a 100644 --- a/tests/skills-contracts.test.mjs +++ b/tests/skills-contracts.test.mjs @@ -19,15 +19,21 @@ function read(relativePath) { test("review skills keep background execution outside the companion command", () => { const review = read("skills/review/SKILL.md"); const adversarial = read("skills/adversarial-review/SKILL.md"); + const installedRootPattern = /\/scripts\/claude-companion\.mjs/i; + assert.match(review, /Do not derive the companion path from this skill file or any cache directory/i); + assert.match(review, installedRootPattern); assert.match(review, /Treat `--wait` and `--background` as Codex-side execution controls only/i); assert.match(review, /Strip them before calling the companion command/i); assert.match(review, /The companion review process itself always runs in the foreground/i); assert.match(review, /review --view-state on-success/i); assert.match(review, /For background review, use Codex's built-in `default` subagent/i); - assert.match(review, /review-reserve-job --json/i); + assert.match(review, /Never satisfy background review by running the companion command itself with shell backgrounding/i); + assert.match(review, /Background here means "spawn the forwarding child via `spawn_agent` and do not wait in the parent turn\."/i); + assert.match(review, /background-routing-context --kind review --json/i); assert.match(review, /internal `--job-id ` routing flag/i); - assert.match(review, /internal `--owner-session-id ` routing flag/i); + assert.match(review, /non-empty `ownerSessionId`/i); + assert.match(review, /omit `--owner-session-id` entirely/i); assert.match(review, /spawn_agent/i); assert.match(review, /`fork_context: false`/i); assert.match(review, /`model: "gpt-5\.4-mini"`/i); @@ -36,9 +42,12 @@ test("review skills keep background execution outside the companion command", () assert.match(review, /Only consider `fork_context: true` as a last resort/i); assert.match(review, /retry once with `model: "gpt-5\.4"`/i); assert.match(review, /review --view-state defer/i); - assert.match(review, /include `--owner-session-id ` so background review jobs stay attached to the parent session/i); + assert.match(review, /include `--owner-session-id ` only when the parent resolved a non-empty owner session id/i); + assert.match(review, /never leave an empty routing placeholder such as `--owner-session-id {2}--job-id`/i); assert.match(review, /allow one extra `send_input` call after a successful shell result/i); + assert.match(review, /must mention the tool name `send_input` literally/i); assert.match(review, /must target the provided parent thread id/i); + assert.match(review, /exact tool shape `send_input\(\{ target: , message: \}\)`/i); assert.match(review, /do not silently drop the completion notification path from the child prompt/i); assert.match(review, /Background Claude Code review finished\. Open it with \$cc:result \./i); assert.match(review, /that `send_input` message should use one of those exact steering messages/i); @@ -50,14 +59,19 @@ test("review skills keep background execution outside the companion command", () assert.doesNotMatch(review, /claude-companion\.mjs" review --background/i); assert.doesNotMatch(review, /claude-companion\.mjs" review \$ARGUMENTS/i); + assert.match(adversarial, /Do not derive the companion path from this skill file or any cache directory/i); + assert.match(adversarial, installedRootPattern); assert.match(adversarial, /Treat `--wait` and `--background` as Codex-side execution controls only/i); assert.match(adversarial, /Strip them before calling the companion command/i); assert.match(adversarial, /The companion review process itself always runs in the foreground/i); assert.match(adversarial, /adversarial-review --view-state on-success/i); assert.match(adversarial, /For background adversarial review, use Codex's built-in `default` subagent/i); - assert.match(adversarial, /review-reserve-job --json/i); + assert.match(adversarial, /Never satisfy background adversarial review by running the companion command itself with shell backgrounding/i); + assert.match(adversarial, /Background here means "spawn the forwarding child via `spawn_agent` and do not wait in the parent turn\."/i); + assert.match(adversarial, /background-routing-context --kind review --json/i); assert.match(adversarial, /internal `--job-id ` routing flag/i); - assert.match(adversarial, /internal `--owner-session-id ` routing flag/i); + assert.match(adversarial, /non-empty `ownerSessionId`/i); + assert.match(adversarial, /omit `--owner-session-id` entirely/i); assert.match(adversarial, /spawn_agent/i); assert.match(adversarial, /`fork_context: false`/i); assert.match(adversarial, /`model: "gpt-5\.4-mini"`/i); @@ -66,9 +80,12 @@ test("review skills keep background execution outside the companion command", () assert.match(adversarial, /Only consider `fork_context: true` as a last resort/i); assert.match(adversarial, /retry once with `model: "gpt-5\.4"`/i); assert.match(adversarial, /adversarial-review --view-state defer/i); - assert.match(adversarial, /include `--owner-session-id ` so background review jobs stay attached to the parent session/i); + assert.match(adversarial, /include `--owner-session-id ` only when the parent resolved a non-empty owner session id/i); + assert.match(adversarial, /never leave an empty routing placeholder such as `--owner-session-id {2}--job-id`/i); assert.match(adversarial, /allow one extra `send_input` call after a successful shell result/i); + assert.match(adversarial, /must mention the tool name `send_input` literally/i); assert.match(adversarial, /must target the provided parent thread id/i); + assert.match(adversarial, /exact tool shape `send_input\(\{ target: , message: \}\)`/i); assert.match(adversarial, /do not silently drop the completion notification path from the child prompt/i); assert.match(adversarial, /Background Claude Code adversarial review finished\. Open it with \$cc:result \./i); assert.match(adversarial, /that `send_input` message should use one of those exact steering messages/i); @@ -83,8 +100,12 @@ test("review skills keep background execution outside the companion command", () test("rescue skill keeps --background and --wait as host-side controls only", () => { const rescue = read("skills/rescue/SKILL.md"); + const installedRootPattern = /\/scripts\/claude-companion\.mjs/i; + assert.match(rescue, /Do not derive the companion path from this skill file or any cache directory/i); + assert.match(rescue, installedRootPattern); assert.match(rescue, /`--background` and `--wait` are Codex-side execution controls only/i); + assert.match(rescue, /Never satisfy background rescue by launching `claude-companion\.mjs task` itself as a detached shell process/i); assert.match(rescue, /Never forward either flag to `claude-companion\.mjs task`/i); assert.match(rescue, /The main Codex thread owns that execution-mode choice/i); assert.match(rescue, /If the user explicitly passed `--background`, run the rescue subagent in the background/i); @@ -94,8 +115,9 @@ test("rescue skill keeps --background and --wait as host-side controls only", () assert.match(rescue, /If the user task text itself begins with a slash command such as `\/simplify`/i); assert.match(rescue, /Remove `--background` and `--wait` before spawning the subagent/i); assert.match(rescue, /If the free-text task begins with `\/`, preserve it verbatim/i); - assert.match(rescue, /--owner-session-id /i); - assert.match(rescue, /task-reserve-job --json/i); + assert.match(rescue, /background-routing-context --kind task --json/i); + assert.match(rescue, /non-empty `ownerSessionId`/i); + assert.match(rescue, /omit `--owner-session-id` entirely/i); assert.match(rescue, /internal `--job-id ` routing flag/i); assert.match(rescue, /Foreground rescue must add `--view-state on-success`/i); assert.match(rescue, /Background rescue must add `--view-state defer`/i); @@ -131,11 +153,12 @@ test("rescue skill documents the experimental built-in-agent forwarding path", ( assert.match(rescue, /retry once with `model: "gpt-5\.4"` and the same `reasoning_effort: "medium"`/i); assert.match(rescue, /clearly says `gpt-5\.4-mini` was unavailable and the parent is retrying with `gpt-5\.4`/i); assert.match(rescue, /Do not use that fallback for arbitrary failures/i); - assert.match(rescue, /capture its own thread id by running:/i); - assert.match(rescue, /process\.env\.CODEX_THREAD_ID/i); + assert.match(rescue, /non-empty `parentThreadId`/i); assert.match(rescue, /pass it into the child prompt as the parent thread id/i); assert.match(rescue, /allow one extra `send_input` call after a successful shell result/i); + assert.match(rescue, /must mention the tool name `send_input` literally/i); assert.match(rescue, /must target the provided parent thread id/i); + assert.match(rescue, /exact tool shape `send_input\(\{ target: , message: \}\)`/i); assert.match(rescue, /do not silently drop the completion notification path from the child prompt/i); assert.match(rescue, /short user-facing template that steers the parent toward explicit result retrieval instead of inlining the raw result/i); assert.match(rescue, /Background Claude Code rescue finished\. Open it with \$cc:result \./i); @@ -224,8 +247,23 @@ test("rescue parent skill owns resume-candidate exploration", () => { test("setup skill auto-installs missing hooks before the final setup report", () => { const setup = read("skills/setup/SKILL.md"); + assert.match(setup, /Do not derive the companion path from this skill file or any cache directory/i); + assert.match(setup, /\/scripts\/claude-companion\.mjs/i); assert.match(setup, /setup --json/i); assert.match(setup, /If setup reports missing hooks, run:/i); - assert.match(setup, /node "\/scripts\/install-hooks\.mjs"/i); + assert.match(setup, /node "\/scripts\/install-hooks\.mjs"/i); assert.match(setup, /rerun the final setup command so the user sees the repaired state immediately/i); }); + +test("simple runtime skills use the installed plugin path instead of cache-relative placeholders", () => { + const status = read("skills/status/SKILL.md"); + const result = read("skills/result/SKILL.md"); + const cancel = read("skills/cancel/SKILL.md"); + const installedRootPattern = /\/scripts\/claude-companion\.mjs/i; + + for (const skillText of [status, result, cancel]) { + assert.match(skillText, /Do not derive the companion path from this skill file or any cache directory/i); + assert.match(skillText, installedRootPattern); + assert.doesNotMatch(skillText, //i); + } +}); diff --git a/tests/state.test.mjs b/tests/state.test.mjs index 29c233a..3716b8d 100644 --- a/tests/state.test.mjs +++ b/tests/state.test.mjs @@ -9,6 +9,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { createHash } from "node:crypto"; +import { fileURLToPath } from "node:url"; // State paths are workspace-hash based and resolveWorkspaceRoot() shells out to // git, so most tests use a real git repo cwd. A dedicated subprocess test below @@ -43,10 +44,7 @@ import { } from "../scripts/lib/state.mjs"; // We'll use the project root as a known git-repo cwd for workspace resolution. -const PROJECT_CWD = path.resolve( - new URL(".", import.meta.url).pathname, - ".." -); +const PROJECT_CWD = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); const STATE_MODULE_URL = new URL("../scripts/lib/state.mjs", import.meta.url).href; function createTempGitRepo() { diff --git a/tests/tracked-jobs.test.mjs b/tests/tracked-jobs.test.mjs index b77c523..7b93dfb 100644 --- a/tests/tracked-jobs.test.mjs +++ b/tests/tracked-jobs.test.mjs @@ -8,6 +8,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { spawnSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; import { SESSION_ID_ENV, @@ -21,7 +22,7 @@ import { } from "../scripts/lib/tracked-jobs.mjs"; import { clearCurrentSession, ensureStateDir, readJobFile, resolveJobFile, resolveJobLogFile, setCurrentSession, writeJobFile } from "../scripts/lib/state.mjs"; -const PROJECT_CWD = path.resolve(new URL(".", import.meta.url).pathname, ".."); +const PROJECT_CWD = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); function createTempGitRepo() { const repoDir = fs.mkdtempSync(path.join(os.tmpdir(), "tracked-jobs-session-"));