Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| db52e28f4d | |||
| 80c31f9957 | |||
| 430e2a8630 | |||
| 807e03ac9d | |||
| bb38412a67 | |||
| 6a5c2ba53b | |||
| 8e9a38cdfe | |||
| b1156235e0 | |||
| c24afe8404 | |||
| 11a720b7db | |||
| bc8fa661a5 | |||
| d216a5fdea | |||
| 40d213d13f | |||
| 4bd783b7ce | |||
| dd335cbc76 | |||
| 62c351a7bf | |||
| f17e7f8486 | |||
| 594fd1e8da | |||
| 8e403f9d4b |
@@ -5,13 +5,13 @@
|
||||
},
|
||||
"metadata": {
|
||||
"description": "Codex plugins to use in Claude Code for delegation and code review.",
|
||||
"version": "1.0.2"
|
||||
"version": "1.0.6"
|
||||
},
|
||||
"plugins": [
|
||||
{
|
||||
"name": "codex",
|
||||
"description": "Use Codex from Claude Code to review code or delegate tasks.",
|
||||
"version": "1.0.2",
|
||||
"version": "1.0.6",
|
||||
"author": {
|
||||
"name": "OpenAI"
|
||||
},
|
||||
|
||||
@@ -11,7 +11,7 @@ they already have.
|
||||
|
||||
- `/codex:review` for a normal read-only Codex review
|
||||
- `/codex:adversarial-review` for a steerable challenge review
|
||||
- `/codex:rescue`, `/codex:status`, `/codex:result`, and `/codex:cancel` to delegate work and manage background jobs
|
||||
- `/codex:rescue`, `/codex:transfer`, `/codex:status`, `/codex:result`, and `/codex:cancel` to delegate work, hand off sessions, and manage background jobs
|
||||
|
||||
## Requirements
|
||||
|
||||
@@ -162,6 +162,21 @@ Ask Codex to redesign the database connection to be more resilient.
|
||||
- if you say `spark`, the plugin maps that to `gpt-5.3-codex-spark`
|
||||
- follow-up rescue requests can continue the latest Codex task in the repo
|
||||
|
||||
### `/codex:transfer`
|
||||
|
||||
Creates a persistent Codex thread from the current Claude Code session and prints a `codex resume <session-id>` command.
|
||||
|
||||
Use it when you started a debugging or implementation conversation in Claude Code and want to continue that same context directly in Codex.
|
||||
|
||||
Examples:
|
||||
|
||||
```bash
|
||||
/codex:transfer
|
||||
/codex:transfer --source ~/.claude/projects/-Users-me-repo/<session-id>.jsonl
|
||||
```
|
||||
|
||||
The plugin's existing `SessionStart` hook supplies the current transcript path automatically; `--source` is available as a manual override. The transfer uses Codex's external-agent session importer, so it follows the same conversion rules as importing Claude history in the Codex App and creates visible turns that can be continued in the App or TUI. The source must be under `~/.claude/projects`, and older Codex versions that do not expose session import must be upgraded before using this command.
|
||||
|
||||
### `/codex:status`
|
||||
|
||||
Shows running and recent Codex jobs for the current repository.
|
||||
@@ -259,7 +274,7 @@ If you want to change the default reasoning effort or the default model that get
|
||||
|
||||
```toml
|
||||
model = "gpt-5.4-mini"
|
||||
model_reasoning_effort = "xhigh"
|
||||
model_reasoning_effort = "high"
|
||||
```
|
||||
|
||||
Your configuration will be picked up based on:
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@openai/codex-plugin-cc",
|
||||
"version": "1.0.2",
|
||||
"version": "1.0.6",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@openai/codex-plugin-cc",
|
||||
"version": "1.0.2",
|
||||
"version": "1.0.6",
|
||||
"license": "Apache-2.0",
|
||||
"devDependencies": {
|
||||
"@types/node": "^25.5.0",
|
||||
|
||||
+3
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openai/codex-plugin-cc",
|
||||
"version": "1.0.2",
|
||||
"version": "1.0.6",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "Use Codex from Claude Code to review code or delegate tasks.",
|
||||
@@ -9,6 +9,8 @@
|
||||
"node": ">=18.18.0"
|
||||
},
|
||||
"scripts": {
|
||||
"bump-version": "node scripts/bump-version.mjs",
|
||||
"check-version": "node scripts/bump-version.mjs --check",
|
||||
"prebuild": "mkdir -p plugins/codex/.generated/app-server-types && codex app-server generate-ts --out plugins/codex/.generated/app-server-types",
|
||||
"build": "tsc -p tsconfig.app-server.json",
|
||||
"test": "node --test tests/*.test.mjs"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "codex",
|
||||
"version": "1.0.2",
|
||||
"version": "1.0.6",
|
||||
"description": "Use Codex from Claude Code to review code or delegate tasks.",
|
||||
"author": {
|
||||
"name": "OpenAI"
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
---
|
||||
name: codex-rescue
|
||||
description: Proactively use when Claude Code is stuck, wants a second implementation or diagnosis pass, needs a deeper root-cause investigation, or should hand a substantial coding task to Codex through the shared runtime
|
||||
model: sonnet
|
||||
tools: Bash
|
||||
skills:
|
||||
- codex-cli-runtime
|
||||
|
||||
@@ -5,4 +5,4 @@ disable-model-invocation: true
|
||||
allowed-tools: Bash(node:*)
|
||||
---
|
||||
|
||||
!`node "${CLAUDE_PLUGIN_ROOT}/scripts/codex-companion.mjs" cancel $ARGUMENTS`
|
||||
!`node "${CLAUDE_PLUGIN_ROOT}/scripts/codex-companion.mjs" cancel "$ARGUMENTS"`
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
---
|
||||
description: Delegate investigation, an explicit fix request, or follow-up rescue work to the Codex rescue subagent
|
||||
argument-hint: "[--background|--wait] [--resume|--fresh] [--model <model|spark>] [--effort <none|minimal|low|medium|high|xhigh>] [what Codex should investigate, solve, or continue]"
|
||||
context: fork
|
||||
allowed-tools: Bash(node:*), AskUserQuestion
|
||||
allowed-tools: Bash(node:*), AskUserQuestion, Agent
|
||||
---
|
||||
|
||||
Route this request to the `codex:codex-rescue` subagent.
|
||||
Invoke the `codex:codex-rescue` subagent via the `Agent` tool (`subagent_type: "codex:codex-rescue"`), forwarding the raw user request as the prompt.
|
||||
`codex:codex-rescue` is a subagent, not a skill — do not call `Skill(codex:codex-rescue)` (no such skill) or `Skill(codex:rescue)` (that re-enters this command and hangs the session). The command runs inline so the `Agent` tool stays in scope; forked general-purpose subagents do not expose it.
|
||||
The final user-visible response must be Codex's output verbatim.
|
||||
|
||||
Raw user request:
|
||||
|
||||
@@ -5,7 +5,7 @@ disable-model-invocation: true
|
||||
allowed-tools: Bash(node:*)
|
||||
---
|
||||
|
||||
!`node "${CLAUDE_PLUGIN_ROOT}/scripts/codex-companion.mjs" result $ARGUMENTS`
|
||||
!`node "${CLAUDE_PLUGIN_ROOT}/scripts/codex-companion.mjs" result "$ARGUMENTS"`
|
||||
|
||||
Present the full command output to the user. Do not summarize or condense it. Preserve all details including:
|
||||
- Job ID and status
|
||||
|
||||
@@ -5,7 +5,7 @@ disable-model-invocation: true
|
||||
allowed-tools: Bash(node:*)
|
||||
---
|
||||
|
||||
!`node "${CLAUDE_PLUGIN_ROOT}/scripts/codex-companion.mjs" status $ARGUMENTS`
|
||||
!`node "${CLAUDE_PLUGIN_ROOT}/scripts/codex-companion.mjs" status "$ARGUMENTS"`
|
||||
|
||||
If the user did not pass a job ID:
|
||||
- Render the command output as a single Markdown table for the current and past runs in this session.
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
---
|
||||
description: Transfer the current Claude Code session into a resumable Codex thread
|
||||
argument-hint: "[--source <claude-jsonl>]"
|
||||
disable-model-invocation: true
|
||||
allowed-tools: Bash(node:*)
|
||||
---
|
||||
|
||||
!`node "${CLAUDE_PLUGIN_ROOT}/scripts/codex-companion.mjs" transfer "$ARGUMENTS"`
|
||||
|
||||
Present the command output to the user exactly as returned. Preserve the Codex session ID and the `codex resume <session-id>` command.
|
||||
@@ -32,6 +32,7 @@ Actively try to disprove the change.
|
||||
Look for violated invariants, missing guards, unhandled failure paths, and assumptions that stop being true under stress.
|
||||
Trace how bad inputs, retries, concurrent actions, or partially completed operations move through the code.
|
||||
If the user supplied a focus area, weight it heavily, but still report any other material issue you can defend.
|
||||
{{REVIEW_COLLECTION_GUIDANCE}}
|
||||
</review_method>
|
||||
|
||||
<finding_bar>
|
||||
|
||||
@@ -11,15 +11,17 @@ import {
|
||||
buildPersistentTaskThreadName,
|
||||
DEFAULT_CONTINUE_PROMPT,
|
||||
findLatestTaskThread,
|
||||
getCodexAuthStatus,
|
||||
getCodexAvailability,
|
||||
getCodexLoginStatus,
|
||||
getSessionRuntimeStatus,
|
||||
importExternalAgentSession,
|
||||
interruptAppServerTurn,
|
||||
parseStructuredOutput,
|
||||
readOutputSchema,
|
||||
runAppServerReview,
|
||||
runAppServerTurn
|
||||
} from "./lib/codex.mjs";
|
||||
import { resolveClaudeSessionPath } from "./lib/claude-session-transfer.mjs";
|
||||
import { readStdinIfPiped } from "./lib/fs.mjs";
|
||||
import { collectReviewContext, ensureGitRepository, resolveReviewTarget } from "./lib/git.mjs";
|
||||
import { binaryAvailable, terminateProcessTree } from "./lib/process.mjs";
|
||||
@@ -78,6 +80,7 @@ function printUsage() {
|
||||
" node scripts/codex-companion.mjs review [--wait|--background] [--base <ref>] [--scope <auto|working-tree|branch>]",
|
||||
" node scripts/codex-companion.mjs adversarial-review [--wait|--background] [--base <ref>] [--scope <auto|working-tree|branch>] [focus text]",
|
||||
" node scripts/codex-companion.mjs task [--background] [--write] [--resume-last|--resume|--fresh] [--model <model|spark>] [--effort <none|minimal|low|medium|high|xhigh>] [prompt]",
|
||||
" node scripts/codex-companion.mjs transfer [--source <claude-jsonl>] [--json]",
|
||||
" node scripts/codex-companion.mjs status [job-id] [--all] [--json]",
|
||||
" node scripts/codex-companion.mjs result [job-id] [--json]",
|
||||
" node scripts/codex-companion.mjs cancel [job-id] [--json]"
|
||||
@@ -176,19 +179,19 @@ function firstMeaningfulLine(text, fallback) {
|
||||
return line ?? fallback;
|
||||
}
|
||||
|
||||
function buildSetupReport(cwd, actionsTaken = []) {
|
||||
async function buildSetupReport(cwd, actionsTaken = []) {
|
||||
const workspaceRoot = resolveWorkspaceRoot(cwd);
|
||||
const nodeStatus = binaryAvailable("node", ["--version"], { cwd });
|
||||
const npmStatus = binaryAvailable("npm", ["--version"], { cwd });
|
||||
const codexStatus = getCodexAvailability(cwd);
|
||||
const authStatus = getCodexLoginStatus(cwd);
|
||||
const authStatus = await getCodexAuthStatus(cwd);
|
||||
const config = getConfig(workspaceRoot);
|
||||
|
||||
const nextSteps = [];
|
||||
if (!codexStatus.available) {
|
||||
nextSteps.push("Install Codex with `npm install -g @openai/codex`.");
|
||||
}
|
||||
if (codexStatus.available && !authStatus.loggedIn) {
|
||||
if (codexStatus.available && !authStatus.loggedIn && authStatus.requiresOpenaiAuth) {
|
||||
nextSteps.push("Run `!codex login`.");
|
||||
nextSteps.push("If browser login is blocked, retry with `!codex login --device-auth` or `!codex login --with-api-key`.");
|
||||
}
|
||||
@@ -202,14 +205,14 @@ function buildSetupReport(cwd, actionsTaken = []) {
|
||||
npm: npmStatus,
|
||||
codex: codexStatus,
|
||||
auth: authStatus,
|
||||
sessionRuntime: getSessionRuntimeStatus(),
|
||||
sessionRuntime: getSessionRuntimeStatus(process.env, workspaceRoot),
|
||||
reviewGateEnabled: Boolean(config.stopReviewGate),
|
||||
actionsTaken,
|
||||
nextSteps
|
||||
};
|
||||
}
|
||||
|
||||
function handleSetup(argv) {
|
||||
async function handleSetup(argv) {
|
||||
const { options } = parseCommandInput(argv, {
|
||||
valueOptions: ["cwd"],
|
||||
booleanOptions: ["json", "enable-review-gate", "disable-review-gate"]
|
||||
@@ -231,7 +234,7 @@ function handleSetup(argv) {
|
||||
actionsTaken.push(`Disabled the stop-time review gate for ${workspaceRoot}.`);
|
||||
}
|
||||
|
||||
const finalReport = buildSetupReport(cwd, actionsTaken);
|
||||
const finalReport = await buildSetupReport(cwd, actionsTaken);
|
||||
outputResult(options.json ? finalReport : renderSetupReport(finalReport), options.json);
|
||||
}
|
||||
|
||||
@@ -241,18 +244,16 @@ function buildAdversarialReviewPrompt(context, focusText) {
|
||||
REVIEW_KIND: "Adversarial Review",
|
||||
TARGET_LABEL: context.target.label,
|
||||
USER_FOCUS: focusText || "No extra focus provided.",
|
||||
REVIEW_COLLECTION_GUIDANCE: context.collectionGuidance,
|
||||
REVIEW_INPUT: context.content
|
||||
});
|
||||
}
|
||||
|
||||
function ensureCodexReady(cwd) {
|
||||
const authStatus = getCodexLoginStatus(cwd);
|
||||
if (!authStatus.available) {
|
||||
function ensureCodexAvailable(cwd) {
|
||||
const availability = getCodexAvailability(cwd);
|
||||
if (!availability.available) {
|
||||
throw new Error("Codex CLI is not installed or is missing required runtime support. Install it with `npm install -g @openai/codex`, then rerun `/codex:setup`.");
|
||||
}
|
||||
if (!authStatus.loggedIn) {
|
||||
throw new Error("Codex CLI is not authenticated. Run `!codex login` and retry.");
|
||||
}
|
||||
}
|
||||
|
||||
function buildNativeReviewTarget(target) {
|
||||
@@ -290,6 +291,30 @@ function isActiveJobStatus(status) {
|
||||
return status === "queued" || status === "running";
|
||||
}
|
||||
|
||||
function getCurrentClaudeSessionId() {
|
||||
return process.env[SESSION_ID_ENV] ?? null;
|
||||
}
|
||||
|
||||
function filterJobsForCurrentClaudeSession(jobs) {
|
||||
const sessionId = getCurrentClaudeSessionId();
|
||||
if (!sessionId) {
|
||||
return jobs;
|
||||
}
|
||||
return jobs.filter((job) => job.sessionId === sessionId);
|
||||
}
|
||||
|
||||
function findLatestResumableTaskJob(jobs) {
|
||||
return (
|
||||
jobs.find(
|
||||
(job) =>
|
||||
job.jobClass === "task" &&
|
||||
job.threadId &&
|
||||
job.status !== "queued" &&
|
||||
job.status !== "running"
|
||||
) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
async function waitForSingleJobSnapshot(cwd, reference, options = {}) {
|
||||
const timeoutMs = Math.max(0, Number(options.timeoutMs) || DEFAULT_STATUS_WAIT_TIMEOUT_MS);
|
||||
const pollIntervalMs = Math.max(100, Number(options.pollIntervalMs) || DEFAULT_STATUS_POLL_INTERVAL_MS);
|
||||
@@ -310,22 +335,28 @@ async function waitForSingleJobSnapshot(cwd, reference, options = {}) {
|
||||
|
||||
async function resolveLatestTrackedTaskThread(cwd, options = {}) {
|
||||
const workspaceRoot = resolveWorkspaceRoot(cwd);
|
||||
const sessionId = getCurrentClaudeSessionId();
|
||||
const jobs = sortJobsNewestFirst(listJobs(workspaceRoot)).filter((job) => job.id !== options.excludeJobId);
|
||||
const activeTask = jobs.find((job) => job.jobClass === "task" && (job.status === "queued" || job.status === "running"));
|
||||
const visibleJobs = filterJobsForCurrentClaudeSession(jobs);
|
||||
const activeTask = visibleJobs.find((job) => job.jobClass === "task" && (job.status === "queued" || job.status === "running"));
|
||||
if (activeTask) {
|
||||
throw new Error(`Task ${activeTask.id} is still running. Use /codex:status before continuing it.`);
|
||||
}
|
||||
|
||||
const trackedTask = jobs.find((job) => job.jobClass === "task" && job.status === "completed" && job.threadId);
|
||||
const trackedTask = findLatestResumableTaskJob(visibleJobs);
|
||||
if (trackedTask) {
|
||||
return { id: trackedTask.threadId };
|
||||
}
|
||||
|
||||
if (sessionId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return findLatestTaskThread(workspaceRoot);
|
||||
}
|
||||
|
||||
async function executeReviewRun(request) {
|
||||
ensureCodexReady(request.cwd);
|
||||
ensureCodexAvailable(request.cwd);
|
||||
ensureGitRepository(request.cwd);
|
||||
|
||||
const target = resolveReviewTarget(request.cwd, {
|
||||
@@ -429,7 +460,7 @@ async function executeReviewRun(request) {
|
||||
|
||||
async function executeTaskRun(request) {
|
||||
const workspaceRoot = resolveWorkspaceRoot(request.cwd);
|
||||
ensureCodexReady(request.cwd);
|
||||
ensureCodexAvailable(request.cwd);
|
||||
|
||||
const taskMetadata = buildTaskRunMetadata({
|
||||
prompt: request.prompt,
|
||||
@@ -582,6 +613,33 @@ function buildTaskRequest({ cwd, model, effort, prompt, write, resumeLast, jobId
|
||||
};
|
||||
}
|
||||
|
||||
function renderTransferResult(payload) {
|
||||
const lines = [
|
||||
"Transferred the Claude session into a Codex thread with visible turn history.",
|
||||
`Codex session ID: ${payload.threadId}`,
|
||||
`Resume in Codex: ${payload.resumeCommand}`
|
||||
];
|
||||
return `${lines.join("\n")}\n`;
|
||||
}
|
||||
|
||||
async function executeTransfer(cwd, options = {}) {
|
||||
const sourcePath = resolveClaudeSessionPath(cwd, {
|
||||
source: options.source
|
||||
});
|
||||
const result = await importExternalAgentSession(cwd, { sourcePath });
|
||||
const payload = {
|
||||
threadId: result.threadId,
|
||||
resumeCommand: `codex resume ${result.threadId}`,
|
||||
sourcePath,
|
||||
sessionId: path.basename(sourcePath, ".jsonl")
|
||||
};
|
||||
|
||||
return {
|
||||
payload,
|
||||
rendered: renderTransferResult(payload)
|
||||
};
|
||||
}
|
||||
|
||||
function readTaskPrompt(cwd, options, positionals) {
|
||||
if (options["prompt-file"]) {
|
||||
return fs.readFileSync(path.resolve(cwd, options["prompt-file"]), "utf8");
|
||||
@@ -728,7 +786,7 @@ async function handleTask(argv) {
|
||||
});
|
||||
|
||||
if (options.background) {
|
||||
ensureCodexReady(cwd);
|
||||
ensureCodexAvailable(cwd);
|
||||
requireTaskRequest(prompt, resumeLast);
|
||||
|
||||
const job = buildTaskJob(workspaceRoot, taskMetadata, write);
|
||||
@@ -764,6 +822,19 @@ async function handleTask(argv) {
|
||||
);
|
||||
}
|
||||
|
||||
async function handleTransfer(argv) {
|
||||
const { options } = parseCommandInput(argv, {
|
||||
valueOptions: ["cwd", "source"],
|
||||
booleanOptions: ["json"]
|
||||
});
|
||||
|
||||
const cwd = resolveCommandCwd(options);
|
||||
const { payload, rendered } = await executeTransfer(cwd, {
|
||||
source: options.source
|
||||
});
|
||||
outputCommandResult(payload, rendered, options.json);
|
||||
}
|
||||
|
||||
async function handleTaskWorker(argv) {
|
||||
const { options } = parseCommandInput(argv, {
|
||||
valueOptions: ["cwd", "job-id"]
|
||||
@@ -862,17 +933,9 @@ function handleTaskResumeCandidate(argv) {
|
||||
|
||||
const cwd = resolveCommandCwd(options);
|
||||
const workspaceRoot = resolveCommandWorkspace(options);
|
||||
const sessionId = process.env[SESSION_ID_ENV] ?? null;
|
||||
const jobs = sortJobsNewestFirst(listJobs(workspaceRoot));
|
||||
const candidate =
|
||||
jobs.find(
|
||||
(job) =>
|
||||
job.jobClass === "task" &&
|
||||
job.threadId &&
|
||||
job.status !== "queued" &&
|
||||
job.status !== "running" &&
|
||||
(!sessionId || job.sessionId === sessionId)
|
||||
) ?? null;
|
||||
const sessionId = getCurrentClaudeSessionId();
|
||||
const jobs = filterJobsForCurrentClaudeSession(sortJobsNewestFirst(listJobs(workspaceRoot)));
|
||||
const candidate = findLatestResumableTaskJob(jobs);
|
||||
|
||||
const payload = {
|
||||
available: Boolean(candidate),
|
||||
@@ -905,7 +968,7 @@ async function handleCancel(argv) {
|
||||
|
||||
const cwd = resolveCommandCwd(options);
|
||||
const reference = positionals[0] ?? "";
|
||||
const { workspaceRoot, job } = resolveCancelableJob(cwd, reference);
|
||||
const { workspaceRoot, job } = resolveCancelableJob(cwd, reference, { env: process.env });
|
||||
const existing = readStoredJob(workspaceRoot, job.id) ?? {};
|
||||
const threadId = existing.threadId ?? job.threadId ?? null;
|
||||
const turnId = existing.turnId ?? job.turnId ?? null;
|
||||
@@ -967,7 +1030,7 @@ async function main() {
|
||||
|
||||
switch (subcommand) {
|
||||
case "setup":
|
||||
handleSetup(argv);
|
||||
await handleSetup(argv);
|
||||
break;
|
||||
case "review":
|
||||
await handleReview(argv);
|
||||
@@ -980,6 +1043,9 @@ async function main() {
|
||||
case "task":
|
||||
await handleTask(argv);
|
||||
break;
|
||||
case "transfer":
|
||||
await handleTransfer(argv);
|
||||
break;
|
||||
case "task-worker":
|
||||
await handleTaskWorker(argv);
|
||||
break;
|
||||
|
||||
@@ -6,6 +6,8 @@ import type {
|
||||
ServerNotification
|
||||
} from "../../.generated/app-server-types/index.js";
|
||||
import type {
|
||||
ExternalAgentConfigImportParams,
|
||||
ExternalAgentConfigImportResponse,
|
||||
ReviewStartParams,
|
||||
ReviewStartResponse,
|
||||
ReviewTarget,
|
||||
@@ -51,10 +53,12 @@ export interface CodexAppServerClientOptions {
|
||||
capabilities?: InitializeCapabilities;
|
||||
brokerEndpoint?: string;
|
||||
disableBroker?: boolean;
|
||||
reuseExistingBroker?: boolean;
|
||||
}
|
||||
|
||||
export interface AppServerMethodMap {
|
||||
initialize: { params: InitializeParams; result: InitializeResponse };
|
||||
"externalAgentConfig/import": { params: ExternalAgentConfigImportParams; result: ExternalAgentConfigImportResponse };
|
||||
"thread/start": { params: ThreadStartParams; result: ThreadStartResponse };
|
||||
"thread/resume": { params: ThreadResumeParams; result: ThreadResumeResponse };
|
||||
"thread/name/set": { params: ThreadSetNameParams; result: ThreadSetNameResponse };
|
||||
|
||||
@@ -13,7 +13,7 @@ import process from "node:process";
|
||||
import { spawn } from "node:child_process";
|
||||
import readline from "node:readline";
|
||||
import { parseBrokerEndpoint } from "./broker-endpoint.mjs";
|
||||
import { ensureBrokerSession } from "./broker-lifecycle.mjs";
|
||||
import { ensureBrokerSession, loadBrokerSession } from "./broker-lifecycle.mjs";
|
||||
import { terminateProcessTree } from "./process.mjs";
|
||||
|
||||
const PLUGIN_MANIFEST_URL = new URL("../../.claude-plugin/plugin.json", import.meta.url);
|
||||
@@ -32,6 +32,7 @@ const DEFAULT_CLIENT_INFO = {
|
||||
/** @type {InitializeCapabilities} */
|
||||
const DEFAULT_CAPABILITIES = {
|
||||
experimentalApi: false,
|
||||
requestAttestation: false,
|
||||
optOutNotificationMethods: [
|
||||
"item/agentMessage/delta",
|
||||
"item/reasoning/summaryTextDelta",
|
||||
@@ -188,9 +189,9 @@ class SpawnedCodexAppServerClient extends AppServerClientBase {
|
||||
async initialize() {
|
||||
this.proc = spawn("codex", ["app-server"], {
|
||||
cwd: this.cwd,
|
||||
env: this.options.env,
|
||||
env: this.options.env ?? process.env,
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
shell: process.platform === "win32",
|
||||
shell: process.platform === "win32" ? (process.env.SHELL || true) : false,
|
||||
windowsHide: true
|
||||
});
|
||||
|
||||
@@ -206,10 +207,13 @@ class SpawnedCodexAppServerClient extends AppServerClientBase {
|
||||
});
|
||||
|
||||
this.proc.on("exit", (code, signal) => {
|
||||
const stderr = this.stderr.trim();
|
||||
const detail =
|
||||
code === 0
|
||||
? null
|
||||
: createProtocolError(`codex app-server exited unexpectedly (${signal ? `signal ${signal}` : `exit ${code}`}).`);
|
||||
: createProtocolError(
|
||||
`codex app-server exited unexpectedly (${signal ? `signal ${signal}` : `exit ${code}`}).${stderr ? `\n${stderr}` : ""}`
|
||||
);
|
||||
this.handleExit(detail);
|
||||
});
|
||||
|
||||
@@ -333,7 +337,10 @@ export class CodexAppServerClient {
|
||||
let brokerEndpoint = null;
|
||||
if (!options.disableBroker) {
|
||||
brokerEndpoint = options.brokerEndpoint ?? options.env?.[BROKER_ENDPOINT_ENV] ?? process.env[BROKER_ENDPOINT_ENV] ?? null;
|
||||
if (!brokerEndpoint) {
|
||||
if (!brokerEndpoint && options.reuseExistingBroker) {
|
||||
brokerEndpoint = loadBrokerSession(cwd)?.endpoint ?? null;
|
||||
}
|
||||
if (!brokerEndpoint && !options.reuseExistingBroker) {
|
||||
const brokerSession = await ensureBrokerSession(cwd, { env: options.env });
|
||||
brokerEndpoint = brokerSession?.endpoint ?? null;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
import { ensureAbsolutePath } from "./fs.mjs";
|
||||
|
||||
export const TRANSCRIPT_PATH_ENV = "CODEX_COMPANION_TRANSCRIPT_PATH";
|
||||
const CLAUDE_PROJECTS_DIR = path.join(os.homedir(), ".claude", "projects");
|
||||
|
||||
function resolveUserPath(cwd, value) {
|
||||
if (value === "~") {
|
||||
return os.homedir();
|
||||
}
|
||||
if (String(value).startsWith("~/")) {
|
||||
return path.join(os.homedir(), String(value).slice(2));
|
||||
}
|
||||
return ensureAbsolutePath(cwd, value);
|
||||
}
|
||||
|
||||
export function resolveClaudeSessionPath(cwd, options = {}) {
|
||||
const requestedPath = options.source || process.env[TRANSCRIPT_PATH_ENV];
|
||||
if (!requestedPath) {
|
||||
throw new Error("Could not identify the current Claude transcript. Retry with --source <path-to-claude-jsonl>.");
|
||||
}
|
||||
|
||||
const sourcePath = resolveUserPath(cwd, requestedPath);
|
||||
if (path.extname(sourcePath) !== ".jsonl") {
|
||||
throw new Error(`Claude session source must be a JSONL file: ${sourcePath}`);
|
||||
}
|
||||
|
||||
let source;
|
||||
let projects;
|
||||
try {
|
||||
source = fs.realpathSync(sourcePath);
|
||||
projects = fs.realpathSync(CLAUDE_PROJECTS_DIR);
|
||||
} catch {
|
||||
throw new Error(`Claude session file not found: ${sourcePath}`);
|
||||
}
|
||||
const relative = path.relative(projects, source);
|
||||
if (relative === "" || relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
|
||||
throw new Error(`Codex can import Claude sessions only from ${CLAUDE_PROJECTS_DIR}: ${source}`);
|
||||
}
|
||||
return source;
|
||||
}
|
||||
@@ -34,15 +34,22 @@
|
||||
* onProgress: ProgressReporter | null
|
||||
* }} TurnCaptureState
|
||||
*/
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
import { readJsonFile } from "./fs.mjs";
|
||||
import { BROKER_BUSY_RPC_CODE, BROKER_ENDPOINT_ENV, CodexAppServerClient } from "./app-server.mjs";
|
||||
import { loadBrokerSession } from "./broker-lifecycle.mjs";
|
||||
import { binaryAvailable, runCommand } from "./process.mjs";
|
||||
import { binaryAvailable } from "./process.mjs";
|
||||
|
||||
const SERVICE_NAME = "claude_code_codex_plugin";
|
||||
const TASK_THREAD_PREFIX = "Codex Companion Task";
|
||||
const DEFAULT_CONTINUE_PROMPT =
|
||||
"Continue from the current thread state. Pick the next highest-value step and follow through until the task is resolved.";
|
||||
const EXTERNAL_AGENT_IMPORT_COMPLETED = "externalAgentConfig/import/completed";
|
||||
const EXTERNAL_AGENT_IMPORT_TIMEOUT_MS = 2 * 60 * 1000;
|
||||
|
||||
function cleanCodexStderr(stderr) {
|
||||
return stderr
|
||||
@@ -60,8 +67,7 @@ function buildThreadParams(cwd, options = {}) {
|
||||
approvalPolicy: options.approvalPolicy ?? "never",
|
||||
sandbox: options.sandbox ?? "read-only",
|
||||
serviceName: SERVICE_NAME,
|
||||
ephemeral: options.ephemeral ?? true,
|
||||
experimentalRawEvents: false
|
||||
ephemeral: options.ephemeral ?? true
|
||||
};
|
||||
}
|
||||
|
||||
@@ -635,11 +641,108 @@ async function withAppServer(cwd, fn) {
|
||||
}
|
||||
}
|
||||
|
||||
async function withDirectAppServer(cwd, fn) {
|
||||
const client = await CodexAppServerClient.connect(cwd, { disableBroker: true });
|
||||
try {
|
||||
return await fn(client);
|
||||
} finally {
|
||||
await client.close();
|
||||
}
|
||||
}
|
||||
|
||||
function resolveCodexHome() {
|
||||
return path.resolve(process.env.CODEX_HOME || path.join(os.homedir(), ".codex"));
|
||||
}
|
||||
|
||||
function sourceContentSha256(sourcePath) {
|
||||
return crypto.createHash("sha256").update(fs.readFileSync(sourcePath)).digest("hex");
|
||||
}
|
||||
|
||||
function importedThreadIdForSource(sourcePath) {
|
||||
const ledgerPath = path.join(resolveCodexHome(), "external_agent_session_imports.json");
|
||||
if (!fs.existsSync(ledgerPath)) {
|
||||
return null;
|
||||
}
|
||||
const ledger = readJsonFile(ledgerPath);
|
||||
const canonicalSource = fs.realpathSync(sourcePath);
|
||||
const contentSha256 = sourceContentSha256(canonicalSource);
|
||||
const records = Array.isArray(ledger?.records) ? ledger.records : [];
|
||||
const match = records
|
||||
.filter(
|
||||
(record) =>
|
||||
record?.source_path === canonicalSource &&
|
||||
record?.content_sha256 === contentSha256 &&
|
||||
typeof record?.imported_thread_id === "string"
|
||||
)
|
||||
.at(-1);
|
||||
return match?.imported_thread_id ?? null;
|
||||
}
|
||||
|
||||
function externalAgentSessionMigration(sourcePath, cwd) {
|
||||
return {
|
||||
migrationItems: [
|
||||
{
|
||||
itemType: "SESSIONS",
|
||||
description: `Transfer Claude session ${path.basename(sourcePath)}`,
|
||||
cwd: null,
|
||||
details: {
|
||||
plugins: [],
|
||||
sessions: [{ path: sourcePath, cwd, title: null }],
|
||||
mcpServers: [],
|
||||
hooks: [],
|
||||
subagents: [],
|
||||
commands: []
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
async function requestExternalAgentSessionImport(client, params) {
|
||||
const previousHandler = client.notificationHandler;
|
||||
let timeout = null;
|
||||
let resolveCompleted;
|
||||
let rejectCompleted;
|
||||
const completed = new Promise((resolve, reject) => {
|
||||
resolveCompleted = resolve;
|
||||
rejectCompleted = reject;
|
||||
});
|
||||
void completed.catch(() => {});
|
||||
|
||||
client.setNotificationHandler((message) => {
|
||||
if (message.method === EXTERNAL_AGENT_IMPORT_COMPLETED) {
|
||||
resolveCompleted();
|
||||
return;
|
||||
}
|
||||
previousHandler?.(message);
|
||||
});
|
||||
timeout = setTimeout(() => {
|
||||
rejectCompleted(new Error("Timed out waiting for Codex to finish importing the Claude session."));
|
||||
}, EXTERNAL_AGENT_IMPORT_TIMEOUT_MS);
|
||||
|
||||
try {
|
||||
await client.request("externalAgentConfig/import", params);
|
||||
await completed;
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
client.setNotificationHandler(previousHandler ?? null);
|
||||
}
|
||||
}
|
||||
|
||||
async function startThread(client, cwd, options = {}) {
|
||||
const response = await client.request("thread/start", buildThreadParams(cwd, options));
|
||||
const threadId = response.thread.id;
|
||||
if (options.threadName) {
|
||||
await client.request("thread/name/set", { threadId, name: options.threadName });
|
||||
try {
|
||||
await client.request("thread/name/set", { threadId, name: options.threadName });
|
||||
} catch (err) {
|
||||
// Only suppress "unknown variant/method" errors from older CLI versions
|
||||
// that don't support thread/name/set. Rethrow auth, network, or server errors.
|
||||
const msg = String(err?.message ?? err ?? "");
|
||||
if (!msg.includes("unknown variant") && !msg.includes("unknown method")) {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
return response;
|
||||
}
|
||||
@@ -652,6 +755,134 @@ function buildResultStatus(turnState) {
|
||||
return turnState.finalTurn?.status === "completed" ? 0 : 1;
|
||||
}
|
||||
|
||||
const BUILTIN_PROVIDER_LABELS = new Map([
|
||||
["openai", "OpenAI"],
|
||||
["ollama", "Ollama"],
|
||||
["lmstudio", "LM Studio"]
|
||||
]);
|
||||
|
||||
function normalizeProviderId(value) {
|
||||
const providerId = typeof value === "string" ? value.trim() : "";
|
||||
return providerId || null;
|
||||
}
|
||||
|
||||
function formatProviderLabel(providerId, providerConfig = null) {
|
||||
const configuredName = typeof providerConfig?.name === "string" ? providerConfig.name.trim() : "";
|
||||
if (configuredName) {
|
||||
return configuredName;
|
||||
}
|
||||
if (!providerId) {
|
||||
return "The active provider";
|
||||
}
|
||||
return BUILTIN_PROVIDER_LABELS.get(providerId) ?? providerId;
|
||||
}
|
||||
|
||||
function buildAuthStatus(fields = {}) {
|
||||
return {
|
||||
available: true,
|
||||
loggedIn: false,
|
||||
detail: "not authenticated",
|
||||
source: "unknown",
|
||||
authMethod: null,
|
||||
verified: null,
|
||||
requiresOpenaiAuth: null,
|
||||
provider: null,
|
||||
...fields
|
||||
};
|
||||
}
|
||||
|
||||
function resolveProviderConfig(configResponse) {
|
||||
const config = configResponse?.config;
|
||||
if (!config || typeof config !== "object") {
|
||||
return {
|
||||
providerId: null,
|
||||
providerConfig: null
|
||||
};
|
||||
}
|
||||
|
||||
const providerId = normalizeProviderId(config.model_provider);
|
||||
const providers =
|
||||
config.model_providers && typeof config.model_providers === "object" && !Array.isArray(config.model_providers)
|
||||
? config.model_providers
|
||||
: null;
|
||||
const providerConfig =
|
||||
providerId && providers?.[providerId] && typeof providers[providerId] === "object" ? providers[providerId] : null;
|
||||
|
||||
return {
|
||||
providerId,
|
||||
providerConfig
|
||||
};
|
||||
}
|
||||
|
||||
function buildAppServerAuthStatus(accountResponse, configResponse) {
|
||||
const account = accountResponse?.account ?? null;
|
||||
const requiresOpenaiAuth =
|
||||
typeof accountResponse?.requiresOpenaiAuth === "boolean" ? accountResponse.requiresOpenaiAuth : null;
|
||||
const { providerId, providerConfig } = resolveProviderConfig(configResponse);
|
||||
const providerLabel = formatProviderLabel(providerId, providerConfig);
|
||||
|
||||
if (account?.type === "chatgpt") {
|
||||
const email = typeof account.email === "string" && account.email.trim() ? account.email.trim() : null;
|
||||
return buildAuthStatus({
|
||||
loggedIn: true,
|
||||
detail: email ? `ChatGPT login active for ${email}` : "ChatGPT login active",
|
||||
source: "app-server",
|
||||
authMethod: "chatgpt",
|
||||
verified: true,
|
||||
requiresOpenaiAuth,
|
||||
provider: providerId
|
||||
});
|
||||
}
|
||||
|
||||
if (account?.type === "apiKey") {
|
||||
return buildAuthStatus({
|
||||
loggedIn: true,
|
||||
detail: "API key configured (unverified)",
|
||||
source: "app-server",
|
||||
authMethod: "apiKey",
|
||||
verified: false,
|
||||
requiresOpenaiAuth,
|
||||
provider: providerId
|
||||
});
|
||||
}
|
||||
|
||||
if (requiresOpenaiAuth === false) {
|
||||
return buildAuthStatus({
|
||||
loggedIn: true,
|
||||
detail: `${providerLabel} is configured and does not require OpenAI authentication`,
|
||||
source: "app-server",
|
||||
requiresOpenaiAuth,
|
||||
provider: providerId
|
||||
});
|
||||
}
|
||||
|
||||
return buildAuthStatus({
|
||||
loggedIn: false,
|
||||
detail: `${providerLabel} requires OpenAI authentication`,
|
||||
source: "app-server",
|
||||
requiresOpenaiAuth,
|
||||
provider: providerId
|
||||
});
|
||||
}
|
||||
|
||||
async function getCodexAuthStatusFromClient(client, cwd) {
|
||||
try {
|
||||
const accountResponse = await client.request("account/read", { refreshToken: false });
|
||||
const configResponse = await client.request("config/read", {
|
||||
includeLayers: false,
|
||||
cwd
|
||||
});
|
||||
|
||||
return buildAppServerAuthStatus(accountResponse, configResponse);
|
||||
} catch (error) {
|
||||
return buildAuthStatus({
|
||||
loggedIn: false,
|
||||
detail: error instanceof Error ? error.message : String(error),
|
||||
source: "app-server"
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function getCodexAvailability(cwd) {
|
||||
const versionStatus = binaryAvailable("codex", ["--version"], { cwd });
|
||||
if (!versionStatus.available) {
|
||||
@@ -691,38 +922,39 @@ export function getSessionRuntimeStatus(env = process.env, cwd = process.cwd())
|
||||
};
|
||||
}
|
||||
|
||||
export function getCodexLoginStatus(cwd) {
|
||||
export async function getCodexAuthStatus(cwd, options = {}) {
|
||||
const availability = getCodexAvailability(cwd);
|
||||
if (!availability.available) {
|
||||
return {
|
||||
available: false,
|
||||
loggedIn: false,
|
||||
detail: availability.detail
|
||||
detail: availability.detail,
|
||||
source: "availability",
|
||||
authMethod: null,
|
||||
verified: null,
|
||||
requiresOpenaiAuth: null,
|
||||
provider: null
|
||||
};
|
||||
}
|
||||
|
||||
const result = runCommand("codex", ["login", "status"], { cwd });
|
||||
if (result.error) {
|
||||
return {
|
||||
available: true,
|
||||
let client = null;
|
||||
try {
|
||||
client = await CodexAppServerClient.connect(cwd, {
|
||||
env: options.env,
|
||||
reuseExistingBroker: true
|
||||
});
|
||||
return await getCodexAuthStatusFromClient(client, cwd);
|
||||
} catch (error) {
|
||||
return buildAuthStatus({
|
||||
loggedIn: false,
|
||||
detail: result.error.message
|
||||
};
|
||||
detail: error instanceof Error ? error.message : String(error),
|
||||
source: "app-server"
|
||||
});
|
||||
} finally {
|
||||
if (client) {
|
||||
await client.close().catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
if (result.status === 0) {
|
||||
return {
|
||||
available: true,
|
||||
loggedIn: true,
|
||||
detail: result.stdout.trim() || "authenticated"
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
available: true,
|
||||
loggedIn: false,
|
||||
detail: result.stderr.trim() || result.stdout.trim() || "not authenticated"
|
||||
};
|
||||
}
|
||||
|
||||
export async function interruptAppServerTurn(cwd, { threadId, turnId }) {
|
||||
@@ -745,12 +977,9 @@ export async function interruptAppServerTurn(cwd, { threadId, turnId }) {
|
||||
};
|
||||
}
|
||||
|
||||
const brokerEndpoint = process.env[BROKER_ENDPOINT_ENV] ?? loadBrokerSession(cwd)?.endpoint ?? null;
|
||||
let client = null;
|
||||
try {
|
||||
client = brokerEndpoint
|
||||
? await CodexAppServerClient.connect(cwd, { brokerEndpoint })
|
||||
: await CodexAppServerClient.connect(cwd, { disableBroker: true });
|
||||
client = await CodexAppServerClient.connect(cwd, { reuseExistingBroker: true });
|
||||
await client.request("turn/interrupt", { threadId, turnId });
|
||||
return {
|
||||
attempted: true,
|
||||
@@ -826,6 +1055,43 @@ export async function runAppServerReview(cwd, options = {}) {
|
||||
});
|
||||
}
|
||||
|
||||
export async function importExternalAgentSession(cwd, options = {}) {
|
||||
const availability = getCodexAvailability(cwd);
|
||||
if (!availability.available) {
|
||||
throw new Error("Codex CLI is not installed or is missing required runtime support. Install it with `npm install -g @openai/codex`, then rerun `/codex:setup`.");
|
||||
}
|
||||
if (!options.sourcePath) {
|
||||
throw new Error("A Claude session source path is required.");
|
||||
}
|
||||
|
||||
return withDirectAppServer(cwd, async (client) => {
|
||||
emitProgress(options.onProgress, "Importing Claude session into Codex.", "transferring");
|
||||
try {
|
||||
await requestExternalAgentSessionImport(client, externalAgentSessionMigration(options.sourcePath, cwd));
|
||||
} catch (error) {
|
||||
if (error?.rpcCode === -32601) {
|
||||
throw new Error(
|
||||
"This Codex version does not support Claude session transfer. Update Codex with `npm install -g @openai/codex@latest`, then retry.",
|
||||
{ cause: error }
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
const threadId = importedThreadIdForSource(options.sourcePath);
|
||||
if (!threadId) {
|
||||
const stderr = cleanCodexStderr(client.stderr);
|
||||
throw new Error(
|
||||
`Codex reported that the Claude import completed, but did not record an imported thread.${stderr ? `\n${stderr}` : " Check the Codex app-server logs for the underlying import error."}`
|
||||
);
|
||||
}
|
||||
emitProgress(options.onProgress, `Claude session imported (${threadId}).`, "completed", { threadId });
|
||||
return {
|
||||
threadId,
|
||||
stderr: cleanCodexStderr(client.stderr)
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export async function runAppServerTurn(cwd, options = {}) {
|
||||
const availability = getCodexAvailability(cwd);
|
||||
if (!availability.available) {
|
||||
|
||||
@@ -2,16 +2,77 @@ import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
import { isProbablyText } from "./fs.mjs";
|
||||
import { runCommand, runCommandChecked } from "./process.mjs";
|
||||
import { formatCommandFailure, runCommand, runCommandChecked } from "./process.mjs";
|
||||
|
||||
const MAX_UNTRACKED_BYTES = 24 * 1024;
|
||||
const DEFAULT_INLINE_DIFF_MAX_FILES = 2;
|
||||
const DEFAULT_INLINE_DIFF_MAX_BYTES = 256 * 1024;
|
||||
|
||||
// Git is directly executable on Windows. Repository-derived arguments must never pass through a shell.
|
||||
function git(cwd, args, options = {}) {
|
||||
return runCommand("git", args, { cwd, ...options });
|
||||
return runCommand("git", args, { cwd, ...options, shell: false });
|
||||
}
|
||||
|
||||
function gitChecked(cwd, args, options = {}) {
|
||||
return runCommandChecked("git", args, { cwd, ...options });
|
||||
return runCommandChecked("git", args, { cwd, ...options, shell: false });
|
||||
}
|
||||
|
||||
function listUniqueFiles(...groups) {
|
||||
return [...new Set(groups.flat().filter(Boolean))].sort();
|
||||
}
|
||||
|
||||
function normalizeMaxInlineFiles(value) {
|
||||
const parsed = Number(value);
|
||||
if (!Number.isFinite(parsed) || parsed < 0) {
|
||||
return DEFAULT_INLINE_DIFF_MAX_FILES;
|
||||
}
|
||||
return Math.floor(parsed);
|
||||
}
|
||||
|
||||
function normalizeMaxInlineDiffBytes(value) {
|
||||
const parsed = Number(value);
|
||||
if (!Number.isFinite(parsed) || parsed < 0) {
|
||||
return DEFAULT_INLINE_DIFF_MAX_BYTES;
|
||||
}
|
||||
return Math.floor(parsed);
|
||||
}
|
||||
|
||||
function measureGitOutputBytes(cwd, args, maxBytes) {
|
||||
const result = git(cwd, args, { maxBuffer: maxBytes + 1 });
|
||||
if (result.error && /** @type {NodeJS.ErrnoException} */ (result.error).code === "ENOBUFS") {
|
||||
return maxBytes + 1;
|
||||
}
|
||||
if (result.error) {
|
||||
throw result.error;
|
||||
}
|
||||
if (result.status !== 0) {
|
||||
throw new Error(formatCommandFailure(result));
|
||||
}
|
||||
return Buffer.byteLength(result.stdout, "utf8");
|
||||
}
|
||||
|
||||
function measureCombinedGitOutputBytes(cwd, argSets, maxBytes) {
|
||||
let totalBytes = 0;
|
||||
for (const args of argSets) {
|
||||
const remainingBytes = maxBytes - totalBytes;
|
||||
if (remainingBytes < 0) {
|
||||
return maxBytes + 1;
|
||||
}
|
||||
totalBytes += measureGitOutputBytes(cwd, args, remainingBytes);
|
||||
if (totalBytes > maxBytes) {
|
||||
return totalBytes;
|
||||
}
|
||||
}
|
||||
return totalBytes;
|
||||
}
|
||||
|
||||
function buildBranchComparison(cwd, baseRef) {
|
||||
const mergeBase = gitChecked(cwd, ["merge-base", "HEAD", baseRef]).stdout.trim();
|
||||
return {
|
||||
mergeBase,
|
||||
commitRange: `${mergeBase}..HEAD`,
|
||||
reviewRange: `${baseRef}...HEAD`
|
||||
};
|
||||
}
|
||||
|
||||
export function ensureGitRepository(cwd) {
|
||||
@@ -135,12 +196,25 @@ function formatSection(title, body) {
|
||||
|
||||
function formatUntrackedFile(cwd, relativePath) {
|
||||
const absolutePath = path.join(cwd, relativePath);
|
||||
const stat = fs.statSync(absolutePath);
|
||||
let stat;
|
||||
try {
|
||||
stat = fs.statSync(absolutePath);
|
||||
} catch {
|
||||
return `### ${relativePath}\n(skipped: broken symlink or unreadable file)`;
|
||||
}
|
||||
if (stat.isDirectory()) {
|
||||
return `### ${relativePath}\n(skipped: directory)`;
|
||||
}
|
||||
if (stat.size > MAX_UNTRACKED_BYTES) {
|
||||
return `### ${relativePath}\n(skipped: ${stat.size} bytes exceeds ${MAX_UNTRACKED_BYTES} byte limit)`;
|
||||
}
|
||||
|
||||
const buffer = fs.readFileSync(absolutePath);
|
||||
let buffer;
|
||||
try {
|
||||
buffer = fs.readFileSync(absolutePath);
|
||||
} catch {
|
||||
return `### ${relativePath}\n(skipped: broken symlink or unreadable file)`;
|
||||
}
|
||||
if (!isProbablyText(buffer)) {
|
||||
return `### ${relativePath}\n(skipped: binary file)`;
|
||||
}
|
||||
@@ -148,55 +222,115 @@ function formatUntrackedFile(cwd, relativePath) {
|
||||
return [`### ${relativePath}`, "```", buffer.toString("utf8").trimEnd(), "```"].join("\n");
|
||||
}
|
||||
|
||||
function collectWorkingTreeContext(cwd, state) {
|
||||
const status = gitChecked(cwd, ["status", "--short"]).stdout.trim();
|
||||
const stagedDiff = gitChecked(cwd, ["diff", "--cached", "--binary", "--no-ext-diff", "--submodule=diff"]).stdout;
|
||||
const unstagedDiff = gitChecked(cwd, ["diff", "--binary", "--no-ext-diff", "--submodule=diff"]).stdout;
|
||||
const untrackedBody = state.untracked.map((file) => formatUntrackedFile(cwd, file)).join("\n\n");
|
||||
function collectWorkingTreeContext(cwd, state, options = {}) {
|
||||
const includeDiff = options.includeDiff !== false;
|
||||
const status = gitChecked(cwd, ["status", "--short", "--untracked-files=all"]).stdout.trim();
|
||||
const changedFiles = listUniqueFiles(state.staged, state.unstaged, state.untracked);
|
||||
|
||||
const parts = [
|
||||
formatSection("Git Status", status),
|
||||
formatSection("Staged Diff", stagedDiff),
|
||||
formatSection("Unstaged Diff", unstagedDiff),
|
||||
formatSection("Untracked Files", untrackedBody)
|
||||
];
|
||||
let parts;
|
||||
if (includeDiff) {
|
||||
const stagedDiff = gitChecked(cwd, ["diff", "--cached", "--binary", "--no-ext-diff", "--submodule=diff"]).stdout;
|
||||
const unstagedDiff = gitChecked(cwd, ["diff", "--binary", "--no-ext-diff", "--submodule=diff"]).stdout;
|
||||
const untrackedBody = state.untracked.map((file) => formatUntrackedFile(cwd, file)).join("\n\n");
|
||||
parts = [
|
||||
formatSection("Git Status", status),
|
||||
formatSection("Staged Diff", stagedDiff),
|
||||
formatSection("Unstaged Diff", unstagedDiff),
|
||||
formatSection("Untracked Files", untrackedBody)
|
||||
];
|
||||
} else {
|
||||
const stagedStat = gitChecked(cwd, ["diff", "--shortstat", "--cached"]).stdout.trim();
|
||||
const unstagedStat = gitChecked(cwd, ["diff", "--shortstat"]).stdout.trim();
|
||||
const untrackedBody = state.untracked.map((file) => formatUntrackedFile(cwd, file)).join("\n\n");
|
||||
parts = [
|
||||
formatSection("Git Status", status),
|
||||
formatSection("Staged Diff Stat", stagedStat),
|
||||
formatSection("Unstaged Diff Stat", unstagedStat),
|
||||
formatSection("Changed Files", changedFiles.join("\n")),
|
||||
formatSection("Untracked Files", untrackedBody)
|
||||
];
|
||||
}
|
||||
|
||||
return {
|
||||
mode: "working-tree",
|
||||
summary: `Reviewing ${state.staged.length} staged, ${state.unstaged.length} unstaged, and ${state.untracked.length} untracked file(s).`,
|
||||
content: parts.join("\n")
|
||||
content: parts.join("\n"),
|
||||
changedFiles
|
||||
};
|
||||
}
|
||||
|
||||
function collectBranchContext(cwd, baseRef) {
|
||||
const mergeBase = gitChecked(cwd, ["merge-base", "HEAD", baseRef]).stdout.trim();
|
||||
const commitRange = `${mergeBase}..HEAD`;
|
||||
function collectBranchContext(cwd, baseRef, options = {}) {
|
||||
const includeDiff = options.includeDiff !== false;
|
||||
const comparison = options.comparison ?? buildBranchComparison(cwd, baseRef);
|
||||
const currentBranch = getCurrentBranch(cwd);
|
||||
const logOutput = gitChecked(cwd, ["log", "--oneline", "--decorate", commitRange]).stdout.trim();
|
||||
const diffStat = gitChecked(cwd, ["diff", "--stat", commitRange]).stdout.trim();
|
||||
const diff = gitChecked(cwd, ["diff", "--binary", "--no-ext-diff", "--submodule=diff", commitRange]).stdout;
|
||||
const changedFiles = gitChecked(cwd, ["diff", "--name-only", comparison.commitRange]).stdout.trim().split("\n").filter(Boolean);
|
||||
const logOutput = gitChecked(cwd, ["log", "--oneline", "--decorate", comparison.commitRange]).stdout.trim();
|
||||
const diffStat = gitChecked(cwd, ["diff", "--stat", comparison.commitRange]).stdout.trim();
|
||||
|
||||
return {
|
||||
mode: "branch",
|
||||
summary: `Reviewing branch ${currentBranch} against ${baseRef} from merge-base ${mergeBase}.`,
|
||||
content: [
|
||||
formatSection("Commit Log", logOutput),
|
||||
formatSection("Diff Stat", diffStat),
|
||||
formatSection("Branch Diff", diff)
|
||||
].join("\n")
|
||||
summary: `Reviewing branch ${currentBranch} against ${baseRef} from merge-base ${comparison.mergeBase}.`,
|
||||
content: includeDiff
|
||||
? [
|
||||
formatSection("Commit Log", logOutput),
|
||||
formatSection("Diff Stat", diffStat),
|
||||
formatSection(
|
||||
"Branch Diff",
|
||||
gitChecked(cwd, ["diff", "--binary", "--no-ext-diff", "--submodule=diff", comparison.commitRange]).stdout
|
||||
)
|
||||
].join("\n")
|
||||
: [
|
||||
formatSection("Commit Log", logOutput),
|
||||
formatSection("Diff Stat", diffStat),
|
||||
formatSection("Changed Files", changedFiles.join("\n"))
|
||||
].join("\n"),
|
||||
changedFiles,
|
||||
comparison
|
||||
};
|
||||
}
|
||||
|
||||
export function collectReviewContext(cwd, target) {
|
||||
function buildAdversarialCollectionGuidance(options = {}) {
|
||||
if (options.includeDiff !== false) {
|
||||
return "Use the repository context below as primary evidence.";
|
||||
}
|
||||
|
||||
return "The repository context below is a lightweight summary. Inspect the target diff yourself with read-only git commands before finalizing findings.";
|
||||
}
|
||||
|
||||
export function collectReviewContext(cwd, target, options = {}) {
|
||||
const repoRoot = getRepoRoot(cwd);
|
||||
const state = getWorkingTreeState(cwd);
|
||||
const currentBranch = getCurrentBranch(cwd);
|
||||
const currentBranch = getCurrentBranch(repoRoot);
|
||||
const maxInlineFiles = normalizeMaxInlineFiles(options.maxInlineFiles);
|
||||
const maxInlineDiffBytes = normalizeMaxInlineDiffBytes(options.maxInlineDiffBytes);
|
||||
let details;
|
||||
let includeDiff;
|
||||
let diffBytes;
|
||||
|
||||
if (target.mode === "working-tree") {
|
||||
details = collectWorkingTreeContext(repoRoot, state);
|
||||
const state = getWorkingTreeState(repoRoot);
|
||||
diffBytes = measureCombinedGitOutputBytes(
|
||||
repoRoot,
|
||||
[
|
||||
["diff", "--cached", "--binary", "--no-ext-diff", "--submodule=diff"],
|
||||
["diff", "--binary", "--no-ext-diff", "--submodule=diff"]
|
||||
],
|
||||
maxInlineDiffBytes
|
||||
);
|
||||
includeDiff =
|
||||
options.includeDiff ??
|
||||
(listUniqueFiles(state.staged, state.unstaged, state.untracked).length <= maxInlineFiles &&
|
||||
diffBytes <= maxInlineDiffBytes);
|
||||
details = collectWorkingTreeContext(repoRoot, state, { includeDiff });
|
||||
} else {
|
||||
details = collectBranchContext(repoRoot, target.baseRef);
|
||||
const comparison = buildBranchComparison(repoRoot, target.baseRef);
|
||||
const fileCount = gitChecked(repoRoot, ["diff", "--name-only", comparison.commitRange]).stdout.trim().split("\n").filter(Boolean).length;
|
||||
diffBytes = measureGitOutputBytes(
|
||||
repoRoot,
|
||||
["diff", "--binary", "--no-ext-diff", "--submodule=diff", comparison.commitRange],
|
||||
maxInlineDiffBytes
|
||||
);
|
||||
includeDiff = options.includeDiff ?? (fileCount <= maxInlineFiles && diffBytes <= maxInlineDiffBytes);
|
||||
details = collectBranchContext(repoRoot, target.baseRef, { includeDiff, comparison });
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -204,6 +338,10 @@ export function collectReviewContext(cwd, target) {
|
||||
repoRoot,
|
||||
branch: currentBranch,
|
||||
target,
|
||||
fileCount: details.changedFiles.length,
|
||||
diffBytes,
|
||||
inputMode: includeDiff ? "inline-diff" : "self-collect",
|
||||
collectionGuidance: buildAdversarialCollectionGuidance({ includeDiff }),
|
||||
...details
|
||||
};
|
||||
}
|
||||
|
||||
@@ -231,7 +231,7 @@ export function buildStatusSnapshot(cwd, options = {}) {
|
||||
return {
|
||||
workspaceRoot,
|
||||
config,
|
||||
sessionRuntime: getSessionRuntimeStatus(options.env),
|
||||
sessionRuntime: getSessionRuntimeStatus(options.env, workspaceRoot),
|
||||
running,
|
||||
latestFinished,
|
||||
recent,
|
||||
@@ -278,7 +278,7 @@ export function resolveResultJob(cwd, reference) {
|
||||
throw new Error("No finished Codex jobs found for this repository yet.");
|
||||
}
|
||||
|
||||
export function resolveCancelableJob(cwd, reference) {
|
||||
export function resolveCancelableJob(cwd, reference, options = {}) {
|
||||
const workspaceRoot = resolveWorkspaceRoot(cwd);
|
||||
const jobs = sortJobsNewestFirst(listJobs(workspaceRoot));
|
||||
const activeJobs = jobs.filter((job) => job.status === "queued" || job.status === "running");
|
||||
@@ -291,12 +291,18 @@ export function resolveCancelableJob(cwd, reference) {
|
||||
return { workspaceRoot, job: selected };
|
||||
}
|
||||
|
||||
if (activeJobs.length === 1) {
|
||||
return { workspaceRoot, job: activeJobs[0] };
|
||||
const sessionScopedActiveJobs = filterJobsForCurrentSession(activeJobs, options);
|
||||
|
||||
if (sessionScopedActiveJobs.length === 1) {
|
||||
return { workspaceRoot, job: sessionScopedActiveJobs[0] };
|
||||
}
|
||||
if (activeJobs.length > 1) {
|
||||
if (sessionScopedActiveJobs.length > 1) {
|
||||
throw new Error("Multiple Codex jobs are active. Pass a job id to /codex:cancel.");
|
||||
}
|
||||
|
||||
if (getCurrentSessionId(options)) {
|
||||
throw new Error("No active Codex jobs to cancel for this session.");
|
||||
}
|
||||
|
||||
throw new Error("No active Codex jobs to cancel.");
|
||||
}
|
||||
|
||||
@@ -7,8 +7,9 @@ export function runCommand(command, args = [], options = {}) {
|
||||
env: options.env,
|
||||
encoding: "utf8",
|
||||
input: options.input,
|
||||
maxBuffer: options.maxBuffer,
|
||||
stdio: options.stdio ?? "pipe",
|
||||
shell: process.platform === "win32",
|
||||
shell: options.shell ?? (process.platform === "win32" ? (process.env.SHELL || true) : false),
|
||||
windowsHide: true
|
||||
});
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
teardownBrokerSession
|
||||
} from "./lib/broker-lifecycle.mjs";
|
||||
import { loadState, resolveStateFile, saveState } from "./lib/state.mjs";
|
||||
import { TRANSCRIPT_PATH_ENV } from "./lib/claude-session-transfer.mjs";
|
||||
import { resolveWorkspaceRoot } from "./lib/workspace.mjs";
|
||||
|
||||
export const SESSION_ID_ENV = "CODEX_COMPANION_SESSION_ID";
|
||||
@@ -75,6 +76,7 @@ function cleanupSessionJobs(cwd, sessionId) {
|
||||
|
||||
function handleSessionStart(input) {
|
||||
appendEnvVar(SESSION_ID_ENV, input.session_id);
|
||||
appendEnvVar(TRANSCRIPT_PATH_ENV, input.transcript_path);
|
||||
appendEnvVar(PLUGIN_DATA_ENV, process.env[PLUGIN_DATA_ENV]);
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import path from "node:path";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import { getCodexLoginStatus } from "./lib/codex.mjs";
|
||||
import { getCodexAvailability } from "./lib/codex.mjs";
|
||||
import { loadPromptTemplate, interpolateTemplate } from "./lib/prompts.mjs";
|
||||
import { getConfig, listJobs } from "./lib/state.mjs";
|
||||
import { sortJobsNewestFirst } from "./lib/job-control.mjs";
|
||||
@@ -57,13 +57,13 @@ function buildStopReviewPrompt(input = {}) {
|
||||
}
|
||||
|
||||
function buildSetupNote(cwd) {
|
||||
const authStatus = getCodexLoginStatus(cwd);
|
||||
if (authStatus.available && authStatus.loggedIn) {
|
||||
const availability = getCodexAvailability(cwd);
|
||||
if (availability.available) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const detail = authStatus.detail ? ` ${authStatus.detail}.` : "";
|
||||
return `Codex is not set up for the review gate.${detail} Run /codex:setup and, if needed, !codex login.`;
|
||||
const detail = availability.detail ? ` ${availability.detail}.` : "";
|
||||
return `Codex is not set up for the review gate.${detail} Run /codex:setup.`;
|
||||
}
|
||||
|
||||
function parseStopReviewOutput(rawOutput) {
|
||||
@@ -175,4 +175,10 @@ function main() {
|
||||
logNote(runningTaskNote);
|
||||
}
|
||||
|
||||
main();
|
||||
try {
|
||||
main();
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
process.stderr.write(`${message}\n`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
#!/usr/bin/env node
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import process from "node:process";
|
||||
|
||||
const VERSION_PATTERN = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
|
||||
|
||||
const TARGETS = [
|
||||
{
|
||||
file: "package.json",
|
||||
values: [
|
||||
{
|
||||
label: "version",
|
||||
get: (json) => json.version,
|
||||
set: (json, version) => {
|
||||
json.version = version;
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
file: "package-lock.json",
|
||||
values: [
|
||||
{
|
||||
label: "version",
|
||||
get: (json) => json.version,
|
||||
set: (json, version) => {
|
||||
json.version = version;
|
||||
}
|
||||
},
|
||||
{
|
||||
label: "packages[\"\"].version",
|
||||
get: (json) => json.packages?.[""]?.version,
|
||||
set: (json, version) => {
|
||||
requireObject(json.packages?.[""], "package-lock.json packages[\"\"]");
|
||||
json.packages[""].version = version;
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
file: "plugins/codex/.claude-plugin/plugin.json",
|
||||
values: [
|
||||
{
|
||||
label: "version",
|
||||
get: (json) => json.version,
|
||||
set: (json, version) => {
|
||||
json.version = version;
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
file: ".claude-plugin/marketplace.json",
|
||||
values: [
|
||||
{
|
||||
label: "metadata.version",
|
||||
get: (json) => json.metadata?.version,
|
||||
set: (json, version) => {
|
||||
requireObject(json.metadata, ".claude-plugin/marketplace.json metadata");
|
||||
json.metadata.version = version;
|
||||
}
|
||||
},
|
||||
{
|
||||
label: "plugins[codex].version",
|
||||
get: (json) => findMarketplacePlugin(json).version,
|
||||
set: (json, version) => {
|
||||
findMarketplacePlugin(json).version = version;
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
function usage() {
|
||||
return [
|
||||
"Usage:",
|
||||
" node scripts/bump-version.mjs <version>",
|
||||
" node scripts/bump-version.mjs --check [version]",
|
||||
"",
|
||||
"Options:",
|
||||
" --check Verify manifest versions. Uses package.json when version is omitted.",
|
||||
" --root <dir> Run against a different repository root.",
|
||||
" --help Print this help."
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
const options = {
|
||||
check: false,
|
||||
root: process.cwd(),
|
||||
version: null
|
||||
};
|
||||
|
||||
for (let i = 0; i < argv.length; i += 1) {
|
||||
const arg = argv[i];
|
||||
|
||||
if (arg === "--check") {
|
||||
options.check = true;
|
||||
} else if (arg === "--root") {
|
||||
const root = argv[i + 1];
|
||||
if (!root) {
|
||||
throw new Error("--root requires a directory.");
|
||||
}
|
||||
options.root = root;
|
||||
i += 1;
|
||||
} else if (arg === "--help" || arg === "-h") {
|
||||
options.help = true;
|
||||
} else if (arg.startsWith("-")) {
|
||||
throw new Error(`Unknown option: ${arg}`);
|
||||
} else if (options.version) {
|
||||
throw new Error(`Unexpected extra argument: ${arg}`);
|
||||
} else {
|
||||
options.version = arg;
|
||||
}
|
||||
}
|
||||
|
||||
options.root = path.resolve(options.root);
|
||||
return options;
|
||||
}
|
||||
|
||||
function validateVersion(version) {
|
||||
if (!VERSION_PATTERN.test(version)) {
|
||||
throw new Error(`Expected a semver-like version such as 1.0.3, got: ${version}`);
|
||||
}
|
||||
}
|
||||
|
||||
function requireObject(value, label) {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
throw new Error(`Expected ${label} to be an object.`);
|
||||
}
|
||||
}
|
||||
|
||||
function findMarketplacePlugin(json) {
|
||||
const plugin = json.plugins?.find((entry) => entry?.name === "codex");
|
||||
requireObject(plugin, ".claude-plugin/marketplace.json plugins[codex]");
|
||||
return plugin;
|
||||
}
|
||||
|
||||
function readJson(root, file) {
|
||||
const filePath = path.join(root, file);
|
||||
return JSON.parse(fs.readFileSync(filePath, "utf8"));
|
||||
}
|
||||
|
||||
function writeJson(root, file, json) {
|
||||
const filePath = path.join(root, file);
|
||||
fs.writeFileSync(filePath, `${JSON.stringify(json, null, 2)}\n`);
|
||||
}
|
||||
|
||||
function readPackageVersion(root) {
|
||||
const packageJson = readJson(root, "package.json");
|
||||
if (typeof packageJson.version !== "string") {
|
||||
throw new Error("package.json version must be a string.");
|
||||
}
|
||||
validateVersion(packageJson.version);
|
||||
return packageJson.version;
|
||||
}
|
||||
|
||||
function checkVersions(root, expectedVersion) {
|
||||
const mismatches = [];
|
||||
|
||||
for (const target of TARGETS) {
|
||||
const json = readJson(root, target.file);
|
||||
for (const value of target.values) {
|
||||
const actual = value.get(json);
|
||||
if (actual !== expectedVersion) {
|
||||
mismatches.push(`${target.file} ${value.label}: expected ${expectedVersion}, found ${actual ?? "<missing>"}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return mismatches;
|
||||
}
|
||||
|
||||
function bumpVersion(root, version) {
|
||||
const changedFiles = [];
|
||||
|
||||
for (const target of TARGETS) {
|
||||
const json = readJson(root, target.file);
|
||||
const before = JSON.stringify(json);
|
||||
|
||||
for (const value of target.values) {
|
||||
value.set(json, version);
|
||||
}
|
||||
|
||||
if (JSON.stringify(json) !== before) {
|
||||
writeJson(root, target.file, json);
|
||||
changedFiles.push(target.file);
|
||||
}
|
||||
}
|
||||
|
||||
return changedFiles;
|
||||
}
|
||||
|
||||
function main() {
|
||||
const options = parseArgs(process.argv.slice(2));
|
||||
if (options.help) {
|
||||
console.log(usage());
|
||||
return;
|
||||
}
|
||||
|
||||
const version = options.version ?? (options.check ? readPackageVersion(options.root) : null);
|
||||
if (!version) {
|
||||
throw new Error(`Missing version.\n\n${usage()}`);
|
||||
}
|
||||
validateVersion(version);
|
||||
|
||||
if (options.check) {
|
||||
const mismatches = checkVersions(options.root, version);
|
||||
if (mismatches.length > 0) {
|
||||
throw new Error(`Version metadata is out of sync:\n${mismatches.join("\n")}`);
|
||||
}
|
||||
console.log(`All version metadata matches ${version}.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const changedFiles = bumpVersion(options.root, version);
|
||||
const touched = changedFiles.length > 0 ? changedFiles.join(", ") : "no files changed";
|
||||
console.log(`Set version metadata to ${version}: ${touched}.`);
|
||||
}
|
||||
|
||||
try {
|
||||
main();
|
||||
} catch (error) {
|
||||
console.error(error instanceof Error ? error.message : String(error));
|
||||
process.exitCode = 1;
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import { makeTempDir, run } from "./helpers.mjs";
|
||||
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const SCRIPT = path.join(ROOT, "scripts", "bump-version.mjs");
|
||||
|
||||
function writeJson(filePath, json) {
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||
fs.writeFileSync(filePath, `${JSON.stringify(json, null, 2)}\n`);
|
||||
}
|
||||
|
||||
function readJson(filePath) {
|
||||
return JSON.parse(fs.readFileSync(filePath, "utf8"));
|
||||
}
|
||||
|
||||
function makeVersionFixture() {
|
||||
const root = makeTempDir();
|
||||
|
||||
writeJson(path.join(root, "package.json"), {
|
||||
name: "@openai/codex-plugin-cc",
|
||||
version: "1.0.2"
|
||||
});
|
||||
writeJson(path.join(root, "package-lock.json"), {
|
||||
name: "@openai/codex-plugin-cc",
|
||||
version: "1.0.2",
|
||||
lockfileVersion: 3,
|
||||
packages: {
|
||||
"": {
|
||||
name: "@openai/codex-plugin-cc",
|
||||
version: "1.0.2"
|
||||
}
|
||||
}
|
||||
});
|
||||
writeJson(path.join(root, "plugins", "codex", ".claude-plugin", "plugin.json"), {
|
||||
name: "codex",
|
||||
version: "1.0.2"
|
||||
});
|
||||
writeJson(path.join(root, ".claude-plugin", "marketplace.json"), {
|
||||
metadata: {
|
||||
version: "1.0.2"
|
||||
},
|
||||
plugins: [
|
||||
{
|
||||
name: "codex",
|
||||
version: "1.0.2"
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
return root;
|
||||
}
|
||||
|
||||
test("bump-version updates every release manifest", () => {
|
||||
const root = makeVersionFixture();
|
||||
|
||||
const result = run("node", [SCRIPT, "--root", root, "1.2.3"], {
|
||||
cwd: ROOT
|
||||
});
|
||||
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
assert.equal(readJson(path.join(root, "package.json")).version, "1.2.3");
|
||||
assert.equal(readJson(path.join(root, "package-lock.json")).version, "1.2.3");
|
||||
assert.equal(readJson(path.join(root, "package-lock.json")).packages[""].version, "1.2.3");
|
||||
assert.equal(readJson(path.join(root, "plugins", "codex", ".claude-plugin", "plugin.json")).version, "1.2.3");
|
||||
assert.equal(readJson(path.join(root, ".claude-plugin", "marketplace.json")).metadata.version, "1.2.3");
|
||||
assert.equal(readJson(path.join(root, ".claude-plugin", "marketplace.json")).plugins[0].version, "1.2.3");
|
||||
});
|
||||
|
||||
test("bump-version check mode reports stale metadata", () => {
|
||||
const root = makeVersionFixture();
|
||||
writeJson(path.join(root, "package.json"), {
|
||||
name: "@openai/codex-plugin-cc",
|
||||
version: "1.0.3"
|
||||
});
|
||||
|
||||
const result = run("node", [SCRIPT, "--root", root, "--check"], {
|
||||
cwd: ROOT
|
||||
});
|
||||
|
||||
assert.notEqual(result.status, 0);
|
||||
assert.match(result.stderr, /plugins\/codex\/\.claude-plugin\/plugin\.json version/);
|
||||
assert.match(result.stderr, /\.claude-plugin\/marketplace\.json metadata\.version/);
|
||||
});
|
||||
+20
-5
@@ -79,7 +79,8 @@ test("continue is not exposed as a user-facing command", () => {
|
||||
"result.md",
|
||||
"review.md",
|
||||
"setup.md",
|
||||
"status.md"
|
||||
"status.md",
|
||||
"transfer.md"
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -90,7 +91,16 @@ test("rescue command absorbs continue semantics", () => {
|
||||
const runtimeSkill = read("skills/codex-cli-runtime/SKILL.md");
|
||||
|
||||
assert.match(rescue, /The final user-visible response must be Codex's output verbatim/i);
|
||||
assert.match(rescue, /allowed-tools:\s*Bash\(node:\*\),\s*AskUserQuestion/);
|
||||
assert.match(rescue, /allowed-tools:\s*Bash\(node:\*\),\s*AskUserQuestion,\s*Agent/);
|
||||
// Regression for #234: `Skill(codex:rescue)` from the main agent recursed
|
||||
// because rescue.md named the routing with ambiguous prose ("Route this
|
||||
// request to the `codex:codex-rescue` subagent") while running under
|
||||
// `context: fork` — forked general-purpose subagents do not expose the
|
||||
// `Agent` tool, so the fork fell back to `Skill` and re-entered this
|
||||
// command. Pin the explicit transport and the inline (no-fork) execution.
|
||||
assert.match(rescue, /subagent_type: "codex:codex-rescue"/);
|
||||
assert.match(rescue, /do not call `Skill\(codex:codex-rescue\)`/i);
|
||||
assert.doesNotMatch(rescue, /^context:\s*fork\b/m);
|
||||
assert.match(rescue, /--background\|--wait/);
|
||||
assert.match(rescue, /--resume\|--fresh/);
|
||||
assert.match(rescue, /--model <model\|spark>/);
|
||||
@@ -154,20 +164,25 @@ test("rescue command absorbs continue semantics", () => {
|
||||
assert.match(readme, /uses the same review target selection as `\/codex:review`/i);
|
||||
assert.match(readme, /--base main challenge whether this was the right caching and retry design/);
|
||||
assert.match(readme, /### `\/codex:rescue`/);
|
||||
assert.match(readme, /### `\/codex:transfer`/);
|
||||
assert.match(readme, /### `\/codex:status`/);
|
||||
assert.match(readme, /### `\/codex:result`/);
|
||||
assert.match(readme, /### `\/codex:cancel`/);
|
||||
});
|
||||
|
||||
test("result and cancel commands are exposed as deterministic runtime entrypoints", () => {
|
||||
test("transfer, result, and cancel commands are exposed as deterministic runtime entrypoints", () => {
|
||||
const transfer = read("commands/transfer.md");
|
||||
const result = read("commands/result.md");
|
||||
const cancel = read("commands/cancel.md");
|
||||
const resultHandling = read("skills/codex-result-handling/SKILL.md");
|
||||
|
||||
assert.match(transfer, /disable-model-invocation:\s*true/);
|
||||
assert.match(transfer, /codex-companion\.mjs" transfer "\$ARGUMENTS"/);
|
||||
assert.match(transfer, /codex resume <session-id>/);
|
||||
assert.match(result, /disable-model-invocation:\s*true/);
|
||||
assert.match(result, /codex-companion\.mjs" result \$ARGUMENTS/);
|
||||
assert.match(result, /codex-companion\.mjs" result "\$ARGUMENTS"/);
|
||||
assert.match(cancel, /disable-model-invocation:\s*true/);
|
||||
assert.match(cancel, /codex-companion\.mjs" cancel \$ARGUMENTS/);
|
||||
assert.match(cancel, /codex-companion\.mjs" cancel "\$ARGUMENTS"/);
|
||||
assert.match(resultHandling, /do not turn a failed or incomplete Codex run into a Claude-side implementation attempt/i);
|
||||
assert.match(resultHandling, /if Codex was never successfully invoked, do not generate a substitute answer at all/i);
|
||||
});
|
||||
|
||||
@@ -9,6 +9,7 @@ export function installFakeCodex(binDir, behavior = "review-ok") {
|
||||
const scriptPath = path.join(binDir, "codex");
|
||||
const source = `#!/usr/bin/env node
|
||||
const fs = require("node:fs");
|
||||
const crypto = require("node:crypto");
|
||||
const path = require("node:path");
|
||||
const readline = require("node:readline");
|
||||
|
||||
@@ -63,6 +64,54 @@ function buildTurn(id, status = "inProgress", error = null) {
|
||||
return { id, status, items: [], error };
|
||||
}
|
||||
|
||||
function buildAccountReadResult() {
|
||||
switch (BEHAVIOR) {
|
||||
case "logged-out":
|
||||
case "refreshable-auth":
|
||||
case "auth-run-fails":
|
||||
return { account: null, requiresOpenaiAuth: true };
|
||||
case "provider-no-auth":
|
||||
case "env-key-provider":
|
||||
return { account: null, requiresOpenaiAuth: false };
|
||||
case "api-key-account-only":
|
||||
return { account: { type: "apiKey" }, requiresOpenaiAuth: true };
|
||||
default:
|
||||
return {
|
||||
account: { type: "chatgpt", email: "test@example.com", planType: "plus" },
|
||||
requiresOpenaiAuth: true
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function buildConfigReadResult() {
|
||||
switch (BEHAVIOR) {
|
||||
case "provider-no-auth":
|
||||
return {
|
||||
config: { model_provider: "ollama" },
|
||||
origins: {}
|
||||
};
|
||||
case "env-key-provider":
|
||||
return {
|
||||
config: {
|
||||
model_provider: "openai-custom",
|
||||
model_providers: {
|
||||
"openai-custom": {
|
||||
name: "OpenAI custom",
|
||||
env_key: "OPENAI_API_KEY",
|
||||
requires_openai_auth: false
|
||||
}
|
||||
}
|
||||
},
|
||||
origins: {}
|
||||
};
|
||||
default:
|
||||
return {
|
||||
config: { model_provider: "openai" },
|
||||
origins: {}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function send(message) {
|
||||
process.stdout.write(JSON.stringify(message) + "\\n");
|
||||
}
|
||||
@@ -96,6 +145,21 @@ function nextTurnId(state) {
|
||||
return turnId;
|
||||
}
|
||||
|
||||
function importLedgerPath() {
|
||||
return path.join(process.env.CODEX_HOME || path.join(process.env.HOME, ".codex"), "external_agent_session_imports.json");
|
||||
}
|
||||
|
||||
function loadImportLedger() {
|
||||
const ledgerPath = importLedgerPath();
|
||||
return fs.existsSync(ledgerPath) ? JSON.parse(fs.readFileSync(ledgerPath, "utf8")) : { records: [] };
|
||||
}
|
||||
|
||||
function saveImportLedger(ledger) {
|
||||
const ledgerPath = importLedgerPath();
|
||||
fs.mkdirSync(path.dirname(ledgerPath), { recursive: true });
|
||||
fs.writeFileSync(ledgerPath, JSON.stringify(ledger, null, 2));
|
||||
}
|
||||
|
||||
function emitTurnCompleted(threadId, turnId, item) {
|
||||
const items = Array.isArray(item) ? item : [item];
|
||||
send({ method: "turn/started", params: { threadId, turn: buildTurn(turnId) } });
|
||||
@@ -193,7 +257,7 @@ if (args[0] === "app-server" && args[1] === "--help") {
|
||||
process.exit(0);
|
||||
}
|
||||
if (args[0] === "login" && args[1] === "status") {
|
||||
if (BEHAVIOR === "logged-out") {
|
||||
if (BEHAVIOR === "logged-out" || BEHAVIOR === "refreshable-auth" || BEHAVIOR === "auth-run-fails" || BEHAVIOR === "provider-no-auth" || BEHAVIOR === "env-key-provider" || BEHAVIOR === "api-key-account-only") {
|
||||
console.error("not authenticated");
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -230,7 +294,21 @@ rl.on("line", (line) => {
|
||||
case "initialized":
|
||||
break;
|
||||
|
||||
case "account/read":
|
||||
send({ id: message.id, result: buildAccountReadResult() });
|
||||
break;
|
||||
|
||||
case "config/read":
|
||||
if (BEHAVIOR === "config-read-fails") {
|
||||
throw new Error("config/read failed for cwd");
|
||||
}
|
||||
send({ id: message.id, result: buildConfigReadResult() });
|
||||
break;
|
||||
|
||||
case "thread/start": {
|
||||
if (BEHAVIOR === "auth-run-fails") {
|
||||
throw new Error("authentication expired; run codex login");
|
||||
}
|
||||
if (requiresExperimental("persistExtendedHistory", message, state) || requiresExperimental("persistFullHistory", message, state)) {
|
||||
throw new Error("thread/start.persistFullHistory requires experimentalApi capability");
|
||||
}
|
||||
@@ -273,6 +351,59 @@ rl.on("line", (line) => {
|
||||
break;
|
||||
}
|
||||
|
||||
case "externalAgentConfig/import": {
|
||||
if (BEHAVIOR === "external-import-unsupported") {
|
||||
send({ id: message.id, error: { code: -32601, message: "Unsupported method: externalAgentConfig/import" } });
|
||||
break;
|
||||
}
|
||||
if (BEHAVIOR === "external-import-fails") {
|
||||
send({ id: message.id, result: {} });
|
||||
send({ method: "externalAgentConfig/import/completed", params: {} });
|
||||
break;
|
||||
}
|
||||
const sessions = (message.params.migrationItems || [])
|
||||
.flatMap((item) => item.details && Array.isArray(item.details.sessions) ? item.details.sessions : []);
|
||||
const session = sessions[0];
|
||||
if (!session) {
|
||||
throw new Error("missing external session migration");
|
||||
}
|
||||
const sourcePath = fs.realpathSync(session.path);
|
||||
const contents = fs.readFileSync(sourcePath, "utf8");
|
||||
const contentSha256 = crypto.createHash("sha256").update(contents).digest("hex");
|
||||
const ledger = loadImportLedger();
|
||||
let record = ledger.records.find(
|
||||
(candidate) => candidate.source_path === sourcePath && candidate.content_sha256 === contentSha256
|
||||
);
|
||||
let thread;
|
||||
if (record) {
|
||||
thread = ensureThread(state, record.imported_thread_id);
|
||||
} else {
|
||||
const records = contents.split(/\\r?\\n/).filter(Boolean).map((line) => JSON.parse(line));
|
||||
const title = records.find((entry) => entry.type === "custom-title")?.customTitle || null;
|
||||
const messages = records
|
||||
.filter((entry) => entry.type === "user" || entry.type === "assistant")
|
||||
.map((entry) => ({ role: entry.type, text: entry.message?.content || "" }));
|
||||
thread = nextThread(state, session.cwd, false);
|
||||
thread.name = title;
|
||||
thread.preview = messages.find((entry) => entry.role === "user")?.text || "";
|
||||
thread.visibleMessages = messages;
|
||||
state.lastExternalAgentImport = { sourcePath, threadId: thread.id, messages };
|
||||
record = {
|
||||
source_path: sourcePath,
|
||||
content_sha256: contentSha256,
|
||||
imported_thread_id: thread.id,
|
||||
imported_at: now(),
|
||||
source_modified_at: null
|
||||
};
|
||||
ledger.records.push(record);
|
||||
saveState(state);
|
||||
saveImportLedger(ledger);
|
||||
}
|
||||
send({ id: message.id, result: {} });
|
||||
send({ method: "externalAgentConfig/import/completed", params: {} });
|
||||
break;
|
||||
}
|
||||
|
||||
case "review/start": {
|
||||
const thread = ensureThread(state, message.params.threadId);
|
||||
let reviewThread = thread;
|
||||
@@ -467,7 +598,7 @@ rl.on("line", (line) => {
|
||||
}
|
||||
}
|
||||
send({ method: "turn/completed", params: { threadId: thread.id, turn: buildTurn(turnId, "completed") } });
|
||||
}, 400);
|
||||
}, 5000);
|
||||
interruptibleTurns.set(turnId, { threadId: thread.id, timer });
|
||||
} else if (BEHAVIOR === "slow-task") {
|
||||
emitTurnCompletedLater(thread.id, turnId, items, 400);
|
||||
|
||||
@@ -38,6 +38,35 @@ test("resolveReviewTarget falls back to branch diff when repo is clean", () => {
|
||||
assert.match(context.content, /Branch Diff/);
|
||||
});
|
||||
|
||||
test("default branch names with special characters are passed to git literally", () => {
|
||||
const cwd = makeTempDir();
|
||||
const branchName = "main&branch-helper&x";
|
||||
const helperOutputPath = path.join(cwd, "branch-helper-output");
|
||||
initGitRepo(cwd);
|
||||
fs.writeFileSync(path.join(cwd, "branch-helper.cmd"), "@echo branch-helper>branch-helper-output\r\n");
|
||||
fs.writeFileSync(path.join(cwd, "app.js"), "console.log('base');\n");
|
||||
run("git", ["add", "app.js", "branch-helper.cmd"], { cwd });
|
||||
run("git", ["commit", "-m", "base"], { cwd });
|
||||
run("git", ["branch", "-m", branchName], { cwd, shell: false });
|
||||
run("git", ["update-ref", `refs/remotes/origin/${branchName}`, branchName], { cwd, shell: false });
|
||||
run("git", ["symbolic-ref", "refs/remotes/origin/HEAD", `refs/remotes/origin/${branchName}`], {
|
||||
cwd,
|
||||
shell: false
|
||||
});
|
||||
run("git", ["checkout", "-b", "feature/test"], { cwd });
|
||||
fs.writeFileSync(path.join(cwd, "app.js"), "console.log('feature');\n");
|
||||
run("git", ["add", "app.js"], { cwd });
|
||||
run("git", ["commit", "-m", "feature"], { cwd });
|
||||
|
||||
const target = resolveReviewTarget(cwd, {});
|
||||
const context = collectReviewContext(cwd, target);
|
||||
|
||||
assert.equal(target.mode, "branch");
|
||||
assert.equal(target.baseRef, branchName);
|
||||
assert.match(context.content, /Branch Diff/);
|
||||
assert.equal(fs.existsSync(helperOutputPath), false);
|
||||
});
|
||||
|
||||
test("resolveReviewTarget honors explicit base overrides", () => {
|
||||
const cwd = makeTempDir();
|
||||
initGitRepo(cwd);
|
||||
@@ -68,3 +97,116 @@ test("resolveReviewTarget requires an explicit base when no default branch can b
|
||||
/Unable to detect the repository default branch\. Pass --base <ref> or use --scope working-tree\./
|
||||
);
|
||||
});
|
||||
|
||||
test("collectReviewContext keeps inline diffs for tiny adversarial reviews", () => {
|
||||
const cwd = makeTempDir();
|
||||
initGitRepo(cwd);
|
||||
fs.writeFileSync(path.join(cwd, "app.js"), "console.log('v1');\n");
|
||||
run("git", ["add", "app.js"], { cwd });
|
||||
run("git", ["commit", "-m", "init"], { cwd });
|
||||
fs.writeFileSync(path.join(cwd, "app.js"), "console.log('INLINE_MARKER');\n");
|
||||
|
||||
const target = resolveReviewTarget(cwd, {});
|
||||
const context = collectReviewContext(cwd, target);
|
||||
|
||||
assert.equal(context.inputMode, "inline-diff");
|
||||
assert.equal(context.fileCount, 1);
|
||||
assert.match(context.collectionGuidance, /primary evidence/i);
|
||||
assert.match(context.content, /INLINE_MARKER/);
|
||||
});
|
||||
|
||||
test("collectReviewContext skips untracked directories in working tree review", () => {
|
||||
const cwd = makeTempDir();
|
||||
initGitRepo(cwd);
|
||||
fs.writeFileSync(path.join(cwd, "app.js"), "console.log('v1');\n");
|
||||
run("git", ["add", "app.js"], { cwd });
|
||||
run("git", ["commit", "-m", "init"], { cwd });
|
||||
|
||||
const nestedRepoDir = path.join(cwd, ".claude", "worktrees", "agent-test");
|
||||
fs.mkdirSync(nestedRepoDir, { recursive: true });
|
||||
initGitRepo(nestedRepoDir);
|
||||
|
||||
const target = resolveReviewTarget(cwd, { scope: "working-tree" });
|
||||
const context = collectReviewContext(cwd, target);
|
||||
|
||||
assert.match(context.content, /### \.claude\/worktrees\/agent-test\/\n\(skipped: directory\)/);
|
||||
});
|
||||
|
||||
test("collectReviewContext skips broken untracked symlinks instead of crashing", () => {
|
||||
const cwd = makeTempDir();
|
||||
initGitRepo(cwd);
|
||||
fs.writeFileSync(path.join(cwd, "app.js"), "console.log('v1');\n");
|
||||
run("git", ["add", "app.js"], { cwd });
|
||||
run("git", ["commit", "-m", "init"], { cwd });
|
||||
fs.symlinkSync("missing-target", path.join(cwd, "broken-link"));
|
||||
|
||||
const target = resolveReviewTarget(cwd, {});
|
||||
const context = collectReviewContext(cwd, target);
|
||||
|
||||
assert.equal(target.mode, "working-tree");
|
||||
assert.match(context.content, /### broken-link/);
|
||||
assert.match(context.content, /skipped: broken symlink or unreadable file/i);
|
||||
});
|
||||
|
||||
test("collectReviewContext falls back to lightweight context for larger adversarial reviews", () => {
|
||||
const cwd = makeTempDir();
|
||||
initGitRepo(cwd);
|
||||
for (const name of ["a.js", "b.js", "c.js"]) {
|
||||
fs.writeFileSync(path.join(cwd, name), `export const value = "${name}-v1";\n`);
|
||||
}
|
||||
run("git", ["add", "a.js", "b.js", "c.js"], { cwd });
|
||||
run("git", ["commit", "-m", "init"], { cwd });
|
||||
fs.writeFileSync(path.join(cwd, "a.js"), 'export const value = "SELF_COLLECT_MARKER_A";\n');
|
||||
fs.writeFileSync(path.join(cwd, "b.js"), 'export const value = "SELF_COLLECT_MARKER_B";\n');
|
||||
fs.writeFileSync(path.join(cwd, "c.js"), 'export const value = "SELF_COLLECT_MARKER_C";\n');
|
||||
|
||||
const target = resolveReviewTarget(cwd, {});
|
||||
const context = collectReviewContext(cwd, target);
|
||||
|
||||
assert.equal(context.inputMode, "self-collect");
|
||||
assert.equal(context.fileCount, 3);
|
||||
assert.match(context.collectionGuidance, /lightweight summary/i);
|
||||
assert.match(context.collectionGuidance, /read-only git commands/i);
|
||||
assert.doesNotMatch(context.content, /SELF_COLLECT_MARKER_[ABC]/);
|
||||
assert.match(context.content, /## Changed Files/);
|
||||
});
|
||||
|
||||
test("collectReviewContext falls back to lightweight context for oversized single-file diffs", () => {
|
||||
const cwd = makeTempDir();
|
||||
initGitRepo(cwd);
|
||||
fs.writeFileSync(path.join(cwd, "app.js"), "export const value = 'v1';\n");
|
||||
run("git", ["add", "app.js"], { cwd });
|
||||
run("git", ["commit", "-m", "init"], { cwd });
|
||||
fs.writeFileSync(path.join(cwd, "app.js"), `export const value = '${"x".repeat(512)}';\n`);
|
||||
|
||||
const target = resolveReviewTarget(cwd, {});
|
||||
const context = collectReviewContext(cwd, target, { maxInlineDiffBytes: 128 });
|
||||
|
||||
assert.equal(context.fileCount, 1);
|
||||
assert.equal(context.inputMode, "self-collect");
|
||||
assert.ok(context.diffBytes > 128);
|
||||
assert.doesNotMatch(context.content, /xxx/);
|
||||
assert.match(context.content, /## Changed Files/);
|
||||
});
|
||||
|
||||
test("collectReviewContext keeps untracked file content in lightweight working tree context", () => {
|
||||
const cwd = makeTempDir();
|
||||
initGitRepo(cwd);
|
||||
for (const name of ["a.js", "b.js"]) {
|
||||
fs.writeFileSync(path.join(cwd, name), `export const value = "${name}-v1";\n`);
|
||||
}
|
||||
run("git", ["add", "a.js", "b.js"], { cwd });
|
||||
run("git", ["commit", "-m", "init"], { cwd });
|
||||
fs.writeFileSync(path.join(cwd, "a.js"), 'export const value = "TRACKED_MARKER_A";\n');
|
||||
fs.writeFileSync(path.join(cwd, "b.js"), 'export const value = "TRACKED_MARKER_B";\n');
|
||||
fs.writeFileSync(path.join(cwd, "new-risk.js"), 'export const value = "UNTRACKED_RISK_MARKER";\n');
|
||||
|
||||
const target = resolveReviewTarget(cwd, {});
|
||||
const context = collectReviewContext(cwd, target);
|
||||
|
||||
assert.equal(context.inputMode, "self-collect");
|
||||
assert.equal(context.fileCount, 3);
|
||||
assert.doesNotMatch(context.content, /TRACKED_MARKER_[AB]/);
|
||||
assert.match(context.content, /## Untracked Files/);
|
||||
assert.match(context.content, /UNTRACKED_RISK_MARKER/);
|
||||
});
|
||||
|
||||
+1
-1
@@ -18,7 +18,7 @@ export function run(command, args, options = {}) {
|
||||
env: options.env,
|
||||
encoding: "utf8",
|
||||
input: options.input,
|
||||
shell: process.platform === "win32" && !path.isAbsolute(command),
|
||||
shell: options.shell ?? (process.platform === "win32" && !path.isAbsolute(command)),
|
||||
windowsHide: true
|
||||
});
|
||||
}
|
||||
|
||||
+569
-11
@@ -7,7 +7,7 @@ import { fileURLToPath } from "node:url";
|
||||
|
||||
import { buildEnv, installFakeCodex } from "./fake-codex-fixture.mjs";
|
||||
import { initGitRepo, makeTempDir, run } from "./helpers.mjs";
|
||||
import { loadBrokerSession } from "../plugins/codex/scripts/lib/broker-lifecycle.mjs";
|
||||
import { loadBrokerSession, saveBrokerSession } from "../plugins/codex/scripts/lib/broker-lifecycle.mjs";
|
||||
import { resolveStateDir } from "../plugins/codex/scripts/lib/state.mjs";
|
||||
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
@@ -65,6 +65,77 @@ test("setup is ready without npm when Codex is already installed and authenticat
|
||||
assert.equal(payload.auth.loggedIn, true);
|
||||
});
|
||||
|
||||
test("setup trusts app-server API key auth even when login status alone would fail", () => {
|
||||
const binDir = makeTempDir();
|
||||
installFakeCodex(binDir, "api-key-account-only");
|
||||
|
||||
const result = run("node", [SCRIPT, "setup", "--json"], {
|
||||
cwd: ROOT,
|
||||
env: buildEnv(binDir)
|
||||
});
|
||||
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
const payload = JSON.parse(result.stdout);
|
||||
assert.equal(payload.ready, true);
|
||||
assert.equal(payload.auth.loggedIn, true);
|
||||
assert.equal(payload.auth.authMethod, "apiKey");
|
||||
assert.equal(payload.auth.source, "app-server");
|
||||
assert.match(payload.auth.detail, /API key configured \(unverified\)/);
|
||||
});
|
||||
|
||||
test("setup is ready when the active provider does not require OpenAI login", () => {
|
||||
const binDir = makeTempDir();
|
||||
installFakeCodex(binDir, "provider-no-auth");
|
||||
|
||||
const result = run("node", [SCRIPT, "setup", "--json"], {
|
||||
cwd: ROOT,
|
||||
env: buildEnv(binDir)
|
||||
});
|
||||
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
const payload = JSON.parse(result.stdout);
|
||||
assert.equal(payload.ready, true);
|
||||
assert.equal(payload.auth.loggedIn, true);
|
||||
assert.equal(payload.auth.authMethod, null);
|
||||
assert.equal(payload.auth.source, "app-server");
|
||||
assert.match(payload.auth.detail, /configured and does not require OpenAI authentication/i);
|
||||
});
|
||||
|
||||
test("setup treats custom providers with app-server-ready config as ready", () => {
|
||||
const binDir = makeTempDir();
|
||||
installFakeCodex(binDir, "env-key-provider");
|
||||
|
||||
const result = run("node", [SCRIPT, "setup", "--json"], {
|
||||
cwd: ROOT,
|
||||
env: buildEnv(binDir)
|
||||
});
|
||||
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
const payload = JSON.parse(result.stdout);
|
||||
assert.equal(payload.ready, true);
|
||||
assert.equal(payload.auth.loggedIn, true);
|
||||
assert.equal(payload.auth.authMethod, null);
|
||||
assert.equal(payload.auth.source, "app-server");
|
||||
assert.match(payload.auth.detail, /configured and does not require OpenAI authentication/i);
|
||||
});
|
||||
|
||||
test("setup reports not ready when app-server config read fails", () => {
|
||||
const binDir = makeTempDir();
|
||||
installFakeCodex(binDir, "config-read-fails");
|
||||
|
||||
const result = run("node", [SCRIPT, "setup", "--json"], {
|
||||
cwd: ROOT,
|
||||
env: buildEnv(binDir)
|
||||
});
|
||||
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
const payload = JSON.parse(result.stdout);
|
||||
assert.equal(payload.ready, false);
|
||||
assert.equal(payload.auth.loggedIn, false);
|
||||
assert.equal(payload.auth.source, "app-server");
|
||||
assert.match(payload.auth.detail, /config\/read failed for cwd/);
|
||||
});
|
||||
|
||||
test("review renders a no-findings result from app-server review/start", () => {
|
||||
const repo = makeTempDir();
|
||||
const binDir = makeTempDir();
|
||||
@@ -86,6 +157,194 @@ test("review renders a no-findings result from app-server review/start", () => {
|
||||
assert.match(result.stdout, /No material issues found/);
|
||||
});
|
||||
|
||||
test("task runs when the active provider does not require OpenAI login", () => {
|
||||
const repo = makeTempDir();
|
||||
const binDir = makeTempDir();
|
||||
installFakeCodex(binDir, "provider-no-auth");
|
||||
initGitRepo(repo);
|
||||
fs.writeFileSync(path.join(repo, "README.md"), "hello\n");
|
||||
run("git", ["add", "README.md"], { cwd: repo });
|
||||
run("git", ["commit", "-m", "init"], { cwd: repo });
|
||||
|
||||
const result = run("node", [SCRIPT, "task", "check auth preflight"], {
|
||||
cwd: repo,
|
||||
env: buildEnv(binDir)
|
||||
});
|
||||
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
assert.match(result.stdout, /Handled the requested task/);
|
||||
});
|
||||
|
||||
test("task runs without auth preflight so Codex can refresh an expired session", () => {
|
||||
const repo = makeTempDir();
|
||||
const binDir = makeTempDir();
|
||||
installFakeCodex(binDir, "refreshable-auth");
|
||||
initGitRepo(repo);
|
||||
fs.writeFileSync(path.join(repo, "README.md"), "hello\n");
|
||||
run("git", ["add", "README.md"], { cwd: repo });
|
||||
run("git", ["commit", "-m", "init"], { cwd: repo });
|
||||
|
||||
const result = run("node", [SCRIPT, "task", "check refreshable auth"], {
|
||||
cwd: repo,
|
||||
env: buildEnv(binDir)
|
||||
});
|
||||
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
assert.match(result.stdout, /Handled the requested task/);
|
||||
});
|
||||
|
||||
test("transfer delegates the current Claude session directly to native import", () => {
|
||||
const home = makeTempDir();
|
||||
const repo = path.join(home, "repo");
|
||||
const binDir = makeTempDir();
|
||||
const sessionId = "sess-native-transfer";
|
||||
fs.mkdirSync(repo, { recursive: true });
|
||||
const projectDir = path.join(home, ".claude", "projects", "-repo");
|
||||
const sourcePath = path.join(projectDir, `${sessionId}.jsonl`);
|
||||
fs.mkdirSync(projectDir, { recursive: true });
|
||||
installFakeCodex(binDir);
|
||||
initGitRepo(repo);
|
||||
|
||||
fs.writeFileSync(
|
||||
sourcePath,
|
||||
[
|
||||
{ type: "custom-title", customTitle: "Native transfer" },
|
||||
{ type: "user", cwd: repo, message: { role: "user", content: "Initial request" } },
|
||||
{ type: "assistant", cwd: repo, message: { role: "assistant", content: "Initial answer" } },
|
||||
{ type: "user", cwd: repo, message: { role: "user", content: "/codex:transfer" } }
|
||||
].map((entry) => JSON.stringify(entry)).join("\n") + "\n",
|
||||
"utf8"
|
||||
);
|
||||
const result = run("node", [SCRIPT, "transfer", "--json"], {
|
||||
cwd: repo,
|
||||
env: {
|
||||
...buildEnv(binDir),
|
||||
HOME: home,
|
||||
CODEX_HOME: path.join(home, ".codex"),
|
||||
CODEX_COMPANION_TRANSCRIPT_PATH: sourcePath
|
||||
}
|
||||
});
|
||||
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
const payload = JSON.parse(result.stdout);
|
||||
const canonicalSourcePath = fs.realpathSync(sourcePath);
|
||||
assert.equal(payload.threadId, "thr_1");
|
||||
assert.equal(payload.resumeCommand, "codex resume thr_1");
|
||||
assert.equal(payload.sourcePath, canonicalSourcePath);
|
||||
assert.equal(payload.sessionId, sessionId);
|
||||
|
||||
const fakeState = JSON.parse(fs.readFileSync(path.join(binDir, "fake-codex-state.json"), "utf8"));
|
||||
assert.equal(fakeState.threads.length, 1);
|
||||
assert.equal(fakeState.threads[0].ephemeral, false);
|
||||
assert.equal(fakeState.threads[0].name, "Native transfer");
|
||||
assert.equal(fakeState.lastExternalAgentImport.sourcePath, canonicalSourcePath);
|
||||
assert.deepEqual(
|
||||
fakeState.threads[0].visibleMessages.map((message) => message.text),
|
||||
["Initial request", "Initial answer", "/codex:transfer"]
|
||||
);
|
||||
});
|
||||
|
||||
test("transfer reports an actionable upgrade error when native import is unsupported", () => {
|
||||
const home = makeTempDir();
|
||||
const repo = path.join(home, "repo");
|
||||
const binDir = makeTempDir();
|
||||
const projectDir = path.join(home, ".claude", "projects", "-repo");
|
||||
const sourcePath = path.join(projectDir, "session.jsonl");
|
||||
fs.mkdirSync(repo, { recursive: true });
|
||||
fs.mkdirSync(projectDir, { recursive: true });
|
||||
installFakeCodex(binDir, "external-import-unsupported");
|
||||
initGitRepo(repo);
|
||||
fs.writeFileSync(
|
||||
sourcePath,
|
||||
`${JSON.stringify({ type: "user", cwd: repo, message: { role: "user", content: "Continue this work." } })}\n`,
|
||||
"utf8"
|
||||
);
|
||||
|
||||
const result = run("node", [SCRIPT, "transfer", "--source", sourcePath, "--json"], {
|
||||
cwd: repo,
|
||||
env: {
|
||||
...buildEnv(binDir),
|
||||
HOME: home,
|
||||
CODEX_HOME: path.join(home, ".codex")
|
||||
}
|
||||
});
|
||||
|
||||
assert.notEqual(result.status, 0);
|
||||
assert.match(result.stderr, /does not support Claude session transfer/);
|
||||
assert.match(result.stderr, /@openai\/codex@latest/);
|
||||
});
|
||||
|
||||
test("transfer fails visibly when native import completes without a ledger record", () => {
|
||||
const home = makeTempDir();
|
||||
const repo = path.join(home, "repo");
|
||||
const binDir = makeTempDir();
|
||||
const projectDir = path.join(home, ".claude", "projects", "-repo");
|
||||
const sourcePath = path.join(projectDir, "session.jsonl");
|
||||
fs.mkdirSync(repo, { recursive: true });
|
||||
fs.mkdirSync(projectDir, { recursive: true });
|
||||
installFakeCodex(binDir, "external-import-fails");
|
||||
initGitRepo(repo);
|
||||
fs.writeFileSync(
|
||||
sourcePath,
|
||||
`${JSON.stringify({ type: "user", cwd: repo, message: { role: "user", content: "Do not lose this request." } })}\n`,
|
||||
"utf8"
|
||||
);
|
||||
|
||||
const result = run("node", [SCRIPT, "transfer", "--source", sourcePath], {
|
||||
cwd: repo,
|
||||
env: {
|
||||
...buildEnv(binDir),
|
||||
HOME: home,
|
||||
CODEX_HOME: path.join(home, ".codex")
|
||||
}
|
||||
});
|
||||
|
||||
assert.notEqual(result.status, 0);
|
||||
assert.match(result.stderr, /did not record an imported thread/);
|
||||
});
|
||||
|
||||
test("transfer rejects sources outside the Claude projects directory", () => {
|
||||
const home = makeTempDir();
|
||||
const repo = path.join(home, "repo");
|
||||
const binDir = makeTempDir();
|
||||
const sourcePath = path.join(home, "session.jsonl");
|
||||
fs.mkdirSync(repo, { recursive: true });
|
||||
fs.mkdirSync(path.join(home, ".claude", "projects"), { recursive: true });
|
||||
installFakeCodex(binDir);
|
||||
initGitRepo(repo);
|
||||
fs.writeFileSync(
|
||||
sourcePath,
|
||||
`${JSON.stringify({ type: "user", cwd: repo, message: { role: "user", content: "Outside source." } })}\n`,
|
||||
"utf8"
|
||||
);
|
||||
|
||||
const result = run("node", [SCRIPT, "transfer", "--source", sourcePath], {
|
||||
cwd: repo,
|
||||
env: { ...buildEnv(binDir), HOME: home }
|
||||
});
|
||||
|
||||
assert.notEqual(result.status, 0);
|
||||
assert.match(result.stderr, /only from .*\.claude.*projects/);
|
||||
});
|
||||
|
||||
test("task reports the actual Codex auth error when the run is rejected", () => {
|
||||
const repo = makeTempDir();
|
||||
const binDir = makeTempDir();
|
||||
installFakeCodex(binDir, "auth-run-fails");
|
||||
initGitRepo(repo);
|
||||
fs.writeFileSync(path.join(repo, "README.md"), "hello\n");
|
||||
run("git", ["add", "README.md"], { cwd: repo });
|
||||
run("git", ["commit", "-m", "init"], { cwd: repo });
|
||||
|
||||
const result = run("node", [SCRIPT, "task", "check failed auth"], {
|
||||
cwd: repo,
|
||||
env: buildEnv(binDir)
|
||||
});
|
||||
|
||||
assert.notEqual(result.status, 0);
|
||||
assert.match(result.stderr, /authentication expired; run codex login/);
|
||||
});
|
||||
|
||||
test("review accepts the quoted raw argument style for built-in base-branch review", () => {
|
||||
const repo = makeTempDir();
|
||||
const binDir = makeTempDir();
|
||||
@@ -148,6 +407,33 @@ test("adversarial review accepts the same base-branch targeting as review", () =
|
||||
assert.match(result.stdout, /Missing empty-state guard/);
|
||||
});
|
||||
|
||||
test("adversarial review asks Codex to inspect larger diffs itself", () => {
|
||||
const repo = makeTempDir();
|
||||
const binDir = makeTempDir();
|
||||
installFakeCodex(binDir);
|
||||
initGitRepo(repo);
|
||||
fs.mkdirSync(path.join(repo, "src"));
|
||||
for (const name of ["a.js", "b.js", "c.js"]) {
|
||||
fs.writeFileSync(path.join(repo, "src", name), `export const value = "${name}-v1";\n`);
|
||||
}
|
||||
run("git", ["add", "src/a.js", "src/b.js", "src/c.js"], { cwd: repo });
|
||||
run("git", ["commit", "-m", "init"], { cwd: repo });
|
||||
fs.writeFileSync(path.join(repo, "src", "a.js"), 'export const value = "PROMPT_SELF_COLLECT_A";\n');
|
||||
fs.writeFileSync(path.join(repo, "src", "b.js"), 'export const value = "PROMPT_SELF_COLLECT_B";\n');
|
||||
fs.writeFileSync(path.join(repo, "src", "c.js"), 'export const value = "PROMPT_SELF_COLLECT_C";\n');
|
||||
|
||||
const result = run("node", [SCRIPT, "adversarial-review"], {
|
||||
cwd: repo,
|
||||
env: buildEnv(binDir)
|
||||
});
|
||||
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
const state = JSON.parse(fs.readFileSync(path.join(binDir, "fake-codex-state.json"), "utf8"));
|
||||
assert.match(state.lastTurnStart.prompt, /lightweight summary/i);
|
||||
assert.match(state.lastTurnStart.prompt, /read-only git commands/i);
|
||||
assert.doesNotMatch(state.lastTurnStart.prompt, /PROMPT_SELF_COLLECT_[ABC]/);
|
||||
});
|
||||
|
||||
test("review includes reasoning output when the app server returns it", () => {
|
||||
const repo = makeTempDir();
|
||||
const binDir = makeTempDir();
|
||||
@@ -284,11 +570,111 @@ test("task-resume-candidate returns the latest rescue thread from the current se
|
||||
assert.equal(payload.candidate.threadId, "thr_current");
|
||||
});
|
||||
|
||||
test("session start hook exports the Claude session id and plugin data dir for later commands", () => {
|
||||
test("task --resume-last does not resume a task from another Claude session", () => {
|
||||
const repo = makeTempDir();
|
||||
const binDir = makeTempDir();
|
||||
const statePath = path.join(binDir, "fake-codex-state.json");
|
||||
installFakeCodex(binDir);
|
||||
initGitRepo(repo);
|
||||
fs.writeFileSync(path.join(repo, "README.md"), "hello\n");
|
||||
run("git", ["add", "README.md"], { cwd: repo });
|
||||
run("git", ["commit", "-m", "init"], { cwd: repo });
|
||||
|
||||
const otherEnv = {
|
||||
...buildEnv(binDir),
|
||||
CODEX_COMPANION_SESSION_ID: "sess-other"
|
||||
};
|
||||
const currentEnv = {
|
||||
...buildEnv(binDir),
|
||||
CODEX_COMPANION_SESSION_ID: "sess-current"
|
||||
};
|
||||
|
||||
const firstRun = run("node", [SCRIPT, "task", "initial task"], {
|
||||
cwd: repo,
|
||||
env: otherEnv
|
||||
});
|
||||
assert.equal(firstRun.status, 0, firstRun.stderr);
|
||||
|
||||
const candidate = run("node", [SCRIPT, "task-resume-candidate", "--json"], {
|
||||
cwd: repo,
|
||||
env: currentEnv
|
||||
});
|
||||
assert.equal(candidate.status, 0, candidate.stderr);
|
||||
assert.equal(JSON.parse(candidate.stdout).available, false);
|
||||
|
||||
const resume = run("node", [SCRIPT, "task", "--resume-last", "follow up"], {
|
||||
cwd: repo,
|
||||
env: currentEnv
|
||||
});
|
||||
assert.equal(resume.status, 1);
|
||||
assert.match(resume.stderr, /No previous Codex task thread was found for this repository\./);
|
||||
|
||||
const fakeState = JSON.parse(fs.readFileSync(statePath, "utf8"));
|
||||
assert.equal(fakeState.lastTurnStart.threadId, "thr_1");
|
||||
assert.equal(fakeState.lastTurnStart.prompt, "initial task");
|
||||
});
|
||||
|
||||
test("task --resume-last ignores running tasks from other Claude sessions", () => {
|
||||
const repo = makeTempDir();
|
||||
const binDir = makeTempDir();
|
||||
installFakeCodex(binDir);
|
||||
initGitRepo(repo);
|
||||
fs.writeFileSync(path.join(repo, "README.md"), "hello\n");
|
||||
run("git", ["add", "README.md"], { cwd: repo });
|
||||
run("git", ["commit", "-m", "init"], { cwd: repo });
|
||||
|
||||
const stateDir = resolveStateDir(repo);
|
||||
fs.mkdirSync(path.join(stateDir, "jobs"), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(stateDir, "state.json"),
|
||||
`${JSON.stringify(
|
||||
{
|
||||
version: 1,
|
||||
config: { stopReviewGate: false },
|
||||
jobs: [
|
||||
{
|
||||
id: "task-other-running",
|
||||
status: "running",
|
||||
title: "Codex Task",
|
||||
jobClass: "task",
|
||||
sessionId: "sess-other",
|
||||
threadId: "thr_other",
|
||||
summary: "Other session active task",
|
||||
updatedAt: "2026-03-24T20:05:00.000Z"
|
||||
}
|
||||
]
|
||||
},
|
||||
null,
|
||||
2
|
||||
)}\n`,
|
||||
"utf8"
|
||||
);
|
||||
|
||||
const env = {
|
||||
...buildEnv(binDir),
|
||||
CODEX_COMPANION_SESSION_ID: "sess-current"
|
||||
};
|
||||
const status = run("node", [SCRIPT, "status", "--json"], {
|
||||
cwd: repo,
|
||||
env
|
||||
});
|
||||
assert.equal(status.status, 0, status.stderr);
|
||||
assert.deepEqual(JSON.parse(status.stdout).running, []);
|
||||
|
||||
const resume = run("node", [SCRIPT, "task", "--resume-last", "follow up"], {
|
||||
cwd: repo,
|
||||
env
|
||||
});
|
||||
assert.equal(resume.status, 1);
|
||||
assert.match(resume.stderr, /No previous Codex task thread was found for this repository\./);
|
||||
});
|
||||
|
||||
test("session start hook exports the Claude session id, transcript path, and plugin data dir", () => {
|
||||
const repo = makeTempDir();
|
||||
const envFile = path.join(makeTempDir(), "claude-env.sh");
|
||||
fs.writeFileSync(envFile, "", "utf8");
|
||||
const pluginDataDir = makeTempDir();
|
||||
const transcriptPath = path.join(repo, "session.jsonl");
|
||||
|
||||
const result = run("node", [SESSION_HOOK, "SessionStart"], {
|
||||
cwd: repo,
|
||||
@@ -300,6 +686,7 @@ test("session start hook exports the Claude session id and plugin data dir for l
|
||||
input: JSON.stringify({
|
||||
hook_event_name: "SessionStart",
|
||||
session_id: "sess-current",
|
||||
transcript_path: transcriptPath,
|
||||
cwd: repo
|
||||
})
|
||||
});
|
||||
@@ -307,7 +694,7 @@ test("session start hook exports the Claude session id and plugin data dir for l
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
assert.equal(
|
||||
fs.readFileSync(envFile, "utf8"),
|
||||
`export CODEX_COMPANION_SESSION_ID='sess-current'\nexport CLAUDE_PLUGIN_DATA='${pluginDataDir}'\n`
|
||||
`export CODEX_COMPANION_SESSION_ID='sess-current'\nexport CODEX_COMPANION_TRANSCRIPT_PATH='${transcriptPath}'\nexport CLAUDE_PLUGIN_DATA='${pluginDataDir}'\n`
|
||||
);
|
||||
});
|
||||
|
||||
@@ -554,7 +941,7 @@ test("task --background enqueues a detached worker and exposes per-job status",
|
||||
|
||||
const waitedStatus = run(
|
||||
"node",
|
||||
[SCRIPT, "status", launchPayload.jobId, "--wait", "--timeout-ms", "5000", "--json"],
|
||||
[SCRIPT, "status", launchPayload.jobId, "--wait", "--timeout-ms", "15000", "--json"],
|
||||
{
|
||||
cwd: repo,
|
||||
env: buildEnv(binDir)
|
||||
@@ -1247,6 +1634,109 @@ test("cancel stops an active background job and marks it cancelled", async (t) =
|
||||
assert.match(fs.readFileSync(logFile, "utf8"), /Cancelled by user/);
|
||||
});
|
||||
|
||||
test("cancel without a job id ignores active jobs from other Claude sessions", () => {
|
||||
const workspace = makeTempDir();
|
||||
const stateDir = resolveStateDir(workspace);
|
||||
const jobsDir = path.join(stateDir, "jobs");
|
||||
fs.mkdirSync(jobsDir, { recursive: true });
|
||||
|
||||
const logFile = path.join(jobsDir, "task-other.log");
|
||||
fs.writeFileSync(logFile, "", "utf8");
|
||||
fs.writeFileSync(
|
||||
path.join(stateDir, "state.json"),
|
||||
`${JSON.stringify(
|
||||
{
|
||||
version: 1,
|
||||
config: { stopReviewGate: false },
|
||||
jobs: [
|
||||
{
|
||||
id: "task-other",
|
||||
status: "running",
|
||||
title: "Codex Task",
|
||||
jobClass: "task",
|
||||
sessionId: "sess-other",
|
||||
summary: "Other session run",
|
||||
updatedAt: "2026-03-24T20:05:00.000Z",
|
||||
logFile
|
||||
}
|
||||
]
|
||||
},
|
||||
null,
|
||||
2
|
||||
)}\n`,
|
||||
"utf8"
|
||||
);
|
||||
|
||||
const env = {
|
||||
...process.env,
|
||||
CODEX_COMPANION_SESSION_ID: "sess-current"
|
||||
};
|
||||
const status = run("node", [SCRIPT, "status", "--json"], {
|
||||
cwd: workspace,
|
||||
env
|
||||
});
|
||||
assert.equal(status.status, 0, status.stderr);
|
||||
assert.deepEqual(JSON.parse(status.stdout).running, []);
|
||||
|
||||
const cancel = run("node", [SCRIPT, "cancel", "--json"], {
|
||||
cwd: workspace,
|
||||
env
|
||||
});
|
||||
assert.equal(cancel.status, 1);
|
||||
assert.match(cancel.stderr, /No active Codex jobs to cancel for this session\./);
|
||||
|
||||
const state = JSON.parse(fs.readFileSync(path.join(stateDir, "state.json"), "utf8"));
|
||||
assert.equal(state.jobs[0].status, "running");
|
||||
});
|
||||
|
||||
test("cancel with a job id can still target an active job from another Claude session", () => {
|
||||
const workspace = makeTempDir();
|
||||
const stateDir = resolveStateDir(workspace);
|
||||
const jobsDir = path.join(stateDir, "jobs");
|
||||
fs.mkdirSync(jobsDir, { recursive: true });
|
||||
|
||||
const logFile = path.join(jobsDir, "task-other.log");
|
||||
fs.writeFileSync(logFile, "", "utf8");
|
||||
fs.writeFileSync(
|
||||
path.join(stateDir, "state.json"),
|
||||
`${JSON.stringify(
|
||||
{
|
||||
version: 1,
|
||||
config: { stopReviewGate: false },
|
||||
jobs: [
|
||||
{
|
||||
id: "task-other",
|
||||
status: "running",
|
||||
title: "Codex Task",
|
||||
jobClass: "task",
|
||||
sessionId: "sess-other",
|
||||
summary: "Other session run",
|
||||
updatedAt: "2026-03-24T20:05:00.000Z",
|
||||
logFile
|
||||
}
|
||||
]
|
||||
},
|
||||
null,
|
||||
2
|
||||
)}\n`,
|
||||
"utf8"
|
||||
);
|
||||
|
||||
const env = {
|
||||
...process.env,
|
||||
CODEX_COMPANION_SESSION_ID: "sess-current"
|
||||
};
|
||||
const cancel = run("node", [SCRIPT, "cancel", "task-other", "--json"], {
|
||||
cwd: workspace,
|
||||
env
|
||||
});
|
||||
assert.equal(cancel.status, 0, cancel.stderr);
|
||||
assert.equal(JSON.parse(cancel.stdout).jobId, "task-other");
|
||||
|
||||
const state = JSON.parse(fs.readFileSync(path.join(stateDir, "state.json"), "utf8"));
|
||||
assert.equal(state.jobs[0].status, "cancelled");
|
||||
});
|
||||
|
||||
test("cancel sends turn interrupt to the shared app-server before killing a brokered task", async () => {
|
||||
const repo = makeTempDir();
|
||||
const binDir = makeTempDir();
|
||||
@@ -1276,7 +1766,7 @@ test("cancel sends turn interrupt to the shared app-server before killing a brok
|
||||
return job;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
}, { timeoutMs: 15000 });
|
||||
|
||||
const cancelResult = run("node", [SCRIPT, "cancel", jobId, "--json"], {
|
||||
cwd: repo,
|
||||
@@ -1598,10 +2088,10 @@ test("stop hook does not block when Codex is unavailable even if the review gate
|
||||
assert.match(allowed.stderr, /Run \/codex:setup/i);
|
||||
});
|
||||
|
||||
test("stop hook does not block when Codex is not authenticated even if the review gate is enabled", () => {
|
||||
test("stop hook runs the actual task when auth status looks stale", () => {
|
||||
const repo = makeTempDir();
|
||||
const binDir = makeTempDir();
|
||||
installFakeCodex(binDir, "logged-out");
|
||||
installFakeCodex(binDir, "refreshable-auth");
|
||||
initGitRepo(repo);
|
||||
fs.writeFileSync(path.join(repo, "README.md"), "hello\n");
|
||||
run("git", ["add", "README.md"], { cwd: repo });
|
||||
@@ -1620,10 +2110,10 @@ test("stop hook does not block when Codex is not authenticated even if the revie
|
||||
});
|
||||
|
||||
assert.equal(allowed.status, 0, allowed.stderr);
|
||||
assert.equal(allowed.stdout.trim(), "");
|
||||
assert.match(allowed.stderr, /Codex is not set up for the review gate/i);
|
||||
assert.match(allowed.stderr, /not authenticated/i);
|
||||
assert.match(allowed.stderr, /!codex login/i);
|
||||
assert.doesNotMatch(allowed.stderr, /Codex is not set up for the review gate/i);
|
||||
const payload = JSON.parse(allowed.stdout);
|
||||
assert.equal(payload.decision, "block");
|
||||
assert.match(payload.reason, /Missing empty-state guard/i);
|
||||
});
|
||||
|
||||
test("commands lazily start and reuse one shared app-server after first use", async () => {
|
||||
@@ -1671,6 +2161,51 @@ test("commands lazily start and reuse one shared app-server after first use", as
|
||||
assert.equal(cleanup.status, 0, cleanup.stderr);
|
||||
});
|
||||
|
||||
test("setup reuses an existing shared app-server without starting another one", () => {
|
||||
const repo = makeTempDir();
|
||||
const binDir = makeTempDir();
|
||||
const fakeStatePath = path.join(binDir, "fake-codex-state.json");
|
||||
|
||||
installFakeCodex(binDir);
|
||||
initGitRepo(repo);
|
||||
fs.writeFileSync(path.join(repo, "README.md"), "hello\n");
|
||||
run("git", ["add", "README.md"], { cwd: repo });
|
||||
run("git", ["commit", "-m", "init"], { cwd: repo });
|
||||
fs.writeFileSync(path.join(repo, "README.md"), "hello again\n");
|
||||
|
||||
const env = buildEnv(binDir);
|
||||
|
||||
const review = run("node", [SCRIPT, "review"], {
|
||||
cwd: repo,
|
||||
env
|
||||
});
|
||||
assert.equal(review.status, 0, review.stderr);
|
||||
|
||||
const brokerSession = loadBrokerSession(repo);
|
||||
if (!brokerSession) {
|
||||
return;
|
||||
}
|
||||
|
||||
const setup = run("node", [SCRIPT, "setup", "--json"], {
|
||||
cwd: repo,
|
||||
env
|
||||
});
|
||||
assert.equal(setup.status, 0, setup.stderr);
|
||||
|
||||
const fakeState = JSON.parse(fs.readFileSync(fakeStatePath, "utf8"));
|
||||
assert.equal(fakeState.appServerStarts, 1);
|
||||
|
||||
const cleanup = run("node", [SESSION_HOOK, "SessionEnd"], {
|
||||
cwd: repo,
|
||||
env,
|
||||
input: JSON.stringify({
|
||||
hook_event_name: "SessionEnd",
|
||||
cwd: repo
|
||||
})
|
||||
});
|
||||
assert.equal(cleanup.status, 0, cleanup.stderr);
|
||||
});
|
||||
|
||||
test("status reports shared session runtime when a lazy broker is active", () => {
|
||||
const repo = makeTempDir();
|
||||
const binDir = makeTempDir();
|
||||
@@ -1699,3 +2234,26 @@ test("status reports shared session runtime when a lazy broker is active", () =>
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
assert.match(result.stdout, /Session runtime: shared session/);
|
||||
});
|
||||
|
||||
test("setup and status honor --cwd when reading shared session runtime", () => {
|
||||
const targetWorkspace = makeTempDir();
|
||||
const invocationWorkspace = makeTempDir();
|
||||
|
||||
saveBrokerSession(targetWorkspace, {
|
||||
endpoint: "unix:/tmp/fake-broker.sock"
|
||||
});
|
||||
|
||||
const status = run("node", [SCRIPT, "status", "--cwd", targetWorkspace], {
|
||||
cwd: invocationWorkspace
|
||||
});
|
||||
assert.equal(status.status, 0, status.stderr);
|
||||
assert.match(status.stdout, /Session runtime: shared session/);
|
||||
|
||||
const setup = run("node", [SCRIPT, "setup", "--cwd", targetWorkspace, "--json"], {
|
||||
cwd: invocationWorkspace
|
||||
});
|
||||
assert.equal(setup.status, 0, setup.stderr);
|
||||
const payload = JSON.parse(setup.stdout);
|
||||
assert.equal(payload.sessionRuntime.mode, "shared");
|
||||
assert.equal(payload.sessionRuntime.endpoint, "unix:/tmp/fake-broker.sock");
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user