@cyber-dash-tech/revela 0.18.10 → 0.18.12

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-726%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-729%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,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.18.10 mcp` to start the MCP server.
37
+ - Your environment must be able to run `npx`; Revela uses `npx -y @cyber-dash-tech/revela@0.18.12 mcp` to start the MCP server.
38
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:
@@ -55,11 +55,11 @@ 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.10
58
+ codex plugin marketplace add https://github.com/cyber-dash-tech/revela --ref v0.18.12
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.10 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.12 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
 
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-726%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-729%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,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.18.10 mcp` 启动 MCP server。
37
+ - 环境中需要可以执行 `npx`;Revela 会用 `npx -y @cyber-dash-tech/revela@0.18.12 mcp` 启动 MCP server。
38
38
  - 如果使用 Review UI 的 Apply,需要 `codex exec` 可用;评论会先保存,点击 Apply 后才执行修复。
39
39
 
40
40
  可选的安装前检查:
@@ -55,11 +55,11 @@ 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.10
58
+ codex plugin marketplace add https://github.com/cyber-dash-tech/revela --ref v0.18.12
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.10 mcp`,由 npm 获取已发布 package 及其 dependencies。
62
+ Git marketplace 安装的是 Codex plugin 壳、skills、hooks 和 MCP 配置。Codex 第一次启动 Revela MCP server 时,会运行 `npx -y @cyber-dash-tech/revela@0.18.12 mcp`,由 npm 获取已发布 package 及其 dependencies。
63
63
 
64
64
  不需要在 Codex marketplace clone 里运行 `bun install`。
65
65
 
@@ -656,9 +656,9 @@ function writeDesignAssets(targetDir: string, assets?: DesignPackageAssetInput[]
656
656
 
657
657
  function normalizeAssetPath(pathInput: string): string {
658
658
  const normalized = pathInput.replace(/\\/g, "/").replace(/^\.\/+/, "")
659
- if (!normalized.startsWith("assets/")) throw new Error(`Design asset paths must start with assets/: ${pathInput}`)
659
+ if (!normalized.startsWith("assets/")) throw new Error(`Design asset path must be located under assets/: ${pathInput}`)
660
660
  if (normalized.includes("\0") || normalized.startsWith("/") || normalized.split("/").some((part) => !part || part === "." || part === "..")) {
661
- throw new Error(`Invalid design asset path: ${pathInput}`)
661
+ throw new Error(`Design asset path must be located under assets/ and must not contain absolute paths, empty segments, '.', '..', or NUL characters: ${pathInput}`)
662
662
  }
663
663
  return normalized
664
664
  }
@@ -1,6 +1,6 @@
1
1
  import type { ReviewBridgeEvent } from "./prompt-bridge"
2
2
 
3
- export type CommentRequestStatus = "pending" | "completed" | "failed" | "expired"
3
+ export type CommentRequestStatus = "pending" | "completed" | "failed" | "expired" | "stopped"
4
4
 
5
5
  export interface PendingCommentRequest {
6
6
  requestId: string
@@ -85,6 +85,24 @@ export function failCommentRequest(requestId: string, error: string, raw?: strin
85
85
  return request
86
86
  }
87
87
 
88
+ export function stopCommentRequest(requestId: string): PendingCommentRequest | undefined {
89
+ const request = getCommentRequest(requestId)
90
+ if (!request || request.status !== "pending") return request
91
+ request.status = "stopped"
92
+ request.error = "Stopped by user."
93
+ request.raw = "Stopped by user."
94
+ request.updatedAt = Date.now()
95
+ if (!hasTerminalEvent(request)) {
96
+ appendCommentRequestEvent(request, {
97
+ type: "stopped",
98
+ message: "Stopped by user.",
99
+ timestamp: request.updatedAt,
100
+ detail: "Stopped by user.",
101
+ })
102
+ }
103
+ return request
104
+ }
105
+
88
106
  function boundedTail(text: string, limit = 4096): string {
89
107
  if (text.length <= limit) return text
90
108
  return text.slice(text.length - limit)
@@ -154,5 +172,5 @@ function appendCommentRequestEvent(request: PendingCommentRequest, event: Review
154
172
  }
155
173
 
156
174
  function hasTerminalEvent(request: PendingCommentRequest): boolean {
157
- return request.events.some((event) => event.type === "completed" || event.type === "failed" || event.type === "timeout")
175
+ return request.events.some((event) => event.type === "completed" || event.type === "failed" || event.type === "timeout" || event.type === "stopped")
158
176
  }
@@ -5,7 +5,7 @@ export type ReviewPromptAction = "comment" | "inspect"
5
5
  export type ReviewPromptBridgeKind = "opencode" | "codex-exec"
6
6
 
7
7
  export type ReviewBridgeEvent = {
8
- type: "started" | "codex_event" | "stdout" | "stderr" | "completed" | "failed" | "timeout"
8
+ type: "started" | "codex_event" | "stdout" | "stderr" | "completed" | "failed" | "timeout" | "stopped"
9
9
  message: string
10
10
  timestamp: number
11
11
  detail?: string
@@ -18,12 +18,13 @@ export interface ReviewPromptInput {
18
18
  file: string
19
19
  requestId?: string
20
20
  timeoutMs?: number
21
+ signal?: AbortSignal
21
22
  onEvent?: (event: ReviewBridgeEvent) => void
22
23
  }
23
24
 
24
25
  export type ReviewPromptResult =
25
26
  | { ok: true; status: "sent" | "completed"; result?: InspectionResult; raw?: string }
26
- | { ok: false; status: "failed" | "unsupported"; error: string; raw?: string }
27
+ | { ok: false; status: "failed" | "unsupported" | "stopped"; error: string; raw?: string }
27
28
 
28
29
  export interface ReviewPromptBridge {
29
30
  kind: ReviewPromptBridgeKind
@@ -46,6 +47,7 @@ export type CodexExecRunner = (input: {
46
47
  timeoutMs: number
47
48
  sandboxMode: "read-only" | "workspace-write"
48
49
  skipGitRepoCheck: boolean
50
+ signal?: AbortSignal
49
51
  onEvent?: (event: ReviewBridgeEvent) => void
50
52
  }) => Promise<CodexExecRunResult>
51
53
 
@@ -84,6 +86,10 @@ export function createCodexExecReviewPromptBridge(options: {
84
86
  const sandboxMode = input.action === "comment" ? "workspace-write" : "read-only"
85
87
  const timeoutMs = input.timeoutMs ?? options.timeoutMs ?? (input.action === "comment" ? DEFAULT_COMMENT_TIMEOUT_MS : DEFAULT_INSPECT_TIMEOUT_MS)
86
88
  input.onEvent?.(bridgeEvent("started", "Starting Codex..."))
89
+ if (input.signal?.aborted) {
90
+ input.onEvent?.(bridgeEvent("stopped", "Stopped by user."))
91
+ return { ok: false, status: "stopped", error: "Stopped by user.", raw: "Stopped by user." }
92
+ }
87
93
  const startedAt = Date.now()
88
94
  const heartbeat = input.onEvent
89
95
  ? setInterval(() => {
@@ -100,12 +106,22 @@ export function createCodexExecReviewPromptBridge(options: {
100
106
  timeoutMs,
101
107
  sandboxMode,
102
108
  skipGitRepoCheck: true,
109
+ signal: input.signal,
103
110
  onEvent: input.onEvent,
104
111
  })
105
112
  } finally {
106
113
  if (heartbeat) clearInterval(heartbeat)
107
114
  }
108
115
  const raw = [output.stdout, output.stderr].filter(Boolean).join("\n")
116
+ if (input.signal?.aborted || raw.includes("Stopped by user.")) {
117
+ input.onEvent?.(bridgeEvent("stopped", "Stopped by user.", boundedTail(raw || "Stopped by user.")))
118
+ return {
119
+ ok: false,
120
+ status: "stopped",
121
+ error: "Stopped by user.",
122
+ raw,
123
+ }
124
+ }
109
125
  if (input.action === "comment" && isCodexWriteBlocked(raw)) {
110
126
  input.onEvent?.(bridgeEvent("failed", "codex exec could not write the deck because its sandbox blocked file changes.", boundedTail(raw)))
111
127
  return {
@@ -155,6 +171,7 @@ async function runCodexExec(input: {
155
171
  timeoutMs: number
156
172
  sandboxMode: "read-only" | "workspace-write"
157
173
  skipGitRepoCheck: boolean
174
+ signal?: AbortSignal
158
175
  onEvent?: (event: ReviewBridgeEvent) => void
159
176
  }): Promise<CodexExecRunResult> {
160
177
  return new Promise((resolve) => {
@@ -169,12 +186,31 @@ async function runCodexExec(input: {
169
186
  let stdoutLineBuffer = ""
170
187
  let sawTrustedCompletion = false
171
188
  let resolved = false
189
+ let stopped = false
172
190
  const resolveOnce = (output: CodexExecRunResult) => {
173
191
  if (resolved) return
174
192
  resolved = true
193
+ if (input.signal) input.signal.removeEventListener("abort", abort)
175
194
  resolve(output)
176
195
  }
196
+ const abort = () => {
197
+ if (stopped) return
198
+ stopped = true
199
+ child.kill()
200
+ input.onEvent?.(bridgeEvent("stopped", "Stopped by user."))
201
+ resolveOnce({
202
+ exitCode: null,
203
+ stdout,
204
+ stderr: `${stderr}${stderr ? "\n" : ""}Stopped by user.`,
205
+ })
206
+ }
207
+ if (input.signal?.aborted) {
208
+ abort()
209
+ return
210
+ }
211
+ input.signal?.addEventListener("abort", abort, { once: true })
177
212
  const timer = setTimeout(() => {
213
+ if (stopped) return
178
214
  child.kill()
179
215
  const nextStderr = `${stderr}${stderr ? "\n" : ""}codex exec timed out after ${input.timeoutMs}ms.`
180
216
  if (input.action === "comment" && sawTrustedCompletion) {
@@ -202,11 +238,13 @@ async function runCodexExec(input: {
202
238
  })
203
239
  child.on("error", (error) => {
204
240
  clearTimeout(timer)
241
+ if (stopped) return
205
242
  input.onEvent?.(bridgeEvent("failed", "Failed to start codex exec.", boundedTail(error.message)))
206
243
  resolveOnce({ exitCode: 127, stdout, stderr: error.message })
207
244
  })
208
245
  child.on("close", (code) => {
209
246
  clearTimeout(timer)
247
+ if (stopped) return
210
248
  const progress = emitCodexJsonProgress(`${stdoutLineBuffer}\n`, input.action, input.onEvent)
211
249
  sawTrustedCompletion = sawTrustedCompletion || progress.sawTrustedCompletion
212
250
  resolveOnce({ exitCode: code, stdout, stderr })
@@ -3,7 +3,7 @@ import { join } from "path"
3
3
  import { randomBytes } from "crypto"
4
4
  import { workspaceMetaPath } from "../workspace-meta"
5
5
 
6
- export type ReviewCommentStatus = "open" | "queued" | "applying" | "applied" | "failed"
6
+ export type ReviewCommentStatus = "open" | "queued" | "applying" | "applied" | "failed" | "stopped"
7
7
 
8
8
  export interface ReviewCommentRecord {
9
9
  id: string
@@ -113,7 +113,12 @@ export function markReviewCommentFailed(workspaceRoot: string, id: string, error
113
113
  }
114
114
 
115
115
  export function markReviewCommentStopped(workspaceRoot: string, id: string): ReviewCommentRecord | undefined {
116
- return markReviewCommentFailed(workspaceRoot, id, "Stopped by user.", "Stopped by user.")
116
+ return updateReviewComment(workspaceRoot, id, (record) => ({
117
+ ...record,
118
+ status: "stopped",
119
+ lastApplyError: "Stopped by user.",
120
+ lastApplyRaw: "Stopped by user.",
121
+ }))
117
122
  }
118
123
 
119
124
  export function deleteReviewComment(workspaceRoot: string, id: string): boolean {
@@ -13,7 +13,7 @@ import { addInspectRequestEvent, completeInspectRequest, createInspectRequest, f
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 { addCommentRequestEvent, completeCommentRequest, createCommentRequest, failCommentRequest, getCommentRequest, subscribeCommentRequestEvents } from "./comment-requests"
16
+ import { addCommentRequestEvent, completeCommentRequest, createCommentRequest, failCommentRequest, getCommentRequest, stopCommentRequest, subscribeCommentRequestEvents } from "./comment-requests"
17
17
  import { createOpenCodeReviewPromptBridge, type ReviewPromptBridge } from "./prompt-bridge"
18
18
  import { suppressReviewApplyFixArtifactQa } from "./qa-suppression"
19
19
  import { createReviewComment, deleteReviewComment, listReviewComments, markReviewCommentApplied, markReviewCommentApplying, markReviewCommentFailed, markReviewCommentQueued, markReviewCommentStopped, readReviewComment, type ReviewCommentRecord } from "./review-comments"
@@ -53,6 +53,8 @@ interface EditSession {
53
53
  visualTargets: Map<string, VisualEditTarget>
54
54
  visualTargetDeckVersion?: string
55
55
  activeApplyCommentId?: string
56
+ activeApplyRequestId?: string
57
+ activeApplyController?: AbortController
56
58
  applyQueue: string[]
57
59
  }
58
60
 
@@ -237,7 +239,6 @@ async function handleRequest(req: Request): Promise<Response> {
237
239
  if (url.pathname === "/api/comments" && req.method === "POST") {
238
240
  const session = validateSession(url.searchParams.get("token"))
239
241
  if (!session.ok) return session.response
240
- if (session.value.activeApplyCommentId) return applyLockedResponse()
241
242
  return handleReviewCommentCreate(req, session.value)
242
243
  }
243
244
 
@@ -245,7 +246,6 @@ async function handleRequest(req: Request): Promise<Response> {
245
246
  if (applyMatch && req.method === "POST") {
246
247
  const session = validateSession(url.searchParams.get("token"))
247
248
  if (!session.ok) return session.response
248
- if (session.value.activeApplyCommentId) return applyLockedResponse()
249
249
  return handleReviewCommentApply(decodeURIComponent(applyMatch[1]), req, session.value)
250
250
  }
251
251
 
@@ -253,7 +253,6 @@ async function handleRequest(req: Request): Promise<Response> {
253
253
  if (stopMatch && req.method === "POST") {
254
254
  const session = validateSession(url.searchParams.get("token"))
255
255
  if (!session.ok) return session.response
256
- if (session.value.activeApplyCommentId) return applyLockedResponse()
257
256
  return handleReviewCommentStop(decodeURIComponent(stopMatch[1]), session.value)
258
257
  }
259
258
 
@@ -856,6 +855,9 @@ async function handleReviewCommentCreate(req: Request, session: EditSession): Pr
856
855
  })
857
856
  session.lastActiveAt = Date.now()
858
857
  scheduleIdleStop()
858
+ if ((body as any).applyNow === true) {
859
+ return enqueueOrStartPersistedReviewCommentApply(session, saved, {})
860
+ }
859
861
  return jsonResponse({ ok: true, comment: saved, deckVersion })
860
862
  } catch (error) {
861
863
  const message = error instanceof Error ? error.message : String(error)
@@ -881,11 +883,20 @@ function handleReviewCommentStop(commentId: string, session: EditSession): Respo
881
883
  const comment = readReviewComment(session.workspaceRoot, commentId)
882
884
  if (!comment || comment.deckFile !== session.file) return jsonResponse({ ok: false, error: "Review comment not found" }, 404)
883
885
  session.applyQueue = (session.applyQueue ?? []).filter((id) => id !== comment.id)
884
- if (session.activeApplyCommentId === comment.id) session.activeApplyCommentId = undefined
886
+ const wasActive = session.activeApplyCommentId === comment.id
887
+ if (wasActive) {
888
+ session.activeApplyController?.abort()
889
+ if (session.activeApplyRequestId) stopCommentRequest(session.activeApplyRequestId)
890
+ session.activeApplyCommentId = undefined
891
+ session.activeApplyRequestId = undefined
892
+ session.activeApplyController = undefined
893
+ } else if (comment.lastApplyRequestId) {
894
+ stopCommentRequest(comment.lastApplyRequestId)
895
+ }
885
896
  const stopped = markReviewCommentStopped(session.workspaceRoot, comment.id) ?? comment
886
897
  session.lastActiveAt = Date.now()
887
898
  scheduleIdleStop()
888
- if (comment.status === "queued") drainPersistedReviewCommentApplyQueue(session)
899
+ if (comment.status === "queued" || wasActive) drainPersistedReviewCommentApplyQueue(session)
889
900
  return jsonResponse({ ok: true, status: "stopped", comment: stopped, deckVersion: readDeckVersion(session).version })
890
901
  }
891
902
 
@@ -903,7 +914,17 @@ function handleReviewCommentDelete(commentId: string, session: EditSession): Res
903
914
 
904
915
  async function enqueueOrStartPersistedReviewCommentApply(session: EditSession, comment: ReviewCommentRecord, body: any = {}): Promise<Response> {
905
916
  session.applyQueue = session.applyQueue ?? []
906
- if (session.activeApplyCommentId) return applyLockedResponse()
917
+ if (session.activeApplyCommentId) {
918
+ if (!session.applyQueue.includes(comment.id)) session.applyQueue.push(comment.id)
919
+ const queued = markReviewCommentQueued(session.workspaceRoot, comment.id) ?? comment
920
+ return jsonResponse({
921
+ ok: true,
922
+ deckVersion: readDeckVersion(session).version,
923
+ status: "queued",
924
+ queuePosition: session.applyQueue.indexOf(comment.id) + 1,
925
+ comment: queued,
926
+ })
927
+ }
907
928
 
908
929
  const queuedIndex = session.applyQueue.indexOf(comment.id)
909
930
  if (queuedIndex >= 0) {
@@ -922,6 +943,10 @@ async function enqueueOrStartPersistedReviewCommentApply(session: EditSession, c
922
943
 
923
944
  async function startPersistedReviewCommentApply(session: EditSession, comment: ReviewCommentRecord, body: any = {}): Promise<Response> {
924
945
  session.activeApplyCommentId = comment.id
946
+ const requestId = typeof body.requestId === "string" && body.requestId.trim() ? body.requestId.trim() : randomBytes(10).toString("base64url")
947
+ const controller = new AbortController()
948
+ session.activeApplyRequestId = requestId
949
+ session.activeApplyController = controller
925
950
  try {
926
951
  const response = await applyCommentPayload({
927
952
  ...body,
@@ -929,9 +954,10 @@ async function startPersistedReviewCommentApply(session: EditSession, comment: R
929
954
  elements: comment.elements,
930
955
  asset: comment.asset,
931
956
  drop: comment.drop,
932
- requestId: typeof body.requestId === "string" && body.requestId.trim() ? body.requestId.trim() : randomBytes(10).toString("base64url"),
957
+ requestId,
933
958
  }, session, {
934
959
  persistedCommentId: comment.id,
960
+ signal: controller.signal,
935
961
  onSettled: () => settlePersistedReviewCommentApply(session, comment.id),
936
962
  })
937
963
  if (!response.ok) settlePersistedReviewCommentApply(session, comment.id)
@@ -944,6 +970,10 @@ async function startPersistedReviewCommentApply(session: EditSession, comment: R
944
970
 
945
971
  function settlePersistedReviewCommentApply(session: EditSession, commentId: string): void {
946
972
  if (session.activeApplyCommentId === commentId) session.activeApplyCommentId = undefined
973
+ if (session.activeApplyCommentId === undefined) {
974
+ session.activeApplyRequestId = undefined
975
+ session.activeApplyController = undefined
976
+ }
947
977
  drainPersistedReviewCommentApplyQueue(session)
948
978
  }
949
979
 
@@ -963,9 +993,9 @@ function drainPersistedReviewCommentApplyQueue(session: EditSession): void {
963
993
  async function applyCommentPayload(
964
994
  body: Partial<EditCommentPayload>,
965
995
  session: EditSession,
966
- options: { persistedCommentId?: string; onSettled?: () => void } = {},
996
+ options: { persistedCommentId?: string; signal?: AbortSignal; onSettled?: () => void } = {},
967
997
  ): Promise<Response> {
968
- const { persistedCommentId, onSettled } = options
998
+ const { persistedCommentId, signal, onSettled } = options
969
999
  const comments = Array.isArray(body.comments)
970
1000
  ? body.comments
971
1001
  .map((draft: any) => ({
@@ -1008,11 +1038,15 @@ async function applyCommentPayload(
1008
1038
  workspaceRoot: session.workspaceRoot,
1009
1039
  file: session.file,
1010
1040
  requestId,
1041
+ signal,
1011
1042
  onEvent: (event) => addCommentRequestEvent(requestId, event),
1012
1043
  }).then((result) => {
1013
1044
  if (result.ok) {
1014
1045
  completeCommentRequest(requestId)
1015
1046
  if (persistedCommentId && readReviewComment(session.workspaceRoot, persistedCommentId)?.status === "applying") markReviewCommentApplied(session.workspaceRoot, persistedCommentId)
1047
+ } else if (result.status === "stopped") {
1048
+ stopCommentRequest(requestId)
1049
+ if (persistedCommentId && readReviewComment(session.workspaceRoot, persistedCommentId)?.status === "applying") markReviewCommentStopped(session.workspaceRoot, persistedCommentId)
1016
1050
  } else {
1017
1051
  failCommentRequest(requestId, result.error, result.raw)
1018
1052
  if (persistedCommentId && readReviewComment(session.workspaceRoot, persistedCommentId)?.status === "applying") markReviewCommentFailed(session.workspaceRoot, persistedCommentId, result.error, result.raw)
@@ -1020,8 +1054,13 @@ async function applyCommentPayload(
1020
1054
  onSettled?.()
1021
1055
  }).catch((error: unknown) => {
1022
1056
  const message = error instanceof Error ? error.message : String(error)
1023
- failCommentRequest(requestId, message)
1024
- if (persistedCommentId && readReviewComment(session.workspaceRoot, persistedCommentId)?.status === "applying") markReviewCommentFailed(session.workspaceRoot, persistedCommentId, message)
1057
+ if (signal?.aborted) {
1058
+ stopCommentRequest(requestId)
1059
+ if (persistedCommentId && readReviewComment(session.workspaceRoot, persistedCommentId)?.status === "applying") markReviewCommentStopped(session.workspaceRoot, persistedCommentId)
1060
+ } else {
1061
+ failCommentRequest(requestId, message)
1062
+ if (persistedCommentId && readReviewComment(session.workspaceRoot, persistedCommentId)?.status === "applying") markReviewCommentFailed(session.workspaceRoot, persistedCommentId, message)
1063
+ }
1025
1064
  onSettled?.()
1026
1065
  })
1027
1066
 
@@ -1052,7 +1091,7 @@ function handleCommentEvents(requestId: string | null, session: EditSession): Re
1052
1091
  }
1053
1092
  unsubscribe = subscribeCommentRequestEvents(requestId, (event) => {
1054
1093
  send(event)
1055
- if (event.type === "completed" || event.type === "failed" || event.type === "timeout") {
1094
+ if (event.type === "completed" || event.type === "failed" || event.type === "timeout" || event.type === "stopped") {
1056
1095
  unsubscribe()
1057
1096
  controller.close()
1058
1097
  }
@@ -1119,7 +1158,7 @@ function handleCommentResult(requestId: string | null, session: EditSession): Re
1119
1158
  if (!request) return jsonResponse({ ok: false, requestId, error: "Comment request not found" }, 404)
1120
1159
  session.lastActiveAt = Date.now()
1121
1160
  scheduleIdleStop()
1122
- if (request.status === "failed" || request.status === "expired") {
1161
+ if (request.status === "failed" || request.status === "expired" || request.status === "stopped") {
1123
1162
  return jsonResponse({
1124
1163
  ok: true,
1125
1164
  requestId,
@@ -1312,7 +1351,7 @@ export function renderRefineShell(token: string, defaultMode: RefineMode = "edit
1312
1351
  const encodedToken = JSON.stringify(token)
1313
1352
  const encodedDefaultMode = JSON.stringify(defaultMode)
1314
1353
  const encodedSurface = JSON.stringify(surface)
1315
- const sendButtonHtml = `${lucideIcon("send")}<span class="sr-only">Leave Comment</span>`
1354
+ const sendButtonHtml = `${lucideIcon("send")}<span class="sr-only">Apply</span>`
1316
1355
  const encodedSendButtonHtml = JSON.stringify(sendButtonHtml)
1317
1356
  const activityLabel = "Comments"
1318
1357
  const bodyClass = surface === "codex" ? "codex-review" : "legacy-review"
@@ -1379,7 +1418,7 @@ export function renderRefineShell(token: string, defaultMode: RefineMode = "edit
1379
1418
  .ref-chip { display: inline-flex; align-items: center; max-width: 32ch; overflow: hidden; text-overflow: ellipsis; margin: 0 2px; padding: 1px 7px; border-radius: 999px; background: var(--ref-bg, #eff6ff); color: var(--ref-text, #1d4ed8); border: 1px solid var(--ref-border, #bfdbfe); font-weight: 800; white-space: nowrap; }
1380
1419
  .activity-panel { display: flex; flex: 1 1 auto; flex-direction: column; gap: 8px; min-height: 0; padding-top: 2px; }
1381
1420
  .comment-thread { display: flex; flex: 1 1 auto; flex-direction: column; gap: 16px; min-height: 160px; overflow-y: auto; overflow-x: hidden; padding: 12px 14px; }
1382
- .comment-thread:empty::before { content: "No activity yet. Leave a comment to start."; display: block; padding: 14px; border: 1px dashed #cbd5e1; border-radius: 16px; color: #64748b; font-size: 12px; line-height: 1.45; background: #f8fafc; box-shadow: none; }
1421
+ .comment-thread:empty::before { content: "No activity yet. Apply a change to start."; display: block; padding: 14px; border: 1px dashed #cbd5e1; border-radius: 16px; color: #64748b; font-size: 12px; line-height: 1.45; background: #f8fafc; box-shadow: none; }
1383
1422
  .comment-bubble { position: relative; display: flex; flex: 0 0 150px; flex-direction: column; min-height: 150px; max-height: 150px; overflow: hidden; border: 1px solid #e2e8f0; border-radius: 15px; padding: 14px 15px 13px 16px; background: #ffffff; color: #334155; font-size: 13px; line-height: 1.45; box-shadow: 0 1px 2px rgba(15,23,42,.04); transition: transform .16s ease, box-shadow .16s ease, border-color .16s ease; cursor: pointer; }
1384
1423
  .comment-bubble:hover { transform: translateY(-1px); border-color: #cbd5e1; box-shadow: 0 8px 20px rgba(15,23,42,.07); }
1385
1424
  .comment-bubble.active { border-color: #93c5fd; box-shadow: 0 0 0 3px rgba(59,130,246,.12), 0 8px 20px rgba(15,23,42,.08); }
@@ -1392,6 +1431,7 @@ export function renderRefineShell(token: string, defaultMode: RefineMode = "edit
1392
1431
  .comment-bubble.updated { border-color: #bbf7d0; background: #f7fef9; }
1393
1432
  .comment-bubble.stale { border-color: #fed7aa; background: #fffaf0; }
1394
1433
  .comment-bubble.failed { border-color: #fecaca; background: #fffafa; }
1434
+ .comment-bubble.stopped { border-color: #fed7aa; background: #fffaf0; }
1395
1435
  .comment-bubble-text { flex: 1 1 auto; min-height: 0; overflow: auto; white-space: pre-wrap; overflow-wrap: anywhere; }
1396
1436
  .comment-bubble-state { margin-top: 8px; align-self: flex-start; padding: 0; background: transparent; color: #475569; font-size: 11px; font-weight: 800; }
1397
1437
  .comment-bubble-meta { margin-bottom: 6px; color: #64748b; font-size: 11px; font-weight: 800; text-transform: uppercase; letter-spacing: .04em; }
@@ -1430,6 +1470,7 @@ export function renderRefineShell(token: string, defaultMode: RefineMode = "edit
1430
1470
  .comment-bubble.queued .comment-bubble-state { color: #92400e; }
1431
1471
  .comment-bubble.stale .comment-bubble-state { color: #9a3412; }
1432
1472
  .comment-bubble.failed .comment-bubble-state { color: #991b1b; }
1473
+ .comment-bubble.stopped .comment-bubble-state { color: #92400e; }
1433
1474
  .inspect-actions { display: flex; flex-direction: column; gap: 8px; }
1434
1475
  .inspect-options { display: flex; flex-direction: column; gap: 5px; }
1435
1476
  .inspect-options label { color: #64748b; font-size: 11px; font-weight: 800; text-transform: uppercase; letter-spacing: .04em; }
@@ -1539,7 +1580,7 @@ export function renderRefineShell(token: string, defaultMode: RefineMode = "edit
1539
1580
  </div>
1540
1581
  </div>
1541
1582
  </div>
1542
- <button id="send" class="primary-action composer-send" type="button" aria-label="Leave Comment" title="Leave Comment" disabled>${sendButtonHtml}</button>
1583
+ <button id="send" class="primary-action composer-send" type="button" aria-label="Apply" title="Apply" disabled>${sendButtonHtml}</button>
1543
1584
  </div>
1544
1585
  </div>
1545
1586
  </div>
@@ -2644,21 +2685,29 @@ export function renderRefineShell(token: string, defaultMode: RefineMode = "edit
2644
2685
  const asset = state.selectedAsset || undefined;
2645
2686
  state.sendingEdit = true;
2646
2687
  updateSendState();
2647
- setStatus('Saving comment...');
2688
+ setStatus('Applying comment...');
2648
2689
  try {
2649
2690
  const res = await fetch('/api/comments?token=' + encodeURIComponent(token), {
2650
2691
  method: 'POST',
2651
2692
  headers: { 'content-type': 'application/json' },
2652
- body: JSON.stringify({ comment: text, elements, asset }),
2693
+ body: JSON.stringify({ comment: text, elements, asset, applyNow: true }),
2653
2694
  });
2654
2695
  const body = await res.json().catch(() => ({}));
2655
- if (!res.ok || !body.ok) throw new Error(body.error || 'Failed to save comment');
2696
+ if (!res.ok || !body.ok) throw new Error(body.error || 'Failed to apply comment');
2656
2697
  upsertPersistedComment(body.comment);
2657
2698
  clearReferences(false);
2658
2699
  state.selectedAsset = null;
2659
2700
  els.comment.textContent = '';
2660
2701
  renderReferenceOutlines();
2661
- setStatus('Comment saved. Use Apply on the comment card when you want Codex to edit the deck.');
2702
+ if (body.status === 'queued') {
2703
+ updatePendingCommentStatus(body.comment.id, 'queued', { requestId: '', progressEvent: null, failureRaw: '', failureMessage: '' });
2704
+ setStatus('Comment queued. It will apply after the current edit finishes.');
2705
+ pollQueuedComment(body.comment.id);
2706
+ } else {
2707
+ updatePendingCommentStatus(body.comment.id, 'applying', { requestId: body.commentRequestId || body.requestId || '', baseDeckVersion: body.deckVersion || state.deckVersion });
2708
+ setStatus('Applying comment with Codex...');
2709
+ if (body.commentRequestId || body.requestId) watchCommentProgress(body.comment.id, body.commentRequestId || body.requestId);
2710
+ }
2662
2711
  } catch (error) {
2663
2712
  reportError(error);
2664
2713
  } finally {
@@ -2711,7 +2760,7 @@ export function renderRefineShell(token: string, defaultMode: RefineMode = "edit
2711
2760
  eventLog: [],
2712
2761
  failureRaw: record.lastApplyRaw || '',
2713
2762
  failureMessage: record.lastApplyError || '',
2714
- stopped: record.status === 'failed' && record.lastApplyError === 'Stopped by user.',
2763
+ stopped: record.status === 'stopped',
2715
2764
  };
2716
2765
  }
2717
2766
 
@@ -2719,7 +2768,7 @@ export function renderRefineShell(token: string, defaultMode: RefineMode = "edit
2719
2768
  if (isApplyLocked()) return setStatus(applyLockStatus());
2720
2769
  const comment = state.pendingComments.find((item) => item.id === commentId);
2721
2770
  if (!comment || comment.status === 'applying' || comment.status === 'queued') return;
2722
- updatePendingCommentStatus(commentId, 'applying', { baseDeckVersion: state.deckVersion || comment.baseDeckVersion, progressEvent: null, eventLog: [], failureRaw: '', failureMessage: '' });
2771
+ updatePendingCommentStatus(commentId, 'applying', { stopped: false, baseDeckVersion: state.deckVersion || comment.baseDeckVersion, progressEvent: null, eventLog: [], failureRaw: '', failureMessage: '' });
2723
2772
  setStatus(comment.status === 'applied' || comment.status === 'updated' || comment.status === 'stale' ? 'Re-applying saved comment...' : 'Applying saved comment...');
2724
2773
  try {
2725
2774
  const res = await fetch('/api/comments/' + encodeURIComponent(commentId) + '/apply?token=' + encodeURIComponent(token), {
@@ -2745,7 +2794,6 @@ export function renderRefineShell(token: string, defaultMode: RefineMode = "edit
2745
2794
  }
2746
2795
 
2747
2796
  async function stopPersistedComment(commentId) {
2748
- if (isApplyLocked()) return setStatus(applyLockStatus());
2749
2797
  const comment = state.pendingComments.find((item) => item.id === commentId);
2750
2798
  if (!comment || !canStopPersistedComment(comment.status)) return;
2751
2799
  try {
@@ -2753,7 +2801,7 @@ export function renderRefineShell(token: string, defaultMode: RefineMode = "edit
2753
2801
  const body = await res.json().catch(() => ({}));
2754
2802
  if (!res.ok || !body.ok) throw new Error(body.error || 'Failed to stop comment');
2755
2803
  if (body.comment) upsertPersistedComment(body.comment);
2756
- updatePendingCommentStatus(commentId, 'failed', { stopped: true, requestId: '', progressEvent: null, failureRaw: 'Stopped by user.', failureMessage: 'Stopped by user.' });
2804
+ updatePendingCommentStatus(commentId, 'stopped', { stopped: true, requestId: '', progressEvent: null, failureRaw: 'Stopped by user.', failureMessage: 'Stopped by user.' });
2757
2805
  setStatus('Stopped comment apply.');
2758
2806
  } catch (error) {
2759
2807
  reportError(error);
@@ -3044,7 +3092,7 @@ export function renderRefineShell(token: string, defaultMode: RefineMode = "edit
3044
3092
  setMode('edit');
3045
3093
  updateSendState();
3046
3094
  closeLocalAssetMenu();
3047
- setStatus('Asset added to the Edit comment. Describe where or how to use it, then Leave Comment.');
3095
+ setStatus('Asset added to the Edit comment. Describe where or how to use it, then Apply.');
3048
3096
  }
3049
3097
 
3050
3098
  function onAssetDragOver(event) {
@@ -3121,7 +3169,7 @@ export function renderRefineShell(token: string, defaultMode: RefineMode = "edit
3121
3169
  const target = placement?.target ? elementFromPayload(placement.target) : null;
3122
3170
  state.assetDropTarget = target;
3123
3171
  renderBox(state.assetDropOutline, target);
3124
- if (placement?.targetLabel) setStatus(placement.targetLabel + '. Drop to send an asset placement comment.');
3172
+ if (placement?.targetLabel) setStatus(placement.targetLabel + '. Drop to apply an asset placement edit.');
3125
3173
  }
3126
3174
 
3127
3175
  function elementFromPayload(payload) {
@@ -3252,17 +3300,25 @@ export function renderRefineShell(token: string, defaultMode: RefineMode = "edit
3252
3300
  : 'add it near the drop point';
3253
3301
  const comment = 'Place workspace asset ' + asset.path + ' on slide ' + placement.slideIndex + ' as a ' + (asset.purpose || 'visual asset') + '; ' + modeText + '. Preserve the current layout and do not cover existing text, charts, tables, or evidence.';
3254
3302
  const elements = placement.target ? [placement.target] : [];
3255
- setStatus('Saving asset placement comment...');
3303
+ setStatus('Applying asset placement edit...');
3256
3304
  try {
3257
3305
  const res = await fetch('/api/comments?token=' + encodeURIComponent(token), {
3258
3306
  method: 'POST',
3259
3307
  headers: { 'content-type': 'application/json' },
3260
- body: JSON.stringify({ comment, elements, asset, drop: placement }),
3308
+ body: JSON.stringify({ comment, elements, asset, drop: placement, applyNow: true }),
3261
3309
  });
3262
3310
  const body = await res.json().catch(() => ({}));
3263
- if (!res.ok || !body.ok) throw new Error(body.error || 'Failed to save asset placement');
3311
+ if (!res.ok || !body.ok) throw new Error(body.error || 'Failed to apply asset placement');
3264
3312
  upsertPersistedComment(body.comment);
3265
- setStatus('Asset placement comment saved. Use Apply on the comment card when ready.');
3313
+ if (body.status === 'queued') {
3314
+ updatePendingCommentStatus(body.comment.id, 'queued', { requestId: '', progressEvent: null, failureRaw: '', failureMessage: '' });
3315
+ setStatus('Asset placement queued. It will apply after the current edit finishes.');
3316
+ pollQueuedComment(body.comment.id);
3317
+ } else {
3318
+ updatePendingCommentStatus(body.comment.id, 'applying', { requestId: body.commentRequestId || body.requestId || '', baseDeckVersion: body.deckVersion || state.deckVersion });
3319
+ setStatus('Applying asset placement with Codex...');
3320
+ if (body.commentRequestId || body.requestId) watchCommentProgress(body.comment.id, body.commentRequestId || body.requestId);
3321
+ }
3266
3322
  } catch (error) {
3267
3323
  reportError(error);
3268
3324
  }
@@ -3358,10 +3414,10 @@ export function renderRefineShell(token: string, defaultMode: RefineMode = "edit
3358
3414
  function updatePendingCommentStatus(id, status, updates) {
3359
3415
  const comment = state.pendingComments.find((item) => item.id === id);
3360
3416
  if (!comment) return;
3361
- if (comment.status === 'updated' && status !== 'failed') return;
3417
+ if (comment.status === 'updated' && status !== 'failed' && status !== 'stopped') return;
3362
3418
  comment.status = status;
3363
3419
  if (updates) Object.assign(comment, updates);
3364
- if (status === 'updated' || status === 'failed' || status === 'applied') comment.progressEvent = null;
3420
+ if (status === 'updated' || status === 'failed' || status === 'applied' || status === 'stopped') comment.progressEvent = null;
3365
3421
  renderCommentThread();
3366
3422
  }
3367
3423
 
@@ -3373,7 +3429,7 @@ export function renderRefineShell(token: string, defaultMode: RefineMode = "edit
3373
3429
  if (!requestId) return;
3374
3430
  for (let attempt = 0; attempt < 140; attempt++) {
3375
3431
  await delay(1000);
3376
- if (pendingCommentStatus(commentId) === 'updated') return;
3432
+ if (pendingCommentStatus(commentId) === 'updated' || pendingCommentStatus(commentId) === 'stopped') return;
3377
3433
  try {
3378
3434
  const res = await fetch('/api/comment-result?token=' + encodeURIComponent(token) + '&requestId=' + encodeURIComponent(requestId), { cache: 'no-store' });
3379
3435
  const body = await res.json().catch(() => ({}));
@@ -3383,6 +3439,11 @@ export function renderRefineShell(token: string, defaultMode: RefineMode = "edit
3383
3439
  setStatus(body.error || 'Review agent failed to apply the comment.');
3384
3440
  return;
3385
3441
  }
3442
+ if (body.status === 'stopped') {
3443
+ updatePendingCommentStatus(commentId, 'stopped', { stopped: true, failureRaw: body.raw || 'Stopped by user.', failureMessage: body.error || 'Stopped by user.' });
3444
+ setStatus(body.error || 'Stopped comment apply.');
3445
+ return;
3446
+ }
3386
3447
  if (body.status === 'completed') {
3387
3448
  if (pendingCommentStatus(commentId) === 'applying') {
3388
3449
  updatePendingCommentStatus(commentId, 'applied', { progressEvent: null });
@@ -3395,7 +3456,7 @@ export function renderRefineShell(token: string, defaultMode: RefineMode = "edit
3395
3456
  return;
3396
3457
  }
3397
3458
  }
3398
- if (pendingCommentStatus(commentId) !== 'updated') {
3459
+ if (pendingCommentStatus(commentId) !== 'updated' && pendingCommentStatus(commentId) !== 'stopped') {
3399
3460
  updatePendingCommentStatus(commentId, 'failed');
3400
3461
  setStatus('Review agent timed out before applying the comment.');
3401
3462
  }
@@ -3434,6 +3495,11 @@ export function renderRefineShell(token: string, defaultMode: RefineMode = "edit
3434
3495
  source.close();
3435
3496
  updatePendingCommentStatus(commentId, 'failed', { failureRaw: payload.detail || '' });
3436
3497
  setStatus(payload.message || 'Review agent failed to apply the comment.');
3498
+ } else if (payload.type === 'stopped') {
3499
+ closed = true;
3500
+ source.close();
3501
+ updatePendingCommentStatus(commentId, 'stopped', { stopped: true, failureRaw: payload.detail || 'Stopped by user.', failureMessage: payload.message || 'Stopped by user.' });
3502
+ setStatus(payload.message || 'Stopped comment apply.');
3437
3503
  } else if (payload.type === 'completed') {
3438
3504
  closed = true;
3439
3505
  source.close();
@@ -3444,7 +3510,7 @@ export function renderRefineShell(token: string, defaultMode: RefineMode = "edit
3444
3510
  });
3445
3511
  source.onerror = () => {
3446
3512
  source.close();
3447
- if (!closed && pendingCommentStatus(commentId) !== 'updated' && pendingCommentStatus(commentId) !== 'failed') {
3513
+ if (!closed && pendingCommentStatus(commentId) !== 'updated' && pendingCommentStatus(commentId) !== 'failed' && pendingCommentStatus(commentId) !== 'stopped') {
3448
3514
  startFallback();
3449
3515
  }
3450
3516
  };
@@ -3453,7 +3519,7 @@ export function renderRefineShell(token: string, defaultMode: RefineMode = "edit
3453
3519
  function recordCommentProgress(commentId, event) {
3454
3520
  const comment = state.pendingComments.find((item) => item.id === commentId);
3455
3521
  if (!comment || !event || !event.message) return;
3456
- if (comment.stopped || comment.status === 'failed' && comment.failureMessage === 'Stopped by user.') return;
3522
+ if (comment.stopped || comment.status === 'stopped') return;
3457
3523
  if (codexReview) {
3458
3524
  appendCodexEventLog(comment, event);
3459
3525
  }
@@ -3594,7 +3660,7 @@ export function renderRefineShell(token: string, defaultMode: RefineMode = "edit
3594
3660
  actions.appendChild(apply);
3595
3661
  bubble.appendChild(actions);
3596
3662
  }
3597
- if (!locked && comment.persisted && canStopPersistedComment(comment.status)) {
3663
+ if (comment.persisted && canStopPersistedComment(comment.status)) {
3598
3664
  const actions = bubble.querySelector('.comment-actions') || document.createElement('div');
3599
3665
  actions.className = 'comment-actions';
3600
3666
  const stop = commentActionButton('Stop', '${lucideIcon("square", "comment-action-icon")}', 'stop');
@@ -3703,12 +3769,13 @@ export function renderRefineShell(token: string, defaultMode: RefineMode = "edit
3703
3769
  if (status === 'applying') return 'Applying with Codex...';
3704
3770
  if (status === 'applied' || status === 'updated' || status === 'stale') return 'Codex completed';
3705
3771
  if (status === 'failed') return 'Failed to apply';
3772
+ if (status === 'stopped') return 'Stopped';
3706
3773
  if (status === 'sending') return 'Sending to Review agent...';
3707
3774
  return 'Sent to Review agent';
3708
3775
  }
3709
3776
 
3710
3777
  function canApplyPersistedComment(status) {
3711
- return status === 'open' || status === 'failed' || isReapplyStatus(status);
3778
+ return status === 'open' || status === 'failed' || status === 'stopped' || isReapplyStatus(status);
3712
3779
  }
3713
3780
 
3714
3781
  function canStopPersistedComment(status) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cyber-dash-tech/revela",
3
- "version": "0.18.10",
3
+ "version": "0.18.12",
4
4
  "description": "OpenCode plugin for trusted narrative artifacts from local sources, research, and evidence",
5
5
  "type": "module",
6
6
  "main": "./index.ts",
@@ -2,7 +2,7 @@
2
2
  "mcpServers": {
3
3
  "revela": {
4
4
  "command": "npx",
5
- "args": ["-y", "@cyber-dash-tech/revela@0.18.10", "mcp"]
5
+ "args": ["-y", "@cyber-dash-tech/revela@0.18.12", "mcp"]
6
6
  }
7
7
  }
8
8
  }
@@ -526,11 +526,11 @@ function arrayObjectProp(description: string) {
526
526
  function designAssetsProp() {
527
527
  return {
528
528
  type: "array",
529
- description: "Optional design-owned assets to write under assets/**. Each item must use path plus content, contentBase64, or sourcePath.",
529
+ description: "Optional design-owned assets for user-uploaded or local materials to archive under assets/**. Each item must use path plus content, contentBase64, or sourcePath.",
530
530
  items: {
531
531
  type: "object",
532
532
  properties: {
533
- path: { type: "string", description: "Package-relative asset path. Must start with assets/." },
533
+ path: { type: "string", description: "Package-relative asset path for uploaded or local design material. Must start with assets/." },
534
534
  content: { type: "string", description: "UTF-8 text asset content." },
535
535
  contentBase64: { type: "string", description: "Base64-encoded binary asset content." },
536
536
  sourcePath: { type: "string", description: "Local file path to copy into the design asset." },
@@ -11,6 +11,8 @@ Use this skill when the user asks to create, customize, edit, validate, package,
11
11
 
12
12
  - Designs define deck visual systems: rules, foundation, layouts, components, chart rules, and preview coverage.
13
13
  - Designs may include package-owned `assets/**` such as cover or closing backgrounds; design tools surface these as design elements, not source evidence.
14
+ - When the user uploads or provides logo, cover, closing, background, texture, brand image, or similar design material, store it inside the design package with `revela_design_draft_create.assets`; use paths under `assets/**` only.
15
+ - Generated `preview.html` must actually reference uploaded design assets with package-relative `assets/...` paths rather than describing them only in text.
14
16
  - Default authoring is workspace draft first, then validate, then install only when appropriate.
15
17
  - Direct user-level creation is reserved for explicit create/install-now requests.
16
18
  - Shareable design archives are `.tar` or `.tar.gz`; install archives only from trusted local paths.
@@ -30,7 +32,7 @@ For new or edited designs:
30
32
  1. Call `revela_design_list`.
31
33
  2. Read the requested base design or active design with `revela_design_read`.
32
34
  3. Draft complete `DESIGN.md` and complete `preview.html` content.
33
- 4. Call `revela_design_draft_create`.
35
+ 4. Call `revela_design_draft_create`; when uploaded or local design material exists, pass `assets: [{ path: "assets/...", contentBase64|content|sourcePath }]` so the files are written into the draft package.
34
36
  5. Call `revela_design_draft_validate`.
35
37
  6. If validation fails, revise the draft content and repeat draft create/validate.
36
38
  7. Call `revela_design_draft_install` only after the draft validates and the user intent is to install it.
@@ -52,7 +54,9 @@ Use `revela_design_create` only when the user explicitly requests direct local c
52
54
  - `DESIGN.md` must include valid frontmatter and complete design marker sections.
53
55
  - Include design rules, foundation guidance, at least one layout, and at least one component.
54
56
  - Optional assets must live under `assets/**`; reference them as package-relative paths like `assets/cover-background.png`.
57
+ - `DESIGN.md` may reference package assets in rules, layouts, or components with `assets/...`; do not reference workspace `assets/` media manifest entries for design-owned visuals.
55
58
  - `preview.html` must use the fixed Revela preview canvas contract and visibly preview the design.
59
+ - If design assets are present, `preview.html` must visibly use the saved `assets/...` files, for example a cover hero background or logo image.
56
60
  - Preview must include cover and closing examples and showcase every component.
57
61
  - Preserve source inspiration and limitations explicitly; do not copy copyrighted design text or assets into the package.
58
62
 
@@ -61,6 +65,7 @@ Use `revela_design_create` only when the user explicitly requests direct local c
61
65
  - Design draft path/status or installed design name.
62
66
  - Archive path/status when packaging or installing a shareable design.
63
67
  - Asset metadata surfaced by read/inventory tools when `assets/**` exists.
68
+ - Saved asset paths and intended uses, for example `assets/cover-background.png -> cover hero background`.
64
69
  - Validation result and any remaining diagnostics.
65
70
  - Whether the design was activated.
66
71
  - Next step, usually `revela-research` for planning with the design or `revela-make-deck` when a valid `deck-plan.md` already exists.