aiwcli 0.14.0 → 0.15.1

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.
Files changed (21) hide show
  1. package/dist/templates/_shared/.claude/skills/codex/prompt.md +25 -5
  2. package/dist/templates/_shared/lib-ts/agent-exec/index.ts +2 -0
  3. package/dist/templates/_shared/lib-ts/agent-exec/structured-output.ts +166 -0
  4. package/dist/templates/_shared/lib-ts/base/cli-args.ts +4 -0
  5. package/dist/templates/_shared/lib-ts/base/state-io.ts +1 -1
  6. package/dist/templates/_shared/lib-ts/base/subprocess-utils.ts +4 -3
  7. package/dist/templates/_shared/lib-ts/context/context-store.ts +3 -0
  8. package/dist/templates/_shared/scripts/status_line.ts +36 -19
  9. package/dist/templates/_shared/skills/prompt-codex/CLAUDE.md +29 -4
  10. package/dist/templates/_shared/skills/prompt-codex/scripts/launch-codex.ts +140 -7
  11. package/dist/templates/_shared/skills/prompt-codex/scripts/watch-codex.ts +257 -0
  12. package/dist/templates/cc-native/_cc-native/lib-ts/cli-output-parser.ts +9 -133
  13. package/dist/templates/cc-native/_cc-native/lib-ts/settings.ts +118 -42
  14. package/dist/templates/cc-native/_cc-native/lib-ts/state.ts +1 -0
  15. package/dist/templates/cc-native/_cc-native/lib-ts/types.ts +61 -0
  16. package/dist/templates/cc-native/_cc-native/plan-review/lib/agent-selection.ts +5 -4
  17. package/dist/templates/cc-native/_cc-native/plan-review/lib/orchestrator.ts +4 -4
  18. package/dist/templates/cc-native/_cc-native/plan-review/lib/review-pipeline.ts +16 -13
  19. package/dist/templates/cc-native/_cc-native/plan-review/lib/reviewers/providers/orchestrator-claude-agent.ts +54 -23
  20. package/oclif.manifest.json +1 -1
  21. package/package.json +1 -1
@@ -3,9 +3,9 @@
3
3
  * Launch Codex in a tmux pane and inject a prompt into its REPL.
4
4
  *
5
5
  * Usage:
6
- * bun launch-codex.ts [--model fast|standard|smart|<model-id>] [--sandbox read-only|workspace-write|danger-full-access] [--context <id>] plan
7
- * bun launch-codex.ts [--model fast|standard|smart|<model-id>] [--sandbox read-only|workspace-write|danger-full-access] [--context <id>] --file <path>
8
- * bun launch-codex.ts [--model fast|standard|smart|<model-id>] [--sandbox read-only|workspace-write|danger-full-access] [--context <id>] <inline text...>
6
+ * bun launch-codex.ts [--model fast|standard|smart|<model-id>] [--sandbox read-only|workspace-write|danger-full-access] [--no-yolo] [--capture] [--context <id>] plan
7
+ * bun launch-codex.ts [--model fast|standard|smart|<model-id>] [--sandbox read-only|workspace-write|danger-full-access] [--no-yolo] [--capture] [--context <id>] --file <path>
8
+ * bun launch-codex.ts [--model fast|standard|smart|<model-id>] [--sandbox read-only|workspace-write|danger-full-access] [--no-yolo] [--capture] [--context <id>] <inline text...>
9
9
  */
10
10
  import * as fs from "node:fs";
11
11
  import * as os from "node:os";
@@ -32,6 +32,10 @@ const CODEX_ALIASES: Record<string, string> = {
32
32
  gpt: CODEX_MODELS.gpt,
33
33
  };
34
34
 
35
+ const SESSION_DISCOVERY_TIMEOUT_MS = 12000;
36
+ const SESSION_DISCOVERY_POLL_MS = 250;
37
+ const SESSION_MTIME_WINDOW_MS = 120000;
38
+
35
39
  // ---------------------------------------------------------------------------
36
40
  // Helpers
37
41
  // ---------------------------------------------------------------------------
@@ -40,6 +44,107 @@ function eprint(...args: unknown[]): void {
40
44
  process.stderr.write(args.map(String).join(" ") + "\n");
41
45
  }
42
46
 
47
+ function sleep(ms: number): Promise<void> {
48
+ return new Promise((resolve) => setTimeout(resolve, ms));
49
+ }
50
+
51
+ function collectSessionJsonlFiles(rootDir: string): string[] {
52
+ if (!fs.existsSync(rootDir)) return [];
53
+ const stack: string[] = [rootDir];
54
+ const files: string[] = [];
55
+
56
+ while (stack.length > 0) {
57
+ const dir = stack.pop();
58
+ if (!dir) continue;
59
+ let entries: fs.Dirent[] = [];
60
+ try {
61
+ entries = fs.readdirSync(dir, { withFileTypes: true });
62
+ } catch {
63
+ continue;
64
+ }
65
+
66
+ for (const entry of entries) {
67
+ const fullPath = path.join(dir, entry.name);
68
+ if (entry.isDirectory()) {
69
+ stack.push(fullPath);
70
+ } else if (entry.isFile() && entry.name.endsWith(".jsonl")) {
71
+ files.push(fullPath);
72
+ }
73
+ }
74
+ }
75
+
76
+ return files;
77
+ }
78
+
79
+ function samePath(a: string, b: string): boolean {
80
+ const left = path.resolve(a);
81
+ const right = path.resolve(b);
82
+ if (process.platform === "win32") {
83
+ return left.toLowerCase() === right.toLowerCase();
84
+ }
85
+ return left === right;
86
+ }
87
+
88
+ function readSessionMeta(sessionFile: string): { sessionId: string; cwd: string } | null {
89
+ try {
90
+ const raw = fs.readFileSync(sessionFile, "utf-8");
91
+ const firstLine = raw.split(/\r?\n/).find((line) => line.trim().length > 0);
92
+ if (!firstLine) return null;
93
+ const parsed = JSON.parse(firstLine);
94
+ if (parsed?.type !== "session_meta") return null;
95
+ const sessionId = parsed?.payload?.id;
96
+ const cwd = parsed?.payload?.cwd;
97
+ if (typeof sessionId !== "string" || typeof cwd !== "string") return null;
98
+ return { sessionId, cwd };
99
+ } catch {
100
+ return null;
101
+ }
102
+ }
103
+
104
+ function findLatestSessionCandidate(
105
+ projectRoot: string,
106
+ launchStartedAtMs: number,
107
+ ): { sessionId: string; sessionFile: string } | null {
108
+ const sessionsRoot = path.join(os.homedir(), ".codex", "sessions");
109
+ const files = collectSessionJsonlFiles(sessionsRoot);
110
+ if (files.length === 0) return null;
111
+
112
+ const candidates: Array<{ sessionId: string; sessionFile: string; mtimeMs: number }> = [];
113
+ for (const sessionFile of files) {
114
+ let mtimeMs = 0;
115
+ try {
116
+ mtimeMs = fs.statSync(sessionFile).mtimeMs;
117
+ } catch {
118
+ continue;
119
+ }
120
+ if (mtimeMs < launchStartedAtMs - SESSION_MTIME_WINDOW_MS) continue;
121
+
122
+ const meta = readSessionMeta(sessionFile);
123
+ if (!meta) continue;
124
+ if (!samePath(meta.cwd, projectRoot)) continue;
125
+
126
+ candidates.push({ sessionId: meta.sessionId, sessionFile, mtimeMs });
127
+ }
128
+
129
+ if (candidates.length === 0) return null;
130
+ candidates.sort((a, b) => b.mtimeMs - a.mtimeMs);
131
+ const best = candidates[0];
132
+ return best ? { sessionId: best.sessionId, sessionFile: best.sessionFile } : null;
133
+ }
134
+
135
+ async function waitForCaptureSession(
136
+ projectRoot: string,
137
+ launchStartedAtMs: number,
138
+ ): Promise<{ sessionId: string; sessionFile: string } | null> {
139
+ const deadline = Date.now() + SESSION_DISCOVERY_TIMEOUT_MS;
140
+ while (Date.now() < deadline) {
141
+ const candidate = findLatestSessionCandidate(projectRoot, launchStartedAtMs);
142
+ if (candidate) return candidate;
143
+ await sleep(SESSION_DISCOVERY_POLL_MS);
144
+ }
145
+ return findLatestSessionCandidate(projectRoot, launchStartedAtMs);
146
+ }
147
+
43
148
  /** Fallback plan discovery: scan all context plan dirs by mtime. */
44
149
  function findLatestPlanByMtime(projectRoot: string): string | null {
45
150
  const contextsDir = path.join(projectRoot, "_output", "contexts");
@@ -74,7 +179,7 @@ function findLatestPlanByMtime(projectRoot: string): string | null {
74
179
  const rawArgs = process.argv.slice(2);
75
180
 
76
181
  if (rawArgs.length === 0) {
77
- eprint("Usage: launch-codex.ts [--model <model>] [--sandbox <mode>] [--context <id>] plan | --file <path> | <text...>");
182
+ eprint("Usage: launch-codex.ts [--model <model>] [--sandbox <mode>] [--no-yolo] [--capture] [--context <id>] plan | --file <path> | <text...>");
78
183
  process.exit(1);
79
184
  }
80
185
 
@@ -82,6 +187,8 @@ if (rawArgs.length === 0) {
82
187
  let modelFlag: string | undefined;
83
188
  let sandboxFlag: CodexSandbox | undefined;
84
189
  let contextFlag: string | undefined;
190
+ let yolo = true;
191
+ let capture = false;
85
192
  const args: string[] = [];
86
193
 
87
194
  for (let i = 0; i < rawArgs.length; i++) {
@@ -96,13 +203,19 @@ for (let i = 0; i < rawArgs.length; i++) {
96
203
  sandboxFlag = val;
97
204
  } else if (rawArgs[i] === "--context" && i + 1 < rawArgs.length) {
98
205
  contextFlag = rawArgs[++i];
206
+ } else if (rawArgs[i] === "--yolo") {
207
+ yolo = true;
208
+ } else if (rawArgs[i] === "--no-yolo") {
209
+ yolo = false;
210
+ } else if (rawArgs[i] === "--capture") {
211
+ capture = true;
99
212
  } else {
100
213
  args.push(rawArgs[i]);
101
214
  }
102
215
  }
103
216
 
104
217
  if (args.length === 0) {
105
- eprint("Usage: launch-codex.ts [--model <model>] [--sandbox <mode>] [--context <id>] plan | --file <path> | <text...>");
218
+ eprint("Usage: launch-codex.ts [--model <model>] [--sandbox <mode>] [--no-yolo] [--capture] [--context <id>] plan | --file <path> | <text...>");
106
219
  process.exit(1);
107
220
  }
108
221
 
@@ -206,11 +319,13 @@ if (!tmux.available) {
206
319
  // ---------------------------------------------------------------------------
207
320
 
208
321
  // Build args via centralized CLI builder
209
- const codexArgs = buildCliInvocation(codexReplSpec(resolvedModel, sandboxFlag)).args;
322
+ const codexArgs = buildCliInvocation(codexReplSpec(resolvedModel, sandboxFlag, yolo)).args;
323
+ if (yolo) console.log("Mode: YOLO (bypass approvals and sandbox)");
210
324
  if (sandboxFlag) console.log(`Sandbox: ${sandboxFlag}`);
211
325
  if (resolvedModel) console.log(`Model: ${resolvedModel}${modelFlag !== resolvedModel ? ` (from "${modelFlag}")` : ""}`);
212
326
 
213
- logDebug("codex-skill", `Launching: model=${resolvedModel ?? "default"}, sandbox=${sandboxFlag ?? "default"}, source=${args[0]}, bytes=${promptPath ? fs.statSync(promptPath).size : 0}`);
327
+ logDebug("codex-skill", `Launching: model=${resolvedModel ?? "default"}, sandbox=${sandboxFlag ?? "default"}, yolo=${yolo}, source=${args[0]}, bytes=${promptPath ? fs.statSync(promptPath).size : 0}`);
328
+ const launchStartedAtMs = Date.now();
214
329
 
215
330
  const result = await launchDriverInTmuxOrFallback({
216
331
  toolName: "codex",
@@ -248,6 +363,24 @@ if (result.paneId) {
248
363
  console.log("Codex launched in tmux pane.");
249
364
  }
250
365
 
366
+ if (capture && result.paneId) {
367
+ try {
368
+ const sessionInfo = await waitForCaptureSession(projectRoot, launchStartedAtMs);
369
+ if (sessionInfo) {
370
+ console.log(`CODEX_CAPTURE_PANE=${result.paneId}`);
371
+ console.log(`CODEX_CAPTURE_SESSION_ID=${sessionInfo.sessionId}`);
372
+ console.log(`CODEX_CAPTURE_SESSION_FILE=${sessionInfo.sessionFile}`);
373
+ } else {
374
+ logWarn(
375
+ "codex-skill",
376
+ `Capture session discovery failed for pane ${result.paneId} in ${SESSION_DISCOVERY_TIMEOUT_MS}ms`,
377
+ );
378
+ }
379
+ } catch (error) {
380
+ logWarn("codex-skill", `Capture session discovery threw for ${result.paneId}: ${String(error)}`);
381
+ }
382
+ }
383
+
251
384
  if (result.reason) {
252
385
  // Partial success (e.g., launched but prompt injection failed)
253
386
  eprint(`Warning: ${result.reason}`);
@@ -0,0 +1,257 @@
1
+ #!/usr/bin/env bun
2
+
3
+ import * as fs from "node:fs";
4
+ import * as os from "node:os";
5
+ import * as path from "node:path";
6
+
7
+ import { inference } from "../../../lib-ts/base/inference.js";
8
+ import { logDebug, logWarn } from "../../../lib-ts/base/logger.js";
9
+ import { CODEX_MODELS } from "../../../lib-ts/base/models.js";
10
+ import { execFileAsync } from "../../../lib-ts/base/subprocess-utils.js";
11
+ import { getTmuxAvailability } from "../../../lib-ts/base/tmux-driver.js";
12
+
13
+ const POLL_INTERVAL_MS = 2000;
14
+ const POLL_TIMEOUT_MS = 3000;
15
+ const SUMMARY_TIMEOUT_SEC = 8;
16
+ const RESUME_TIMEOUT_MS = 45000;
17
+ const MAX_TRANSCRIPT_LINES = 220;
18
+ const MAX_LINE_LENGTH = 500;
19
+ const SUMMARY_UNAVAILABLE_MESSAGE = "Codex session completed. Summary unavailable.";
20
+
21
+ const TRANSCRIPT_SUMMARY_PROMPT = `Summarize this Codex session transcript excerpt.
22
+ Return 3-5 concise bullet points.
23
+ Focus on:
24
+ - what was accomplished
25
+ - files changed
26
+ - errors or blockers
27
+ Do not ask follow-up questions.
28
+ Do not request additional input.
29
+ If information is partial, provide best-effort summary from available text.`;
30
+
31
+ const RESUME_SUMMARY_PROMPT = `Summarize the previous Codex session in 3-5 concise bullet points.
32
+ Focus on:
33
+ - what was accomplished
34
+ - files changed
35
+ - errors or blockers
36
+ Do not ask follow-up questions.
37
+ Do not request additional input.
38
+ If the prior session was brief, still provide a best-effort summary.`;
39
+
40
+ function sleep(ms: number): Promise<void> {
41
+ return new Promise((resolve) => setTimeout(resolve, ms));
42
+ }
43
+
44
+ function safeCleanup(filePath: string): void {
45
+ try {
46
+ if (fs.existsSync(filePath)) fs.unlinkSync(filePath);
47
+ } catch {
48
+ // Best-effort cleanup only.
49
+ }
50
+ }
51
+
52
+ function readTextIfExists(filePath: string): string {
53
+ try {
54
+ if (!filePath || !fs.existsSync(filePath)) return "";
55
+ return fs.readFileSync(filePath, "utf-8").trim();
56
+ } catch {
57
+ return "";
58
+ }
59
+ }
60
+
61
+ function normalizeText(text: string): string {
62
+ return text
63
+ .replace(/\r/g, "")
64
+ .replace(/[\x00-\x08\x0B-\x1F\x7F]/g, "")
65
+ .replace(/\s+/g, " ")
66
+ .trim();
67
+ }
68
+
69
+ function getMessageContentText(content: unknown): string {
70
+ if (!Array.isArray(content)) return "";
71
+ return content
72
+ .map((entry: any) => {
73
+ if (!entry || typeof entry !== "object") return "";
74
+ if (typeof entry.text !== "string") return "";
75
+ return entry.text;
76
+ })
77
+ .join("\n");
78
+ }
79
+
80
+ function collectTranscriptLines(sessionFile: string): string[] {
81
+ if (!sessionFile || !fs.existsSync(sessionFile)) return [];
82
+
83
+ const out: string[] = [];
84
+ const seen = new Set<string>();
85
+
86
+ try {
87
+ const raw = fs.readFileSync(sessionFile, "utf-8");
88
+ const lines = raw.split(/\r?\n/).filter((line) => line.trim().length > 0);
89
+
90
+ for (const line of lines) {
91
+ let parsed: any;
92
+ try {
93
+ parsed = JSON.parse(line);
94
+ } catch {
95
+ continue;
96
+ }
97
+
98
+ let role = "";
99
+ let text = "";
100
+
101
+ if (parsed?.type === "response_item" && parsed?.payload?.type === "message") {
102
+ role = parsed?.payload?.role === "assistant" ? "assistant" : (parsed?.payload?.role === "user" ? "user" : "");
103
+ text = getMessageContentText(parsed?.payload?.content);
104
+ } else if (parsed?.type === "item.completed" && parsed?.item?.type === "agent_message" && typeof parsed?.item?.text === "string") {
105
+ role = "assistant";
106
+ text = parsed.item.text;
107
+ } else if (parsed?.type === "event_msg" && parsed?.payload?.type === "agent_message" && typeof parsed?.payload?.message === "string") {
108
+ role = "assistant";
109
+ text = parsed.payload.message;
110
+ }
111
+
112
+ const normalized = normalizeText(text);
113
+ if (!role || !normalized) continue;
114
+
115
+ const truncated = normalized.length > MAX_LINE_LENGTH ? `${normalized.slice(0, MAX_LINE_LENGTH)}...` : normalized;
116
+ const tagged = `${role}: ${truncated}`;
117
+ if (seen.has(tagged)) continue;
118
+
119
+ seen.add(tagged);
120
+ out.push(tagged);
121
+ }
122
+ } catch (error) {
123
+ logWarn("codex-capture", `Session transcript parse failed: ${String(error)}`);
124
+ }
125
+
126
+ return out.slice(-MAX_TRANSCRIPT_LINES);
127
+ }
128
+
129
+ function looksLikeBadSummary(output: string): boolean {
130
+ const normalized = output.toLowerCase();
131
+ return (
132
+ normalized.includes("don't see") ||
133
+ normalized.includes("no output") ||
134
+ normalized.includes("could you provide") ||
135
+ normalized.includes("paste")
136
+ );
137
+ }
138
+
139
+ async function waitForPaneClose(paneId: string): Promise<void> {
140
+ const tmux = getTmuxAvailability();
141
+ if (!tmux.available || !tmux.tmuxPath) {
142
+ logWarn("codex-capture", `tmux unavailable while watching pane ${paneId}: ${tmux.reason ?? "unknown reason"}`);
143
+ return;
144
+ }
145
+
146
+ while (true) {
147
+ const result = await execFileAsync(tmux.tmuxPath, ["list-panes", "-a", "-F", "#{pane_id}"], {
148
+ timeout: POLL_TIMEOUT_MS,
149
+ });
150
+
151
+ if (result.exitCode !== 0) {
152
+ logDebug("codex-capture", `list-panes failed; assuming pane closed (${result.stderr.trim() || "no stderr"})`);
153
+ return;
154
+ }
155
+
156
+ const activePaneIds = result.stdout.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
157
+ if (!activePaneIds.includes(paneId)) return;
158
+
159
+ await sleep(POLL_INTERVAL_MS);
160
+ }
161
+ }
162
+
163
+ function summarizeViaSessionFileSpark(sessionFile: string): string | null {
164
+ const transcriptLines = collectTranscriptLines(sessionFile);
165
+ if (transcriptLines.length === 0) return null;
166
+
167
+ const transcript = transcriptLines.join("\n");
168
+ const result = inference(
169
+ TRANSCRIPT_SUMMARY_PROMPT,
170
+ `Session transcript excerpt:\n\n${transcript}`,
171
+ "fast",
172
+ SUMMARY_TIMEOUT_SEC,
173
+ { model: CODEX_MODELS.spark },
174
+ );
175
+
176
+ if (result.success && result.output && result.output.trim() && !looksLikeBadSummary(result.output)) {
177
+ return result.output.trim();
178
+ }
179
+
180
+ logWarn(
181
+ "codex-capture",
182
+ `Session-file Spark summary failed: ${result.error ?? "empty or low-signal output"}`,
183
+ );
184
+ return null;
185
+ }
186
+
187
+ async function summarizeViaResume(sessionId: string): Promise<string | null> {
188
+ const outputFile = path.join(os.tmpdir(), `codex-resume-summary-${Date.now()}-${process.pid}.txt`);
189
+
190
+ const result = await execFileAsync(
191
+ "codex",
192
+ [
193
+ "exec",
194
+ "resume",
195
+ sessionId,
196
+ RESUME_SUMMARY_PROMPT,
197
+ "--json",
198
+ "--model",
199
+ CODEX_MODELS.spark,
200
+ "--output-last-message",
201
+ outputFile,
202
+ ],
203
+ { timeout: RESUME_TIMEOUT_MS },
204
+ );
205
+
206
+ const summary = readTextIfExists(outputFile);
207
+ safeCleanup(outputFile);
208
+
209
+ if (summary && !looksLikeBadSummary(summary)) return summary;
210
+ logWarn("codex-capture", `codex exec resume failed for ${sessionId}: exit=${result.exitCode}, stderr=${result.stderr.trim() || "none"}`);
211
+ return null;
212
+ }
213
+
214
+ function summarizeFromSessionFileFallback(sessionFile: string): string | null {
215
+ const lines = collectTranscriptLines(sessionFile).slice(-12);
216
+ if (lines.length === 0) return null;
217
+ return `Codex session completed. Transcript fallback:\n- ${lines.join("\n- ")}`;
218
+ }
219
+
220
+ async function main(): Promise<void> {
221
+ const [paneId, sessionId, sessionFile] = process.argv.slice(2);
222
+
223
+ if (!paneId) {
224
+ console.log(SUMMARY_UNAVAILABLE_MESSAGE);
225
+ return;
226
+ }
227
+
228
+ await waitForPaneClose(paneId);
229
+
230
+ const transcriptSummary = summarizeViaSessionFileSpark(sessionFile ?? "");
231
+ if (transcriptSummary) {
232
+ console.log(transcriptSummary);
233
+ return;
234
+ }
235
+
236
+ if (sessionId) {
237
+ const resumeSummary = await summarizeViaResume(sessionId);
238
+ if (resumeSummary) {
239
+ console.log(resumeSummary);
240
+ return;
241
+ }
242
+ }
243
+
244
+ const fallback = summarizeFromSessionFileFallback(sessionFile ?? "");
245
+ if (fallback) {
246
+ console.log(fallback);
247
+ return;
248
+ }
249
+
250
+ console.log(SUMMARY_UNAVAILABLE_MESSAGE);
251
+ }
252
+
253
+ main().catch((error) => {
254
+ logWarn("codex-capture", `watch-codex failed: ${String(error)}`);
255
+ console.log(SUMMARY_UNAVAILABLE_MESSAGE);
256
+ process.exit(0);
257
+ });
@@ -1,144 +1,20 @@
1
1
  /**
2
- * Unified Claude CLI output parser.
3
- * Deduplicates identical logic from orchestrator.ts and reviewers/agent.ts.
4
- * See cc-native-plan-review-spec.md §4.6
2
+ * CC-native wrapper around shared structured-output parsing.
3
+ * Keeps existing import surface stable for provider implementations.
5
4
  */
6
5
 
7
- import { parseJsonMaybe } from "./json-parser.js";
8
- import { logDebug, logError, logWarn } from "../../_shared/lib-ts/base/logger.js";
6
+ import { parseStructuredOutput } from "../../_shared/lib-ts/agent-exec/structured-output.js";
9
7
 
10
8
  /**
11
- * Parse Claude CLI JSON output, handling various formats.
12
- *
13
- * Claude CLI can output in several formats:
14
- * - Direct structured_output dict
15
- * - Assistant message with StructuredOutput tool use
16
- * - List of events with assistant messages
17
- * - Raw text with embedded JSON (heuristic fallback)
18
- *
19
- * @param raw - Raw stdout from Claude CLI
20
- * @param requireFields - Optional fields to validate in heuristic fallback
21
- * @returns Parsed JSON dict or null if parsing failed
9
+ * Parse CLI JSON output into a structured object.
10
+ * Delegates to shared parser with cc-native logging tag.
22
11
  */
23
12
  export function parseCliOutput(
24
13
  raw: string,
25
14
  requireFields?: string[],
26
15
  ): null | Record<string, unknown> {
27
- try {
28
- const result: unknown = JSON.parse(raw);
29
-
30
- if (result !== null && typeof result === "object" && !Array.isArray(result)) {
31
- const dict = result as Record<string, unknown>;
32
-
33
- // Strategy 1: Direct structured_output key
34
- if ("structured_output" in dict) {
35
- logDebug("cli_parser", "Found structured_output in root dict");
36
- return dict.structured_output as Record<string, unknown>;
37
- }
38
-
39
- // Strategy 1.5: Session result envelope (type: "result")
40
- // When the model fails to call StructuredOutput, the CLI returns a
41
- // session metadata object with type/subtype/duration_ms/usage/etc.
42
- // but no structured_output key. Check for the `result` text field
43
- // (model may have written review as text) and for error states.
44
- if (dict.type === "result" || ("duration_ms" in dict && "session_id" in dict)) {
45
- if (dict.is_error === true || (Array.isArray(dict.errors) && (dict.errors as unknown[]).length > 0)) {
46
- logWarn("cli_parser", `CLI returned error result: ${JSON.stringify(dict.errors ?? "is_error=true")}`);
47
- return null;
48
- }
49
- if (typeof dict.result === "string" && dict.result) {
50
- logDebug("cli_parser", "Found result text in session envelope, attempting JSON extraction");
51
- const extracted = parseJsonMaybe(dict.result as string, requireFields);
52
- if (extracted) return extracted;
53
- logWarn("cli_parser", "Session envelope result text contained no extractable JSON");
54
- }
55
- logDebug("cli_parser", "Session result envelope with no structured_output or extractable result");
56
- return null;
57
- }
58
-
59
- // Strategy 2: Assistant message with StructuredOutput tool use
60
- if (dict.type === "assistant") {
61
- const message = dict.message as Record<string, unknown> | undefined;
62
- const content = message?.content;
63
- if (Array.isArray(content)) {
64
- for (const item of content) {
65
- if (
66
- item !== null &&
67
- typeof item === "object" &&
68
- (item as Record<string, unknown>).name === "StructuredOutput"
69
- ) {
70
- logDebug(
71
- "cli_parser",
72
- "Found StructuredOutput in assistant message content",
73
- );
74
- return (item as Record<string, unknown>).input as Record<
75
- string,
76
- unknown
77
- >;
78
- }
79
- }
80
- }
81
-
82
- logDebug(
83
- "cli_parser",
84
- "Assistant message found but no StructuredOutput tool use in content",
85
- );
86
- }
87
- } else if (Array.isArray(result)) {
88
- // Strategy 3: List of events with assistant messages
89
- logDebug(
90
- "cli_parser",
91
- `Received list of ${(result as unknown[]).length} events, searching for assistant message`,
92
- );
93
- for (let i = 0; i < (result as unknown[]).length; i++) {
94
- const event = (result as unknown[])[i];
95
- if (event === null || typeof event !== "object") continue;
96
-
97
- const dict = event as Record<string, unknown>;
98
- if (dict.type === "assistant") {
99
- const message = dict.message as Record<string, unknown> | undefined;
100
- const content = message?.content;
101
- if (Array.isArray(content)) {
102
- for (const item of content) {
103
- if (
104
- item !== null &&
105
- typeof item === "object" &&
106
- (item as Record<string, unknown>).name === "StructuredOutput"
107
- ) {
108
- logDebug(
109
- "cli_parser",
110
- `Found StructuredOutput in event[${i}] assistant message`,
111
- );
112
- return (item as Record<string, unknown>).input as Record<
113
- string,
114
- unknown
115
- >;
116
- }
117
- }
118
- }
119
- }
120
- }
121
-
122
- logDebug(
123
- "cli_parser",
124
- "No StructuredOutput found in any assistant message in event list",
125
- );
126
- }
127
- } catch (error: unknown) {
128
- if (error instanceof SyntaxError) {
129
- logWarn("cli_parser", `JSON decode error: ${error.message}`);
130
- } else {
131
- logError(
132
- "cli_parser",
133
- `Unexpected error during structured parsing: ${error}`,
134
- );
135
- }
136
- }
137
-
138
- // Strategy 4: Heuristic {…} extraction fallback
139
- logDebug(
140
- "cli_parser",
141
- "No structured output found, falling back to heuristic JSON extraction",
142
- );
143
- return parseJsonMaybe(raw, requireFields);
16
+ return parseStructuredOutput(raw, {
17
+ requireFields,
18
+ loggerTag: "cli_parser",
19
+ });
144
20
  }