aiwcli 0.13.8 → 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 (47) hide show
  1. package/README.md +11 -1
  2. package/dist/commands/launch.d.ts +8 -0
  3. package/dist/commands/launch.js +96 -5
  4. package/dist/templates/_shared/.claude/skills/codex/SKILL.md +42 -0
  5. package/dist/templates/_shared/.claude/skills/codex/prompt.md +30 -0
  6. package/dist/templates/_shared/lib-ts/agent-exec/backends/headless.ts +33 -0
  7. package/dist/templates/_shared/lib-ts/agent-exec/backends/index.ts +6 -0
  8. package/dist/templates/_shared/lib-ts/agent-exec/backends/tmux.ts +145 -0
  9. package/dist/templates/_shared/lib-ts/agent-exec/base-agent.ts +229 -0
  10. package/dist/templates/_shared/lib-ts/agent-exec/execution-backend.ts +50 -0
  11. package/dist/templates/_shared/lib-ts/agent-exec/index.ts +6 -0
  12. package/dist/templates/_shared/lib-ts/agent-exec/structured-output.ts +166 -0
  13. package/dist/templates/_shared/lib-ts/base/cli-args.ts +287 -0
  14. package/dist/templates/_shared/lib-ts/base/inference.ts +53 -47
  15. package/dist/templates/_shared/lib-ts/base/models.ts +16 -0
  16. package/dist/templates/_shared/lib-ts/base/preflight.ts +98 -0
  17. package/dist/templates/_shared/lib-ts/base/state-io.ts +1 -1
  18. package/dist/templates/_shared/lib-ts/base/subprocess-utils.ts +4 -3
  19. package/dist/templates/_shared/lib-ts/base/tmux-driver.ts +381 -0
  20. package/dist/templates/_shared/lib-ts/base/utils.ts +8 -0
  21. package/dist/templates/_shared/lib-ts/context/context-formatter.ts +35 -11
  22. package/dist/templates/_shared/lib-ts/context/context-store.ts +3 -0
  23. package/dist/templates/_shared/lib-ts/types.ts +17 -0
  24. package/dist/templates/_shared/scripts/status_line.ts +93 -47
  25. package/dist/templates/_shared/skills/prompt-codex/CLAUDE.md +71 -0
  26. package/dist/templates/_shared/skills/prompt-codex/scripts/launch-codex.ts +387 -0
  27. package/dist/templates/_shared/skills/prompt-codex/scripts/watch-codex.ts +257 -0
  28. package/dist/templates/cc-native/.claude/settings.json +121 -1
  29. package/dist/templates/cc-native/_cc-native/CLAUDE.md +73 -0
  30. package/dist/templates/cc-native/_cc-native/lib-ts/CLAUDE.md +70 -0
  31. package/dist/templates/cc-native/_cc-native/lib-ts/cli-output-parser.ts +9 -133
  32. package/dist/templates/cc-native/_cc-native/lib-ts/settings.ts +120 -43
  33. package/dist/templates/cc-native/_cc-native/lib-ts/state.ts +1 -0
  34. package/dist/templates/cc-native/_cc-native/lib-ts/types.ts +66 -12
  35. package/dist/templates/cc-native/_cc-native/plan-review/lib/agent-selection.ts +5 -4
  36. package/dist/templates/cc-native/_cc-native/plan-review/lib/orchestrator.ts +4 -4
  37. package/dist/templates/cc-native/_cc-native/plan-review/lib/preflight.ts +14 -80
  38. package/dist/templates/cc-native/_cc-native/plan-review/lib/review-pipeline.ts +16 -13
  39. package/dist/templates/cc-native/_cc-native/plan-review/lib/reviewers/agent.ts +19 -7
  40. package/dist/templates/cc-native/_cc-native/plan-review/lib/reviewers/base/base-agent.ts +4 -215
  41. package/dist/templates/cc-native/_cc-native/plan-review/lib/reviewers/index.ts +1 -1
  42. package/dist/templates/cc-native/_cc-native/plan-review/lib/reviewers/providers/claude-agent.ts +9 -39
  43. package/dist/templates/cc-native/_cc-native/plan-review/lib/reviewers/providers/codex-agent.ts +19 -22
  44. package/dist/templates/cc-native/_cc-native/plan-review/lib/reviewers/providers/gemini-agent.ts +2 -1
  45. package/dist/templates/cc-native/_cc-native/plan-review/lib/reviewers/providers/orchestrator-claude-agent.ts +65 -36
  46. package/oclif.manifest.json +21 -3
  47. package/package.json +1 -1
@@ -0,0 +1,387 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * Launch Codex in a tmux pane and inject a prompt into its REPL.
4
+ *
5
+ * Usage:
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
+ */
10
+ import * as fs from "node:fs";
11
+ import * as os from "node:os";
12
+ import * as path from "node:path";
13
+
14
+ import {
15
+ getTmuxAvailability,
16
+ launchDriverInTmuxOrFallback,
17
+ } from "../../../lib-ts/base/tmux-driver.js";
18
+ import { getProjectRoot } from "../../../lib-ts/base/constants.js";
19
+ import { resolveCodexModel, codexReplSpec, buildCliInvocation, isCodexSandbox, type CodexSandbox } from "../../../lib-ts/base/cli-args.js";
20
+ import { CODEX_MODELS } from "../../../lib-ts/base/models.js";
21
+ import { logDebug, logWarn } from "../../../lib-ts/base/logger.js";
22
+ import { displayPath } from "../../../lib-ts/base/utils.js";
23
+ import { getContextBySessionId, getContext } from "../../../lib-ts/context/context-store.js";
24
+ import { buildExternalAgentContext } from "../../../lib-ts/context/context-formatter.js";
25
+ import { findLatestPlan } from "../../../lib-ts/context/plan-manager.js";
26
+ import type { ContextState } from "../../../lib-ts/types.js";
27
+
28
+ /** Codex-specific model abbreviations. Checked before tier resolution. */
29
+ const CODEX_ALIASES: Record<string, string> = {
30
+ spark: CODEX_MODELS.spark,
31
+ codex: CODEX_MODELS.codex,
32
+ gpt: CODEX_MODELS.gpt,
33
+ };
34
+
35
+ const SESSION_DISCOVERY_TIMEOUT_MS = 12000;
36
+ const SESSION_DISCOVERY_POLL_MS = 250;
37
+ const SESSION_MTIME_WINDOW_MS = 120000;
38
+
39
+ // ---------------------------------------------------------------------------
40
+ // Helpers
41
+ // ---------------------------------------------------------------------------
42
+
43
+ function eprint(...args: unknown[]): void {
44
+ process.stderr.write(args.map(String).join(" ") + "\n");
45
+ }
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
+
148
+ /** Fallback plan discovery: scan all context plan dirs by mtime. */
149
+ function findLatestPlanByMtime(projectRoot: string): string | null {
150
+ const contextsDir = path.join(projectRoot, "_output", "contexts");
151
+ if (!fs.existsSync(contextsDir)) return null;
152
+
153
+ let best: { path: string; mtime: number } | null = null;
154
+
155
+ for (const ctxEntry of fs.readdirSync(contextsDir)) {
156
+ if (ctxEntry.startsWith("_")) continue;
157
+ const plansDir = path.join(contextsDir, ctxEntry, "plans");
158
+ if (!fs.existsSync(plansDir)) continue;
159
+
160
+ for (const file of fs.readdirSync(plansDir)) {
161
+ if (!file.endsWith(".md")) continue;
162
+ const fullPath = path.join(plansDir, file);
163
+ try {
164
+ const mtime = fs.statSync(fullPath).mtimeMs;
165
+ if (!best || mtime > best.mtime) {
166
+ best = { path: fullPath, mtime };
167
+ }
168
+ } catch { /* skip unreadable */ }
169
+ }
170
+ }
171
+
172
+ return best?.path ?? null;
173
+ }
174
+
175
+ // ---------------------------------------------------------------------------
176
+ // Arg parsing
177
+ // ---------------------------------------------------------------------------
178
+
179
+ const rawArgs = process.argv.slice(2);
180
+
181
+ if (rawArgs.length === 0) {
182
+ eprint("Usage: launch-codex.ts [--model <model>] [--sandbox <mode>] [--no-yolo] [--capture] [--context <id>] plan | --file <path> | <text...>");
183
+ process.exit(1);
184
+ }
185
+
186
+ // Extract --model, --sandbox, and --context flags before mode dispatch
187
+ let modelFlag: string | undefined;
188
+ let sandboxFlag: CodexSandbox | undefined;
189
+ let contextFlag: string | undefined;
190
+ let yolo = true;
191
+ let capture = false;
192
+ const args: string[] = [];
193
+
194
+ for (let i = 0; i < rawArgs.length; i++) {
195
+ if (rawArgs[i] === "--model" && i + 1 < rawArgs.length) {
196
+ modelFlag = rawArgs[++i];
197
+ } else if (rawArgs[i] === "--sandbox" && i + 1 < rawArgs.length) {
198
+ const val = rawArgs[++i];
199
+ if (!isCodexSandbox(val)) {
200
+ eprint(`Error: Invalid sandbox mode "${val}". Valid: read-only, workspace-write, danger-full-access`);
201
+ process.exit(1);
202
+ }
203
+ sandboxFlag = val;
204
+ } else if (rawArgs[i] === "--context" && i + 1 < rawArgs.length) {
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;
212
+ } else {
213
+ args.push(rawArgs[i]);
214
+ }
215
+ }
216
+
217
+ if (args.length === 0) {
218
+ eprint("Usage: launch-codex.ts [--model <model>] [--sandbox <mode>] [--no-yolo] [--capture] [--context <id>] plan | --file <path> | <text...>");
219
+ process.exit(1);
220
+ }
221
+
222
+ // Resolve model: alias first, then tier/pass-through via shared resolver
223
+ let resolvedModel: string | undefined;
224
+ if (modelFlag) {
225
+ const lower = modelFlag.toLowerCase();
226
+ resolvedModel = lower in CODEX_ALIASES
227
+ ? CODEX_ALIASES[lower]
228
+ : resolveCodexModel(modelFlag);
229
+ }
230
+
231
+ let promptPath: string | null = null;
232
+ let tempFile: string | null = null;
233
+
234
+ const projectRoot = getProjectRoot(process.cwd());
235
+
236
+ // Context lookup — available for all modes (orientation header + plan discovery)
237
+ // --context flag preferred (passed by skill caller); CLAUDE_SESSION_ID as fallback (hooks only)
238
+ let ctx: ContextState | null = null;
239
+ if (contextFlag) {
240
+ ctx = getContext(contextFlag, projectRoot) ?? null;
241
+ } else {
242
+ const sessionId = process.env.CLAUDE_SESSION_ID;
243
+ if (sessionId) {
244
+ ctx = getContextBySessionId(sessionId, projectRoot) ?? null;
245
+ }
246
+ }
247
+
248
+ if (args[0] === "plan") {
249
+ // Plan discovery: context system first, mtime fallback second
250
+ let planPath: string | null = null;
251
+
252
+ if (ctx) {
253
+ planPath = findLatestPlan(ctx.id, projectRoot);
254
+ }
255
+
256
+ if (!planPath) {
257
+ planPath = findLatestPlanByMtime(projectRoot);
258
+ }
259
+
260
+ if (!planPath) {
261
+ eprint("Error: No plan found. Create a plan first (use plan mode), then run this command.");
262
+ process.exit(1);
263
+ }
264
+
265
+ promptPath = planPath;
266
+ console.log(`Found plan: ${displayPath(planPath)}`);
267
+
268
+ } else if (args[0] === "--file") {
269
+ if (!args[1]) {
270
+ eprint("Error: --file requires a path argument.");
271
+ process.exit(1);
272
+ }
273
+ const filePath = path.resolve(args[1]);
274
+ if (!fs.existsSync(filePath)) {
275
+ eprint(`Error: File not found: ${filePath}`);
276
+ process.exit(1);
277
+ }
278
+ promptPath = filePath;
279
+
280
+ } else {
281
+ // Inline text: join args, write to temp file
282
+ const text = args.join(" ");
283
+ tempFile = path.join(os.tmpdir(), `codex-prompt-${Date.now()}.md`);
284
+ fs.writeFileSync(tempFile, text, "utf-8");
285
+ promptPath = tempFile;
286
+ }
287
+
288
+ // Prepend context orientation if available — graceful degradation on failure
289
+ if (ctx && promptPath) {
290
+ try {
291
+ const orientation = buildExternalAgentContext(ctx, projectRoot);
292
+ const original = fs.readFileSync(promptPath, "utf-8");
293
+ const combined = `${orientation}\n\n---\n\n${original}`;
294
+ const contextPromptPath = path.join(os.tmpdir(), `codex-ctx-prompt-${Date.now()}.md`);
295
+ fs.writeFileSync(contextPromptPath, combined, "utf-8");
296
+ if (tempFile) {
297
+ try { fs.unlinkSync(tempFile); } catch { /* ignore */ }
298
+ }
299
+ promptPath = contextPromptPath;
300
+ tempFile = contextPromptPath;
301
+ } catch {
302
+ logWarn("codex-skill", `Context orientation prepend failed for ${ctx.id}, continuing without header`);
303
+ }
304
+ }
305
+
306
+ // ---------------------------------------------------------------------------
307
+ // Pre-flight: tmux required
308
+ // ---------------------------------------------------------------------------
309
+
310
+ const tmux = getTmuxAvailability();
311
+ if (!tmux.available) {
312
+ eprint(`Error: tmux is required for Codex REPL mode. ${tmux.reason ?? ""}`);
313
+ if (tempFile) try { fs.unlinkSync(tempFile); } catch { /* ignore */ }
314
+ process.exit(1);
315
+ }
316
+
317
+ // ---------------------------------------------------------------------------
318
+ // Launch Codex REPL in tmux pane
319
+ // ---------------------------------------------------------------------------
320
+
321
+ // Build args via centralized CLI builder
322
+ const codexArgs = buildCliInvocation(codexReplSpec(resolvedModel, sandboxFlag, yolo)).args;
323
+ if (yolo) console.log("Mode: YOLO (bypass approvals and sandbox)");
324
+ if (sandboxFlag) console.log(`Sandbox: ${sandboxFlag}`);
325
+ if (resolvedModel) console.log(`Model: ${resolvedModel}${modelFlag !== resolvedModel ? ` (from "${modelFlag}")` : ""}`);
326
+
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();
329
+
330
+ const result = await launchDriverInTmuxOrFallback({
331
+ toolName: "codex",
332
+ mode: "repl",
333
+ args: codexArgs,
334
+ promptPath,
335
+ sendPromptInRepl: true,
336
+ allowExecFallback: false,
337
+ });
338
+
339
+ // Cleanup temp file after injection
340
+ if (tempFile) {
341
+ try { fs.unlinkSync(tempFile); } catch { /* ignore */ }
342
+ }
343
+
344
+ if (!result.launched) {
345
+ logWarn("codex-skill", `Launch failed: ${result.reason}`);
346
+ eprint(`Error: Failed to launch Codex. ${result.reason ?? ""}`);
347
+ process.exit(1);
348
+ }
349
+
350
+ // Log injection diagnostics
351
+ const diag = result.sendDiagnostics;
352
+ if (diag) {
353
+ if (diag.success) {
354
+ logDebug("codex-skill", `Injection OK: promptWait=${diag.promptWaitMs}ms, retrySent=${diag.retrySent}`);
355
+ } else {
356
+ logWarn("codex-skill", `Injection failed at ${diag.failedAt}: wait=${diag.promptWaitMs}ms, stderr=${diag.tmuxStderr ?? "none"}, paneTail=${diag.paneTailOnTimeout ?? "none"}`);
357
+ }
358
+ }
359
+
360
+ if (result.paneId) {
361
+ console.log(`Codex launched in tmux pane: ${result.paneId}`);
362
+ } else {
363
+ console.log("Codex launched in tmux pane.");
364
+ }
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
+
384
+ if (result.reason) {
385
+ // Partial success (e.g., launched but prompt injection failed)
386
+ eprint(`Warning: ${result.reason}`);
387
+ }
@@ -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
+ });