ccgx-workflow 2.4.1 → 2.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +134 -277
- package/README.zh-CN.md +134 -272
- package/dist/chunks/version-build.mjs +1 -1
- package/dist/cli.mjs +1 -1
- package/dist/index.d.mts +709 -16
- package/dist/index.d.ts +709 -16
- package/dist/index.mjs +1061 -30
- package/dist/shared/{ccgx-workflow.j1spUsik.mjs → ccgx-workflow.CdHnJLak.mjs} +106 -22
- package/package.json +1 -1
- package/templates/commands/agents/code-fixer.md +6 -6
- package/templates/commands/agents/phase-runner.md +46 -14
- package/templates/commands/agents/plan-checker.md +10 -0
- package/templates/commands/analyze.md +66 -25
- package/templates/commands/autonomous.md +428 -225
- package/templates/commands/cancel.md +9 -0
- package/templates/commands/codex-exec.md +12 -11
- package/templates/commands/context.md +14 -0
- package/templates/commands/debate.md +10 -6
- package/templates/commands/execute.md +76 -28
- package/templates/commands/optimize.md +53 -25
- package/templates/commands/plan.md +78 -28
- package/templates/commands/review.md +26 -19
- package/templates/commands/spec-impl.md +68 -127
- package/templates/commands/spec-plan.md +61 -82
- package/templates/commands/spec-research.md +35 -92
- package/templates/commands/spec-review.md +34 -119
- package/templates/commands/status.md +1 -0
- package/templates/commands/team-exec.md +45 -13
- package/templates/commands/team.md +64 -167
- package/templates/commands/test.md +56 -34
- package/templates/commands/verify-work.md +36 -13
- package/templates/commands/verify.md +35 -0
- package/templates/commands/workflow.md +22 -37
- package/templates/hooks/ccg-loop-detector.cjs +39 -8
- package/templates/hooks/ccg-statusline.js +142 -2
- package/templates/hooks/ccg-stop-gate.cjs +248 -19
- package/templates/hooks/ccg-subagent-context.cjs +505 -0
- package/templates/scripts/ccg-state-lock.cjs +510 -0
- package/templates/scripts/ccg-team-schedule.cjs +328 -0
- package/templates/scripts/ccgx-call-plugin.mjs +494 -141
- package/templates/scripts/invoke-model.mjs +28 -1
- package/templates/scripts/task-store.cjs +614 -0
- package/templates/skills/tools/verify-change/SKILL.md +7 -0
- package/templates/skills/tools/verify-module/SKILL.md +7 -0
- package/templates/skills/tools/verify-quality/SKILL.md +7 -0
- package/templates/skills/tools/verify-security/SKILL.md +8 -0
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
// =============================================================================
|
|
3
|
-
// ccgx-call-plugin.mjs 1.0
|
|
3
|
+
// ccgx-call-plugin.mjs 1.1.0
|
|
4
|
+
// -----------------------------------------------------------------------------
|
|
5
|
+
// 1.1.0: gemini-ccgx fork version gate (>=1.3.0) + gemini --prompt-file path,
|
|
6
|
+
// --timeout-ms passthrough + win32 tree-kill, upstream gemini-companion
|
|
7
|
+
// fallback (positional prompt + warnings), try/finally tmp cleanup.
|
|
4
8
|
// -----------------------------------------------------------------------------
|
|
5
9
|
// Pure-Node helper for invoking codex/gemini plugin companions WITHOUT any
|
|
6
10
|
// shell-escape risk. Replaces the 1.0.4 heredoc-via-Bash pattern.
|
|
@@ -27,25 +31,44 @@
|
|
|
27
31
|
// --prompt-file /tmp/ccg-codex-1234.txt
|
|
28
32
|
// 3. Parse the JSON output emitted to stdout
|
|
29
33
|
//
|
|
30
|
-
// Output schema (always JSON, even on error):
|
|
34
|
+
// Output schema (always JSON, even on error) — THREE layers, flattened:
|
|
35
|
+
//
|
|
36
|
+
// Layer 1 (this helper, the OUTER envelope):
|
|
31
37
|
// {
|
|
32
38
|
// "status": "ok" | "error",
|
|
33
39
|
// "vendor": "codex" | "gemini",
|
|
34
40
|
// "version": "<plugin version>",
|
|
35
|
-
// "durationMs": <number>,
|
|
41
|
+
// "durationMs": <number>, // helper-measured wall time
|
|
36
42
|
// "exitCode": <number | null>,
|
|
37
|
-
// "stdout": "<companion stdout>",
|
|
43
|
+
// "stdout": "<companion stdout>", // raw Layer-2 JSON string (kept for debug)
|
|
38
44
|
// "stderr": "<companion stderr>",
|
|
39
|
-
// "error": "<
|
|
45
|
+
// "error": "<message if status=error, else absent>",
|
|
46
|
+
// "warnings": ["..."], // only when flags were dropped (upstream
|
|
47
|
+
// // gemini-companion fallback), else absent
|
|
48
|
+
//
|
|
49
|
+
// // ---- flattened from Layer 2 (companion envelope) when --json & parseable:
|
|
50
|
+
// "text": "<model textual output>", // codex/gemini final answer (rawOutput)
|
|
51
|
+
// "threadId": "<session id | null>", // for resume; null if absent
|
|
52
|
+
// "stats": { ... } | null // tokens / latency etc, vendor-verbatim
|
|
40
53
|
// }
|
|
41
54
|
//
|
|
55
|
+
// Layer 2 = the companion's own JSON envelope, emitted on its stdout. For
|
|
56
|
+
// gemini-batch it is {status, threadId, rawOutput, stats, ...}; for
|
|
57
|
+
// codex-companion `task --json` it is the job-result envelope. The helper
|
|
58
|
+
// JSON.parse(stdout)s it and lifts rawOutput→text, session→threadId,
|
|
59
|
+
// stats→stats so the LLM never has to reach into a nested string.
|
|
60
|
+
// Layer 3 = the model's textual answer itself, already surfaced as `.text`.
|
|
61
|
+
//
|
|
42
62
|
// CLI args:
|
|
43
|
-
// <vendor>
|
|
44
|
-
// --prompt-file <path>
|
|
45
|
-
// --json
|
|
46
|
-
// --no-json
|
|
47
|
-
// --timeout-ms <N>
|
|
48
|
-
// --
|
|
63
|
+
// <vendor> Required. 'codex' or 'gemini'.
|
|
64
|
+
// --prompt-file <path> Required. Path to file containing prompt body.
|
|
65
|
+
// --json Pass --json to companion (default: true).
|
|
66
|
+
// --no-json Disable --json (text output).
|
|
67
|
+
// --timeout-ms <N> Kill companion if total wall-time exceeds N ms (default: 7200000, 2h; pass 0 to disable).
|
|
68
|
+
// --resume-last Resume the most recent thread instead of a fresh one (codex live; gemini requires fork gemini-ccgx >=1.3.0).
|
|
69
|
+
// --include-directories <l> Extra read dirs for gemini (comma list); requires fork gemini-ccgx >=1.3.0; ignored by codex.
|
|
70
|
+
// --mcp-allow <names> Selectively re-enable named MCP servers (comma list); requires fork gemini-ccgx >=1.3.0; empty = vendor default (codex keeps, gemini suppresses all).
|
|
71
|
+
// --max-budget-usd <N> NO-OP soft hint (no plugin reads it today; see C10).
|
|
49
72
|
//
|
|
50
73
|
// Cross-cutting:
|
|
51
74
|
// - Pure stdlib (fs, child_process, path, os). No deps.
|
|
@@ -54,9 +77,10 @@
|
|
|
54
77
|
// - spawn uses array args + windowsHide:true → zero shell escape surface.
|
|
55
78
|
// =============================================================================
|
|
56
79
|
|
|
57
|
-
import {
|
|
58
|
-
import {
|
|
59
|
-
import {
|
|
80
|
+
import { randomUUID } from 'node:crypto'
|
|
81
|
+
import { spawn, spawnSync } from 'node:child_process'
|
|
82
|
+
import { existsSync, readFileSync, realpathSync, unlinkSync, writeFileSync } from 'node:fs'
|
|
83
|
+
import { homedir, tmpdir } from 'node:os'
|
|
60
84
|
import { basename, join, resolve, sep } from 'node:path'
|
|
61
85
|
import { fileURLToPath } from 'node:url'
|
|
62
86
|
|
|
@@ -94,6 +118,49 @@ const VENDOR_ENTRY_SCRIPTS = {
|
|
|
94
118
|
gemini: ['gemini-batch.mjs', 'gemini-companion.mjs'],
|
|
95
119
|
}
|
|
96
120
|
|
|
121
|
+
// ---------------------------------------------------------------------------
|
|
122
|
+
// gemini-ccgx fork capability gate.
|
|
123
|
+
//
|
|
124
|
+
// The fork's flag surface is version-dependent:
|
|
125
|
+
// >=1.3.0 : --prompt-file / --resume-last / --mcp-allow / --include-directories
|
|
126
|
+
// / --timeout-ms / --idle-timeout-ms are all live
|
|
127
|
+
// <1.3.0 : --resume-last is a SILENT NO-OP, --mcp-allow and
|
|
128
|
+
// --include-directories THROW unknown-flag, prompt must inline
|
|
129
|
+
// via -p (subject to the Windows ~32K argv ceiling)
|
|
130
|
+
// ---------------------------------------------------------------------------
|
|
131
|
+
|
|
132
|
+
const MIN_GEMINI_FORK_VERSION = '1.3.0'
|
|
133
|
+
|
|
134
|
+
// Hard ceiling for prompts inlined into a single argv element. Windows'
|
|
135
|
+
// CreateProcess command line tops out at 32767 chars and Node spawn throws
|
|
136
|
+
// ENAMETOOLONG synchronously past it; 30000 leaves headroom for the
|
|
137
|
+
// companion path + remaining flags.
|
|
138
|
+
const INLINE_PROMPT_MAX_CHARS = 30000
|
|
139
|
+
|
|
140
|
+
// Dependency-free semver compare → -1 / 0 / 1. Numeric triplets only;
|
|
141
|
+
// non-numeric segments (and 'unknown') count as 0, so an unparseable
|
|
142
|
+
// version conservatively sorts below every real release.
|
|
143
|
+
function compareSemver(a, b) {
|
|
144
|
+
const pa = String(a).split('.')
|
|
145
|
+
const pb = String(b).split('.')
|
|
146
|
+
for (let i = 0; i < 3; i++) {
|
|
147
|
+
const x = Number.parseInt(pa[i], 10)
|
|
148
|
+
const y = Number.parseInt(pb[i], 10)
|
|
149
|
+
const xv = Number.isFinite(x) ? x : 0
|
|
150
|
+
const yv = Number.isFinite(y) ? y : 0
|
|
151
|
+
if (xv !== yv) return xv < yv ? -1 : 1
|
|
152
|
+
}
|
|
153
|
+
return 0
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function isGeminiBatchFork(disc) {
|
|
157
|
+
return disc.entryName === 'gemini-batch.mjs'
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function geminiForkReady(disc) {
|
|
161
|
+
return isGeminiBatchFork(disc) && compareSemver(disc.version, MIN_GEMINI_FORK_VERSION) >= 0
|
|
162
|
+
}
|
|
163
|
+
|
|
97
164
|
// ---------------------------------------------------------------------------
|
|
98
165
|
// Argument parsing — minimal, KISS, no external CLI lib.
|
|
99
166
|
// ---------------------------------------------------------------------------
|
|
@@ -123,6 +190,9 @@ function parseArgs(argv) {
|
|
|
123
190
|
// task class is supposed to stream (e.g. future protocol additions)
|
|
124
191
|
// can pass --idle-timeout-ms <N> explicitly.
|
|
125
192
|
idleTimeoutMs: 0,
|
|
193
|
+
// C10: NO-OP soft hint. Audited 2026-06: neither codex-companion 1.0.x nor
|
|
194
|
+
// gemini-batch 1.2.x has a single read point for CODEX/GEMINI_MAX_BUDGET_USD.
|
|
195
|
+
// Kept only so callers/templates don't break; real cost lives in stats.tokens.
|
|
126
196
|
maxBudgetUsd: 50,
|
|
127
197
|
// 1.0.5 regression fix: pre-1.0.5 callers always passed --write directly
|
|
128
198
|
// to codex-companion. Default true preserves BC; --no-write opt-out for
|
|
@@ -131,6 +201,19 @@ function parseArgs(argv) {
|
|
|
131
201
|
model: null,
|
|
132
202
|
effort: null,
|
|
133
203
|
cwd: null,
|
|
204
|
+
// C5: resume the most recent thread (codex --resume-last is live; gemini
|
|
205
|
+
// forwards `gemini -r latest` with fork gemini-ccgx >=1.3.0 — older forks
|
|
206
|
+
// are hard-blocked by checkGeminiPreflight instead of silently no-op'ing).
|
|
207
|
+
resumeLast: false,
|
|
208
|
+
// C9: extra read dirs forwarded to gemini-cli --include-directories (comma
|
|
209
|
+
// list, fork gemini-ccgx >=1.3.0); codex ignores (its sandbox is
|
|
210
|
+
// single-cwd, no --add-dir surface in companion).
|
|
211
|
+
includeDirectories: null,
|
|
212
|
+
// C8: selective MCP allowlist (comma list of server names, fork
|
|
213
|
+
// gemini-ccgx >=1.3.0). Empty/null = vendor default (codex keeps its
|
|
214
|
+
// config, gemini-batch suppresses ALL via its __ccgx_no_mcp__ sentinel).
|
|
215
|
+
// Set to re-enable only retrieval servers.
|
|
216
|
+
mcpAllow: null,
|
|
134
217
|
}
|
|
135
218
|
|
|
136
219
|
const positional = []
|
|
@@ -153,6 +236,9 @@ function parseArgs(argv) {
|
|
|
153
236
|
case '--model': opts.model = next(); break
|
|
154
237
|
case '--effort': opts.effort = next(); break
|
|
155
238
|
case '--cwd': opts.cwd = next(); break
|
|
239
|
+
case '--resume-last': opts.resumeLast = true; break
|
|
240
|
+
case '--include-directories': opts.includeDirectories = next(); break
|
|
241
|
+
case '--mcp-allow': opts.mcpAllow = next(); break
|
|
156
242
|
case '--help':
|
|
157
243
|
case '-h':
|
|
158
244
|
printHelp()
|
|
@@ -188,14 +274,20 @@ Optional:
|
|
|
188
274
|
--no-json Disable --json (text output)
|
|
189
275
|
--timeout-ms <N> Kill companion if total wall-time exceeds N ms (default: 7200000, 2h; pass 0 to disable)
|
|
190
276
|
--idle-timeout-ms <N> Kill companion if no stdout/stderr output for N ms (default: 0 = disabled). NOTE: codex-companion and gemini-batch both emit NOTHING to stdout/stderr during execution (progress lives in internal state files); idle defaults to disabled to avoid false-positive kills of healthy long tasks. Set to a positive value only if your task class is known to stream.
|
|
191
|
-
--max-budget-usd <N>
|
|
277
|
+
--max-budget-usd <N> NO-OP soft hint (default: 50). No plugin reads it today; kept for BC. Real cost surfaces in output .stats.tokens.
|
|
192
278
|
--write Enable workspace-write sandbox (default: true; codex needs this to spawn read commands too under default approval policy)
|
|
193
279
|
--no-write Force read-only sandbox (companion approval-policy will decline most commands)
|
|
194
|
-
--model <name> Override model
|
|
195
|
-
--effort <level> Reasoning effort: none|minimal|low|medium|high|xhigh (codex only)
|
|
280
|
+
--model <name> Override model. codex: forwarded as --model. gemini: forwarded as -m to gemini-cli (NOT ignored). flash/fast aliases route to the lighter tier.
|
|
281
|
+
--effort <level> Reasoning effort: none|minimal|low|medium|high|xhigh (codex only; gemini ignores)
|
|
196
282
|
--cwd <path> Override working directory for the companion call. codex sandbox is cwd-bound; set this when auditing a repo other than the caller's cwd.
|
|
197
|
-
|
|
198
|
-
|
|
283
|
+
--resume-last Resume the most recent thread instead of starting fresh. codex: live (--resume-last). gemini: requires fork gemini-ccgx >=1.3.0 (older installs hard-error with an upgrade hint). Prefer explicit threadId in parallel runs (latest can pick the wrong thread).
|
|
284
|
+
--include-directories <list> Extra read-only dirs for gemini-cli (comma list). Requires fork gemini-ccgx >=1.3.0. codex ignores (no --add-dir in companion).
|
|
285
|
+
--mcp-allow <names> Selectively re-enable named MCP servers (comma list). Requires fork gemini-ccgx >=1.3.0. Empty = vendor default (codex keeps config; gemini suppresses ALL). Pass only retrieval servers to keep gemini batch startup fast.
|
|
286
|
+
|
|
287
|
+
Outputs JSON to stdout (flattened 3-layer envelope):
|
|
288
|
+
{status, vendor, version, durationMs, exitCode, stdout, stderr, text, threadId, stats, warnings?, error?}
|
|
289
|
+
.text = model output (codex/gemini rawOutput), .threadId = resume handle, .stats = tokens/latency.
|
|
290
|
+
.warnings only appears when flags were dropped (upstream gemini-companion fallback).
|
|
199
291
|
`)
|
|
200
292
|
}
|
|
201
293
|
|
|
@@ -243,10 +335,12 @@ function discoverCompanion(vendor, homeDir = homedir()) {
|
|
|
243
335
|
// BC: vendors with only the legacy companion still resolve correctly.
|
|
244
336
|
const entryCandidates = VENDOR_ENTRY_SCRIPTS[vendor] ?? [`${vendor}-companion.mjs`]
|
|
245
337
|
let companionPath = null
|
|
338
|
+
let entryName = null
|
|
246
339
|
for (const name of entryCandidates) {
|
|
247
340
|
const candidate = join(installPath, 'scripts', name)
|
|
248
341
|
if (existsSync(candidate)) {
|
|
249
342
|
companionPath = candidate
|
|
343
|
+
entryName = name
|
|
250
344
|
break
|
|
251
345
|
}
|
|
252
346
|
}
|
|
@@ -256,6 +350,9 @@ function discoverCompanion(vendor, homeDir = homedir()) {
|
|
|
256
350
|
|
|
257
351
|
return {
|
|
258
352
|
companionPath,
|
|
353
|
+
// entryName drives the version gate + arg-building branches downstream
|
|
354
|
+
// (gemini-batch fork vs upstream gemini-companion fallback).
|
|
355
|
+
entryName,
|
|
259
356
|
version: typeof inst?.version === 'string' ? inst.version : 'unknown',
|
|
260
357
|
}
|
|
261
358
|
}
|
|
@@ -282,6 +379,178 @@ function emitError(vendor, version, error, extra = {}) {
|
|
|
282
379
|
})
|
|
283
380
|
}
|
|
284
381
|
|
|
382
|
+
// ---------------------------------------------------------------------------
|
|
383
|
+
// C7: flatten the Layer-2 companion envelope into stable top-level fields.
|
|
384
|
+
//
|
|
385
|
+
// Both vendors emit a JSON envelope on stdout when --json is set:
|
|
386
|
+
// gemini-batch: { status, threadId, rawOutput, stats, ... }
|
|
387
|
+
// codex-companion task --json: a job-result envelope (rawOutput/text + stats)
|
|
388
|
+
// The LLM should never have to reach into a nested JSON string, so we lift the
|
|
389
|
+
// model output → .text, session id → .threadId, stats → .stats. Best-effort:
|
|
390
|
+
// if stdout isn't parseable JSON (e.g. --no-json), we leave .text null and the
|
|
391
|
+
// raw stdout string remains available on .stdout.
|
|
392
|
+
// ---------------------------------------------------------------------------
|
|
393
|
+
|
|
394
|
+
function flattenCompanionEnvelope(stdout) {
|
|
395
|
+
const flat = { text: null, threadId: null, stats: null }
|
|
396
|
+
if (typeof stdout !== 'string' || stdout.trim() === '') return flat
|
|
397
|
+
let env
|
|
398
|
+
try {
|
|
399
|
+
env = JSON.parse(stdout)
|
|
400
|
+
}
|
|
401
|
+
catch {
|
|
402
|
+
return flat // not JSON (text mode / partial output) — leave nulls
|
|
403
|
+
}
|
|
404
|
+
if (!env || typeof env !== 'object') return flat
|
|
405
|
+
// Model output: gemini → rawOutput; codex → text or rawOutput.
|
|
406
|
+
if (typeof env.text === 'string') flat.text = env.text
|
|
407
|
+
else if (typeof env.rawOutput === 'string') flat.text = env.rawOutput
|
|
408
|
+
// Session/thread id for resume.
|
|
409
|
+
if (typeof env.threadId === 'string') flat.threadId = env.threadId
|
|
410
|
+
else if (typeof env.sessionId === 'string') flat.threadId = env.sessionId
|
|
411
|
+
// Stats (tokens/latency) verbatim.
|
|
412
|
+
if (env.stats !== undefined) flat.stats = env.stats
|
|
413
|
+
return flat
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
// ---------------------------------------------------------------------------
|
|
417
|
+
// Gemini preflight gate — fail fast BEFORE writing tmp files or spawning.
|
|
418
|
+
//
|
|
419
|
+
// Two classes of rejection (both better than the runtime alternative):
|
|
420
|
+
// 1. <1.3.0 fork + fork-only flag requested: --resume-last would be a
|
|
421
|
+
// SILENT no-op and --mcp-allow / --include-directories would make
|
|
422
|
+
// gemini-batch throw unknown-flag. Hard-error with an upgrade hint.
|
|
423
|
+
// 2. Inline-prompt path (fork <1.3.0 `-p`, or upstream companion
|
|
424
|
+
// positional) + prompt >30000 chars: Windows spawn would die with a
|
|
425
|
+
// bare ENAMETOOLONG at the ~32K argv ceiling. Reject with context.
|
|
426
|
+
// Returns an error string (caller emits + exits 78 EX_CONFIG) or null.
|
|
427
|
+
// ---------------------------------------------------------------------------
|
|
428
|
+
|
|
429
|
+
function checkGeminiPreflight(opts, disc, promptBody) {
|
|
430
|
+
if (opts.vendor !== 'gemini') return null
|
|
431
|
+
if (isGeminiBatchFork(disc) && !geminiForkReady(disc)) {
|
|
432
|
+
const requested = []
|
|
433
|
+
if (opts.resumeLast) requested.push('--resume-last')
|
|
434
|
+
if (opts.mcpAllow) requested.push('--mcp-allow')
|
|
435
|
+
if (opts.includeDirectories) requested.push('--include-directories')
|
|
436
|
+
if (requested.length > 0) {
|
|
437
|
+
return `installed gemini-ccgx ${disc.version} does not support ${requested.join(', ')}`
|
|
438
|
+
+ ` (--resume-last is a silent no-op, the others throw unknown-flag);`
|
|
439
|
+
+ ` update the plugin to >=${MIN_GEMINI_FORK_VERSION}`
|
|
440
|
+
}
|
|
441
|
+
if (promptBody.length > INLINE_PROMPT_MAX_CHARS) {
|
|
442
|
+
return `prompt is ${promptBody.length} chars but gemini-ccgx ${disc.version} only accepts it`
|
|
443
|
+
+ ` inlined via -p (Windows spawn throws ENAMETOOLONG past the ~32K argv ceiling);`
|
|
444
|
+
+ ` update the plugin to >=${MIN_GEMINI_FORK_VERSION} for --prompt-file support`
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
if (disc.entryName === 'gemini-companion.mjs' && promptBody.length > INLINE_PROMPT_MAX_CHARS) {
|
|
448
|
+
return `prompt is ${promptBody.length} chars but upstream gemini-companion only accepts it`
|
|
449
|
+
+ ` as a positional argv element (Windows spawn throws ENAMETOOLONG past the ~32K argv ceiling);`
|
|
450
|
+
+ ` install the gemini-ccgx fork >=${MIN_GEMINI_FORK_VERSION} for --prompt-file support`
|
|
451
|
+
}
|
|
452
|
+
return null
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
// ---------------------------------------------------------------------------
|
|
456
|
+
// C1/C5/C8/C9: build the companion argv array. Pure function (no I/O) so unit
|
|
457
|
+
// tests can assert the exact vendor-specific flag surface. Returns
|
|
458
|
+
// { args, warnings } — warnings records flags dropped on the upstream
|
|
459
|
+
// gemini-companion fallback so the caller can surface them in the envelope.
|
|
460
|
+
//
|
|
461
|
+
// codex → task --prompt-file <tmp> [--json] [--write]
|
|
462
|
+
// [--model m] [--effort e] [--resume-last] [--cwd p]
|
|
463
|
+
// (NEVER -p, NEVER inlined prompt)
|
|
464
|
+
// gemini gemini-batch >=1.3.0 → task --prompt-file <tmp> [--json] [--write]
|
|
465
|
+
// [--model m] [--resume-last] [--include-directories l] [--mcp-allow n]
|
|
466
|
+
// [--timeout-ms n] [--idle-timeout-ms n] [--cwd p]
|
|
467
|
+
// gemini gemini-batch <1.3.0 → task -p <promptBody> [--json] [--write]
|
|
468
|
+
// [--model m] [--cwd p]
|
|
469
|
+
// (fork-only flags + oversized prompts hard-blocked by checkGeminiPreflight)
|
|
470
|
+
// gemini gemini-companion (upstream fallback) → task <promptBody> [--model m]
|
|
471
|
+
// [--cwd p] (fork-only flags dropped + recorded in warnings)
|
|
472
|
+
// ---------------------------------------------------------------------------
|
|
473
|
+
|
|
474
|
+
function buildCompanionArgs(opts, disc, tmpPromptPath, promptBody) {
|
|
475
|
+
const warnings = []
|
|
476
|
+
const args = [disc.companionPath, 'task']
|
|
477
|
+
const companionFallback = opts.vendor === 'gemini' && disc.entryName === 'gemini-companion.mjs'
|
|
478
|
+
const forkReady = opts.vendor === 'gemini' && geminiForkReady(disc)
|
|
479
|
+
|
|
480
|
+
if (companionFallback) {
|
|
481
|
+
// Upstream ACP companion: positional prompt (it has no -p / --prompt-file)
|
|
482
|
+
// and a minimal flag surface — forward only --model/--cwd, drop the
|
|
483
|
+
// fork-only flags and record each drop for the result envelope.
|
|
484
|
+
args.push(promptBody)
|
|
485
|
+
if (opts.model) args.push('--model', opts.model)
|
|
486
|
+
if (opts.cwd) args.push('--cwd', opts.cwd)
|
|
487
|
+
const dropped = []
|
|
488
|
+
if (opts.resumeLast) dropped.push('--resume-last')
|
|
489
|
+
if (opts.includeDirectories) dropped.push('--include-directories')
|
|
490
|
+
if (opts.mcpAllow) dropped.push('--mcp-allow')
|
|
491
|
+
for (const flag of dropped) {
|
|
492
|
+
warnings.push(`${flag} dropped: upstream gemini-companion does not support it (install gemini-ccgx >=${MIN_GEMINI_FORK_VERSION})`)
|
|
493
|
+
}
|
|
494
|
+
return { args, warnings }
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
if (opts.vendor === 'codex') {
|
|
498
|
+
// --prompt-file is a first-class codex valueOption (companion:614). This
|
|
499
|
+
// removes both the `-p`→positional pollution and the Windows 32K argv risk.
|
|
500
|
+
args.push('--prompt-file', tmpPromptPath)
|
|
501
|
+
}
|
|
502
|
+
else if (forkReady) {
|
|
503
|
+
// fork gemini-ccgx >=1.3.0 ships the same --prompt-file valueOption, so
|
|
504
|
+
// gemini joins codex off the argv path (no 32K ceiling, no -p).
|
|
505
|
+
args.push('--prompt-file', tmpPromptPath)
|
|
506
|
+
}
|
|
507
|
+
else {
|
|
508
|
+
// gemini-batch <1.3.0 has no --prompt-file (throws on unknown flags) and
|
|
509
|
+
// requires -p (body piped over stdin internally). Size is pre-checked by
|
|
510
|
+
// checkGeminiPreflight, so this never exceeds INLINE_PROMPT_MAX_CHARS.
|
|
511
|
+
args.push('-p', promptBody)
|
|
512
|
+
}
|
|
513
|
+
if (opts.json) args.push('--json')
|
|
514
|
+
if (opts.write) args.push('--write')
|
|
515
|
+
// --model: codex forwards as --model; gemini-batch forwards as -m internally.
|
|
516
|
+
if (opts.model) args.push('--model', opts.model)
|
|
517
|
+
// --effort is codex-only (gemini-batch consumes-and-ignores it; keep it off
|
|
518
|
+
// the gemini path to avoid relying on that no-op).
|
|
519
|
+
if (opts.effort && opts.vendor === 'codex') args.push('--effort', opts.effort)
|
|
520
|
+
// C5: resume the most recent thread. codex --resume-last is live; gemini
|
|
521
|
+
// forwards `gemini -r latest` with fork gemini-ccgx >=1.3.0. Older forks are
|
|
522
|
+
// hard-blocked by checkGeminiPreflight — never emitted here defensively.
|
|
523
|
+
if (opts.resumeLast && (opts.vendor === 'codex' || forkReady)) args.push('--resume-last')
|
|
524
|
+
// C9: gemini-only extra read dirs (codex companion has no --add-dir surface),
|
|
525
|
+
// fork gemini-ccgx >=1.3.0 only (older forks throw unknown-flag).
|
|
526
|
+
if (opts.includeDirectories && forkReady) {
|
|
527
|
+
args.push('--include-directories', opts.includeDirectories)
|
|
528
|
+
}
|
|
529
|
+
// C8: selective MCP allowlist (gemini fork >=1.3.0 only). Forward real
|
|
530
|
+
// server names so retrieval MCPs (fast-context/context7) survive instead of
|
|
531
|
+
// being blanket-suppressed by gemini-batch's __ccgx_no_mcp__ sentinel.
|
|
532
|
+
// codex-companion 1.0.4 has no MCP-selection flag (its task valueOptions are
|
|
533
|
+
// model/effort/cwd/prompt-file), so MCP scoping is not forwardable to codex
|
|
534
|
+
// — emitting a flag there is a silent no-op, so we don't.
|
|
535
|
+
if (opts.mcpAllow && forkReady) {
|
|
536
|
+
args.push('--mcp-allow', opts.mcpAllow)
|
|
537
|
+
}
|
|
538
|
+
// Timeout passthrough (fork >=1.3.0): hand gemini-batch a slightly SHORTER
|
|
539
|
+
// deadline than the helper's own so its internal taskkill /T /F tree-kill
|
|
540
|
+
// fires first — a helper-side SIGTERM on win32 cannot reach gemini-cli's
|
|
541
|
+
// grandchildren (MCP servers), the fork's in-process tree-kill can.
|
|
542
|
+
if (forkReady && opts.timeoutMs > 0) {
|
|
543
|
+
args.push('--timeout-ms', String(Math.max(60000, opts.timeoutMs - 10000)))
|
|
544
|
+
}
|
|
545
|
+
if (forkReady && opts.idleTimeoutMs > 0) {
|
|
546
|
+
args.push('--idle-timeout-ms', String(Math.max(60000, opts.idleTimeoutMs - 10000)))
|
|
547
|
+
}
|
|
548
|
+
// --cwd pass-through: codex sandbox is cwd-bound. For cross-repo audits the
|
|
549
|
+
// caller must set this to the repo being audited.
|
|
550
|
+
if (opts.cwd) args.push('--cwd', opts.cwd)
|
|
551
|
+
return { args, warnings }
|
|
552
|
+
}
|
|
553
|
+
|
|
285
554
|
// ---------------------------------------------------------------------------
|
|
286
555
|
// Main: spawn companion with array args (no shell)
|
|
287
556
|
// ---------------------------------------------------------------------------
|
|
@@ -306,35 +575,6 @@ async function main(argv) {
|
|
|
306
575
|
process.exit(66) // EX_NOINPUT
|
|
307
576
|
}
|
|
308
577
|
|
|
309
|
-
// 2.1.1: auto-delete the prompt file after a successful read iff the
|
|
310
|
-
// PHYSICAL path sits inside the caller's `<cwd>/.context/tmp/` AND the
|
|
311
|
-
// filename starts with `ccg-`. This is the workflow-managed throwaway-
|
|
312
|
-
// prompt directory (see templates/commands/review.md — Step 1 writes
|
|
313
|
-
// prompts here).
|
|
314
|
-
//
|
|
315
|
-
// CRITICAL — codex audit 2026-05-12 raised: lexical `resolve()` is
|
|
316
|
-
// insufficient. If `.context` or `.context/tmp` is a symlink/junction
|
|
317
|
-
// pointing outside the workspace, OR the caller passes a path whose
|
|
318
|
-
// lexical form is `<cwd>/.context/tmp/ccg-x.txt` but its physical
|
|
319
|
-
// target is `/etc/passwd` (or worse, `C:\Windows\System32\...`), the
|
|
320
|
-
// lexical-only check would still authorize the unlink. realpathSync
|
|
321
|
-
// canonicalizes ALL symlinks/junctions/`..` segments before the
|
|
322
|
-
// whitelist compare — both for the candidate file AND the safeRoot.
|
|
323
|
-
// Two ANDed gates: physical prefix + filename prefix.
|
|
324
|
-
try {
|
|
325
|
-
const resolvedFile = realpathSync(opts.promptFile)
|
|
326
|
-
const safeRoot = realpathSync(resolve(process.cwd(), '.context', 'tmp')) + sep
|
|
327
|
-
if (resolvedFile.startsWith(safeRoot) && basename(resolvedFile).startsWith('ccg-')) {
|
|
328
|
-
unlinkSync(resolvedFile)
|
|
329
|
-
}
|
|
330
|
-
}
|
|
331
|
-
catch {
|
|
332
|
-
// Best-effort. ENOENT (.context/tmp absent, file already gone, etc.)
|
|
333
|
-
// is the dominant non-error case here; we deliberately don't surface
|
|
334
|
-
// it. Real failures (permission etc.) will leave a file behind that
|
|
335
|
-
// .gitignore catches and the user's next run overwrites.
|
|
336
|
-
}
|
|
337
|
-
|
|
338
578
|
// Discover companion via SSoT (no glob, no head -1)
|
|
339
579
|
const disc = discoverCompanion(opts.vendor)
|
|
340
580
|
if (disc.error) {
|
|
@@ -342,106 +582,212 @@ async function main(argv) {
|
|
|
342
582
|
process.exit(69) // EX_UNAVAILABLE
|
|
343
583
|
}
|
|
344
584
|
|
|
345
|
-
//
|
|
346
|
-
const
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
]
|
|
352
|
-
if (opts.json) companionArgs.push('--json')
|
|
353
|
-
// codex-companion flag pass-through (1.0.5 regression fix). gemini-companion
|
|
354
|
-
// ignores unknown flags safely; both vendors accept this surface.
|
|
355
|
-
if (opts.write) companionArgs.push('--write')
|
|
356
|
-
if (opts.model) companionArgs.push('--model', opts.model)
|
|
357
|
-
if (opts.effort) companionArgs.push('--effort', opts.effort)
|
|
358
|
-
// --cwd pass-through: codex sandbox is cwd-bound. For cross-repo audits
|
|
359
|
-
// the caller must set this to the repo being audited (codex-companion
|
|
360
|
-
// does not expose --add-dir; multi-workspace requires deeper changes).
|
|
361
|
-
if (opts.cwd) companionArgs.push('--cwd', opts.cwd)
|
|
585
|
+
// Version/flag/size gate — fail fast before tmp files or spawn.
|
|
586
|
+
const gateError = checkGeminiPreflight(opts, disc, promptBody)
|
|
587
|
+
if (gateError) {
|
|
588
|
+
emitError(opts.vendor, disc.version, gateError)
|
|
589
|
+
process.exit(78) // EX_CONFIG
|
|
590
|
+
}
|
|
362
591
|
|
|
363
|
-
|
|
592
|
+
// C1: write promptBody to a helper-owned temp file so the companion receives
|
|
593
|
+
// it via `--prompt-file <tmp>` (NOT inlined into argv). This kills two bugs:
|
|
594
|
+
// (a) Windows' 32767-char command-line ceiling — a big review/spec diff
|
|
595
|
+
// inlined as a single argv element used to blow the spawn outright.
|
|
596
|
+
// (b) codex-companion treats a bare `-p` as an UNKNOWN short flag → it
|
|
597
|
+
// falls into positionals and gets join(' ')'d into the prompt, so every
|
|
598
|
+
// codex prompt was silently prefixed with "-p ". --prompt-file is a
|
|
599
|
+
// first-class valueOption (codex-companion.mjs:614, resolved vs cwd).
|
|
600
|
+
// gemini joins this path with fork gemini-ccgx >=1.3.0 (same --prompt-file
|
|
601
|
+
// flag). Older gemini-batch (<1.3.0) and the upstream gemini-companion
|
|
602
|
+
// fallback still inline the prompt into argv — for those the 32K risk is
|
|
603
|
+
// bounded by checkGeminiPreflight's explicit >30000-char rejection above.
|
|
604
|
+
let tmpPromptPath = null
|
|
605
|
+
try {
|
|
606
|
+
tmpPromptPath = join(tmpdir(), `ccgx-prompt-${randomUUID()}.txt`)
|
|
607
|
+
writeFileSync(tmpPromptPath, promptBody, 'utf-8')
|
|
608
|
+
}
|
|
609
|
+
catch (err) {
|
|
610
|
+
emitError(opts.vendor, disc.version, `temp prompt write failed: ${err.message}`)
|
|
611
|
+
process.exit(73) // EX_CANTCREAT
|
|
612
|
+
}
|
|
364
613
|
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
windowsHide: true,
|
|
370
|
-
env: {
|
|
371
|
-
...process.env,
|
|
372
|
-
// Forward budget cap if companion respects it (codex does)
|
|
373
|
-
CODEX_MAX_BUDGET_USD: String(opts.maxBudgetUsd),
|
|
374
|
-
GEMINI_MAX_BUDGET_USD: String(opts.maxBudgetUsd),
|
|
375
|
-
},
|
|
376
|
-
})
|
|
614
|
+
const result = await executeCompanion(opts, disc, tmpPromptPath, promptBody)
|
|
615
|
+
emitJson(result)
|
|
616
|
+
process.exit(result.status === 'ok' ? 0 : 1)
|
|
617
|
+
}
|
|
377
618
|
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
619
|
+
// ---------------------------------------------------------------------------
|
|
620
|
+
// executeCompanion: spawn → timers → wait → flatten → result envelope.
|
|
621
|
+
//
|
|
622
|
+
// The try/finally makes temp-prompt cleanup unconditional: a SYNCHRONOUS spawn
|
|
623
|
+
// throw (e.g. Windows ENAMETOOLONG on an oversized argv) must not leak
|
|
624
|
+
// tmpPromptPath, not just the normal exit path. `deps` is a test seam
|
|
625
|
+
// (spawn/spawnSync injection); production callers pass nothing.
|
|
626
|
+
// ---------------------------------------------------------------------------
|
|
627
|
+
|
|
628
|
+
async function executeCompanion(opts, disc, tmpPromptPath, promptBody, deps = {}) {
|
|
629
|
+
const spawnImpl = deps.spawn ?? spawn
|
|
630
|
+
const spawnSyncImpl = deps.spawnSync ?? spawnSync
|
|
631
|
+
|
|
632
|
+
// Build companion argv as ARRAY — no shell layer ever (pure + testable).
|
|
633
|
+
const { args: companionArgs, warnings } = buildCompanionArgs(opts, disc, tmpPromptPath, promptBody)
|
|
389
634
|
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
635
|
+
const startedAt = Date.now()
|
|
636
|
+
try {
|
|
637
|
+
// spawn 'node' with array args. Node will look up its own executable;
|
|
638
|
+
// companionPath is passed as a literal arg, no shell interpretation.
|
|
639
|
+
// NOTE (C10): we no longer inject CODEX/GEMINI_MAX_BUDGET_USD — audited
|
|
640
|
+
// 2026-06, zero read points in either plugin. --max-budget-usd is a no-op
|
|
641
|
+
// soft hint; real cost lives in the flattened .stats.tokens.
|
|
642
|
+
const child = spawnImpl(process.execPath, companionArgs, {
|
|
643
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
644
|
+
windowsHide: true,
|
|
645
|
+
env: process.env,
|
|
646
|
+
})
|
|
647
|
+
|
|
648
|
+
let stdoutBuf = ''
|
|
649
|
+
let stderrBuf = ''
|
|
650
|
+
let lastActivityAt = Date.now()
|
|
651
|
+
child.stdout.on('data', (chunk) => {
|
|
652
|
+
stdoutBuf += chunk.toString('utf-8')
|
|
653
|
+
lastActivityAt = Date.now()
|
|
654
|
+
})
|
|
655
|
+
child.stderr.on('data', (chunk) => {
|
|
656
|
+
stderrBuf += chunk.toString('utf-8')
|
|
657
|
+
lastActivityAt = Date.now()
|
|
658
|
+
})
|
|
659
|
+
|
|
660
|
+
// 2.1.1: two-layer timeout. wall-time is the safety ceiling; idle is the
|
|
661
|
+
// primary "stuck" detector — a healthy long task keeps producing tool-call
|
|
662
|
+
// / progress chunks, a truly hung companion produces nothing.
|
|
663
|
+
//
|
|
664
|
+
// C6: a bare child.kill() is INSUFFICIENT for codex. The codex turn runs in
|
|
665
|
+
// a DETACHED broker process (codex.mjs spawns it), so killing the foreground
|
|
666
|
+
// companion leaves the turn burning tokens, the job stuck "running", and the
|
|
667
|
+
// next --resume-last blocked by "Task X is still running". The proper
|
|
668
|
+
// teardown is codex-companion's own `cancel` subcommand, which does
|
|
669
|
+
// turn/interrupt + taskkill /T /F on the broker tree (codex-companion.mjs
|
|
670
|
+
// handleCancel:920-979). So on timeout we run `cancel` FIRST, then fall back
|
|
671
|
+
// to SIGTERM/SIGKILL.
|
|
672
|
+
//
|
|
673
|
+
// gemini has no detached broker and no `cancel` subcommand, but gemini-cli
|
|
674
|
+
// spawns its own children (MCP servers, ripgrep): on win32 a bare SIGTERM
|
|
675
|
+
// kills only the foreground node and orphans that tree, so we tree-kill via
|
|
676
|
+
// taskkill /pid <pid> /T /F. Non-win32 keeps the signal path (process
|
|
677
|
+
// groups reap children there). With fork >=1.3.0 the forwarded --timeout-ms
|
|
678
|
+
// (10s shorter) normally fires gemini-batch's own tree-kill before this.
|
|
679
|
+
let timedOut = false
|
|
680
|
+
let timeoutReason = null
|
|
681
|
+
const killChild = (reason) => {
|
|
682
|
+
timedOut = true
|
|
683
|
+
timeoutReason = reason
|
|
684
|
+
if (opts.vendor === 'codex') {
|
|
685
|
+
try {
|
|
686
|
+
const cancelArgs = [disc.companionPath, 'cancel', '--json']
|
|
687
|
+
if (opts.cwd) cancelArgs.push('--cwd', opts.cwd)
|
|
688
|
+
const canceller = spawnImpl(process.execPath, cancelArgs, {
|
|
689
|
+
stdio: 'ignore',
|
|
690
|
+
windowsHide: true,
|
|
691
|
+
env: process.env,
|
|
692
|
+
})
|
|
693
|
+
canceller.unref?.()
|
|
694
|
+
}
|
|
695
|
+
catch { /* fall through to signal kill */ }
|
|
696
|
+
}
|
|
697
|
+
else if (process.platform === 'win32') {
|
|
698
|
+
try {
|
|
699
|
+
spawnSyncImpl('taskkill', ['/pid', String(child.pid), '/T', '/F'], {
|
|
700
|
+
stdio: 'ignore',
|
|
701
|
+
windowsHide: true,
|
|
702
|
+
})
|
|
703
|
+
return
|
|
416
704
|
}
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
705
|
+
catch { /* fall through to signal kill */ }
|
|
706
|
+
}
|
|
707
|
+
try { child.kill('SIGTERM') } catch { /* ignore */ }
|
|
708
|
+
setTimeout(() => {
|
|
709
|
+
try { child.kill('SIGKILL') } catch { /* ignore */ }
|
|
710
|
+
}, 5000).unref?.()
|
|
711
|
+
}
|
|
712
|
+
const wallTimer = opts.timeoutMs > 0
|
|
713
|
+
? setTimeout(() => killChild(`wall-time ${opts.timeoutMs}ms`), opts.timeoutMs)
|
|
714
|
+
: null
|
|
715
|
+
// Check idle every 30s. A 30s sampling granularity adds at most 30s slop
|
|
716
|
+
// to the configured idle threshold, which is negligible at 600s default.
|
|
717
|
+
const idleChecker = opts.idleTimeoutMs > 0
|
|
718
|
+
? setInterval(() => {
|
|
719
|
+
const silent = Date.now() - lastActivityAt
|
|
720
|
+
if (silent >= opts.idleTimeoutMs) {
|
|
721
|
+
killChild(`idle ${silent}ms exceeds ${opts.idleTimeoutMs}ms`)
|
|
722
|
+
}
|
|
723
|
+
}, 30000)
|
|
724
|
+
: null
|
|
725
|
+
if (idleChecker?.unref) idleChecker.unref()
|
|
726
|
+
|
|
727
|
+
const exit = await new Promise((resolve) => {
|
|
728
|
+
child.once('exit', (code, signal) => resolve({ code, signal }))
|
|
729
|
+
child.once('error', (err) => resolve({ code: 70, signal: null, errorMsg: err.message }))
|
|
730
|
+
})
|
|
731
|
+
if (wallTimer) clearTimeout(wallTimer)
|
|
732
|
+
if (idleChecker) clearInterval(idleChecker)
|
|
733
|
+
|
|
734
|
+
const durationMs = Date.now() - startedAt
|
|
735
|
+
const status = (!timedOut && exit.code === 0) ? 'ok' : 'error'
|
|
736
|
+
const flat = flattenCompanionEnvelope(stdoutBuf)
|
|
737
|
+
const result = {
|
|
738
|
+
status,
|
|
739
|
+
vendor: opts.vendor,
|
|
740
|
+
version: disc.version,
|
|
741
|
+
durationMs,
|
|
742
|
+
exitCode: exit.code,
|
|
743
|
+
stdout: stdoutBuf,
|
|
744
|
+
stderr: stderrBuf,
|
|
745
|
+
// C7: flattened Layer-2 fields so the LLM parses once.
|
|
746
|
+
text: flat.text,
|
|
747
|
+
threadId: flat.threadId,
|
|
748
|
+
stats: flat.stats,
|
|
749
|
+
}
|
|
750
|
+
if (warnings.length > 0) result.warnings = warnings
|
|
751
|
+
if (timedOut) result.error = `companion killed: ${timeoutReason}`
|
|
752
|
+
else if (exit.errorMsg) result.error = `spawn error: ${exit.errorMsg}`
|
|
753
|
+
else if (exit.code !== 0) result.error = `companion exited with code ${exit.code}`
|
|
754
|
+
return result
|
|
755
|
+
}
|
|
756
|
+
finally {
|
|
757
|
+
// C1 cleanup, in finally so it runs on BOTH the normal post-exit path and
|
|
758
|
+
// the synchronous-throw path (spawn ENAMETOOLONG/EPERM). Deferring past
|
|
759
|
+
// child exit is mandatory on the normal path — codex reads --prompt-file
|
|
760
|
+
// lazily, so an early unlink would race the child's open(). realpathSync
|
|
761
|
+
// canonicalizes symlinks/junctions/`..` before the whitelist compare on
|
|
762
|
+
// BOTH the candidate and safeRoot (codex audit 2026-05-12: lexical
|
|
763
|
+
// resolve() is insufficient).
|
|
764
|
+
cleanupTempFiles(tmpPromptPath, opts.promptFile)
|
|
765
|
+
}
|
|
766
|
+
}
|
|
420
767
|
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
const durationMs = Date.now() - startedAt
|
|
429
|
-
const status = (!timedOut && exit.code === 0) ? 'ok' : 'error'
|
|
430
|
-
const result = {
|
|
431
|
-
status,
|
|
432
|
-
vendor: opts.vendor,
|
|
433
|
-
version: disc.version,
|
|
434
|
-
durationMs,
|
|
435
|
-
exitCode: exit.code,
|
|
436
|
-
stdout: stdoutBuf,
|
|
437
|
-
stderr: stderrBuf,
|
|
438
|
-
}
|
|
439
|
-
if (timedOut) result.error = `companion killed: ${timeoutReason}`
|
|
440
|
-
else if (exit.errorMsg) result.error = `spawn error: ${exit.errorMsg}`
|
|
441
|
-
else if (exit.code !== 0) result.error = `companion exited with code ${exit.code}`
|
|
768
|
+
// ---------------------------------------------------------------------------
|
|
769
|
+
// Temp-file cleanup — best-effort, never throws.
|
|
770
|
+
// - tmpPromptPath: helper-owned throwaway (always safe to unlink).
|
|
771
|
+
// - sourcePromptFile: caller's file; only unlink when its PHYSICAL path is
|
|
772
|
+
// inside <cwd>/.context/tmp/ AND basename starts with ccg- (workflow-
|
|
773
|
+
// managed throwaway, see templates/commands/review.md Step 1).
|
|
774
|
+
// ---------------------------------------------------------------------------
|
|
442
775
|
|
|
443
|
-
|
|
444
|
-
|
|
776
|
+
function cleanupTempFiles(tmpPromptPath, sourcePromptFile) {
|
|
777
|
+
if (tmpPromptPath) {
|
|
778
|
+
try { unlinkSync(tmpPromptPath) } catch { /* best-effort */ }
|
|
779
|
+
}
|
|
780
|
+
try {
|
|
781
|
+
const resolvedFile = realpathSync(sourcePromptFile)
|
|
782
|
+
const safeRoot = realpathSync(resolve(process.cwd(), '.context', 'tmp')) + sep
|
|
783
|
+
if (resolvedFile.startsWith(safeRoot) && basename(resolvedFile).startsWith('ccg-')) {
|
|
784
|
+
unlinkSync(resolvedFile)
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
catch {
|
|
788
|
+
// ENOENT (.context/tmp absent / file already gone) is the dominant
|
|
789
|
+
// non-error case; real failures leave a file the .gitignore catches.
|
|
790
|
+
}
|
|
445
791
|
}
|
|
446
792
|
|
|
447
793
|
// ---------------------------------------------------------------------------
|
|
@@ -470,5 +816,12 @@ if (isMainModule()) {
|
|
|
470
816
|
export const ccgxCallPluginExports = {
|
|
471
817
|
parseArgs,
|
|
472
818
|
discoverCompanion,
|
|
819
|
+
buildCompanionArgs,
|
|
820
|
+
flattenCompanionEnvelope,
|
|
821
|
+
checkGeminiPreflight,
|
|
822
|
+
compareSemver,
|
|
823
|
+
executeCompanion,
|
|
824
|
+
INLINE_PROMPT_MAX_CHARS,
|
|
825
|
+
MIN_GEMINI_FORK_VERSION,
|
|
473
826
|
VENDOR_KEYS,
|
|
474
827
|
}
|