@cyber-dash-tech/revela 0.17.11 → 0.17.13

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
@@ -34,7 +34,7 @@ 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.17.11 mcp` to start the MCP server.
37
+ - Your environment must be able to run `npx`; Revela uses `npx -y @cyber-dash-tech/revela@0.17.12 mcp` to start the MCP server.
38
38
  - For interactive Review actions, `codex exec` must also work because the Review UI uses it for Insight and Comment/Apply Fix requests.
39
39
 
40
40
  Optional preflight:
@@ -45,19 +45,28 @@ codex exec --help
45
45
  npx --version
46
46
  ```
47
47
 
48
+ If `npx` fails with an npm cache permission error, repair the cache ownership or use a writable cache for local checks:
49
+
50
+ ```bash
51
+ sudo chown -R "$(id -u):$(id -g)" ~/.npm
52
+ npm_config_cache=/tmp/revela-npm-cache bun run smoke:mcp-pack
53
+ ```
54
+
48
55
  Install Revela through the Codex Git marketplace:
49
56
 
50
57
  ```bash
51
- codex plugin marketplace add https://github.com/cyber-dash-tech/revela --ref v0.17.11
58
+ codex plugin marketplace add https://github.com/cyber-dash-tech/revela --ref v0.17.12
52
59
  codex plugin add revela@revela
53
60
  ```
54
61
 
55
- 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.17.11 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.17.12 mcp` so npm can fetch the published package and its dependencies.
56
63
 
57
64
  You do not need to run `bun install` inside the Codex marketplace clone.
58
65
 
59
66
  Start a new Codex thread after installing so Codex loads the Revela skills, MCP tools, and hooks.
60
67
 
68
+ 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
+
61
70
  ## Built-In Designs
62
71
 
63
72
  Revela includes built-in deck designs:
package/README.zh-CN.md CHANGED
@@ -34,7 +34,7 @@ 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.17.11 mcp` 启动 MCP server。
37
+ - 环境中需要可以执行 `npx`;Revela 会用 `npx -y @cyber-dash-tech/revela@0.17.12 mcp` 启动 MCP server。
38
38
  - 如果使用 Review UI 的 Insight、Comment 或 Apply Fix,需要 `codex exec` 可用。
39
39
 
40
40
  可选的安装前检查:
@@ -45,19 +45,28 @@ codex exec --help
45
45
  npx --version
46
46
  ```
47
47
 
48
+ 如果 `npx` 报 npm cache 权限错误,可以修复 cache owner,或在本地检查时使用可写 cache:
49
+
50
+ ```bash
51
+ sudo chown -R "$(id -u):$(id -g)" ~/.npm
52
+ npm_config_cache=/tmp/revela-npm-cache bun run smoke:mcp-pack
53
+ ```
54
+
48
55
  通过 Codex Git marketplace 安装 Revela:
49
56
 
50
57
  ```bash
51
- codex plugin marketplace add https://github.com/cyber-dash-tech/revela --ref v0.17.11
58
+ codex plugin marketplace add https://github.com/cyber-dash-tech/revela --ref v0.17.12
52
59
  codex plugin add revela@revela
53
60
  ```
54
61
 
55
- Git marketplace 安装的是 Codex plugin 壳、skills、hooks 和 MCP 配置。Codex 第一次启动 Revela MCP server 时,会运行 `npx -y @cyber-dash-tech/revela@0.17.11 mcp`,由 npm 获取已发布 package 及其 dependencies。
62
+ Git marketplace 安装的是 Codex plugin 壳、skills、hooks 和 MCP 配置。Codex 第一次启动 Revela MCP server 时,会运行 `npx -y @cyber-dash-tech/revela@0.17.12 mcp`,由 npm 获取已发布 package 及其 dependencies。
56
63
 
57
64
  不需要在 Codex marketplace clone 里运行 `bun install`。
58
65
 
59
66
  安装后开启一个新的 Codex thread,让 Codex 加载 Revela 的 skills、MCP tools 和 hooks。
60
67
 
68
+ 如果要按发布路径做本地验证,运行 `bun run smoke:mcp-pack`。它会把当前 checkout 打成临时 npm tarball,再通过 `npx` 启动 MCP server,不需要先发布到 registry。
69
+
61
70
  ## 内置设计
62
71
 
63
72
  Revela 内置多个 deck design:
@@ -1,5 +1,6 @@
1
1
  import type { InspectionPromptProjection } from "../inspection-context/project"
2
2
  import { buildDeterministicInspectionResult, type InspectionResult } from "../inspection-context/result"
3
+ import type { ReviewBridgeEvent } from "../refine/prompt-bridge"
3
4
 
4
5
  export type InspectRequestStatus = "pending" | "completed" | "failed" | "expired"
5
6
 
@@ -10,12 +11,15 @@ export interface PendingInspectRequest {
10
11
  deckVersion: string
11
12
  createdAt: number
12
13
  updatedAt: number
14
+ events: ReviewBridgeEvent[]
13
15
  result?: InspectionResult
14
16
  error?: string
17
+ raw?: string
15
18
  }
16
19
 
17
20
  const REQUEST_TTL_MS = 90 * 1000
18
21
  const requests = new Map<string, PendingInspectRequest>()
22
+ const subscribers = new Map<string, Set<(event: ReviewBridgeEvent) => void>>()
19
23
 
20
24
  export function createInspectRequest(input: {
21
25
  requestId: string
@@ -31,6 +35,7 @@ export function createInspectRequest(input: {
31
35
  deckVersion: input.deckVersion,
32
36
  createdAt: now,
33
37
  updatedAt: now,
38
+ events: [],
34
39
  }
35
40
  requests.set(input.requestId, request)
36
41
  return request
@@ -43,6 +48,11 @@ export function getInspectRequest(requestId: string): PendingInspectRequest | un
43
48
  if (request.status === "pending" && Date.now() - request.createdAt > REQUEST_TTL_MS) {
44
49
  request.status = "expired"
45
50
  request.error = "Inspection timed out before the LLM submitted a result."
51
+ appendInspectRequestEvent(request, {
52
+ type: "timeout",
53
+ message: request.error,
54
+ timestamp: Date.now(),
55
+ })
46
56
  request.updatedAt = Date.now()
47
57
  }
48
58
  return request
@@ -55,6 +65,13 @@ export function completeInspectRequest(requestId: string, result: InspectionResu
55
65
  request.status = "completed"
56
66
  request.result = normalizeInspectionResult(request.projection, result, requestId)
57
67
  request.updatedAt = Date.now()
68
+ if (!hasTerminalEvent(request)) {
69
+ appendInspectRequestEvent(request, {
70
+ type: "completed",
71
+ message: "Codex completed the inspection.",
72
+ timestamp: request.updatedAt,
73
+ })
74
+ }
58
75
  return request
59
76
  }
60
77
 
@@ -77,29 +94,92 @@ function normalizeInspectionResult(
77
94
  }
78
95
  }
79
96
 
80
- export function failInspectRequest(requestId: string, error: string): PendingInspectRequest | undefined {
97
+ export function failInspectRequest(requestId: string, error: string, raw?: string): PendingInspectRequest | undefined {
81
98
  const request = getInspectRequest(requestId)
82
99
  if (!request || request.status !== "pending") return request
83
100
  request.status = "failed"
84
101
  request.error = error
102
+ if (raw) request.raw = boundedTail(raw)
103
+ request.updatedAt = Date.now()
104
+ if (!hasTerminalEvent(request)) {
105
+ appendInspectRequestEvent(request, {
106
+ type: "failed",
107
+ message: error,
108
+ timestamp: request.updatedAt,
109
+ ...(raw ? { detail: boundedTail(raw) } : {}),
110
+ })
111
+ }
112
+ return request
113
+ }
114
+
115
+ export function addInspectRequestEvent(requestId: string, event: ReviewBridgeEvent): PendingInspectRequest | undefined {
116
+ const request = getInspectRequest(requestId)
117
+ if (!request) return undefined
118
+ appendInspectRequestEvent(request, event)
85
119
  request.updatedAt = Date.now()
86
120
  return request
87
121
  }
88
122
 
123
+ export function subscribeInspectRequestEvents(
124
+ requestId: string,
125
+ listener: (event: ReviewBridgeEvent) => void,
126
+ ): () => void {
127
+ const set = subscribers.get(requestId) ?? new Set<(event: ReviewBridgeEvent) => void>()
128
+ set.add(listener)
129
+ subscribers.set(requestId, set)
130
+ return () => {
131
+ set.delete(listener)
132
+ if (set.size === 0) subscribers.delete(requestId)
133
+ }
134
+ }
135
+
89
136
  export function cleanupInspectRequests(now = Date.now()): void {
90
137
  for (const [requestId, request] of requests) {
91
138
  if (request.status === "pending" && now - request.createdAt > REQUEST_TTL_MS) {
92
139
  request.status = "expired"
93
140
  request.error = "Inspection timed out before the LLM submitted a result."
94
141
  request.updatedAt = now
142
+ appendInspectRequestEvent(request, {
143
+ type: "timeout",
144
+ message: request.error,
145
+ timestamp: now,
146
+ })
95
147
  continue
96
148
  }
97
149
  if (request.status !== "pending" && now - request.updatedAt > REQUEST_TTL_MS) {
98
150
  requests.delete(requestId)
151
+ subscribers.delete(requestId)
99
152
  }
100
153
  }
101
154
  }
102
155
 
103
156
  export function clearInspectRequestsForTests(): void {
104
157
  requests.clear()
158
+ subscribers.clear()
159
+ }
160
+
161
+ function appendInspectRequestEvent(request: PendingInspectRequest, event: ReviewBridgeEvent): void {
162
+ const previous = request.events.at(-1)
163
+ if (
164
+ previous
165
+ && previous.type === event.type
166
+ && previous.message === event.message
167
+ && previous.detail === event.detail
168
+ && Math.abs(previous.timestamp - event.timestamp) < 100
169
+ ) {
170
+ return
171
+ }
172
+ request.events.push(event)
173
+ const set = subscribers.get(request.requestId)
174
+ if (!set) return
175
+ for (const listener of set) listener(event)
176
+ }
177
+
178
+ function hasTerminalEvent(request: PendingInspectRequest): boolean {
179
+ return request.events.some((event) => event.type === "completed" || event.type === "failed" || event.type === "timeout")
180
+ }
181
+
182
+ function boundedTail(text: string, limit = 4096): string {
183
+ if (text.length <= limit) return text
184
+ return text.slice(text.length - limit)
105
185
  }
@@ -1,3 +1,5 @@
1
+ import type { ReviewBridgeEvent } from "./prompt-bridge"
2
+
1
3
  export type CommentRequestStatus = "pending" | "completed" | "failed" | "expired"
2
4
 
3
5
  export interface PendingCommentRequest {
@@ -6,11 +8,14 @@ export interface PendingCommentRequest {
6
8
  deckVersion: string
7
9
  createdAt: number
8
10
  updatedAt: number
11
+ events: ReviewBridgeEvent[]
9
12
  error?: string
13
+ raw?: string
10
14
  }
11
15
 
12
16
  const REQUEST_TTL_MS = 120 * 1000
13
17
  const requests = new Map<string, PendingCommentRequest>()
18
+ const subscribers = new Map<string, Set<(event: ReviewBridgeEvent) => void>>()
14
19
 
15
20
  export function createCommentRequest(input: {
16
21
  requestId: string
@@ -24,6 +29,7 @@ export function createCommentRequest(input: {
24
29
  deckVersion: input.deckVersion,
25
30
  createdAt: now,
26
31
  updatedAt: now,
32
+ events: [],
27
33
  }
28
34
  requests.set(input.requestId, request)
29
35
  return request
@@ -36,6 +42,11 @@ export function getCommentRequest(requestId: string): PendingCommentRequest | un
36
42
  if (request.status === "pending" && Date.now() - request.createdAt > REQUEST_TTL_MS) {
37
43
  request.status = "expired"
38
44
  request.error = "Review agent timed out before completing the comment request."
45
+ appendCommentRequestEvent(request, {
46
+ type: "timeout",
47
+ message: request.error,
48
+ timestamp: Date.now(),
49
+ })
39
50
  request.updatedAt = Date.now()
40
51
  }
41
52
  return request
@@ -46,32 +57,102 @@ export function completeCommentRequest(requestId: string): PendingCommentRequest
46
57
  if (!request || request.status !== "pending") return request
47
58
  request.status = "completed"
48
59
  request.updatedAt = Date.now()
60
+ if (!hasTerminalEvent(request)) {
61
+ appendCommentRequestEvent(request, {
62
+ type: "completed",
63
+ message: "Codex completed.",
64
+ timestamp: request.updatedAt,
65
+ })
66
+ }
49
67
  return request
50
68
  }
51
69
 
52
- export function failCommentRequest(requestId: string, error: string): PendingCommentRequest | undefined {
70
+ export function failCommentRequest(requestId: string, error: string, raw?: string): PendingCommentRequest | undefined {
53
71
  const request = getCommentRequest(requestId)
54
72
  if (!request || request.status !== "pending") return request
55
73
  request.status = "failed"
56
74
  request.error = error
75
+ if (raw) request.raw = boundedTail(raw)
57
76
  request.updatedAt = Date.now()
77
+ if (!hasTerminalEvent(request)) {
78
+ appendCommentRequestEvent(request, {
79
+ type: "failed",
80
+ message: error,
81
+ timestamp: request.updatedAt,
82
+ ...(raw ? { detail: boundedTail(raw) } : {}),
83
+ })
84
+ }
58
85
  return request
59
86
  }
60
87
 
88
+ function boundedTail(text: string, limit = 4096): string {
89
+ if (text.length <= limit) return text
90
+ return text.slice(text.length - limit)
91
+ }
92
+
93
+ export function addCommentRequestEvent(requestId: string, event: ReviewBridgeEvent): PendingCommentRequest | undefined {
94
+ const request = getCommentRequest(requestId)
95
+ if (!request) return undefined
96
+ appendCommentRequestEvent(request, event)
97
+ request.updatedAt = Date.now()
98
+ return request
99
+ }
100
+
101
+ export function subscribeCommentRequestEvents(
102
+ requestId: string,
103
+ listener: (event: ReviewBridgeEvent) => void,
104
+ ): () => void {
105
+ const set = subscribers.get(requestId) ?? new Set<(event: ReviewBridgeEvent) => void>()
106
+ set.add(listener)
107
+ subscribers.set(requestId, set)
108
+ return () => {
109
+ set.delete(listener)
110
+ if (set.size === 0) subscribers.delete(requestId)
111
+ }
112
+ }
113
+
61
114
  export function cleanupCommentRequests(now = Date.now()): void {
62
115
  for (const [requestId, request] of requests) {
63
116
  if (request.status === "pending" && now - request.createdAt > REQUEST_TTL_MS) {
64
117
  request.status = "expired"
65
118
  request.error = "Review agent timed out before completing the comment request."
66
119
  request.updatedAt = now
120
+ appendCommentRequestEvent(request, {
121
+ type: "timeout",
122
+ message: request.error,
123
+ timestamp: now,
124
+ })
67
125
  continue
68
126
  }
69
127
  if (request.status !== "pending" && now - request.updatedAt > REQUEST_TTL_MS) {
70
128
  requests.delete(requestId)
129
+ subscribers.delete(requestId)
71
130
  }
72
131
  }
73
132
  }
74
133
 
75
134
  export function clearCommentRequestsForTests(): void {
76
135
  requests.clear()
136
+ subscribers.clear()
137
+ }
138
+
139
+ function appendCommentRequestEvent(request: PendingCommentRequest, event: ReviewBridgeEvent): void {
140
+ const previous = request.events.at(-1)
141
+ if (
142
+ previous
143
+ && previous.type === event.type
144
+ && previous.message === event.message
145
+ && previous.detail === event.detail
146
+ && Math.abs(previous.timestamp - event.timestamp) < 100
147
+ ) {
148
+ return
149
+ }
150
+ request.events.push(event)
151
+ const set = subscribers.get(request.requestId)
152
+ if (!set) return
153
+ for (const listener of set) listener(event)
154
+ }
155
+
156
+ function hasTerminalEvent(request: PendingCommentRequest): boolean {
157
+ return request.events.some((event) => event.type === "completed" || event.type === "failed" || event.type === "timeout")
77
158
  }
@@ -9,7 +9,7 @@ import { openUrl } from "../edit/open"
9
9
  import { resolveEditableDeck, type EditableDeck } from "../edit/resolve-deck"
10
10
  import { buildPrompt } from "../prompt-builder"
11
11
  import type { ReviewPromptBridge } from "./prompt-bridge"
12
- import { startRefineServer, type RefineMode } from "./server"
12
+ import { startRefineServer, type RefineMode, type ReviewShellSurface } from "./server"
13
13
 
14
14
  export interface OpenRefineDeckResult {
15
15
  deck: EditableDeck
@@ -35,6 +35,7 @@ export interface OpenRefineDeckOptions {
35
35
  openBrowser?: boolean
36
36
  openUrl?: (url: string) => void
37
37
  promptBridge?: ReviewPromptBridge
38
+ surface?: ReviewShellSurface
38
39
  }
39
40
 
40
41
  export function openRefineDeck(target: string, options: OpenRefineDeckOptions): OpenRefineDeckResult {
@@ -74,7 +75,8 @@ function openRefineDeckInternal(
74
75
  mode,
75
76
  promptBridge: options.promptBridge,
76
77
  })
77
- const url = `${refineServer.baseUrl}/refine?token=${encodeURIComponent(session.token)}`
78
+ const route = options.surface === "codex" ? "/codex-review" : "/refine"
79
+ const url = `${refineServer.baseUrl}${route}?token=${encodeURIComponent(session.token)}`
78
80
  const shouldOpen = options.openBrowser !== false && !(behavior.skipLiveSession && session.live)
79
81
  if (shouldOpen) (options.openUrl ?? openUrl)(url)
80
82
 
@@ -4,6 +4,13 @@ import type { InspectionResult } from "../inspection-context/result"
4
4
  export type ReviewPromptAction = "comment" | "inspect"
5
5
  export type ReviewPromptBridgeKind = "opencode" | "codex-exec"
6
6
 
7
+ export type ReviewBridgeEvent = {
8
+ type: "started" | "codex_event" | "stdout" | "stderr" | "completed" | "failed" | "timeout"
9
+ message: string
10
+ timestamp: number
11
+ detail?: string
12
+ }
13
+
7
14
  export interface ReviewPromptInput {
8
15
  action: ReviewPromptAction
9
16
  prompt: string
@@ -11,6 +18,7 @@ export interface ReviewPromptInput {
11
18
  file: string
12
19
  requestId?: string
13
20
  timeoutMs?: number
21
+ onEvent?: (event: ReviewBridgeEvent) => void
14
22
  }
15
23
 
16
24
  export type ReviewPromptResult =
@@ -34,6 +42,8 @@ export type CodexExecRunner = (input: {
34
42
  workspaceRoot: string
35
43
  timeoutMs: number
36
44
  sandboxMode: "read-only" | "workspace-write"
45
+ skipGitRepoCheck: boolean
46
+ onEvent?: (event: ReviewBridgeEvent) => void
37
47
  }) => Promise<CodexExecRunResult>
38
48
 
39
49
  export function createOpenCodeReviewPromptBridge(client: any, sessionID: string): ReviewPromptBridge {
@@ -68,15 +78,19 @@ export function createCodexExecReviewPromptBridge(options: {
68
78
  kind: "codex-exec",
69
79
  async send(input) {
70
80
  const sandboxMode = input.action === "comment" ? "workspace-write" : "read-only"
81
+ input.onEvent?.(bridgeEvent("started", "Starting Codex..."))
71
82
  const output = await runner({
72
83
  action: input.action,
73
84
  prompt: input.prompt,
74
85
  workspaceRoot: input.workspaceRoot,
75
86
  timeoutMs: input.timeoutMs ?? timeoutMs,
76
87
  sandboxMode,
88
+ skipGitRepoCheck: true,
89
+ onEvent: input.onEvent,
77
90
  })
78
91
  const raw = [output.stdout, output.stderr].filter(Boolean).join("\n")
79
92
  if (output.exitCode !== 0) {
93
+ input.onEvent?.(bridgeEvent("failed", `codex exec failed with exit code ${output.exitCode ?? "unknown"}.`, boundedTail(raw)))
80
94
  return {
81
95
  ok: false,
82
96
  status: "failed",
@@ -85,6 +99,7 @@ export function createCodexExecReviewPromptBridge(options: {
85
99
  }
86
100
  }
87
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)))
88
103
  return {
89
104
  ok: false,
90
105
  status: "failed",
@@ -92,9 +107,13 @@ export function createCodexExecReviewPromptBridge(options: {
92
107
  raw,
93
108
  }
94
109
  }
95
- if (input.action === "comment") return { ok: true, status: "completed", raw }
110
+ if (input.action === "comment") {
111
+ input.onEvent?.(bridgeEvent("completed", "Codex completed."))
112
+ return { ok: true, status: "completed", raw }
113
+ }
96
114
  const result = extractInspectionResult(output.stdout)
97
115
  if (!result) {
116
+ input.onEvent?.(bridgeEvent("failed", "codex exec did not return a valid inspection result JSON object.", boundedTail(raw)))
98
117
  return {
99
118
  ok: false,
100
119
  status: "failed",
@@ -102,6 +121,7 @@ export function createCodexExecReviewPromptBridge(options: {
102
121
  raw,
103
122
  }
104
123
  }
124
+ input.onEvent?.(bridgeEvent("completed", "Codex completed the inspection."))
105
125
  return { ok: true, status: "completed", result, raw }
106
126
  },
107
127
  }
@@ -113,38 +133,100 @@ async function runCodexExec(input: {
113
133
  workspaceRoot: string
114
134
  timeoutMs: number
115
135
  sandboxMode: "read-only" | "workspace-write"
136
+ skipGitRepoCheck: boolean
137
+ onEvent?: (event: ReviewBridgeEvent) => void
116
138
  }): Promise<CodexExecRunResult> {
117
139
  return new Promise((resolve) => {
118
- const child = spawn("codex", ["exec", "--json", "--ephemeral", "--sandbox", input.sandboxMode, "-C", input.workspaceRoot, input.prompt], {
140
+ const args = ["exec", "--json", "--ephemeral"]
141
+ if (input.skipGitRepoCheck) args.push("--skip-git-repo-check")
142
+ args.push("--sandbox", input.sandboxMode, "-C", input.workspaceRoot, input.prompt)
143
+ const child = spawn("codex", args, {
119
144
  stdio: ["ignore", "pipe", "pipe"],
120
145
  })
121
146
  let stdout = ""
122
147
  let stderr = ""
148
+ let stdoutLineBuffer = ""
149
+ let resolved = false
150
+ const resolveOnce = (output: CodexExecRunResult) => {
151
+ if (resolved) return
152
+ resolved = true
153
+ resolve(output)
154
+ }
123
155
  const timer = setTimeout(() => {
124
156
  child.kill()
125
- resolve({
157
+ 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)))
159
+ resolveOnce({
126
160
  exitCode: 124,
127
161
  stdout,
128
- stderr: `${stderr}${stderr ? "\n" : ""}codex exec timed out after ${input.timeoutMs}ms.`,
162
+ stderr: nextStderr,
129
163
  })
130
164
  }, input.timeoutMs)
131
165
  child.stdout?.on("data", (chunk) => {
132
- stdout += chunk.toString()
166
+ const text = chunk.toString()
167
+ stdout += text
168
+ stdoutLineBuffer = emitCodexJsonProgress(stdoutLineBuffer + text, input.action, input.onEvent)
133
169
  })
134
170
  child.stderr?.on("data", (chunk) => {
135
- stderr += chunk.toString()
171
+ const text = chunk.toString()
172
+ stderr += text
173
+ input.onEvent?.(bridgeEvent("stderr", "Codex wrote diagnostic output.", boundedTail(text)))
136
174
  })
137
175
  child.on("error", (error) => {
138
176
  clearTimeout(timer)
139
- resolve({ exitCode: 127, stdout, stderr: error.message })
177
+ input.onEvent?.(bridgeEvent("failed", "Failed to start codex exec.", boundedTail(error.message)))
178
+ resolveOnce({ exitCode: 127, stdout, stderr: error.message })
140
179
  })
141
180
  child.on("close", (code) => {
142
181
  clearTimeout(timer)
143
- resolve({ exitCode: code, stdout, stderr })
182
+ emitCodexJsonProgress(`${stdoutLineBuffer}\n`, input.action, input.onEvent)
183
+ resolveOnce({ exitCode: code, stdout, stderr })
144
184
  })
145
185
  })
146
186
  }
147
187
 
188
+ function emitCodexJsonProgress(buffer: string, action: ReviewPromptAction, onEvent?: (event: ReviewBridgeEvent) => void): string {
189
+ const lines = buffer.split(/\r?\n/)
190
+ const remainder = lines.pop() ?? ""
191
+ for (const line of lines) {
192
+ const parsed = parseJson(line)
193
+ const message = codexProgressMessage(parsed, action)
194
+ if (message) {
195
+ onEvent?.(bridgeEvent("codex_event", message, boundedTail(line)))
196
+ } else if (parsed === undefined && line.trim()) {
197
+ onEvent?.(bridgeEvent("stdout", "Codex wrote output.", boundedTail(line)))
198
+ }
199
+ }
200
+ return remainder
201
+ }
202
+
203
+ function codexProgressMessage(value: unknown, action: ReviewPromptAction): string | undefined {
204
+ if (!value || typeof value !== "object") return undefined
205
+ const record = value as Record<string, unknown>
206
+ const type = typeof record.type === "string" ? record.type.toLowerCase() : ""
207
+ const event = typeof record.event === "string" ? record.event.toLowerCase() : ""
208
+ const name = `${type} ${event}`
209
+ if (!name.trim()) return undefined
210
+ if (name.includes("turn_completed") || name.includes("completed")) {
211
+ return action === "comment" ? undefined : "Codex completed the inspection."
212
+ }
213
+ if (name.includes("exec") || name.includes("patch") || name.includes("tool") || name.includes("apply")) {
214
+ return action === "comment" ? "Codex is applying the requested edit..." : "Codex is reading the deck..."
215
+ }
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..."
218
+ return "Codex is working..."
219
+ }
220
+
221
+ function bridgeEvent(type: ReviewBridgeEvent["type"], message: string, detail?: string): ReviewBridgeEvent {
222
+ return { type, message, timestamp: Date.now(), ...(detail ? { detail } : {}) }
223
+ }
224
+
225
+ function boundedTail(text: string, limit = 4096): string {
226
+ if (text.length <= limit) return text
227
+ return text.slice(text.length - limit)
228
+ }
229
+
148
230
  function isCodexWriteBlocked(raw: string): boolean {
149
231
  const text = raw.toLowerCase()
150
232
  return (
@@ -9,11 +9,11 @@ import { buildPrompt } from "../prompt-builder"
9
9
  import type { InspectionElementSnapshot } from "../inspection-context/match"
10
10
  import { buildInspectionPrompt } from "../inspect/prompt"
11
11
  import { projectWorkspaceElement } from "../inspect/request"
12
- import { completeInspectRequest, createInspectRequest, failInspectRequest, getInspectRequest } from "../inspect/requests"
12
+ import { addInspectRequestEvent, completeInspectRequest, createInspectRequest, failInspectRequest, getInspectRequest, subscribeInspectRequestEvents } from "../inspect/requests"
13
13
  import { saveMediaAsset } from "../media/save"
14
14
  import { searchRemoteImages, type ImageCandidate } from "../media/search"
15
15
  import type { MediaAssetRecord, MediaPurpose } from "../media/types"
16
- import { completeCommentRequest, createCommentRequest, failCommentRequest, getCommentRequest } from "./comment-requests"
16
+ import { addCommentRequestEvent, completeCommentRequest, createCommentRequest, failCommentRequest, getCommentRequest, subscribeCommentRequestEvents } from "./comment-requests"
17
17
  import { createOpenCodeReviewPromptBridge, type ReviewPromptBridge } from "./prompt-bridge"
18
18
  import { suppressReviewApplyFixArtifactQa } from "./qa-suppression"
19
19
  import { annotateVisualEditTargets, applyVisualTargetChanges, type VisualEditTarget } from "./visual-targets"
@@ -48,6 +48,7 @@ interface EditSession {
48
48
  }
49
49
 
50
50
  export type RefineMode = "edit" | "inspect"
51
+ export type ReviewShellSurface = "legacy" | "codex"
51
52
 
52
53
  export interface RefineServerHandle {
53
54
  baseUrl: string
@@ -177,6 +178,12 @@ async function handleRequest(req: Request): Promise<Response> {
177
178
  return htmlResponse(renderRefineShell(session.value.token, session.value.defaultMode))
178
179
  }
179
180
 
181
+ if (url.pathname === "/codex-review" && req.method === "GET") {
182
+ const session = validateSession(url.searchParams.get("token"))
183
+ if (!session.ok) return session.response
184
+ return htmlResponse(renderCodexReviewShell(session.value.token, session.value.defaultMode))
185
+ }
186
+
180
187
  if (url.pathname === "/deck" && req.method === "GET") {
181
188
  const session = validateSession(url.searchParams.get("token"))
182
189
  if (!session.ok) return session.response
@@ -201,6 +208,12 @@ async function handleRequest(req: Request): Promise<Response> {
201
208
  return handleCommentResult(url.searchParams.get("requestId"), session.value)
202
209
  }
203
210
 
211
+ if (url.pathname === "/api/comment-events" && req.method === "GET") {
212
+ const session = validateSession(url.searchParams.get("token"))
213
+ if (!session.ok) return session.response
214
+ return handleCommentEvents(url.searchParams.get("requestId"), session.value)
215
+ }
216
+
204
217
  if (url.pathname === "/api/inspect" && req.method === "POST") {
205
218
  const session = validateSession(url.searchParams.get("token"))
206
219
  if (!session.ok) return session.response
@@ -213,6 +226,12 @@ async function handleRequest(req: Request): Promise<Response> {
213
226
  return handleInspectResult(url.searchParams.get("requestId"), session.value)
214
227
  }
215
228
 
229
+ if (url.pathname === "/api/inspect-events" && req.method === "GET") {
230
+ const session = validateSession(url.searchParams.get("token"))
231
+ if (!session.ok) return session.response
232
+ return handleInspectEvents(url.searchParams.get("requestId"), session.value)
233
+ }
234
+
216
235
  if (url.pathname === "/api/deck-version" && req.method === "GET") {
217
236
  const session = validateSession(url.searchParams.get("token"))
218
237
  if (!session.ok) return session.response
@@ -765,11 +784,12 @@ async function handleComment(req: Request, session: EditSession): Promise<Respon
765
784
  workspaceRoot: session.workspaceRoot,
766
785
  file: session.file,
767
786
  requestId,
787
+ onEvent: (event) => addCommentRequestEvent(requestId, event),
768
788
  }).then((result) => {
769
789
  if (result.ok) {
770
790
  completeCommentRequest(requestId)
771
791
  } else {
772
- failCommentRequest(requestId, result.error)
792
+ failCommentRequest(requestId, result.error, result.raw)
773
793
  }
774
794
  }).catch((error: unknown) => {
775
795
  const message = error instanceof Error ? error.message : String(error)
@@ -781,6 +801,88 @@ async function handleComment(req: Request, session: EditSession): Promise<Respon
781
801
  return jsonResponse({ ok: true, requestId, commentRequestId: requestId, deckVersion, status: "pending" })
782
802
  }
783
803
 
804
+ function handleCommentEvents(requestId: string | null, session: EditSession): Response {
805
+ if (!requestId) return jsonResponse({ ok: false, error: "Missing requestId" }, 400)
806
+ const request = getCommentRequest(requestId)
807
+ if (!request) return jsonResponse({ ok: false, requestId, error: "Comment request not found" }, 404)
808
+ session.lastActiveAt = Date.now()
809
+ scheduleIdleStop()
810
+
811
+ const encoder = new TextEncoder()
812
+ let unsubscribe = () => {}
813
+ const stream = new ReadableStream<Uint8Array>({
814
+ start(controller) {
815
+ const send = (event: unknown) => {
816
+ controller.enqueue(encoder.encode(`event: progress\ndata: ${JSON.stringify(event)}\n\n`))
817
+ }
818
+ for (const event of request.events) send(event)
819
+ if (request.status !== "pending") {
820
+ controller.close()
821
+ return
822
+ }
823
+ unsubscribe = subscribeCommentRequestEvents(requestId, (event) => {
824
+ send(event)
825
+ if (event.type === "completed" || event.type === "failed" || event.type === "timeout") {
826
+ unsubscribe()
827
+ controller.close()
828
+ }
829
+ })
830
+ },
831
+ cancel() {
832
+ unsubscribe()
833
+ },
834
+ })
835
+
836
+ return new Response(stream, {
837
+ headers: {
838
+ "content-type": "text/event-stream; charset=utf-8",
839
+ "cache-control": "no-store",
840
+ connection: "keep-alive",
841
+ },
842
+ })
843
+ }
844
+
845
+ function handleInspectEvents(requestId: string | null, session: EditSession): Response {
846
+ if (!requestId) return jsonResponse({ ok: false, error: "Missing requestId" }, 400)
847
+ const request = getInspectRequest(requestId)
848
+ if (!request) return jsonResponse({ ok: false, requestId, error: "Inspection request not found" }, 404)
849
+ session.lastActiveAt = Date.now()
850
+ scheduleIdleStop()
851
+
852
+ const encoder = new TextEncoder()
853
+ let unsubscribe = () => {}
854
+ const stream = new ReadableStream<Uint8Array>({
855
+ start(controller) {
856
+ const send = (event: unknown) => {
857
+ controller.enqueue(encoder.encode(`event: progress\ndata: ${JSON.stringify(event)}\n\n`))
858
+ }
859
+ for (const event of request.events) send(event)
860
+ if (request.status !== "pending") {
861
+ controller.close()
862
+ return
863
+ }
864
+ unsubscribe = subscribeInspectRequestEvents(requestId, (event) => {
865
+ send(event)
866
+ if (event.type === "completed" || event.type === "failed" || event.type === "timeout") {
867
+ unsubscribe()
868
+ controller.close()
869
+ }
870
+ })
871
+ },
872
+ cancel() {
873
+ unsubscribe()
874
+ },
875
+ })
876
+
877
+ return new Response(stream, {
878
+ headers: {
879
+ "content-type": "text/event-stream; charset=utf-8",
880
+ "cache-control": "no-store",
881
+ connection: "keep-alive",
882
+ },
883
+ })
884
+ }
885
+
784
886
  function handleCommentResult(requestId: string | null, session: EditSession): Response {
785
887
  if (!requestId) return jsonResponse({ ok: false, error: "Missing requestId" }, 400)
786
888
  const request = getCommentRequest(requestId)
@@ -788,7 +890,14 @@ function handleCommentResult(requestId: string | null, session: EditSession): Re
788
890
  session.lastActiveAt = Date.now()
789
891
  scheduleIdleStop()
790
892
  if (request.status === "failed" || request.status === "expired") {
791
- return jsonResponse({ ok: true, requestId, status: request.status, deckVersion: request.deckVersion, error: request.error || "Review agent failed" })
893
+ return jsonResponse({
894
+ ok: true,
895
+ requestId,
896
+ status: request.status,
897
+ deckVersion: request.deckVersion,
898
+ error: request.error || "Review agent failed",
899
+ raw: request.raw,
900
+ })
792
901
  }
793
902
  return jsonResponse({ ok: true, requestId, status: request.status, deckVersion: request.deckVersion })
794
903
  }
@@ -834,11 +943,12 @@ async function handleInspect(req: Request, session: EditSession): Promise<Respon
834
943
  workspaceRoot: session.workspaceRoot,
835
944
  file: session.file,
836
945
  requestId,
946
+ onEvent: (event) => addInspectRequestEvent(requestId, event),
837
947
  }).then((result) => {
838
948
  if (result.ok && result.result) {
839
949
  completeInspectRequest(requestId, result.result)
840
950
  } else if (!result.ok) {
841
- failInspectRequest(requestId, result.error)
951
+ failInspectRequest(requestId, result.error, result.raw)
842
952
  }
843
953
  }).catch((error: unknown) => {
844
954
  const message = error instanceof Error ? error.message : String(error)
@@ -860,7 +970,7 @@ function handleInspectResult(requestId: string | null, session: EditSession): Re
860
970
  session.lastActiveAt = Date.now()
861
971
  scheduleIdleStop()
862
972
  if (request.status === "completed") return jsonResponse({ ok: true, requestId, status: request.status, deckVersion: request.deckVersion, result: request.result })
863
- if (request.status === "failed" || request.status === "expired") return jsonResponse({ ok: true, requestId, status: request.status, deckVersion: request.deckVersion, error: request.error || "Insight failed" })
973
+ if (request.status === "failed" || request.status === "expired") return jsonResponse({ ok: true, requestId, status: request.status, deckVersion: request.deckVersion, error: request.error || "Insight failed", raw: request.raw })
864
974
  return jsonResponse({ ok: true, requestId, status: request.status, deckVersion: request.deckVersion })
865
975
  }
866
976
 
@@ -964,9 +1074,16 @@ function jsonResponse(body: unknown, status = 200): Response {
964
1074
  })
965
1075
  }
966
1076
 
967
- export function renderRefineShell(token: string, defaultMode: RefineMode = "edit"): string {
1077
+ export function renderCodexReviewShell(token: string, defaultMode: RefineMode = "edit"): string {
1078
+ return renderRefineShell(token, defaultMode, "codex")
1079
+ }
1080
+
1081
+ export function renderRefineShell(token: string, defaultMode: RefineMode = "edit", surface: ReviewShellSurface = "legacy"): string {
968
1082
  const encodedToken = JSON.stringify(token)
969
1083
  const encodedDefaultMode = JSON.stringify(defaultMode)
1084
+ const encodedSurface = JSON.stringify(surface)
1085
+ const activityLabel = surface === "codex" ? "Codex Activity" : "Activity"
1086
+ const bodyClass = surface === "codex" ? "codex-review" : "legacy-review"
970
1087
  return `<!doctype html>
971
1088
  <html lang="en">
972
1089
  <head>
@@ -1029,6 +1146,19 @@ export function renderRefineShell(token: string, defaultMode: RefineMode = "edit
1029
1146
  .comment-bubble.failed { border-color: #c58f82; background: #f7eae5; }
1030
1147
  .comment-bubble-text { white-space: pre-wrap; overflow-wrap: anywhere; }
1031
1148
  .comment-bubble-state { margin-top: 8px; color: #8a6231; font-size: 12px; font-weight: 800; }
1149
+ .comment-progress { margin-top: 8px; display: flex; flex-direction: column; gap: 4px; color: #5f574d; font-size: 12px; }
1150
+ .comment-progress-line { display: flex; gap: 6px; align-items: flex-start; }
1151
+ .comment-progress-line::before { content: ""; width: 6px; height: 6px; margin-top: 6px; border-radius: 999px; background: #b48b52; flex: 0 0 auto; }
1152
+ .comment-raw { margin-top: 8px; color: #6f473c; font-size: 12px; }
1153
+ .comment-raw summary { cursor: pointer; font-weight: 800; }
1154
+ .comment-raw pre { margin: 6px 0 0; max-height: 160px; overflow: auto; white-space: pre-wrap; overflow-wrap: anywhere; background: rgba(255,255,255,.55); border: 1px solid rgba(143,70,56,.22); border-radius: 8px; padding: 8px; }
1155
+ .codex-log { margin-top: 8px; color: #4b5563; font-size: 12px; }
1156
+ .codex-log summary { cursor: pointer; font-weight: 900; }
1157
+ .codex-log-list { margin-top: 7px; display: flex; flex-direction: column; gap: 6px; max-height: 240px; overflow: auto; }
1158
+ .codex-log-entry { padding: 7px 8px; border: 1px solid rgba(148,163,184,.34); border-radius: 8px; background: rgba(255,255,255,.58); }
1159
+ .codex-log-meta { display: flex; justify-content: space-between; gap: 8px; color: #6b7280; font-size: 11px; font-weight: 800; text-transform: uppercase; }
1160
+ .codex-log-message { margin-top: 4px; color: #374151; white-space: pre-wrap; overflow-wrap: anywhere; }
1161
+ .codex-log-detail { margin: 5px 0 0; max-height: 120px; overflow: auto; white-space: pre-wrap; overflow-wrap: anywhere; color: #111827; background: rgba(17,24,39,.05); border-radius: 6px; padding: 6px; }
1032
1162
  .comment-bubble.updated .comment-bubble-state { color: #556b3f; }
1033
1163
  .comment-bubble.stale .comment-bubble-state { color: #8a6231; }
1034
1164
  .comment-bubble.failed .comment-bubble-state { color: #8f4638; }
@@ -1098,7 +1228,7 @@ export function renderRefineShell(token: string, defaultMode: RefineMode = "edit
1098
1228
  @media (max-width: 900px) { .app { grid-template-columns: 1fr; grid-template-rows: minmax(0, 1fr) auto; } .resize-handle { display: none; } aside { max-height: 48vh; } .deck-nav { bottom: 10px; } }
1099
1229
  </style>
1100
1230
  </head>
1101
- <body>
1231
+ <body class="${bodyClass}">
1102
1232
  <main class="app">
1103
1233
  <section class="preview"><iframe id="deck" src="/deck?token=${encodeURIComponent(token)}"></iframe><div id="hitbox" class="hitbox" aria-label="Deck element selection layer"></div><div id="visualMoveHandle" class="visual-move-handle" aria-hidden="true"></div><div id="visualResizeHandle" class="visual-resize-handle" aria-hidden="true"></div><div id="visualEditToolbar" class="visual-edit-toolbar" aria-live="polite"><span id="visualEditCount">No unsaved visual changes</span><button id="visualUndo" type="button">Undo</button><button id="visualReset" type="button">Reset</button><button id="visualSave" class="save-visual" type="button">Save Changes</button></div><nav class="deck-nav" aria-label="Deck navigation"><button id="deckPrev" type="button" title="Previous slide (ArrowLeft / ArrowUp / PageUp)">Previous</button><div id="deckCounter" class="deck-nav-status" aria-live="polite">-- / --</div><button id="deckNext" type="button" title="Next slide (ArrowRight / ArrowDown / Space / PageDown)">Next</button></nav></section>
1104
1234
  <div id="resizeHandle" class="resize-handle" role="separator" aria-label="Resize editor panel" aria-orientation="vertical" title="Drag to resize editor. Double-click to reset."></div>
@@ -1123,7 +1253,7 @@ export function renderRefineShell(token: string, defaultMode: RefineMode = "edit
1123
1253
  </div>
1124
1254
  </div>
1125
1255
  <button id="send" class="primary-action" disabled><svg class="send-icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94L14.7 6.3z"/></svg><span>Apply Fix</span></button>
1126
- <div class="activity-panel"><div class="label">Activity</div><div id="commentThread" class="comment-thread" aria-live="polite"></div></div>
1256
+ <div class="activity-panel"><div class="label">${activityLabel}</div><div id="commentThread" class="comment-thread" aria-live="polite"></div></div>
1127
1257
  </div>
1128
1258
  <div id="inspectPanel" class="tab-panel">
1129
1259
  <div class="panel">
@@ -1155,6 +1285,8 @@ export function renderRefineShell(token: string, defaultMode: RefineMode = "edit
1155
1285
  (() => {
1156
1286
  const token = ${encodedToken};
1157
1287
  const defaultMode = ${encodedDefaultMode};
1288
+ const reviewSurface = ${encodedSurface};
1289
+ const codexReview = reviewSurface === 'codex';
1158
1290
  const COMMENT_STALE_MS = 60000;
1159
1291
  const EDITOR_WIDTH_KEY = 'revela-edit-editor-width';
1160
1292
  const DEFAULT_EDITOR_WIDTH = 376;
@@ -1192,6 +1324,7 @@ export function renderRefineShell(token: string, defaultMode: RefineMode = "edit
1192
1324
  mode: defaultMode === 'inspect' ? 'inspect' : 'edit',
1193
1325
  inspecting: false,
1194
1326
  activeInspectRequestId: '',
1327
+ inspectEventLog: [],
1195
1328
  inspectLanguage: 'Auto',
1196
1329
  inspectFallback: null,
1197
1330
  sendingEdit: false,
@@ -1975,12 +2108,19 @@ export function renderRefineShell(token: string, defaultMode: RefineMode = "edit
1975
2108
  window.setInterval(pollDeckVersion, 2000);
1976
2109
  }
1977
2110
 
2111
+ async function fetchDeckVersion() {
2112
+ const res = await fetch('/api/deck-version?token=' + encodeURIComponent(token), { cache: 'no-store' });
2113
+ const body = await res.json().catch(() => ({}));
2114
+ if (!res.ok || !body.ok) throw new Error(body.error || 'Failed to check deck version');
2115
+ return {
2116
+ body,
2117
+ version: body.version || (String(body.mtimeMs) + ':' + String(body.size)),
2118
+ };
2119
+ }
2120
+
1978
2121
  async function pollDeckVersion() {
1979
2122
  try {
1980
- const res = await fetch('/api/deck-version?token=' + encodeURIComponent(token), { cache: 'no-store' });
1981
- const body = await res.json().catch(() => ({}));
1982
- if (!res.ok || !body.ok) throw new Error(body.error || 'Failed to check deck version');
1983
- const nextVersion = body.version || (String(body.mtimeMs) + ':' + String(body.size));
2123
+ const { body, version: nextVersion } = await fetchDeckVersion();
1984
2124
  if (!state.deckVersion) {
1985
2125
  state.deckVersion = nextVersion;
1986
2126
  markCommentsUpdatedForVersion(nextVersion);
@@ -1999,6 +2139,28 @@ export function renderRefineShell(token: string, defaultMode: RefineMode = "edit
1999
2139
  }
2000
2140
  }
2001
2141
 
2142
+ async function watchDeckVersionAfterComment(commentId) {
2143
+ const comment = state.pendingComments.find((item) => item.id === commentId);
2144
+ const baseDeckVersion = comment?.baseDeckVersion || state.deckVersion;
2145
+ const started = Date.now();
2146
+ while (Date.now() - started < 15000) {
2147
+ if (pendingCommentStatus(commentId) === 'updated' || pendingCommentStatus(commentId) === 'failed') return;
2148
+ await delay(250);
2149
+ try {
2150
+ const { body, version: nextVersion } = await fetchDeckVersion();
2151
+ if (nextVersion && nextVersion !== baseDeckVersion) {
2152
+ state.deckVersion = nextVersion;
2153
+ markCommentsUpdatedForVersion(nextVersion);
2154
+ refreshDeckPreview(body.mtimeMs);
2155
+ return;
2156
+ }
2157
+ } catch (error) {
2158
+ reportError(error);
2159
+ return;
2160
+ }
2161
+ }
2162
+ }
2163
+
2002
2164
  function refreshDeckPreview(version) {
2003
2165
  state.pendingRefreshMessage = true;
2004
2166
  state.pendingDeckSlideRestore = state.deckSlideIndex;
@@ -2098,7 +2260,7 @@ export function renderRefineShell(token: string, defaultMode: RefineMode = "edit
2098
2260
  if (!res.ok || !body.ok) throw new Error(body.error || 'Failed to send comment');
2099
2261
  updatePendingCommentStatus(commentId, 'sent', { baseDeckVersion: body.deckVersion || state.deckVersion, requestId: body.commentRequestId || body.requestId || '' });
2100
2262
  if (pendingCommentStatus(commentId) !== 'updated') setStatus('Comment sent. Waiting for deck update...');
2101
- if (body.commentRequestId || body.requestId) pollCommentResult(commentId, body.commentRequestId || body.requestId);
2263
+ if (body.commentRequestId || body.requestId) watchCommentProgress(commentId, body.commentRequestId || body.requestId);
2102
2264
  } catch (error) {
2103
2265
  updatePendingCommentStatus(commentId, 'failed');
2104
2266
  reportError(error);
@@ -2458,7 +2620,7 @@ export function renderRefineShell(token: string, defaultMode: RefineMode = "edit
2458
2620
  if (!res.ok || !body.ok) throw new Error(body.error || 'Failed to send asset placement');
2459
2621
  updatePendingCommentStatus(commentId, 'sent', { baseDeckVersion: body.deckVersion || state.deckVersion, requestId: body.commentRequestId || body.requestId || '' });
2460
2622
  if (pendingCommentStatus(commentId) !== 'updated') setStatus('Asset placement sent. Waiting for deck update...');
2461
- if (body.commentRequestId || body.requestId) pollCommentResult(commentId, body.commentRequestId || body.requestId);
2623
+ if (body.commentRequestId || body.requestId) watchCommentProgress(commentId, body.commentRequestId || body.requestId);
2462
2624
  } catch (error) {
2463
2625
  updatePendingCommentStatus(commentId, 'failed');
2464
2626
  reportError(error);
@@ -2543,6 +2705,9 @@ export function renderRefineShell(token: string, defaultMode: RefineMode = "edit
2543
2705
  baseDeckVersion: state.deckVersion,
2544
2706
  updatedVersion: null,
2545
2707
  requestId: '',
2708
+ progressEvent: null,
2709
+ eventLog: [],
2710
+ failureRaw: '',
2546
2711
  });
2547
2712
  renderCommentThread();
2548
2713
  return id;
@@ -2554,6 +2719,7 @@ export function renderRefineShell(token: string, defaultMode: RefineMode = "edit
2554
2719
  if (comment.status === 'updated' && status !== 'failed') return;
2555
2720
  comment.status = status;
2556
2721
  if (updates) Object.assign(comment, updates);
2722
+ if (status === 'updated' || status === 'failed') comment.progressEvent = null;
2557
2723
  renderCommentThread();
2558
2724
  }
2559
2725
 
@@ -2563,6 +2729,7 @@ export function renderRefineShell(token: string, defaultMode: RefineMode = "edit
2563
2729
  if ((comment.status === 'sent' || comment.status === 'sending' || comment.status === 'stale') && comment.baseDeckVersion !== version) {
2564
2730
  comment.status = 'updated';
2565
2731
  comment.updatedVersion = version;
2732
+ comment.progressEvent = null;
2566
2733
  changed = true;
2567
2734
  }
2568
2735
  });
@@ -2601,7 +2768,7 @@ export function renderRefineShell(token: string, defaultMode: RefineMode = "edit
2601
2768
  const body = await res.json().catch(() => ({}));
2602
2769
  if (!res.ok || !body.ok) throw new Error(body.error || 'Comment result failed');
2603
2770
  if (body.status === 'failed' || body.status === 'expired') {
2604
- updatePendingCommentStatus(commentId, 'failed');
2771
+ updatePendingCommentStatus(commentId, 'failed', { failureRaw: body.raw || '' });
2605
2772
  setStatus(body.error || 'Review agent failed to apply the comment.');
2606
2773
  return;
2607
2774
  }
@@ -2619,6 +2786,135 @@ export function renderRefineShell(token: string, defaultMode: RefineMode = "edit
2619
2786
  }
2620
2787
  }
2621
2788
 
2789
+ function watchCommentProgress(commentId, requestId) {
2790
+ if (!requestId) return;
2791
+ if (!('EventSource' in window)) {
2792
+ pollCommentResult(commentId, requestId);
2793
+ return;
2794
+ }
2795
+ let closed = false;
2796
+ let fallbackStarted = false;
2797
+ const startFallback = () => {
2798
+ if (fallbackStarted) return;
2799
+ fallbackStarted = true;
2800
+ pollCommentResult(commentId, requestId);
2801
+ };
2802
+ let source;
2803
+ try {
2804
+ source = new EventSource('/api/comment-events?token=' + encodeURIComponent(token) + '&requestId=' + encodeURIComponent(requestId));
2805
+ } catch {
2806
+ startFallback();
2807
+ return;
2808
+ }
2809
+ source.addEventListener('progress', (event) => {
2810
+ let payload;
2811
+ try {
2812
+ payload = JSON.parse(event.data || '{}');
2813
+ } catch {
2814
+ return;
2815
+ }
2816
+ recordCommentProgress(commentId, payload);
2817
+ if (payload.type === 'failed' || payload.type === 'timeout') {
2818
+ closed = true;
2819
+ source.close();
2820
+ updatePendingCommentStatus(commentId, 'failed', { failureRaw: payload.detail || '' });
2821
+ setStatus(payload.message || 'Review agent failed to apply the comment.');
2822
+ } else if (payload.type === 'completed') {
2823
+ closed = true;
2824
+ source.close();
2825
+ if (pendingCommentStatus(commentId) !== 'updated') setStatus(payload.message || 'Waiting for deck file update...');
2826
+ watchDeckVersionAfterComment(commentId);
2827
+ } else if (payload.message) {
2828
+ setStatus(payload.message);
2829
+ }
2830
+ });
2831
+ source.onerror = () => {
2832
+ source.close();
2833
+ if (!closed && pendingCommentStatus(commentId) !== 'updated' && pendingCommentStatus(commentId) !== 'failed') {
2834
+ startFallback();
2835
+ }
2836
+ };
2837
+ }
2838
+
2839
+ function recordCommentProgress(commentId, event) {
2840
+ const comment = state.pendingComments.find((item) => item.id === commentId);
2841
+ if (!comment || !event || !event.message) return;
2842
+ if (codexReview) {
2843
+ appendCodexEventLog(comment, event);
2844
+ }
2845
+ if (event.type === 'completed') {
2846
+ comment.progressEvent = null;
2847
+ if (codexReview) renderCommentThread();
2848
+ return;
2849
+ }
2850
+ const nextEvent = {
2851
+ type: event.type || 'codex_event',
2852
+ message: String(event.message).slice(0, 240),
2853
+ detail: typeof event.detail === 'string' ? event.detail.slice(-4096) : '',
2854
+ };
2855
+ const duplicate = comment.progressEvent;
2856
+ if (duplicate && duplicate.type === nextEvent.type && duplicate.message === nextEvent.message && duplicate.detail === nextEvent.detail) return;
2857
+ comment.progressEvent = nextEvent;
2858
+ if (event.type === 'failed' || event.type === 'timeout') comment.failureRaw = typeof event.detail === 'string' ? event.detail.slice(-4096) : '';
2859
+ renderCommentThread();
2860
+ }
2861
+
2862
+ function appendCodexEventLog(target, event) {
2863
+ if (!target.eventLog) target.eventLog = [];
2864
+ const next = {
2865
+ type: event.type || 'codex_event',
2866
+ message: String(event.message || '').slice(0, 500),
2867
+ detail: typeof event.detail === 'string' ? event.detail.slice(-12000) : '',
2868
+ timestamp: typeof event.timestamp === 'number' ? event.timestamp : Date.now(),
2869
+ };
2870
+ const previous = target.eventLog[target.eventLog.length - 1];
2871
+ if (previous && previous.type === next.type && previous.message === next.message && previous.detail === next.detail) return;
2872
+ target.eventLog.push(next);
2873
+ if (target.eventLog.length > 250) target.eventLog.splice(0, target.eventLog.length - 250);
2874
+ }
2875
+
2876
+ function codexLogSummary(log) {
2877
+ const count = Array.isArray(log) ? log.length : 0;
2878
+ return count === 1 ? 'Codex execution log (1 event)' : 'Codex execution log (' + count + ' events)';
2879
+ }
2880
+
2881
+ function renderCodexLog(log) {
2882
+ if (!codexReview || !Array.isArray(log) || !log.length) return null;
2883
+ const details = document.createElement('details');
2884
+ details.className = 'codex-log';
2885
+ const summary = document.createElement('summary');
2886
+ summary.textContent = codexLogSummary(log);
2887
+ const list = document.createElement('div');
2888
+ list.className = 'codex-log-list';
2889
+ log.forEach((item) => {
2890
+ const row = document.createElement('div');
2891
+ row.className = 'codex-log-entry';
2892
+ const meta = document.createElement('div');
2893
+ meta.className = 'codex-log-meta';
2894
+ const type = document.createElement('span');
2895
+ type.textContent = item.type || 'event';
2896
+ const time = document.createElement('span');
2897
+ time.textContent = item.timestamp ? new Date(item.timestamp).toLocaleTimeString() : '';
2898
+ meta.appendChild(type);
2899
+ meta.appendChild(time);
2900
+ const message = document.createElement('div');
2901
+ message.className = 'codex-log-message';
2902
+ message.textContent = item.message || '';
2903
+ row.appendChild(meta);
2904
+ row.appendChild(message);
2905
+ if (item.detail) {
2906
+ const detail = document.createElement('pre');
2907
+ detail.className = 'codex-log-detail';
2908
+ detail.textContent = item.detail;
2909
+ row.appendChild(detail);
2910
+ }
2911
+ list.appendChild(row);
2912
+ });
2913
+ details.appendChild(summary);
2914
+ details.appendChild(list);
2915
+ return details;
2916
+ }
2917
+
2622
2918
  function renderCommentThread() {
2623
2919
  els.commentThread.textContent = '';
2624
2920
  state.pendingComments.forEach((comment) => {
@@ -2635,6 +2931,28 @@ export function renderRefineShell(token: string, defaultMode: RefineMode = "edit
2635
2931
 
2636
2932
  bubble.appendChild(text);
2637
2933
  bubble.appendChild(status);
2934
+ if (comment.progressEvent) {
2935
+ const progress = document.createElement('div');
2936
+ progress.className = 'comment-progress';
2937
+ const line = document.createElement('div');
2938
+ line.className = 'comment-progress-line';
2939
+ line.textContent = comment.progressEvent.message;
2940
+ progress.appendChild(line);
2941
+ bubble.appendChild(progress);
2942
+ }
2943
+ if (comment.status === 'failed' && comment.failureRaw) {
2944
+ const details = document.createElement('details');
2945
+ details.className = 'comment-raw';
2946
+ const summary = document.createElement('summary');
2947
+ summary.textContent = 'Details';
2948
+ const pre = document.createElement('pre');
2949
+ pre.textContent = comment.failureRaw;
2950
+ details.appendChild(summary);
2951
+ details.appendChild(pre);
2952
+ bubble.appendChild(details);
2953
+ }
2954
+ const codexLog = renderCodexLog(comment.eventLog);
2955
+ if (codexLog) bubble.appendChild(codexLog);
2638
2956
  els.commentThread.appendChild(bubble);
2639
2957
  });
2640
2958
  }
@@ -2767,6 +3085,7 @@ export function renderRefineShell(token: string, defaultMode: RefineMode = "edit
2767
3085
  els.inspectCards.innerHTML = '<div class="inspect-loading"><span class="loading-row"><span class="spinner" aria-hidden="true"></span><b>' + escapeHtml(message) + '</b></span><br>Preparing concise Purpose and Source context.</div>'
2768
3086
  + '<div class="skeleton-card"><div class="skeleton-line short"></div><div class="skeleton-line long"></div><div class="skeleton-line medium"></div></div>'
2769
3087
  + '<div class="skeleton-card"><div class="skeleton-line short"></div><div class="skeleton-line long"></div><div class="skeleton-line medium"></div></div>';
3088
+ renderInspectCodexLog();
2770
3089
  }
2771
3090
 
2772
3091
  function getInspectComment() {
@@ -2783,6 +3102,7 @@ export function renderRefineShell(token: string, defaultMode: RefineMode = "edit
2783
3102
  setMode('inspect');
2784
3103
  els.inspectStale.innerHTML = '';
2785
3104
  state.inspectFallback = null;
3105
+ state.inspectEventLog = [];
2786
3106
  renderInspectLoading('Reading selection...');
2787
3107
  try {
2788
3108
  const res = await fetch('/api/inspect?token=' + encodeURIComponent(token), {
@@ -2796,7 +3116,7 @@ export function renderRefineShell(token: string, defaultMode: RefineMode = "edit
2796
3116
  state.activeInspectRequestId = body.requestId;
2797
3117
  state.inspectFallback = body.preprocess || null;
2798
3118
  renderInspectLoading('Waiting for Purpose and Source...');
2799
- await pollInspectResult(body.requestId);
3119
+ await watchInspectProgress(body.requestId);
2800
3120
  } catch (error) {
2801
3121
  if (state.inspectFallback) {
2802
3122
  renderInspectResult(state.inspectFallback, 'Deterministic fallback');
@@ -2826,6 +3146,92 @@ export function renderRefineShell(token: string, defaultMode: RefineMode = "edit
2826
3146
  throw new Error('Insight timed out while waiting for Review agent result');
2827
3147
  }
2828
3148
 
3149
+ async function fetchInspectResultOnce(requestId) {
3150
+ const res = await fetch('/api/inspect-result?token=' + encodeURIComponent(token) + '&requestId=' + encodeURIComponent(requestId), { cache: 'no-store' });
3151
+ const body = await res.json().catch(() => ({}));
3152
+ if (!res.ok || !body.ok) throw new Error(body.error || 'Insight result failed');
3153
+ if (body.status === 'completed') {
3154
+ state.deckVersion = body.deckVersion || state.deckVersion;
3155
+ renderInspectResult(body.result, 'Generated');
3156
+ renderInspectCodexLog();
3157
+ return true;
3158
+ }
3159
+ if (body.status === 'failed' || body.status === 'expired') {
3160
+ const error = new Error(body.error || 'Insight failed');
3161
+ error.raw = body.raw || '';
3162
+ throw error;
3163
+ }
3164
+ return false;
3165
+ }
3166
+
3167
+ async function watchInspectProgress(requestId) {
3168
+ if (!requestId) return;
3169
+ if (!codexReview || !('EventSource' in window)) {
3170
+ await pollInspectResult(requestId);
3171
+ renderInspectCodexLog();
3172
+ return;
3173
+ }
3174
+ await new Promise((resolve, reject) => {
3175
+ let settled = false;
3176
+ let source;
3177
+ const finish = (ok, error) => {
3178
+ if (settled) return;
3179
+ settled = true;
3180
+ if (source) source.close();
3181
+ if (ok) resolve();
3182
+ else reject(error);
3183
+ };
3184
+ try {
3185
+ source = new EventSource('/api/inspect-events?token=' + encodeURIComponent(token) + '&requestId=' + encodeURIComponent(requestId));
3186
+ } catch (error) {
3187
+ pollInspectResult(requestId).then(resolve, reject);
3188
+ return;
3189
+ }
3190
+ source.addEventListener('progress', (event) => {
3191
+ let payload;
3192
+ try {
3193
+ payload = JSON.parse(event.data || '{}');
3194
+ } catch {
3195
+ return;
3196
+ }
3197
+ recordInspectProgress(payload);
3198
+ if (payload.type === 'failed' || payload.type === 'timeout') {
3199
+ const error = new Error(payload.message || 'Insight failed');
3200
+ error.raw = payload.detail || '';
3201
+ finish(false, error);
3202
+ } else if (payload.type === 'completed') {
3203
+ fetchInspectResultOnce(requestId).then((ready) => {
3204
+ if (ready) finish(true);
3205
+ else pollInspectResult(requestId).then(() => finish(true), (error) => finish(false, error));
3206
+ }, (error) => finish(false, error));
3207
+ }
3208
+ });
3209
+ source.onerror = () => {
3210
+ if (!settled) {
3211
+ source.close();
3212
+ pollInspectResult(requestId).then(() => finish(true), (error) => finish(false, error));
3213
+ }
3214
+ };
3215
+ });
3216
+ }
3217
+
3218
+ function recordInspectProgress(event) {
3219
+ if (!event || !event.message) return;
3220
+ const inspectLog = { eventLog: state.inspectEventLog };
3221
+ appendCodexEventLog(inspectLog, event);
3222
+ state.inspectEventLog = inspectLog.eventLog;
3223
+ if (event.type !== 'completed') {
3224
+ renderInspectLoading(event.message);
3225
+ } else {
3226
+ renderInspectCodexLog();
3227
+ }
3228
+ }
3229
+
3230
+ function renderInspectCodexLog() {
3231
+ const codexLog = renderCodexLog(state.inspectEventLog);
3232
+ if (codexLog) els.inspectCards.appendChild(codexLog);
3233
+ }
3234
+
2829
3235
  function collectReferenceSnapshot() {
2830
3236
  const elements = state.references.map((reference) => reference.payload);
2831
3237
  const first = elements[0] || {};
@@ -105,6 +105,7 @@ export async function reviewDeckOpen(input: ReviewDeckOpenInput): Promise<any> {
105
105
  openUrl: input.openUrl,
106
106
  sessionID: `codex-review:${requestedFile}`,
107
107
  promptBridge: createCodexExecReviewPromptBridge(),
108
+ surface: "codex",
108
109
  })
109
110
  return {
110
111
  ok: true,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cyber-dash-tech/revela",
3
- "version": "0.17.11",
3
+ "version": "0.17.13",
4
4
  "description": "OpenCode plugin for trusted narrative artifacts from local sources, research, and evidence",
5
5
  "type": "module",
6
6
  "main": "./index.ts",
@@ -53,7 +53,9 @@
53
53
  },
54
54
  "scripts": {
55
55
  "test": "bun test",
56
- "typecheck": "tsc"
56
+ "typecheck": "tsc",
57
+ "smoke:mcp": "bun scripts/codex-mcp-smoke.ts",
58
+ "smoke:mcp-pack": "bun scripts/codex-mcp-pack-smoke.ts"
57
59
  },
58
60
  "peerDependencies": {
59
61
  "@opencode-ai/plugin": "*"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "revela",
3
- "version": "0.1.0+codex.20260524145000",
3
+ "version": "0.1.0+codex.20260524164007",
4
4
  "description": "Use Revela in Codex to build trusted, traceable narrative decision artifacts from local sources and research.",
5
5
  "author": {
6
6
  "name": "cyber-dash-tech",
@@ -2,7 +2,7 @@
2
2
  "mcpServers": {
3
3
  "revela": {
4
4
  "command": "npx",
5
- "args": ["-y", "@cyber-dash-tech/revela@0.17.11", "mcp"]
5
+ "args": ["-y", "@cyber-dash-tech/revela@0.17.13", "mcp"]
6
6
  }
7
7
  }
8
8
  }