Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 12 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,22 @@ on:
branches:
- main

permissions:
contents: read

jobs:
test:
runs-on: ubuntu-latest
name: Node ${{ matrix.node }}
strategy:
fail-fast: false
matrix:
node: [20, 22, 24]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with:
node-version: "20"
node-version: ${{ matrix.node }}
package-manager-cache: false
- run: npm test
- run: node tests/validate-plugin.mjs
1 change: 1 addition & 0 deletions .node-version
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
24
50 changes: 44 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -244,6 +244,26 @@ so `$claude status`, `$claude result`, and `$claude cancel` can work across
turns. It does not intentionally collect analytics, phone home, or send data to
the repository owner.

## State Storage

By default the companion stores state under:

```text
~/.codex/claude-plugin-codex
```

Set `CLAUDE_COMPANION_STATE_ROOT` to use another local directory:

```bash
CLAUDE_COMPANION_STATE_ROOT=/path/to/writable/state \
node plugins/claude-code-advisor/scripts/claude-companion.mjs setup --json
```

This is useful in sandboxed Codex environments where the default Codex home
path is readable but not writable. The state root should be local, private, and
excluded from version control because it can contain job prompts, Claude output,
workspace paths, and review results.

## Terms

This project is provided under the MIT License. You are responsible for how you
Expand All @@ -269,6 +289,9 @@ The companion owns:

## Development

Use Node.js 24 for development. The repository includes `.node-version` for
compatible version managers, and CI also checks Node.js 20 and 22 compatibility.

```bash
npm test
npm run validate
Expand All @@ -290,7 +313,10 @@ npm run test:e2e:codex
This requires `codex plugin marketplace add ./`, `Claude` installed from
Codex's plugin directory, and a logged-in Claude Code CLI. It starts a fresh
`codex exec` session and verifies that `$claude advise --model sonnet` routes
through the installed skill. Sonnet is used only for this small routing test.
through the installed skill. The test uses Codex's `workspace-write` sandbox,
supplies a private temporary companion state root inside the checkout, and
removes that state before checking the worktree. Sonnet is used only for this
small routing test.

## Current Limits

Expand All @@ -309,12 +335,17 @@ through the installed skill. Sonnet is used only for this small routing test.
- Foreground prepared task routes use a larger default turn budget than
structured review. If Claude reports that it hit the max-turn limit, rerun
with `--max-turns <higher>` or narrow the task.
- Working-tree structured reviews stop when untracked files exist because their
contents are absent from a Git diff and review mode cannot read the workspace.
Stage the intended files before rerunning the review.
- `$claude monitor` checks a background job every 30 seconds by default. It
reads `claude logs` and `claude agents`, filters routine terminal noise, and
marks repeated output as stale after two minutes.
- Structured review depends on Claude returning valid JSON inside the
`--output-format json` result envelope. The companion validates and retries
once before failing.
reads `claude logs` and `claude agents --json --all`, filters routine terminal
noise, and marks repeated output as stale after two minutes.
- Structured review extracts a single complete JSON object from Claude's
`--output-format json` result envelope, tolerating leading status prose or
tool-call markup while rejecting ambiguous multiple objects. The extracted
review payload is still validated strictly, and the companion retries once
before failing.

## Troubleshooting

Expand All @@ -323,6 +354,13 @@ or start a new thread. Codex may still point at an older cached skill path after
a plugin version bump. If the error remains, remove and reinstall the
`claude-plugin-codex` marketplace.

If `$claude setup` or a companion command fails with a write permission error
under `~/.codex/claude-plugin-codex`, rerun it with `CLAUDE_COMPANION_STATE_ROOT`
pointing at a writable directory. Within that root, the companion restricts its
workspace and thread directories to mode `0700` and state/pointer files to mode
`0600`. Do not point it at the project repository unless you also ignore that
path in Git.

## License

MIT.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "claude-plugin-codex",
"version": "0.1.12",
"version": "0.1.13",
"description": "Bring local Claude Code into Codex for reviews, prepared tasks, advice, and rescue work.",
"type": "module",
"private": true,
Expand Down
7 changes: 3 additions & 4 deletions plugins/claude-code-advisor/.codex-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "claude-code-advisor",
"version": "0.1.12",
"version": "0.1.13",
"description": "Bring local Claude Code into Codex for reviews, prepared tasks, advice, and rescue work.",
"author": {
"name": "Yanchuk"
Expand Down Expand Up @@ -33,9 +33,8 @@
"termsOfServiceURL": "https://github.com/yanchuk/claude-plugin-codex#terms",
"defaultPrompt": [
"Check Claude Code setup.",
"Review this diff with Claude Code.",
"Do a prepared task with Claude Code.",
"Rescue this task with Claude Code."
"Review or adversarially review this diff with Claude Code.",
"Do a prepared task or rescue this work with Claude Code."
],
"brandColor": "#D97706",
"composerIcon": "./assets/icon.svg",
Expand Down
74 changes: 64 additions & 10 deletions plugins/claude-code-advisor/scripts/claude-companion.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -317,19 +317,36 @@ function assertBackgroundMcpSafe(ctx, options = {}) {

function persistContext(ctx, state) {
const saved = saveState(ctx.stateDir, state);
fs.mkdirSync(ctx.indexDir, { recursive: true });
fs.writeFileSync(path.join(ctx.indexDir, "latest-state-dir"), `${ctx.stateDir}\n`, "utf8");
fs.mkdirSync(ctx.indexDir, { recursive: true, mode: 0o700 });
fs.chmodSync(ctx.indexDir, 0o700);
const latestStateFile = path.join(ctx.indexDir, "latest-state-dir");
fs.writeFileSync(latestStateFile, `${ctx.stateDir}\n`, { encoding: "utf8", mode: 0o600 });
fs.chmodSync(latestStateFile, 0o600);
return saved;
}

function gitContext(cwd, options = {}) {
const target = options.base ? `${options.base}...HEAD` : null;
if (!target) {
const untracked = spawnSync("git", ["ls-files", "--others", "--exclude-standard"], { cwd, encoding: "utf8" });
const paths = untracked.status === 0 ? untracked.stdout.trim().split(/\r?\n/).filter(Boolean) : [];
if (paths.length) {
const shown = paths.slice(0, 20).map((file) => `- ${file}`).join("\n");
const remaining = paths.length > 20 ? `\n- ...and ${paths.length - 20} more` : "";
throw new Error(
`Working-tree review cannot safely include untracked file contents. Stage the intended files first:\n${shown}${remaining}`
);
}
}
const args = target ? ["diff", "--stat", target] : ["status", "--short", "--untracked-files=all"];
const result = spawnSync("git", args, { cwd, encoding: "utf8" });
const first = result.status === 0 ? result.stdout : result.stderr;
const diffArgs = target ? ["diff", "--", target] : ["diff", "--"];
const diff = spawnSync("git", diffArgs, { cwd, encoding: "utf8", maxBuffer: 1024 * 1024 });
return [first, diff.status === 0 ? diff.stdout : ""].join("\n").trim();
const diffArgs = target ? ["diff", target, "--"] : ["diff", "HEAD", "--"];
let diff = spawnSync("git", diffArgs, { cwd, encoding: "utf8", maxBuffer: 1024 * 1024 });
if (!target && diff.status !== 0) {
diff = spawnSync("git", ["diff", "--"], { cwd, encoding: "utf8", maxBuffer: 1024 * 1024 });
}
return [first, diff.status === 0 ? diff.stdout : diff.stderr].join("\n").trim();
}

function detectCapabilities() {
Expand Down Expand Up @@ -569,6 +586,34 @@ function findJob(ctx, reference) {
return ctx.state.jobs.find((job) => job.id === reference || job.claudeSessionId === reference) || null;
}

function parseAgentsJson(output, claudeSessionId) {
const text = String(output || "").trim();
if (!text) {
return { output: "" };
}
try {
const parsed = JSON.parse(text);
const sessions = Array.isArray(parsed) ? parsed : parsed.sessions || parsed.agents || [];
const match = sessions.find((session) => {
return (
session?.id === claudeSessionId ||
session?.sessionId === claudeSessionId ||
session?.session_id === claudeSessionId
);
});
const lifecycle = `${match?.status || ""} ${match?.state || ""}`.toLowerCase();
return {
output: text,
sessions,
match: match || null,
active: /\b(active|running|busy|working)\b/.test(lifecycle),
completed: /\b(done|completed|complete|stopped|exited|finished)\b/.test(lifecycle)
};
} catch {
return { output: text };
}
}

function readLiveStatus(job, options = {}) {
if (!job?.claudeSessionId) {
return {
Expand All @@ -581,16 +626,17 @@ function readLiveStatus(job, options = {}) {
}
const timeoutMs = Number(options["timeout-ms"] || 10000);
const logs = runClaude(["logs", job.claudeSessionId], { timeoutMs });
const agents = runClaude(["agents"], { timeoutMs });
const agents = runClaude(["agents", "--json", "--all"], { timeoutMs });
const agentsOutput = stripTerminalControl(`${agents.stdout || ""}${agents.stderr || ""}`);
const logsOutput = stripTerminalControl(`${logs.stdout || ""}${logs.stderr || ""}`);
const agentStatus = agents.status === 0 ? parseAgentsJson(agentsOutput, job.claudeSessionId) : { output: agentsOutput };
const meaningfulLogLines = extractMeaningfulLogLines(logsOutput);
const completed = isCompletedLogOutput(logsOutput);
const completed = isCompletedLogOutput(logsOutput) || Boolean(agentStatus.completed);
return {
checkedAt: new Date().toISOString(),
jobId: job.id,
claudeSessionId: job.claudeSessionId,
active: logs.status === 0 && !completed,
active: !completed && (logs.status === 0 || Boolean(agentStatus.active)),
completed,
available: logs.status === 0 || agents.status === 0,
logs: {
Expand All @@ -600,7 +646,8 @@ function readLiveStatus(job, options = {}) {
},
agents: {
available: agents.status === 0,
output: agentsOutput.trim()
output: agentStatus.output || agentsOutput.trim(),
match: agentStatus.match || null
}
};
}
Expand Down Expand Up @@ -771,10 +818,17 @@ function handleCancel(argv) {
try {
const live = summarizeLiveStatus(readLiveStatus(job, options), {}, options);
latest = persistMonitorSnapshot(ctx, job, live);
if (live.completed) {
output({ jobId: latest.id, status: "completed" }, options.json);
return;
}
} catch {
latest = job;
}
runClaude(["stop", job.claudeSessionId], { timeoutMs: Number(options["timeout-ms"] || 10000) });
const stopped = runClaude(["stop", job.claudeSessionId], { timeoutMs: Number(options["timeout-ms"] || 10000) });
if (stopped.status !== 0) {
throw new Error(stopped.stderr || stopped.stdout || `Claude failed to stop session ${job.claudeSessionId}.`);
}
}
const cancelled = completeJob(ctx, latest, { status: "cancelled" });
output({ jobId: cancelled.id, status: "cancelled" }, options.json);
Expand Down
67 changes: 63 additions & 4 deletions plugins/claude-code-advisor/scripts/lib/runtime.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -83,13 +83,16 @@ export function loadState(stateDir) {
}

export function saveState(stateDir, state) {
fs.mkdirSync(stateDir, { recursive: true });
fs.mkdirSync(stateDir, { recursive: true, mode: 0o700 });
fs.chmodSync(stateDir, 0o700);
const next = {
...emptyState(),
...state,
jobs: [...(state.jobs || [])].sort((a, b) => String(b.updatedAt || "").localeCompare(String(a.updatedAt || "")))
};
fs.writeFileSync(path.join(stateDir, "state.json"), `${JSON.stringify(next, null, 2)}\n`, "utf8");
const stateFile = path.join(stateDir, "state.json");
fs.writeFileSync(stateFile, `${JSON.stringify(next, null, 2)}\n`, { encoding: "utf8", mode: 0o600 });
fs.chmodSync(stateFile, 0o600);
return next;
}

Expand Down Expand Up @@ -267,8 +270,8 @@ export function parseClaudeJsonResult(raw) {
}
const envelope = JSON.parse(text);
const sessionId = envelope.session_id || envelope.sessionId || null;
const contentRaw = typeof envelope.result === "string" ? envelope.result : text;
const content = typeof envelope.result === "string" ? JSON.parse(envelope.result) : envelope;
const contentRaw = typeof envelope.result === "string" ? normalizeClaudeResult(envelope.result) : text;
const content = typeof envelope.result === "string" ? JSON.parse(contentRaw) : envelope;
return {
envelope,
content,
Expand All @@ -277,6 +280,62 @@ export function parseClaudeJsonResult(raw) {
};
}

function normalizeClaudeResult(raw) {
const trimmed = String(raw || "").trim();
const toolCalls = trimmed.match(/^<function_calls>[\s\S]*?<\/function_calls>\s*/);
const withoutToolCalls = toolCalls ? trimmed.slice(toolCalls[0].length).trim() : trimmed;
try {
JSON.parse(withoutToolCalls);
return withoutToolCalls;
} catch {
const candidates = extractJsonObjects(withoutToolCalls);
if (candidates.length > 1) {
throw new Error("Ambiguous JSON Claude result: multiple complete objects were returned.");
}
return candidates[0] || withoutToolCalls;
}
}

function extractJsonObjects(value) {
const text = String(value || "");
const objects = [];
let start = -1;
let depth = 0;
let inString = false;
let escaped = false;
for (let index = 0; index < text.length; index += 1) {
const char = text[index];
if (escaped) {
escaped = false;
continue;
}
if (inString && char === "\\") {
escaped = true;
continue;
}
if (char === '"') {
inString = !inString;
continue;
}
if (inString) {
continue;
}
if (char === "{") {
if (depth === 0) {
start = index;
}
depth += 1;
} else if (char === "}" && depth > 0) {
depth -= 1;
if (depth === 0) {
objects.push(text.slice(start, index + 1).trim());
start = -1;
}
}
}
return objects;
}

export function buildReviewPrompt({ kind, targetLabel, gitContext, focus = "" }) {
const reviewKind = kind === "adversarial-review" ? "adversarial reviewer" : "code reviewer";
const focusLine = focus ? `Focus: ${focus}\n` : "";
Expand Down
Loading