@cyber-dash-tech/revela 0.18.3 → 0.18.5

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 CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  **English** | [中文](README.zh-CN.md)
4
4
 
5
- [![npm version](https://img.shields.io/npm/v/@cyber-dash-tech/revela)](https://www.npmjs.com/package/@cyber-dash-tech/revela) [![license](https://img.shields.io/npm/l/@cyber-dash-tech/revela)](LICENSE) [![tests](https://img.shields.io/badge/tests-688%20passing-brightgreen)](tests/) [![OpenCode plugin](https://img.shields.io/badge/OpenCode-plugin-blue)](https://opencode.ai) [![Bun](https://img.shields.io/badge/Bun-%E2%89%A51.0-orange)](https://bun.sh)
5
+ [![npm version](https://img.shields.io/npm/v/@cyber-dash-tech/revela)](https://www.npmjs.com/package/@cyber-dash-tech/revela) [![license](https://img.shields.io/npm/l/@cyber-dash-tech/revela)](LICENSE) [![tests](https://img.shields.io/badge/tests-709%20passing-brightgreen)](tests/) [![OpenCode plugin](https://img.shields.io/badge/OpenCode-plugin-blue)](https://opencode.ai) [![Bun](https://img.shields.io/badge/Bun-%E2%89%A51.0-orange)](https://bun.sh)
6
6
 
7
7
  <p align="center">
8
8
  <img src="assets/img/logo.png" alt="Revela" width="560" />
@@ -34,8 +34,8 @@ To install globally, add the same entry to `~/.config/opencode/opencode.json`.
34
34
  Requirements:
35
35
 
36
36
  - The Codex CLI must be installed and the `codex` command must be available in your shell.
37
- - Your environment must be able to run `npx`; Revela uses `npx -y @cyber-dash-tech/revela@0.18.3 mcp` to start the MCP server.
38
- - For interactive Review actions, `codex exec` must also work because the Review UI uses it for Comment/Apply Fix requests.
37
+ - Your environment must be able to run `npx`; Revela uses `npx -y @cyber-dash-tech/revela@0.18.5 mcp` to start the MCP server.
38
+ - For interactive Review Apply actions, `codex exec` must also work because the Review UI uses it after saved comments are applied.
39
39
 
40
40
  Optional preflight:
41
41
 
@@ -55,16 +55,18 @@ npm_config_cache=/tmp/revela-npm-cache bun run smoke:mcp-pack
55
55
  Install Revela through the Codex Git marketplace:
56
56
 
57
57
  ```bash
58
- codex plugin marketplace add https://github.com/cyber-dash-tech/revela --ref v0.18.3
58
+ codex plugin marketplace add https://github.com/cyber-dash-tech/revela --ref v0.18.5
59
59
  codex plugin add revela@revela
60
60
  ```
61
61
 
62
- The Git marketplace install provides the Codex plugin shell, skills, hooks, and MCP configuration. When Codex starts the Revela MCP server for the first time, it runs `npx -y @cyber-dash-tech/revela@0.18.3 mcp` so npm can fetch the published package and its dependencies.
62
+ The Git marketplace install provides the Codex plugin shell, skills, hooks, and MCP configuration. When Codex starts the Revela MCP server for the first time, it runs `npx -y @cyber-dash-tech/revela@0.18.5 mcp` so npm can fetch the published package and its dependencies.
63
63
 
64
64
  You do not need to run `bun install` inside the Codex marketplace clone.
65
65
 
66
66
  Start a new Codex thread after installing so Codex loads the Revela skills, MCP tools, and hooks.
67
67
 
68
+ Codex uses five Revela skills: `revela-helper` for status and active design/domain, `revela-research` for local and web research saved under `researches/`, `revela-make-deck` for design-aware `deck-plan.md` plus `decks/*.html`, `revela-review` for the Review UI, and `revela-export` for PDF/PPTX/PNG.
69
+
68
70
  For release-aligned local validation, run `bun run smoke:mcp-pack`. It packs the current checkout to a temporary npm tarball and starts the MCP server through `npx`, matching the published Codex launcher path without requiring a registry publish.
69
71
 
70
72
  #### Codex Upgrade
package/README.zh-CN.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  [English](README.md) | **中文**
4
4
 
5
- [![npm version](https://img.shields.io/npm/v/@cyber-dash-tech/revela)](https://www.npmjs.com/package/@cyber-dash-tech/revela) [![license](https://img.shields.io/npm/l/@cyber-dash-tech/revela)](LICENSE) [![tests](https://img.shields.io/badge/tests-688%20passing-brightgreen)](tests/) [![OpenCode plugin](https://img.shields.io/badge/OpenCode-plugin-blue)](https://opencode.ai) [![Bun](https://img.shields.io/badge/Bun-%E2%89%A51.0-orange)](https://bun.sh)
5
+ [![npm version](https://img.shields.io/npm/v/@cyber-dash-tech/revela)](https://www.npmjs.com/package/@cyber-dash-tech/revela) [![license](https://img.shields.io/npm/l/@cyber-dash-tech/revela)](LICENSE) [![tests](https://img.shields.io/badge/tests-709%20passing-brightgreen)](tests/) [![OpenCode plugin](https://img.shields.io/badge/OpenCode-plugin-blue)](https://opencode.ai) [![Bun](https://img.shields.io/badge/Bun-%E2%89%A51.0-orange)](https://bun.sh)
6
6
 
7
7
  <p align="center">
8
8
  <img src="assets/img/logo.png" alt="Revela" width="560" />
@@ -34,8 +34,8 @@ Revela 可在 [OpenCode](https://opencode.ai) 和 Codex 中使用,把来源材
34
34
  环境要求:
35
35
 
36
36
  - 需要已安装 Codex CLI,并且 shell 中可以执行 `codex`。
37
- - 环境中需要可以执行 `npx`;Revela 会用 `npx -y @cyber-dash-tech/revela@0.18.3 mcp` 启动 MCP server。
38
- - 如果使用 Review UI 的 Comment 或 Apply Fix,需要 `codex exec` 可用。
37
+ - 环境中需要可以执行 `npx`;Revela 会用 `npx -y @cyber-dash-tech/revela@0.18.5 mcp` 启动 MCP server。
38
+ - 如果使用 Review UI 的 Apply,需要 `codex exec` 可用;评论会先保存,点击 Apply 后才执行修复。
39
39
 
40
40
  可选的安装前检查:
41
41
 
@@ -55,16 +55,18 @@ npm_config_cache=/tmp/revela-npm-cache bun run smoke:mcp-pack
55
55
  通过 Codex Git marketplace 安装 Revela:
56
56
 
57
57
  ```bash
58
- codex plugin marketplace add https://github.com/cyber-dash-tech/revela --ref v0.18.3
58
+ codex plugin marketplace add https://github.com/cyber-dash-tech/revela --ref v0.18.5
59
59
  codex plugin add revela@revela
60
60
  ```
61
61
 
62
- Git marketplace 安装的是 Codex plugin 壳、skills、hooks 和 MCP 配置。Codex 第一次启动 Revela MCP server 时,会运行 `npx -y @cyber-dash-tech/revela@0.18.3 mcp`,由 npm 获取已发布 package 及其 dependencies。
62
+ Git marketplace 安装的是 Codex plugin 壳、skills、hooks 和 MCP 配置。Codex 第一次启动 Revela MCP server 时,会运行 `npx -y @cyber-dash-tech/revela@0.18.5 mcp`,由 npm 获取已发布 package 及其 dependencies。
63
63
 
64
64
  不需要在 Codex marketplace clone 里运行 `bun install`。
65
65
 
66
66
  安装后开启一个新的 Codex thread,让 Codex 加载 Revela 的 skills、MCP tools 和 hooks。
67
67
 
68
+ Codex 使用五个 Revela skills:`revela-helper` 查看状态和 active design/domain,`revela-research` 调研本地与网络资料并保存到 `researches/`,`revela-make-deck` 基于 design 工具生成 `deck-plan.md` 和 `decks/*.html`,`revela-review` 打开 Review UI,`revela-export` 导出 PDF/PPTX/PNG。
69
+
68
70
  如果要按发布路径做本地验证,运行 `bun run smoke:mcp-pack`。它会把当前 checkout 打成临时 npm tarball,再通过 `npx` 启动 MCP server,不需要先发布到 registry。
69
71
 
70
72
  #### Codex 升级
@@ -18,7 +18,7 @@ export async function handleRefine(
18
18
  `File: \`${result.deck.file}\`\n` +
19
19
  `${result.stateNote}\n` +
20
20
  `URL: ${result.url}\n\n` +
21
- `Use Ctrl/Cmd-click in the browser to reference deck elements. The Comment tab sends targeted change comments and applies deck edits after artifact QA.`
21
+ `Use Ctrl/Cmd-click in the browser to reference deck elements. The Comment tab saves targeted comments; use Apply on a saved comment to run deck edits.`
22
22
  )
23
23
  } catch (e: any) {
24
24
  await send(`**Review failed:** ${e.message || String(e)}`)
@@ -1651,6 +1651,7 @@ function isIgnorableSourceMaterial(path: string): boolean {
1651
1651
  normalized.startsWith("decks/") ||
1652
1652
  normalized.startsWith("researches/") ||
1653
1653
  normalized.startsWith("assets/") ||
1654
+ normalized.startsWith(".revela/") ||
1654
1655
  normalized.startsWith(".opencode/"),
1655
1656
  )
1656
1657
  }
@@ -11,6 +11,7 @@ import { extractXlsx } from "../read-hooks/extractors/xlsx"
11
11
  import { hasDecksState, readDecksState, writeDecksState } from "../decks-state"
12
12
  import { computeSourceFingerprint, sourceMaterialMetadata, upsertSourceMaterial } from "../source-materials"
13
13
  import { recordWorkspaceAction } from "../workspace-state/actions"
14
+ import { existingWorkspaceMetaPath, workspaceMetaPath } from "../workspace-meta"
14
15
 
15
16
  export type DocumentMaterial = {
16
17
  path: string
@@ -852,7 +853,7 @@ async function extractPdfImages(buf: Buffer, cacheDir: string, workspaceDir: str
852
853
  async function processPdfFile(filePath: string, workspaceDir: string): Promise<DocumentMaterialsResult> {
853
854
  const relativeSource = workspaceRelative(filePath, workspaceDir)
854
855
  const fingerprint = buildFingerprint(filePath)
855
- const cacheDir = join(workspaceDir, ".opencode", "revela", "doc-materials", fingerprint)
856
+ const cacheDir = existingWorkspaceMetaPath(workspaceDir, "doc-materials", fingerprint)
856
857
  const manifestPath = join(cacheDir, "manifest.json")
857
858
 
858
859
  if (existsSync(manifestPath)) {
@@ -874,19 +875,21 @@ async function processPdfFile(filePath: string, workspaceDir: string): Promise<D
874
875
  }
875
876
  }
876
877
 
877
- mkdirSync(join(cacheDir, "images"), { recursive: true })
878
- mkdirSync(join(cacheDir, "tables"), { recursive: true })
878
+ const writeCacheDir = workspaceMetaPath(workspaceDir, "doc-materials", fingerprint)
879
+ const writeManifestPath = join(writeCacheDir, "manifest.json")
880
+ mkdirSync(join(writeCacheDir, "images"), { recursive: true })
881
+ mkdirSync(join(writeCacheDir, "tables"), { recursive: true })
879
882
 
880
883
  const buf = readFileSync(filePath)
881
884
  const text = await extractPdfText(buf)
882
- const textPath = join(cacheDir, "text.txt")
885
+ const textPath = join(writeCacheDir, "text.txt")
883
886
  writeFileSync(textPath, `[Extracted from: ${basename(filePath)}]\n\n${text}`, "utf-8")
884
887
 
885
- const images = await extractPdfImages(buf, cacheDir, workspaceDir)
886
- const relativeManifestPath = workspaceRelative(manifestPath, workspaceDir)
888
+ const images = await extractPdfImages(buf, writeCacheDir, workspaceDir)
889
+ const relativeManifestPath = workspaceRelative(writeManifestPath, workspaceDir)
887
890
  const relativeTextPath = workspaceRelative(textPath, workspaceDir)
888
891
  const readViewPath = writeReadView({
889
- cacheDir,
892
+ cacheDir: writeCacheDir,
890
893
  workspaceDir,
891
894
  source: relativeSource,
892
895
  type: "pdf",
@@ -905,7 +908,7 @@ async function processPdfFile(filePath: string, workspaceDir: string): Promise<D
905
908
  cache_status: "miss",
906
909
  source: relativeSource,
907
910
  type: "pdf",
908
- cache_dir: workspaceRelative(cacheDir, workspaceDir),
911
+ cache_dir: workspaceRelative(writeCacheDir, workspaceDir),
909
912
  manifest_path: relativeManifestPath,
910
913
  text_path: relativeTextPath,
911
914
  read_view_path: readViewPath,
@@ -929,14 +932,14 @@ async function processPdfFile(filePath: string, workspaceDir: string): Promise<D
929
932
  tables: [],
930
933
  }
931
934
 
932
- writeFileSync(manifestPath, JSON.stringify(manifest, null, 2), "utf-8")
935
+ writeFileSync(writeManifestPath, JSON.stringify(manifest, null, 2), "utf-8")
933
936
  return result
934
937
  }
935
938
 
936
939
  async function processOfficeFile(filePath: string, workspaceDir: string, type: SupportedType): Promise<DocumentMaterialsResult> {
937
940
  const relativeSource = workspaceRelative(filePath, workspaceDir)
938
941
  const fingerprint = buildFingerprint(filePath)
939
- const cacheDir = join(workspaceDir, ".opencode", "revela", "doc-materials", fingerprint)
942
+ const cacheDir = existingWorkspaceMetaPath(workspaceDir, "doc-materials", fingerprint)
940
943
  const manifestPath = join(cacheDir, "manifest.json")
941
944
 
942
945
  if (existsSync(manifestPath)) {
@@ -958,8 +961,10 @@ async function processOfficeFile(filePath: string, workspaceDir: string, type: S
958
961
  }
959
962
  }
960
963
 
961
- mkdirSync(join(cacheDir, "images"), { recursive: true })
962
- mkdirSync(join(cacheDir, "tables"), { recursive: true })
964
+ const writeCacheDir = workspaceMetaPath(workspaceDir, "doc-materials", fingerprint)
965
+ const writeManifestPath = join(writeCacheDir, "manifest.json")
966
+ mkdirSync(join(writeCacheDir, "images"), { recursive: true })
967
+ mkdirSync(join(writeCacheDir, "tables"), { recursive: true })
963
968
 
964
969
  const buf = readFileSync(filePath)
965
970
  const files = unzipSync(new Uint8Array(buf))
@@ -970,26 +975,26 @@ async function processOfficeFile(filePath: string, workspaceDir: string, type: S
970
975
  ? await extractDocx(buf)
971
976
  : await extractXlsx(buf)
972
977
 
973
- const textPath = join(cacheDir, "text.txt")
978
+ const textPath = join(writeCacheDir, "text.txt")
974
979
  writeFileSync(textPath, `[Extracted from: ${basename(filePath)}]\n\n${text}`, "utf-8")
975
980
 
976
981
  const pptxAssets = type === "pptx"
977
- ? extractPptxImages(files, cacheDir, workspaceDir)
982
+ ? extractPptxImages(files, writeCacheDir, workspaceDir)
978
983
  : null
979
984
  const images = type === "pptx"
980
985
  ? pptxAssets!.images
981
986
  : type === "docx"
982
- ? extractDocxImages(files, cacheDir, workspaceDir)
983
- : extractXlsxImages(files, cacheDir, workspaceDir)
987
+ ? extractDocxImages(files, writeCacheDir, workspaceDir)
988
+ : extractXlsxImages(files, writeCacheDir, workspaceDir)
984
989
  const slides = type === "pptx"
985
990
  ? extractPptxSlides(files, images, pptxAssets!.skipped_assets)
986
991
  : undefined
987
- const relativeManifestPath = workspaceRelative(manifestPath, workspaceDir)
992
+ const relativeManifestPath = workspaceRelative(writeManifestPath, workspaceDir)
988
993
  const relativeTextPath = workspaceRelative(textPath, workspaceDir)
989
994
  const tables = extractTables(type, relativeTextPath)
990
995
  const skippedAssets = pptxAssets?.skipped_assets ?? []
991
996
  const readViewPath = writeReadView({
992
- cacheDir,
997
+ cacheDir: writeCacheDir,
993
998
  workspaceDir,
994
999
  source: relativeSource,
995
1000
  type,
@@ -1008,7 +1013,7 @@ async function processOfficeFile(filePath: string, workspaceDir: string, type: S
1008
1013
  cache_status: "miss",
1009
1014
  source: relativeSource,
1010
1015
  type,
1011
- cache_dir: workspaceRelative(cacheDir, workspaceDir),
1016
+ cache_dir: workspaceRelative(writeCacheDir, workspaceDir),
1012
1017
  manifest_path: relativeManifestPath,
1013
1018
  text_path: relativeTextPath,
1014
1019
  read_view_path: readViewPath,
@@ -1032,7 +1037,7 @@ async function processOfficeFile(filePath: string, workspaceDir: string, type: S
1032
1037
  tables: result.tables ?? [],
1033
1038
  }
1034
1039
 
1035
- writeFileSync(manifestPath, JSON.stringify(manifest, null, 2), "utf-8")
1040
+ writeFileSync(writeManifestPath, JSON.stringify(manifest, null, 2), "utf-8")
1036
1041
  return result
1037
1042
  }
1038
1043
 
@@ -3,6 +3,7 @@ import { basename, extname, isAbsolute, join, relative, resolve, sep } from "pat
3
3
  import { extractDocumentMaterials, type DocumentMaterialsResult } from "./document-materials/extract"
4
4
  import { sourceMaterialMetadata, sourceMaterialType } from "./source-materials"
5
5
  import type { SourceMaterial } from "./decks-state"
6
+ import { existingWorkspaceMetaPath, workspaceMetaPath } from "./workspace-meta"
6
7
 
7
8
  export type MaterialIntakeStatus =
8
9
  | "scanned"
@@ -107,24 +108,28 @@ export interface CheckMaterialIntakeResult {
107
108
  }
108
109
 
109
110
  const DOC_EXTENSIONS = new Set([".pdf", ".docx", ".doc", ".xlsx", ".xls", ".pptx", ".ppt", ".csv", ".md", ".txt"])
110
- const EXCLUDE_DIRS = new Set(["node_modules", ".git", "dist", ".opencode", "researches", "revela-narrative", "designs", "domains"])
111
+ const EXCLUDE_DIRS = new Set(["node_modules", ".git", "dist", ".opencode", ".revela", "researches", "revela-narrative", "designs", "domains"])
111
112
  const EXCLUDE_FILENAMES = new Set(["AGENTS.md", "DECKS.md", "README.md", "README.zh-CN.md"])
112
113
  const EXTRACTION_EXTENSIONS = new Set(["pdf", "ppt", "pptx", "doc", "docx", "xls", "xlsx"])
113
114
  const SUPPORTED_EXTRACTION_EXTENSIONS = new Set(["pdf", "pptx", "docx", "xlsx"])
114
115
 
115
116
  export function materialRegistryPath(workspaceRoot: string): string {
116
- return join(workspaceRoot, ".opencode", "revela", "material-intake", "registry.json")
117
+ return workspaceMetaPath(workspaceRoot, "material-intake", "registry.json")
118
+ }
119
+
120
+ function existingMaterialRegistryPath(workspaceRoot: string): string {
121
+ return existingWorkspaceMetaPath(workspaceRoot, "material-intake", "registry.json")
117
122
  }
118
123
 
119
124
  export function readMaterialRegistry(workspaceRoot: string): MaterialRegistry {
120
- const path = materialRegistryPath(workspaceRoot)
125
+ const path = existingMaterialRegistryPath(workspaceRoot)
121
126
  if (!existsSync(path)) return { version: 1, updatedAt: new Date(0).toISOString(), sources: [] }
122
127
  return JSON.parse(readFileSync(path, "utf-8")) as MaterialRegistry
123
128
  }
124
129
 
125
130
  export function writeMaterialRegistry(workspaceRoot: string, registry: MaterialRegistry): string {
126
131
  const path = materialRegistryPath(workspaceRoot)
127
- mkdirSync(join(workspaceRoot, ".opencode", "revela", "material-intake"), { recursive: true })
132
+ mkdirSync(join(workspaceRoot, ".revela", "material-intake"), { recursive: true })
128
133
  writeFileSync(path, JSON.stringify({ ...registry, updatedAt: new Date().toISOString() }, null, 2), "utf-8")
129
134
  return workspaceRelative(path, workspaceRoot)
130
135
  }
@@ -1,5 +1,5 @@
1
1
  export const NARRATIVE_VAULT_DIR = "revela-narrative"
2
- export const NARRATIVE_VAULT_CACHE_DIR = ".opencode/revela/narrative-cache"
2
+ export const NARRATIVE_VAULT_CACHE_DIR = ".revela/narrative-cache"
3
3
 
4
4
  export const NARRATIVE_VAULT_NODE_DIRS = ["claims", "evidence", "objections", "risks", "research-gaps"] as const
5
5
 
@@ -1,13 +1,18 @@
1
1
  import { existsSync } from "fs"
2
2
  import { join } from "path"
3
- import { NARRATIVE_VAULT_CACHE_DIR, NARRATIVE_VAULT_DIR } from "./constants"
3
+ import { existingWorkspaceMetaPath, workspaceMetaPath } from "../workspace-meta"
4
+ import { NARRATIVE_VAULT_DIR } from "./constants"
4
5
 
5
6
  export function narrativeVaultPath(workspaceRoot: string): string {
6
7
  return join(workspaceRoot, NARRATIVE_VAULT_DIR)
7
8
  }
8
9
 
9
10
  export function narrativeVaultCachePath(workspaceRoot: string): string {
10
- return join(workspaceRoot, NARRATIVE_VAULT_CACHE_DIR)
11
+ return workspaceMetaPath(workspaceRoot, "narrative-cache")
12
+ }
13
+
14
+ export function existingNarrativeVaultCachePath(workspaceRoot: string): string {
15
+ return existingWorkspaceMetaPath(workspaceRoot, "narrative-cache")
11
16
  }
12
17
 
13
18
  export function hasNarrativeVault(workspaceRoot: string): boolean {
@@ -13,7 +13,7 @@ export interface PendingCommentRequest {
13
13
  raw?: string
14
14
  }
15
15
 
16
- const REQUEST_TTL_MS = 120 * 1000
16
+ const REQUEST_TTL_MS = 360 * 1000
17
17
  const requests = new Map<string, PendingCommentRequest>()
18
18
  const subscribers = new Map<string, Set<(event: ReviewBridgeEvent) => void>>()
19
19
 
@@ -36,6 +36,9 @@ export interface CodexExecRunResult {
36
36
  stderr: string
37
37
  }
38
38
 
39
+ const DEFAULT_COMMENT_TIMEOUT_MS = 300_000
40
+ const DEFAULT_INSPECT_TIMEOUT_MS = 120_000
41
+
39
42
  export type CodexExecRunner = (input: {
40
43
  action: ReviewPromptAction
41
44
  prompt: string
@@ -71,39 +74,57 @@ export function createOpenCodeReviewPromptBridge(client: any, sessionID: string)
71
74
  export function createCodexExecReviewPromptBridge(options: {
72
75
  runner?: CodexExecRunner
73
76
  timeoutMs?: number
77
+ heartbeatMs?: number
74
78
  } = {}): ReviewPromptBridge {
75
79
  const runner = options.runner ?? runCodexExec
76
- const timeoutMs = options.timeoutMs ?? 120_000
80
+ const heartbeatMs = options.heartbeatMs ?? 10_000
77
81
  return {
78
82
  kind: "codex-exec",
79
83
  async send(input) {
80
84
  const sandboxMode = input.action === "comment" ? "workspace-write" : "read-only"
85
+ const timeoutMs = input.timeoutMs ?? options.timeoutMs ?? (input.action === "comment" ? DEFAULT_COMMENT_TIMEOUT_MS : DEFAULT_INSPECT_TIMEOUT_MS)
81
86
  input.onEvent?.(bridgeEvent("started", "Starting Codex..."))
82
- const output = await runner({
83
- action: input.action,
84
- prompt: input.prompt,
85
- workspaceRoot: input.workspaceRoot,
86
- timeoutMs: input.timeoutMs ?? timeoutMs,
87
- sandboxMode,
88
- skipGitRepoCheck: true,
89
- onEvent: input.onEvent,
90
- })
87
+ const startedAt = Date.now()
88
+ const heartbeat = input.onEvent
89
+ ? setInterval(() => {
90
+ const elapsedSeconds = Math.max(1, Math.round((Date.now() - startedAt) / 1000))
91
+ input.onEvent?.(bridgeEvent("codex_event", "Codex is still working...", `elapsedSeconds=${elapsedSeconds}`))
92
+ }, heartbeatMs)
93
+ : undefined
94
+ let output: CodexExecRunResult
95
+ try {
96
+ output = await runner({
97
+ action: input.action,
98
+ prompt: input.prompt,
99
+ workspaceRoot: input.workspaceRoot,
100
+ timeoutMs,
101
+ sandboxMode,
102
+ skipGitRepoCheck: true,
103
+ onEvent: input.onEvent,
104
+ })
105
+ } finally {
106
+ if (heartbeat) clearInterval(heartbeat)
107
+ }
91
108
  const raw = [output.stdout, output.stderr].filter(Boolean).join("\n")
92
- if (output.exitCode !== 0) {
93
- input.onEvent?.(bridgeEvent("failed", `codex exec failed with exit code ${output.exitCode ?? "unknown"}.`, boundedTail(raw)))
109
+ if (input.action === "comment" && isCodexWriteBlocked(raw)) {
110
+ input.onEvent?.(bridgeEvent("failed", "codex exec could not write the deck because its sandbox blocked file changes.", boundedTail(raw)))
94
111
  return {
95
112
  ok: false,
96
113
  status: "failed",
97
- error: `codex exec failed with exit code ${output.exitCode ?? "unknown"}.`,
114
+ error: "codex exec could not write the deck because its sandbox blocked file changes.",
98
115
  raw,
99
116
  }
100
117
  }
101
- if (input.action === "comment" && isCodexWriteBlocked(raw)) {
102
- input.onEvent?.(bridgeEvent("failed", "codex exec could not write the deck because its sandbox blocked file changes.", boundedTail(raw)))
118
+ if (output.exitCode !== 0) {
119
+ if (input.action === "comment" && output.exitCode === 124 && hasTrustedCodexCompletion(output.stdout)) {
120
+ input.onEvent?.(bridgeEvent("completed", "Codex completed."))
121
+ return { ok: true, status: "completed", raw }
122
+ }
123
+ input.onEvent?.(bridgeEvent("failed", `codex exec failed with exit code ${output.exitCode ?? "unknown"}.`, boundedTail(raw)))
103
124
  return {
104
125
  ok: false,
105
126
  status: "failed",
106
- error: "codex exec could not write the deck because its sandbox blocked file changes.",
127
+ error: `codex exec failed with exit code ${output.exitCode ?? "unknown"}.`,
107
128
  raw,
108
129
  }
109
130
  }
@@ -146,6 +167,7 @@ async function runCodexExec(input: {
146
167
  let stdout = ""
147
168
  let stderr = ""
148
169
  let stdoutLineBuffer = ""
170
+ let sawTrustedCompletion = false
149
171
  let resolved = false
150
172
  const resolveOnce = (output: CodexExecRunResult) => {
151
173
  if (resolved) return
@@ -155,7 +177,11 @@ async function runCodexExec(input: {
155
177
  const timer = setTimeout(() => {
156
178
  child.kill()
157
179
  const nextStderr = `${stderr}${stderr ? "\n" : ""}codex exec timed out after ${input.timeoutMs}ms.`
158
- input.onEvent?.(bridgeEvent("timeout", "Codex timed out before completing.", boundedTail(nextStderr)))
180
+ if (input.action === "comment" && sawTrustedCompletion) {
181
+ input.onEvent?.(bridgeEvent("completed", "Codex completed."))
182
+ } else {
183
+ input.onEvent?.(bridgeEvent("timeout", "Codex timed out before completing.", boundedTail(nextStderr)))
184
+ }
159
185
  resolveOnce({
160
186
  exitCode: 124,
161
187
  stdout,
@@ -165,7 +191,9 @@ async function runCodexExec(input: {
165
191
  child.stdout?.on("data", (chunk) => {
166
192
  const text = chunk.toString()
167
193
  stdout += text
168
- stdoutLineBuffer = emitCodexJsonProgress(stdoutLineBuffer + text, input.action, input.onEvent)
194
+ const progress = emitCodexJsonProgress(stdoutLineBuffer + text, input.action, input.onEvent)
195
+ stdoutLineBuffer = progress.remainder
196
+ sawTrustedCompletion = sawTrustedCompletion || progress.sawTrustedCompletion
169
197
  })
170
198
  child.stderr?.on("data", (chunk) => {
171
199
  const text = chunk.toString()
@@ -179,17 +207,20 @@ async function runCodexExec(input: {
179
207
  })
180
208
  child.on("close", (code) => {
181
209
  clearTimeout(timer)
182
- emitCodexJsonProgress(`${stdoutLineBuffer}\n`, input.action, input.onEvent)
210
+ const progress = emitCodexJsonProgress(`${stdoutLineBuffer}\n`, input.action, input.onEvent)
211
+ sawTrustedCompletion = sawTrustedCompletion || progress.sawTrustedCompletion
183
212
  resolveOnce({ exitCode: code, stdout, stderr })
184
213
  })
185
214
  })
186
215
  }
187
216
 
188
- function emitCodexJsonProgress(buffer: string, action: ReviewPromptAction, onEvent?: (event: ReviewBridgeEvent) => void): string {
217
+ function emitCodexJsonProgress(buffer: string, action: ReviewPromptAction, onEvent?: (event: ReviewBridgeEvent) => void): { remainder: string; sawTrustedCompletion: boolean } {
189
218
  const lines = buffer.split(/\r?\n/)
190
219
  const remainder = lines.pop() ?? ""
220
+ let sawTrustedCompletion = false
191
221
  for (const line of lines) {
192
222
  const parsed = parseJson(line)
223
+ sawTrustedCompletion = sawTrustedCompletion || isTrustedCodexCompletionRecord(parsed)
193
224
  const message = codexProgressMessage(parsed, action)
194
225
  if (message) {
195
226
  onEvent?.(bridgeEvent("codex_event", message, boundedTail(line)))
@@ -197,7 +228,7 @@ function emitCodexJsonProgress(buffer: string, action: ReviewPromptAction, onEve
197
228
  onEvent?.(bridgeEvent("stdout", "Codex wrote output.", boundedTail(line)))
198
229
  }
199
230
  }
200
- return remainder
231
+ return { remainder, sawTrustedCompletion }
201
232
  }
202
233
 
203
234
  function codexProgressMessage(value: unknown, action: ReviewPromptAction): string | undefined {
@@ -207,14 +238,18 @@ function codexProgressMessage(value: unknown, action: ReviewPromptAction): strin
207
238
  const event = typeof record.event === "string" ? record.event.toLowerCase() : ""
208
239
  const name = `${type} ${event}`
209
240
  if (!name.trim()) return undefined
210
- if (name.includes("turn_completed") || name.includes("completed")) {
241
+ const normalized = name.replace(/[._-]+/g, " ")
242
+ if (normalized.includes("turn completed") || normalized.includes("thread completed") || normalized.includes("completed")) {
211
243
  return action === "comment" ? undefined : "Codex completed the inspection."
212
244
  }
213
- if (name.includes("exec") || name.includes("patch") || name.includes("tool") || name.includes("apply")) {
245
+ if (normalized.includes("thread started") || normalized.includes("turn started") || normalized.includes("session started")) {
246
+ return "Codex started reading the deck..."
247
+ }
248
+ if (normalized.includes("exec") || normalized.includes("patch") || normalized.includes("tool") || normalized.includes("apply")) {
214
249
  return action === "comment" ? "Codex is applying the requested edit..." : "Codex is reading the deck..."
215
250
  }
216
- if (name.includes("session") || name.includes("turn") || name.includes("start")) return "Codex is reading the deck..."
217
- if (name.includes("message") || name.includes("delta") || name.includes("agent")) return "Codex is working..."
251
+ if (normalized.includes("session") || normalized.includes("thread") || normalized.includes("turn") || normalized.includes("start")) return "Codex is reading the deck..."
252
+ if (normalized.includes("message") || normalized.includes("delta") || normalized.includes("agent")) return "Codex is working..."
218
253
  return "Codex is working..."
219
254
  }
220
255
 
@@ -227,6 +262,40 @@ function boundedTail(text: string, limit = 4096): string {
227
262
  return text.slice(text.length - limit)
228
263
  }
229
264
 
265
+ function hasTrustedCodexCompletion(stdout: string): boolean {
266
+ for (const line of stdout.split(/\r?\n/)) {
267
+ const parsed = parseJson(line)
268
+ if (isTrustedCodexCompletionRecord(parsed)) return true
269
+ }
270
+ for (const block of extractJsonBlocks(stdout)) {
271
+ const parsed = parseJson(block)
272
+ if (isTrustedCodexCompletionRecord(parsed)) return true
273
+ }
274
+ return false
275
+ }
276
+
277
+ function isTrustedCodexCompletionRecord(value: unknown): boolean {
278
+ if (!value) return false
279
+ if (typeof value === "string") return isTrustedCodexCompletionRecord(parseJson(value))
280
+ if (Array.isArray(value)) return value.some((item) => isTrustedCodexCompletionRecord(item))
281
+ if (typeof value !== "object") return false
282
+
283
+ const record = value as Record<string, unknown>
284
+ const type = typeof record.type === "string" ? record.type.toLowerCase() : ""
285
+ const event = typeof record.event === "string" ? record.event.toLowerCase() : ""
286
+ const status = typeof record.status === "string" ? record.status.toLowerCase() : ""
287
+ const normalized = `${type} ${event}`.replace(/[._-]+/g, " ")
288
+ const exitCode = typeof record.exit_code === "number" ? record.exit_code : typeof record.exitCode === "number" ? record.exitCode : undefined
289
+
290
+ if (normalized.includes("turn completed") || normalized.includes("thread completed")) return true
291
+ if (status === "completed" && exitCode === 0) return true
292
+
293
+ for (const key of ["item", "result", "output", "event", "payload"]) {
294
+ if (isTrustedCodexCompletionRecord(record[key])) return true
295
+ }
296
+ return false
297
+ }
298
+
230
299
  function isCodexWriteBlocked(raw: string): boolean {
231
300
  const text = raw.toLowerCase()
232
301
  return (