Skip to content

Commit 952bf96

Browse files
authored
Merge pull request #24 from sendbird/jin/release-1.0.5
Release 1.0.5
2 parents 1f39b74 + c8d1e6f commit 952bf96

17 files changed

Lines changed: 333 additions & 33 deletions

.codex-plugin/plugin.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "cc",
3-
"version": "1.0.4",
3+
"version": "1.0.5",
44
"description": "Claude Code Plugin for Codex. Delegate code reviews, investigations, and tracked tasks to Claude Code from inside Codex.",
55
"author": {
66
"name": "Sendbird, Inc.",

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,12 @@
11
# Changelog
22

3+
## v1.0.5
4+
5+
- Keep built-in background review jobs attached to the parent Codex session so plain `$cc:status` and `$cc:result` stay intuitive after nested rescue/review flows.
6+
- Make `$cc:status --all` show the full job history for the current repository workspace instead of staying session-scoped.
7+
- Harden large-diff review and hook fingerprinting so oversized `git diff` output degrades cleanly instead of failing with `ENOBUFS`.
8+
- Clarify README guidance around review visibility, large diffs, and the difference between session-scoped status and repository-wide status.
9+
310
## v1.0.4
411

512
- Make background built-in rescue/review completions steer users to `$cc:result <job-id>` instead of inlining raw child output.

README.md

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,8 @@ Scope `auto` (the default) inspects `git status` and chooses between working-tre
114114

115115
In foreground, review returns the result directly. In background, the plugin uses a Codex built-in subagent, tracks the review as a job, and nudges you to open the result when it completes.
116116

117+
If the diff is too large to inline safely, the review prompt falls back to concise status/stat context and tells Claude to inspect the diff directly with read-only `git diff` commands instead of failing the run.
118+
117119
### `$cc:adversarial-review`
118120

119121
Same as `$cc:review`, but steers Claude to challenge the implementation — tradeoffs, alternative approaches, hidden assumptions.
@@ -163,10 +165,12 @@ Background rescue runs through a built-in Codex subagent. When the child finishe
163165
```text
164166
$cc:status # list active and recent jobs
165167
$cc:status task-abc123 # detailed status for one job
166-
$cc:status --all # include older jobs
168+
$cc:status --all # show all tracked jobs in this repository workspace
167169
$cc:status --wait task-abc123 # block until job completes
168170
```
169171

172+
By default, `$cc:status` shows jobs owned by the current Codex session. Use `--all` when you want the wider repository view across older or sibling sessions in the same workspace.
173+
170174
### `$cc:result`
171175

172176
```text
@@ -205,7 +209,7 @@ All review and rescue commands support `--background`. Background jobs are track
205209
3. **Completion nudges** — when a background built-in flow finishes, the plugin tries to nudge the parent thread with the right `$cc:result <job-id>`. If that nudge cannot surface cleanly, unread-result hooks are the backstop.
206210
The nudge is intentionally just a pointer. The actual stored result still opens through `$cc:result`.
207211
4. **Unread-result fallback** — when you submit your next prompt after a finished unread job, Codex can remind you that a result is waiting and point you to `$cc:status` / `$cc:result`.
208-
5. **Session ownership** — jobs are scoped to the Codex session that created them. Nested sessions do not steal ownership of parent jobs.
212+
5. **Session ownership** — jobs stay attached to the user-facing parent Codex session even when a built-in rescue/review child does the actual work, so plain `$cc:status` still shows the job you just launched.
209213
6. **Cleanup on exit** — when your Codex session ends, any still-running detached jobs are terminated via PID identity validation, and stale reserved job markers are cleaned up over time.
210214

211215
**Typical background flow:**
@@ -317,6 +321,18 @@ $cc:result
317321
```
318322
The built-in notify path is best-effort. The tracked job store and unread hook remain the reliable fallback.
319323

324+
If you think the job may belong to an older session in the same repository, use:
325+
```text
326+
$cc:status --all
327+
```
328+
329+
**Large review diff caused a failure or was omitted**
330+
That is expected on very large diffs. The plugin now degrades to a compact review context and points Claude toward read-only `git diff` commands instead of trying to inline everything. If you want the full picture, run a narrower review such as:
331+
```text
332+
$cc:review --base main
333+
$cc:review --scope working-tree
334+
```
335+
320336
**Review gate draining tokens**
321337
Disable it: `$cc:setup --disable-review-gate`. The gate fires on every Ctrl+C, which adds up.
322338

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "cc-plugin-codex",
3-
"version": "1.0.4",
3+
"version": "1.0.5",
44
"description": "Claude Code Plugin for Codex by Sendbird",
55
"type": "module",
66
"author": {

scripts/claude-companion.mjs

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,12 @@ function resolveExplicitJobId(value, workspaceRoot) {
176176
return explicitJobId;
177177
}
178178

179+
function resolveOwnerSessionId(value) {
180+
const trimmed = value == null ? "" : String(value).trim();
181+
if (!trimmed) return null;
182+
return sanitizeId(trimmed, "session ID");
183+
}
184+
179185
async function withReleasedReservation(workspaceRoot, explicitJobId, fn) {
180186
try {
181187
return await fn();
@@ -1099,7 +1105,7 @@ async function resolveLatestResumableSession(cwd, options = {}) {
10991105

11001106
async function handleReviewCommand(argv, config) {
11011107
const { options, positionals } = parseCommandInput(argv, {
1102-
valueOptions: ["base", "scope", "model", "cwd", "view-state", "job-id"],
1108+
valueOptions: ["base", "scope", "model", "cwd", "view-state", "job-id", "owner-session-id"],
11031109
booleanOptions: ["json", "background", "wait"],
11041110
aliasMap: {
11051111
m: "model"
@@ -1115,6 +1121,7 @@ async function handleReviewCommand(argv, config) {
11151121
scope: options.scope
11161122
});
11171123
const explicitJobId = resolveExplicitJobId(options["job-id"], workspaceRoot);
1124+
const ownerSessionId = resolveOwnerSessionId(options["owner-session-id"]);
11181125
const markViewedOnSuccess = resolveMarkViewedOnSuccess(
11191126
options["view-state"],
11201127
Boolean(options.background)
@@ -1132,6 +1139,7 @@ async function handleReviewCommand(argv, config) {
11321139
workspaceRoot,
11331140
jobClass: "review",
11341141
summary: metadata.summary,
1142+
sessionId: ownerSessionId,
11351143
explicitJobId
11361144
});
11371145

@@ -1234,7 +1242,7 @@ async function handleTask(argv) {
12341242
ensureClaudeReady(cwd);
12351243

12361244
const write = Boolean(options.write);
1237-
const ownerSessionId = options["owner-session-id"] || null;
1245+
const ownerSessionId = resolveOwnerSessionId(options["owner-session-id"]);
12381246
const explicitJobId = resolveExplicitJobId(options["job-id"], workspaceRoot);
12391247
await withReleasedReservation(workspaceRoot, explicitJobId, async () => {
12401248
const taskMetadata = buildTaskRunMetadata({

scripts/lib/git.mjs

Lines changed: 110 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,12 @@ import { createHash } from "node:crypto";
77
import path from "node:path";
88

99
import { isProbablyText } from "./fs.mjs";
10-
import { runCommand, runCommandChecked } from "./process.mjs";
10+
import { formatCommandFailure, runCommand, runCommandChecked } from "./process.mjs";
1111

1212
const MAX_UNTRACKED_BYTES = 24 * 1024;
1313
const MAX_INLINE_REVIEW_DIFF_BYTES = 64 * 1024;
14+
const REVIEW_DIFF_READ_MAX_BUFFER = MAX_INLINE_REVIEW_DIFF_BYTES + 8 * 1024;
15+
const HASH_OBJECT_BATCH_SIZE = 128;
1416

1517
function git(cwd, args, options = {}) {
1618
return runCommand("git", args, { cwd, ...options });
@@ -109,17 +111,16 @@ function buildUntrackedMetadataFingerprint(repoRoot, relativePaths) {
109111

110112
export function getWorkingTreeFingerprint(cwd) {
111113
const repoRoot = getRepoRoot(cwd);
112-
const stagedDiff = gitChecked(repoRoot, [
114+
const stagedDiffHash = gitChecked(repoRoot, ["write-tree"]).stdout.trim();
115+
const unstaged = gitChecked(repoRoot, [
113116
"diff",
114-
"--cached",
117+
"--name-only",
115118
"--no-ext-diff",
116-
"--submodule=diff",
117-
]).stdout;
118-
const unstagedDiff = gitChecked(repoRoot, [
119-
"diff",
120-
"--no-ext-diff",
121-
"--submodule=diff",
122-
]).stdout;
119+
"-z",
120+
]).stdout
121+
.split("\0")
122+
.filter(Boolean)
123+
.sort();
123124
const untracked = gitChecked(repoRoot, [
124125
"ls-files",
125126
"--others",
@@ -130,8 +131,7 @@ export function getWorkingTreeFingerprint(cwd) {
130131
.filter(Boolean)
131132
.sort();
132133

133-
const stagedDiffHash = hashText(stagedDiff);
134-
const unstagedDiffHash = hashText(unstagedDiff);
134+
const unstagedDiffHash = hashWorkingTreePaths(repoRoot, unstaged);
135135
const untrackedFingerprintHash = buildUntrackedMetadataFingerprint(
136136
repoRoot,
137137
untracked
@@ -155,6 +155,75 @@ export function getWorkingTreeFingerprint(cwd) {
155155
};
156156
}
157157

158+
function hashWorkingTreePaths(repoRoot, relativePaths) {
159+
const hash = createHash("sha256");
160+
const regularPaths = [];
161+
162+
for (const relativePath of relativePaths) {
163+
hash.update(relativePath, "utf8");
164+
hash.update("\0", "utf8");
165+
166+
const absolutePath = path.join(repoRoot, relativePath);
167+
try {
168+
const stat = fs.lstatSync(absolutePath);
169+
if (stat.isDirectory()) {
170+
hash.update("directory", "utf8");
171+
hash.update("\0", "utf8");
172+
hash.update(String(Math.trunc(stat.mtimeMs)), "utf8");
173+
hash.update("\0", "utf8");
174+
continue;
175+
}
176+
177+
if (stat.isSymbolicLink()) {
178+
hash.update("symlink", "utf8");
179+
hash.update("\0", "utf8");
180+
hash.update(fs.readlinkSync(absolutePath), "utf8");
181+
hash.update("\0", "utf8");
182+
continue;
183+
}
184+
185+
regularPaths.push(relativePath);
186+
} catch (error) {
187+
if (error?.code === "ENOENT") {
188+
hash.update("deleted", "utf8");
189+
} else {
190+
throw error;
191+
}
192+
}
193+
hash.update("\0", "utf8");
194+
}
195+
196+
const blobHashes = readBlobHashes(repoRoot, regularPaths);
197+
for (const relativePath of regularPaths) {
198+
hash.update(blobHashes.get(relativePath), "utf8");
199+
hash.update("\0", "utf8");
200+
}
201+
202+
return hash.digest("hex");
203+
}
204+
205+
function readBlobHashes(repoRoot, relativePaths) {
206+
const hashes = new Map();
207+
for (let index = 0; index < relativePaths.length; index += HASH_OBJECT_BATCH_SIZE) {
208+
const batch = relativePaths.slice(index, index + HASH_OBJECT_BATCH_SIZE);
209+
const stdout = gitChecked(repoRoot, ["hash-object", "--no-filters", "--", ...batch]).stdout;
210+
const digestLines = stdout
211+
.trim()
212+
.split("\n")
213+
.map((line) => line.trim())
214+
.filter(Boolean);
215+
if (digestLines.length !== batch.length) {
216+
throw new Error(
217+
`git hash-object returned ${digestLines.length} hashes for ${batch.length} path(s).`
218+
);
219+
}
220+
batch.forEach((relativePath, batchIndex) => {
221+
hashes.set(relativePath, digestLines[batchIndex]);
222+
});
223+
}
224+
return hashes;
225+
}
226+
158227
export function resolveReviewTarget(cwd, options = {}) {
159228
ensureGitRepository(cwd);
160229

@@ -259,25 +328,44 @@ function shouldInlineReviewDiff(...sections) {
259328
return true;
260329
}
261330

331+
function readBoundedGitDiff(cwd, args) {
332+
const result = git(cwd, args, { maxBuffer: REVIEW_DIFF_READ_MAX_BUFFER });
333+
if (result.error) {
334+
if (result.error.code === "ENOBUFS") {
335+
return { text: "", tooLarge: true };
336+
}
337+
throw new Error(
338+
`${result.command} ${result.args.join(" ")}: ${result.error.message}`
339+
);
340+
}
341+
if (result.status !== 0) {
342+
throw new Error(formatCommandFailure(result));
343+
}
344+
return { text: result.stdout, tooLarge: false };
345+
}
346+
262347
function collectWorkingTreeContext(cwd, state) {
263348
const status = gitChecked(cwd, ["status", "--short"]).stdout.trim();
264-
const stagedDiff = gitChecked(cwd, ["diff", "--cached", "--no-ext-diff", "--submodule=diff"]).stdout;
265-
const unstagedDiff = gitChecked(cwd, ["diff", "--no-ext-diff", "--submodule=diff"]).stdout;
266349
const untrackedBody = state.untracked.map((file) => formatUntrackedFile(cwd, file)).join("\n\n");
267-
const inlineDiffs = shouldInlineReviewDiff(status, stagedDiff, unstagedDiff, untrackedBody);
350+
const stagedDiff = readBoundedGitDiff(cwd, ["diff", "--cached", "--no-ext-diff", "--submodule=diff"]);
351+
const unstagedDiff = readBoundedGitDiff(cwd, ["diff", "--no-ext-diff", "--submodule=diff"]);
352+
const inlineDiffs =
353+
!stagedDiff.tooLarge &&
354+
!unstagedDiff.tooLarge &&
355+
shouldInlineReviewDiff(status, stagedDiff.text, unstagedDiff.text, untrackedBody);
268356

269357
const parts = [
270358
formatSection("Git Status", status),
271359
formatSection(
272360
"Staged Diff",
273361
inlineDiffs
274-
? stagedDiff
362+
? stagedDiff.text
275363
: "Large diff omitted. Inspect staged changes directly with read-only git commands such as `git diff --cached --no-ext-diff --submodule=diff`."
276364
),
277365
formatSection(
278366
"Unstaged Diff",
279367
inlineDiffs
280-
? unstagedDiff
368+
? unstagedDiff.text
281369
: "Large diff omitted. Inspect unstaged changes directly with read-only git commands such as `git diff --no-ext-diff --submodule=diff`."
282370
),
283371
formatSection("Untracked Files", untrackedBody)
@@ -296,8 +384,10 @@ function collectBranchContext(cwd, baseRef) {
296384
const currentBranch = getCurrentBranch(cwd);
297385
const logOutput = gitChecked(cwd, ["log", "--oneline", "--decorate", commitRange]).stdout.trim();
298386
const diffStat = gitChecked(cwd, ["diff", "--stat", commitRange]).stdout.trim();
299-
const diff = gitChecked(cwd, ["diff", "--no-ext-diff", "--submodule=diff", commitRange]).stdout;
300-
const inlineDiff = shouldInlineReviewDiff(logOutput, diffStat, diff);
387+
const diff = readBoundedGitDiff(cwd, ["diff", "--no-ext-diff", "--submodule=diff", commitRange]);
388+
const inlineDiff =
389+
!diff.tooLarge &&
390+
shouldInlineReviewDiff(logOutput, diffStat, diff.text);
301391

302392
return {
303393
mode: "branch",
@@ -308,7 +398,7 @@ function collectBranchContext(cwd, baseRef) {
308398
formatSection(
309399
"Branch Diff",
310400
inlineDiff
311-
? diff
401+
? diff.text
312402
: `Large diff omitted. Inspect the branch diff directly with read-only git commands such as \`git diff --no-ext-diff --submodule=diff ${commitRange}\`.`
313403
)
314404
].join("\n")

scripts/lib/job-control.mjs

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -156,10 +156,12 @@ export function buildStatusSnapshot(cwd, options = {}) {
156156
const workspaceRoot = resolveWorkspaceRoot(cwd);
157157
const config = getConfig(workspaceRoot);
158158
const jobs = sortJobsNewestFirst(
159-
filterJobsForCurrentSession(listJobs(workspaceRoot), {
160-
...options,
161-
cwd: workspaceRoot,
162-
})
159+
options.all
160+
? listJobs(workspaceRoot)
161+
: filterJobsForCurrentSession(listJobs(workspaceRoot), {
162+
...options,
163+
cwd: workspaceRoot,
164+
})
163165
);
164166
const maxJobs = options.maxJobs ?? DEFAULT_MAX_STATUS_JOBS;
165167
const maxProgressLines = options.maxProgressLines ?? DEFAULT_MAX_PROGRESS_LINES;

scripts/lib/process.mjs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ export function runCommand(command, args = [], options = {}) {
1313
env: options.env,
1414
encoding: "utf8",
1515
input: options.input,
16+
maxBuffer: options.maxBuffer,
1617
stdio: options.stdio ?? "pipe",
1718
shell: false
1819
});

skills/adversarial-review/SKILL.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ Background flow:
5858
- Before spawning the built-in child, reserve a review job id by running:
5959
`node "<plugin-root>/scripts/claude-companion.mjs" review-reserve-job --json`
6060
- If that helper returns a non-empty `jobId`, pass it into the companion command as an internal `--job-id <reserved-job-id>` routing flag.
61+
- Add an internal `--owner-session-id <parent-session-id>` routing flag when spawning the built-in child so the tracked review job stays visible in the parent Codex session's plain `$cc:status`.
6162
- If the built-in review is running in background, the parent should first capture its own thread id by running:
6263
`node -e "process.stdout.write(process.env.CODEX_THREAD_ID || '')"`
6364
- If that command returns a non-empty thread id, pass it into the child prompt as the parent thread id for one-shot completion notification.
@@ -76,6 +77,7 @@ Background flow:
7677
- run exactly one shell command
7778
- execute:
7879
`node "<plugin-root>/scripts/claude-companion.mjs" adversarial-review --view-state defer <arguments with --wait/--background removed>`
80+
- include `--owner-session-id <parent-session-id>` so background review jobs stay attached to the parent session
7981
- include `--job-id <reserved-job-id>` when the parent reserved one
8082
- return only that command's stdout exactly, with no added commentary
8183
- ignore stderr progress chatter such as `[cc] ...` lines and preserve only the final stdout-equivalent result text

0 commit comments

Comments
 (0)