@openprd/cli 0.1.10 → 0.1.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (146) hide show
  1. package/.openprd/benchmarks/index.md +17 -15
  2. package/.openprd/benchmarks/sources.yaml +53 -1
  3. package/.openprd/changes/adaptive-project-framing/.openprd.yaml +2 -0
  4. package/.openprd/changes/adaptive-project-framing/design.md +47 -0
  5. package/.openprd/changes/adaptive-project-framing/proposal.md +35 -0
  6. package/.openprd/changes/adaptive-project-framing/specs/agent-requirements/spec.md +16 -0
  7. package/.openprd/changes/adaptive-project-framing/task-events.jsonl +20 -0
  8. package/.openprd/changes/adaptive-project-framing/tasks.md +340 -0
  9. package/.openprd/changes/docs-anti-pollution-and-design-direction-review/.openprd.yaml +2 -0
  10. package/.openprd/changes/docs-anti-pollution-and-design-direction-review/design.md +34 -0
  11. package/.openprd/changes/docs-anti-pollution-and-design-direction-review/proposal.md +23 -0
  12. package/.openprd/changes/docs-anti-pollution-and-design-direction-review/specs/agent-requirements/spec.md +16 -0
  13. package/.openprd/changes/docs-anti-pollution-and-design-direction-review/task-events.jsonl +14 -0
  14. package/.openprd/changes/docs-anti-pollution-and-design-direction-review/tasks.md +238 -0
  15. package/.openprd/changes/loop-isolated-worktree-commit/.openprd.yaml +2 -0
  16. package/.openprd/changes/loop-isolated-worktree-commit/design.md +53 -0
  17. package/.openprd/changes/loop-isolated-worktree-commit/proposal.md +38 -0
  18. package/.openprd/changes/loop-isolated-worktree-commit/specs/agent-requirements/spec.md +16 -0
  19. package/.openprd/changes/loop-isolated-worktree-commit/task-events.jsonl +22 -0
  20. package/.openprd/changes/loop-isolated-worktree-commit/tasks.md +357 -0
  21. package/.openprd/changes/openprd/.openprd.yaml +2 -0
  22. package/.openprd/changes/openprd/design.md +46 -0
  23. package/.openprd/changes/openprd/proposal.md +36 -0
  24. package/.openprd/changes/openprd/specs/agent-requirements/spec.md +16 -0
  25. package/.openprd/changes/openprd/task-events.jsonl +23 -0
  26. package/.openprd/changes/openprd/tasks.md +242 -0
  27. package/.openprd/changes/pm-human-in-the-loop/.openprd.yaml +2 -0
  28. package/.openprd/changes/pm-human-in-the-loop/design.md +38 -0
  29. package/.openprd/changes/pm-human-in-the-loop/proposal.md +29 -0
  30. package/.openprd/changes/pm-human-in-the-loop/specs/agent-requirements/spec.md +16 -0
  31. package/.openprd/changes/pm-human-in-the-loop/task-events.jsonl +18 -0
  32. package/.openprd/changes/pm-human-in-the-loop/tasks.md +306 -0
  33. package/.openprd/changes/test-strategy-router/.openprd.yaml +2 -0
  34. package/.openprd/changes/test-strategy-router/design.md +65 -0
  35. package/.openprd/changes/test-strategy-router/proposal.md +45 -0
  36. package/.openprd/changes/test-strategy-router/specs/agent-requirements/spec.md +16 -0
  37. package/.openprd/changes/test-strategy-router/task-events.jsonl +1 -0
  38. package/.openprd/changes/test-strategy-router/tasks.md +132 -0
  39. package/.openprd/changes/verify-boundaries-and-historical-refresh/.openprd.yaml +2 -0
  40. package/.openprd/changes/verify-boundaries-and-historical-refresh/design.md +28 -0
  41. package/.openprd/changes/verify-boundaries-and-historical-refresh/proposal.md +23 -0
  42. package/.openprd/changes/verify-boundaries-and-historical-refresh/specs/agent-requirements/spec.md +16 -0
  43. package/.openprd/changes/verify-boundaries-and-historical-refresh/task-events.jsonl +11 -0
  44. package/.openprd/changes/verify-boundaries-and-historical-refresh/tasks.md +66 -0
  45. package/.openprd/changes/vibe-coding-dev-check/.openprd.yaml +2 -0
  46. package/.openprd/changes/vibe-coding-dev-check/design.md +37 -0
  47. package/.openprd/changes/vibe-coding-dev-check/proposal.md +28 -0
  48. package/.openprd/changes/vibe-coding-dev-check/specs/agent-requirements/spec.md +16 -0
  49. package/.openprd/changes/vibe-coding-dev-check/task-events.jsonl +18 -0
  50. package/.openprd/changes/vibe-coding-dev-check/tasks.md +306 -0
  51. package/.openprd/design/README.md +11 -6
  52. package/.openprd/design/README_EN.md +4 -1
  53. package/.openprd/design/active/asset-spec.md +6 -0
  54. package/.openprd/design/active/direction-plan.md +5 -5
  55. package/.openprd/design/active/selected-direction.md +2 -0
  56. package/.openprd/design/anti-slop.md +26 -1
  57. package/.openprd/design/assets/surface-presets.md +6 -0
  58. package/.openprd/design/checklists/ui-quality-gate.md +7 -0
  59. package/.openprd/design/lenses/frontend-lenses.md +28 -0
  60. package/.openprd/design/recipes/content-experience.md +8 -1
  61. package/.openprd/design/recipes/operational-tool.md +7 -0
  62. package/.openprd/design/recipes/product-launch.md +8 -1
  63. package/.openprd/design/templates/README.md +6 -3
  64. package/.openprd/design/templates/README_EN.md +6 -3
  65. package/.openprd/design/templates/ops-dashboard.html +8 -1
  66. package/.openprd/design/templates/product-launch.html +2 -1
  67. package/.openprd/design/themes/theme-catalog.json +23 -5
  68. package/.openprd/engagements/active/prd.md +103 -100
  69. package/.openprd/schema/prd.schema.yaml +15 -0
  70. package/.openprd/standards/config.json +7 -1
  71. package/.openprd/templates/base/intake.md +13 -0
  72. package/.openprd/templates/base/prd.md +15 -0
  73. package/AGENTS.md +14 -4
  74. package/README.md +107 -14
  75. package/README_EN.md +131 -13
  76. package/package.json +1 -1
  77. package/scripts/dev-check-wrapup-copy.mjs +46 -21
  78. package/skills/openprd-benchmark-router/SKILL.md +2 -1
  79. package/skills/openprd-diagram-review/SKILL.md +27 -2
  80. package/skills/openprd-diagram-review/references/explanation-svg-patterns.md +131 -0
  81. package/skills/openprd-discovery-loop/SKILL.md +2 -2
  82. package/skills/openprd-frontend-design/SKILL.md +44 -20
  83. package/skills/openprd-frontend-design/references/design-asset-contract.md +6 -0
  84. package/skills/openprd-frontend-design/references/direction-engine.md +29 -4
  85. package/skills/openprd-harness/SKILL.md +22 -16
  86. package/skills/openprd-quality/SKILL.md +10 -8
  87. package/skills/openprd-requirement-intake/SKILL.md +5 -3
  88. package/skills/openprd-requirement-intake/references/prd-template-lenses.md +2 -2
  89. package/skills/openprd-requirement-intake/references/startup-validation-lens.md +98 -13
  90. package/skills/openprd-router/SKILL.md +2 -1
  91. package/skills/openprd-shared/SKILL.md +27 -17
  92. package/skills/openprd-standards/SKILL.md +4 -2
  93. package/src/agent-canonical-content.js +808 -0
  94. package/src/agent-integration.js +259 -778
  95. package/src/brainstorm-artifacts.js +156 -8
  96. package/src/brainstorm-presentation.js +14 -4
  97. package/src/brainstorm.js +165 -2
  98. package/src/canvas-app.html.js +2910 -0
  99. package/src/canvas-i18n.js +274 -0
  100. package/src/canvas-workspace.js +2271 -0
  101. package/src/cli/args.js +72 -4
  102. package/src/cli/basic-print.js +3 -0
  103. package/src/cli/canvas-print.js +106 -0
  104. package/src/cli/doctor-print.js +20 -0
  105. package/src/cli/print.js +2 -0
  106. package/src/cli/quality-commands.js +222 -0
  107. package/src/cli/quality-print.js +37 -5
  108. package/src/cli/run-print.js +5 -0
  109. package/src/cli/workflow-print.js +3 -0
  110. package/src/codex-app-server-relay.js +251 -0
  111. package/src/codex-hook-runner-template.mjs +963 -65
  112. package/src/design-starter-support.js +2 -2
  113. package/src/design-starter.js +39 -3
  114. package/src/dev-standards.js +125 -10
  115. package/src/diagram-core.js +390 -171
  116. package/src/discovery.js +2 -8
  117. package/src/docs-compact.js +125 -0
  118. package/src/execution-strategy.js +1 -1
  119. package/src/fleet.js +9 -3
  120. package/src/guide.js +227 -0
  121. package/src/html-artifacts.js +825 -51
  122. package/src/knowledge.js +101 -20
  123. package/src/language-policy.js +63 -3
  124. package/src/learning-review.js +1 -1
  125. package/src/loop.js +1 -1
  126. package/src/openprd.js +68 -131
  127. package/src/openspec/change-validate.js +2 -2
  128. package/src/openspec/constants.js +6 -7
  129. package/src/openspec/execute.js +2 -2
  130. package/src/openspec/generate.js +318 -102
  131. package/src/openspec/migration.js +168 -0
  132. package/src/openspec/paths.js +1 -22
  133. package/src/prd-core.js +102 -3
  134. package/src/quality-html-artifact.js +30 -5
  135. package/src/quality-visual-review.js +257 -14
  136. package/src/quality.js +66 -18
  137. package/src/review-presentation.js +60 -0
  138. package/src/run-harness.js +41 -3
  139. package/src/session-evidence.js +363 -0
  140. package/src/standards.js +64 -0
  141. package/src/test-strategy.js +7 -4
  142. package/src/visual-compare-centering.js +622 -0
  143. package/src/visual-compare-core.js +1123 -0
  144. package/src/visual-compare.js +620 -698
  145. package/src/workspace-core.js +132 -4
  146. package/src/workspace-workflow.js +172 -10
@@ -0,0 +1,2271 @@
1
+ /*
2
+ * Core purpose
3
+ * Provide the OpenPrd local canvas service and conversation-bound storage.
4
+ *
5
+ * Inputs
6
+ * Receives a project root plus optional CLI flags such as --thread,
7
+ * --thread-title, --session, --host, --port, --daemon, --stop, --open,
8
+ * --open-target, and --dry-run.
9
+ *
10
+ * Outputs
11
+ * Creates `.openprd/harness/canvas-sessions/<session-key>/`, serves a local
12
+ * Excalidraw browser app, persists scene state, and accepts agent image/card
13
+ * operations for that specific conversation binding.
14
+ *
15
+ * Positioning
16
+ * This is the product boundary for canvas collaboration. It intentionally does
17
+ * not depend on Codex-only globals; Codex, Claude Code, Cursor, and future
18
+ * clients can all bind a canvas by providing session evidence.
19
+ *
20
+ * Maintenance rules
21
+ * Keep the HTTP API small and file-backed. If realtime collaboration is added,
22
+ * preserve this file-backed session format as the review/archive source.
23
+ */
24
+ import crypto from 'node:crypto';
25
+ import fs from 'node:fs/promises';
26
+ import { createReadStream } from 'node:fs';
27
+ import http from 'node:http';
28
+ import os from 'node:os';
29
+ import path from 'node:path';
30
+ import { spawn } from 'node:child_process';
31
+ import { fileURLToPath } from 'node:url';
32
+ import { renderCanvasAppHtml } from './canvas-app.html.js';
33
+ import { cjoin, exists, readJson, writeJson } from './fs-utils.js';
34
+ import { timestamp } from './time.js';
35
+
36
+ const CANVAS_SCHEMA_VERSION = 1;
37
+ const DEFAULT_HOST = '127.0.0.1';
38
+ const DEFAULT_PORT = 0;
39
+ const DEFAULT_DAEMON_PORT = 4767;
40
+ const BODY_LIMIT_BYTES = 35 * 1024 * 1024;
41
+ const SESSION_KEY_MAX = 84;
42
+ const DAEMON_READY_TIMEOUT_MS = 7000;
43
+ const HANDOFF_TEXT_MAX = 24000;
44
+ const CLI_BIN_PATH = cjoin(path.dirname(fileURLToPath(import.meta.url)), '..', 'bin', 'openprd.js');
45
+ const CODEX_APP_SERVER_RELAY_WORKER_PATH = cjoin(path.dirname(fileURLToPath(import.meta.url)), 'codex-app-server-relay.js');
46
+ const CODEX_APP_SERVER_RELAY_TIMEOUT_MS = 20 * 60 * 1000;
47
+ const CODEX_APP_SERVER_RELAY_START_WAIT_MS = 0;
48
+ const CODEX_APP_BUNDLE_BIN_PATH = '/Applications/Codex.app/Contents/Resources/codex';
49
+ const CODEX_DELEGATION_RELAY_TRANSPORT = 'codex-app-server-codex-delegation-turn-start';
50
+ const CODEX_AGENT_BRIDGE_TRANSPORT = 'codex-agent-send-message-to-thread';
51
+ const CANVAS_SURFACE_BACKGROUND = '#ffffff';
52
+ const LEGACY_CANVAS_BACKGROUNDS = new Set(['#fbfaf7', '#fffdf9', '#f8f6f1']);
53
+ const CANVAS_OPEN_TARGETS = new Set(['auto', 'system', 'codex-browser']);
54
+ const CODEX_RELAY_DELIVERED_STATUSES = new Set(['completed']);
55
+ const CODEX_RELAY_ACCEPTED_STATUSES = new Set([...CODEX_RELAY_DELIVERED_STATUSES, 'started']);
56
+ const CODEX_RELAY_PENDING_STATUSES = new Set(['queued', 'starting', 'resuming', 'connecting', 'started']);
57
+ const CODEX_RELAY_WAIT_READY_STATUSES = new Set([
58
+ ...CODEX_RELAY_ACCEPTED_STATUSES,
59
+ 'failed',
60
+ 'timeout-before-start',
61
+ 'timeout-after-start',
62
+ 'closed-before-start',
63
+ 'closed-after-start',
64
+ ]);
65
+ const CODEX_RELAY_AGENT_FALLBACK_STATUSES = new Set([
66
+ 'failed',
67
+ 'timeout-before-start',
68
+ 'closed-before-start',
69
+ ]);
70
+
71
+ const MIME_TYPES = {
72
+ '.html': 'text/html; charset=utf-8',
73
+ '.json': 'application/json; charset=utf-8',
74
+ '.js': 'text/javascript; charset=utf-8',
75
+ '.css': 'text/css; charset=utf-8',
76
+ '.png': 'image/png',
77
+ '.jpg': 'image/jpeg',
78
+ '.jpeg': 'image/jpeg',
79
+ '.webp': 'image/webp',
80
+ '.gif': 'image/gif',
81
+ '.svg': 'image/svg+xml',
82
+ '.excalidraw': 'application/json; charset=utf-8',
83
+ };
84
+
85
+ function isPlainObject(value) {
86
+ return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
87
+ }
88
+
89
+ function normalizeCanvasSceneAppState(appState) {
90
+ const nextAppState = isPlainObject(appState) ? { ...appState } : {};
91
+ const current = String(nextAppState.viewBackgroundColor ?? '').trim().toLowerCase();
92
+ if (!current || LEGACY_CANVAS_BACKGROUNDS.has(current)) {
93
+ nextAppState.viewBackgroundColor = CANVAS_SURFACE_BACKGROUND;
94
+ }
95
+ return nextAppState;
96
+ }
97
+
98
+ function normalizeCanvasScene(scene = {}) {
99
+ const input = isPlainObject(scene) ? scene : {};
100
+ return {
101
+ type: input.type ?? 'openprd.canvas.scene.v1',
102
+ elements: Array.isArray(input.elements) ? input.elements : [],
103
+ appState: normalizeCanvasSceneAppState(input.appState),
104
+ files: isPlainObject(input.files) ? input.files : {},
105
+ savedAt: input.savedAt ?? timestamp(),
106
+ };
107
+ }
108
+
109
+ function normalizeCanvasLibrary(input = {}) {
110
+ const body = isPlainObject(input) ? input : {};
111
+ const rawItems = Array.isArray(body.libraryItems)
112
+ ? body.libraryItems
113
+ : (Array.isArray(body.items) ? body.items : []);
114
+ return {
115
+ type: 'excalidrawlib',
116
+ version: Number.isFinite(Number(body.version)) ? Number(body.version) : 2,
117
+ source: body.source ?? 'https://openprd.local/canvas',
118
+ libraryItems: rawItems.map(normalizeLibraryItem).filter(Boolean),
119
+ };
120
+ }
121
+
122
+ function normalizeLibraryItem(item) {
123
+ if (!isPlainObject(item) || !Array.isArray(item.elements) || item.elements.length === 0) {
124
+ return null;
125
+ }
126
+ const id = String(item.id || `lib-${Date.now()}-${shortHash(JSON.stringify(item.elements))}`);
127
+ const status = item.status === 'published' ? 'published' : 'unpublished';
128
+ const created = Number.isFinite(Number(item.created)) ? Number(item.created) : Date.now();
129
+ const nextItem = {
130
+ id,
131
+ status,
132
+ elements: item.elements,
133
+ created,
134
+ };
135
+ if (typeof item.name === 'string' && item.name.trim()) {
136
+ nextItem.name = trimString(item.name, 120);
137
+ }
138
+ if (typeof item.error === 'string' && item.error.trim()) {
139
+ nextItem.error = trimString(item.error, 500);
140
+ }
141
+ return nextItem;
142
+ }
143
+
144
+ function shortHash(value) {
145
+ return crypto.createHash('sha256').update(String(value ?? '')).digest('hex').slice(0, 10);
146
+ }
147
+
148
+ function sanitizeSessionSegment(value, fallback = 'workspace') {
149
+ const normalized = String(value ?? '')
150
+ .trim()
151
+ .replace(/[/\\:]+/g, '-')
152
+ .replace(/[^a-zA-Z0-9._-]+/g, '-')
153
+ .replace(/^-+|-+$/g, '')
154
+ .slice(0, SESSION_KEY_MAX);
155
+ return normalized || fallback;
156
+ }
157
+
158
+ function buildSessionKey(kind, id) {
159
+ const safeKind = sanitizeSessionSegment(kind, 'session');
160
+ const safeId = sanitizeSessionSegment(id, 'workspace');
161
+ return `${safeKind}-${safeId}-${shortHash(`${kind}:${id}`)}`;
162
+ }
163
+
164
+ function firstPresent(...values) {
165
+ for (const value of values) {
166
+ if (typeof value === 'string' && value.trim()) {
167
+ return value.trim();
168
+ }
169
+ }
170
+ return null;
171
+ }
172
+
173
+ function normalizeBindingTitle(...values) {
174
+ const title = firstPresent(...values);
175
+ if (!title) {
176
+ return null;
177
+ }
178
+ return title.length > 160 ? `${title.slice(0, 157)}...` : title;
179
+ }
180
+
181
+ async function readRuntimeEnvironment(projectRoot) {
182
+ const runtimePath = cjoin(projectRoot, '.openprd', 'harness', 'runtime-environment.json');
183
+ if (!(await exists(runtimePath))) {
184
+ return { path: runtimePath, data: null };
185
+ }
186
+ try {
187
+ return { path: runtimePath, data: await readJson(runtimePath) };
188
+ } catch {
189
+ return { path: runtimePath, data: null };
190
+ }
191
+ }
192
+
193
+ function flattenRuntimeEvidence(runtimeEnvironment) {
194
+ const runtime = runtimeEnvironment ?? {};
195
+ const active = runtime.active ?? runtime.activeClient ?? runtime.current ?? {};
196
+ const detection = runtime.runtimeDetection ?? {};
197
+ const detectionActive = detection.active ?? detection.current ?? {};
198
+ const session = runtime.session ?? runtime.conversation ?? detection.session ?? detectionActive.session ?? {};
199
+ const turn = runtime.turn ?? detection.turn ?? detectionActive.turn ?? {};
200
+ const evidence = runtime.evidence ?? detection.evidence ?? detectionActive.evidence ?? {};
201
+ const observed = runtime.observedCapabilities ?? detection.observedCapabilities ?? detectionActive.observedCapabilities ?? [];
202
+ return {
203
+ active,
204
+ detection,
205
+ detectionActive,
206
+ session,
207
+ turn,
208
+ evidence,
209
+ observed,
210
+ };
211
+ }
212
+
213
+ function selectRuntimeBinding(runtimeEnvironment) {
214
+ if (!isPlainObject(runtimeEnvironment)) {
215
+ return null;
216
+ }
217
+ const parts = flattenRuntimeEvidence(runtimeEnvironment);
218
+ const threadId = firstPresent(
219
+ parts.active.threadId,
220
+ parts.active.thread_id,
221
+ parts.active.conversationId,
222
+ parts.active.conversation_id,
223
+ parts.detectionActive.threadId,
224
+ parts.detectionActive.thread_id,
225
+ parts.detectionActive.conversationId,
226
+ parts.detectionActive.conversation_id,
227
+ parts.session.threadId,
228
+ parts.session.thread_id,
229
+ parts.session.conversationId,
230
+ parts.session.conversation_id,
231
+ parts.turn.threadId,
232
+ parts.turn.thread_id,
233
+ parts.evidence.threadId,
234
+ parts.evidence.thread_id,
235
+ );
236
+ if (threadId) {
237
+ const title = normalizeBindingTitle(
238
+ parts.active.threadTitle,
239
+ parts.active.thread_title,
240
+ parts.active.conversationTitle,
241
+ parts.active.conversation_title,
242
+ parts.active.title,
243
+ parts.active.name,
244
+ parts.detectionActive.threadTitle,
245
+ parts.detectionActive.thread_title,
246
+ parts.detectionActive.conversationTitle,
247
+ parts.detectionActive.conversation_title,
248
+ parts.detectionActive.title,
249
+ parts.detectionActive.name,
250
+ parts.session.threadTitle,
251
+ parts.session.thread_title,
252
+ parts.session.conversationTitle,
253
+ parts.session.conversation_title,
254
+ parts.session.title,
255
+ parts.session.name,
256
+ parts.turn.threadTitle,
257
+ parts.turn.thread_title,
258
+ parts.turn.conversationTitle,
259
+ parts.turn.conversation_title,
260
+ parts.evidence.threadTitle,
261
+ parts.evidence.thread_title,
262
+ parts.evidence.conversationTitle,
263
+ parts.evidence.conversation_title,
264
+ );
265
+ return {
266
+ kind: 'thread',
267
+ id: threadId,
268
+ title,
269
+ threadTitle: title,
270
+ source: 'runtime-environment:thread',
271
+ activeClient: firstPresent(parts.active.client, parts.active.activeClient, parts.detectionActive.activeClient, parts.detection.activeClient),
272
+ surface: firstPresent(parts.active.surface, parts.detectionActive.surface, parts.detection.surface),
273
+ confidence: firstPresent(parts.active.confidence, parts.detectionActive.confidence, parts.detection.confidence),
274
+ observedCapabilities: parts.observed,
275
+ };
276
+ }
277
+
278
+ const sessionId = firstPresent(
279
+ parts.active.sessionId,
280
+ parts.active.session_id,
281
+ parts.detectionActive.sessionId,
282
+ parts.detectionActive.session_id,
283
+ parts.session.id,
284
+ parts.session.sessionId,
285
+ parts.session.session_id,
286
+ parts.turn.sessionId,
287
+ parts.turn.session_id,
288
+ parts.evidence.sessionId,
289
+ parts.evidence.session_id,
290
+ );
291
+ if (sessionId) {
292
+ const title = normalizeBindingTitle(
293
+ parts.active.sessionTitle,
294
+ parts.active.session_title,
295
+ parts.active.conversationTitle,
296
+ parts.active.conversation_title,
297
+ parts.active.title,
298
+ parts.active.name,
299
+ parts.detectionActive.sessionTitle,
300
+ parts.detectionActive.session_title,
301
+ parts.detectionActive.conversationTitle,
302
+ parts.detectionActive.conversation_title,
303
+ parts.detectionActive.title,
304
+ parts.detectionActive.name,
305
+ parts.session.sessionTitle,
306
+ parts.session.session_title,
307
+ parts.session.conversationTitle,
308
+ parts.session.conversation_title,
309
+ parts.session.title,
310
+ parts.session.name,
311
+ parts.turn.sessionTitle,
312
+ parts.turn.session_title,
313
+ parts.turn.conversationTitle,
314
+ parts.turn.conversation_title,
315
+ parts.evidence.sessionTitle,
316
+ parts.evidence.session_title,
317
+ parts.evidence.conversationTitle,
318
+ parts.evidence.conversation_title,
319
+ );
320
+ return {
321
+ kind: 'session',
322
+ id: sessionId,
323
+ title,
324
+ sessionTitle: title,
325
+ source: 'runtime-environment:session',
326
+ activeClient: firstPresent(parts.active.client, parts.active.activeClient, parts.detectionActive.activeClient, parts.detection.activeClient),
327
+ surface: firstPresent(parts.active.surface, parts.detectionActive.surface, parts.detection.surface),
328
+ confidence: firstPresent(parts.active.confidence, parts.detectionActive.confidence, parts.detection.confidence),
329
+ observedCapabilities: parts.observed,
330
+ };
331
+ }
332
+
333
+ return null;
334
+ }
335
+
336
+ async function resolveCanvasSessionBinding(projectRoot, options = {}, env = process.env) {
337
+ const explicitThread = firstPresent(options.thread, options.conversation, options.conversationId);
338
+ if (explicitThread) {
339
+ const title = normalizeBindingTitle(options.threadTitle, options.conversationTitle, options.title);
340
+ return {
341
+ kind: 'thread',
342
+ id: explicitThread,
343
+ displayId: explicitThread,
344
+ title,
345
+ threadTitle: title,
346
+ source: 'flag:thread',
347
+ confidence: 'explicit',
348
+ sessionKey: buildSessionKey('thread', explicitThread),
349
+ };
350
+ }
351
+
352
+ const explicitSession = firstPresent(options.session, options.sessionId);
353
+ if (explicitSession) {
354
+ const title = normalizeBindingTitle(options.sessionTitle, options.conversationTitle, options.title);
355
+ return {
356
+ kind: 'session',
357
+ id: explicitSession,
358
+ displayId: explicitSession,
359
+ title,
360
+ sessionTitle: title,
361
+ source: 'flag:session',
362
+ confidence: 'explicit',
363
+ sessionKey: buildSessionKey('session', explicitSession),
364
+ };
365
+ }
366
+
367
+ const envThread = firstPresent(
368
+ env.OPENPRD_THREAD_ID,
369
+ env.OPENPRD_CONVERSATION_ID,
370
+ env.CODEX_THREAD_ID,
371
+ env.CODEX_CONVERSATION_ID,
372
+ env.CLAUDE_THREAD_ID,
373
+ env.CURSOR_THREAD_ID,
374
+ );
375
+ if (envThread) {
376
+ const title = normalizeBindingTitle(
377
+ env.OPENPRD_THREAD_TITLE,
378
+ env.OPENPRD_CONVERSATION_TITLE,
379
+ env.CODEX_THREAD_TITLE,
380
+ env.CODEX_CONVERSATION_TITLE,
381
+ env.CLAUDE_THREAD_TITLE,
382
+ env.CLAUDE_CONVERSATION_TITLE,
383
+ env.CURSOR_THREAD_TITLE,
384
+ env.CURSOR_CONVERSATION_TITLE,
385
+ );
386
+ return {
387
+ kind: 'thread',
388
+ id: envThread,
389
+ displayId: envThread,
390
+ title,
391
+ threadTitle: title,
392
+ source: 'env:thread',
393
+ confidence: 'high',
394
+ sessionKey: buildSessionKey('thread', envThread),
395
+ };
396
+ }
397
+
398
+ const envSession = firstPresent(
399
+ env.OPENPRD_SESSION_ID,
400
+ env.CODEX_SESSION_ID,
401
+ env.CLAUDE_SESSION_ID,
402
+ env.CLAUDE_CODE_SESSION_ID,
403
+ env.CLAUDE_CODE_CHILD_SESSION,
404
+ env.CURSOR_SESSION_ID,
405
+ );
406
+ if (envSession) {
407
+ const title = normalizeBindingTitle(
408
+ env.OPENPRD_SESSION_TITLE,
409
+ env.OPENPRD_CONVERSATION_TITLE,
410
+ env.CODEX_SESSION_TITLE,
411
+ env.CODEX_CONVERSATION_TITLE,
412
+ env.CLAUDE_SESSION_TITLE,
413
+ env.CLAUDE_CONVERSATION_TITLE,
414
+ env.CURSOR_SESSION_TITLE,
415
+ env.CURSOR_CONVERSATION_TITLE,
416
+ );
417
+ return {
418
+ kind: 'session',
419
+ id: envSession,
420
+ displayId: envSession,
421
+ title,
422
+ sessionTitle: title,
423
+ source: 'env:session',
424
+ confidence: 'medium-high',
425
+ sessionKey: buildSessionKey('session', envSession),
426
+ };
427
+ }
428
+
429
+ const runtime = await readRuntimeEnvironment(projectRoot);
430
+ const runtimeBinding = selectRuntimeBinding(runtime.data);
431
+ if (runtimeBinding) {
432
+ const binding = {
433
+ ...runtimeBinding,
434
+ displayId: runtimeBinding.id,
435
+ runtimePath: runtime.path,
436
+ sessionKey: buildSessionKey(runtimeBinding.kind, runtimeBinding.id),
437
+ };
438
+ const title = normalizeBindingTitle(runtimeBinding.title, runtimeBinding.threadTitle, runtimeBinding.sessionTitle, runtimeBinding.name);
439
+ if (title) {
440
+ binding.title = title;
441
+ if (binding.kind === 'thread') {
442
+ binding.threadTitle = title;
443
+ }
444
+ if (binding.kind === 'session') {
445
+ binding.sessionTitle = title;
446
+ }
447
+ }
448
+ return binding;
449
+ }
450
+
451
+ const workspaceId = shortHash(projectRoot);
452
+ return {
453
+ kind: 'workspace',
454
+ id: workspaceId,
455
+ displayId: `workspace-${workspaceId}`,
456
+ source: 'workspace-fallback',
457
+ confidence: 'low',
458
+ sessionKey: buildSessionKey('workspace', workspaceId),
459
+ note: 'No current conversation evidence was found; pass --thread or --session to avoid sharing a fallback canvas.',
460
+ };
461
+ }
462
+
463
+ function buildCanvasSessionPaths(projectRoot, binding) {
464
+ const sessionDir = cjoin(projectRoot, '.openprd', 'harness', 'canvas-sessions', binding.sessionKey);
465
+ return {
466
+ sessionDir,
467
+ sessionFile: cjoin(sessionDir, 'session.json'),
468
+ sceneFile: cjoin(sessionDir, 'scene.json'),
469
+ libraryFile: cjoin(sessionDir, 'library.json'),
470
+ opsFile: cjoin(sessionDir, 'ops.json'),
471
+ serverFile: cjoin(sessionDir, 'server.json'),
472
+ logFile: cjoin(sessionDir, 'server.log'),
473
+ assetsDir: cjoin(sessionDir, 'assets'),
474
+ exportsDir: cjoin(sessionDir, 'exports'),
475
+ relaysDir: cjoin(sessionDir, 'relays'),
476
+ };
477
+ }
478
+
479
+ function preserveCanvasBindingDisplayTitle(binding, existingBinding) {
480
+ if (!isPlainObject(binding) || !isPlainObject(existingBinding)) {
481
+ return binding;
482
+ }
483
+ if (binding.kind !== existingBinding.kind || binding.id !== existingBinding.id) {
484
+ return binding;
485
+ }
486
+ const title = normalizeBindingTitle(
487
+ binding.title,
488
+ binding.threadTitle,
489
+ binding.sessionTitle,
490
+ binding.name,
491
+ existingBinding.title,
492
+ existingBinding.threadTitle,
493
+ existingBinding.sessionTitle,
494
+ existingBinding.name,
495
+ );
496
+ if (!title) {
497
+ return binding;
498
+ }
499
+ const nextBinding = { ...binding, title };
500
+ if (nextBinding.kind === 'thread') {
501
+ nextBinding.threadTitle = title;
502
+ }
503
+ if (nextBinding.kind === 'session') {
504
+ nextBinding.sessionTitle = title;
505
+ }
506
+ return nextBinding;
507
+ }
508
+
509
+ async function ensureCanvasSession(projectRoot, options = {}, env = process.env) {
510
+ const binding = await resolveCanvasSessionBinding(projectRoot, options, env);
511
+ const paths = buildCanvasSessionPaths(projectRoot, binding);
512
+ await fs.mkdir(paths.assetsDir, { recursive: true });
513
+ await fs.mkdir(paths.exportsDir, { recursive: true });
514
+ await fs.mkdir(paths.relaysDir, { recursive: true });
515
+ const existing = await readJson(paths.sessionFile).catch(() => null);
516
+ const resolvedBinding = preserveCanvasBindingDisplayTitle(binding, existing?.binding);
517
+ const now = timestamp();
518
+ const session = {
519
+ schema: `openprd.canvas.session.v${CANVAS_SCHEMA_VERSION}`,
520
+ version: CANVAS_SCHEMA_VERSION,
521
+ sessionKey: resolvedBinding.sessionKey,
522
+ projectRoot,
523
+ binding: resolvedBinding,
524
+ paths,
525
+ createdAt: existing?.createdAt ?? now,
526
+ updatedAt: now,
527
+ };
528
+ await writeJson(paths.sessionFile, session);
529
+ if (!(await exists(paths.sceneFile))) {
530
+ await writeJson(paths.sceneFile, {
531
+ type: 'openprd.canvas.scene.v1',
532
+ elements: [],
533
+ appState: {
534
+ viewBackgroundColor: CANVAS_SURFACE_BACKGROUND,
535
+ },
536
+ files: {},
537
+ savedAt: now,
538
+ });
539
+ }
540
+ if (!(await exists(paths.opsFile))) {
541
+ await writeJson(paths.opsFile, {
542
+ type: 'openprd.canvas.ops.v1',
543
+ nextSeq: 0,
544
+ ops: [],
545
+ });
546
+ }
547
+ if (!(await exists(paths.libraryFile))) {
548
+ await writeJson(paths.libraryFile, {
549
+ type: 'excalidrawlib',
550
+ version: 2,
551
+ source: 'https://openprd.local/canvas',
552
+ libraryItems: [],
553
+ });
554
+ }
555
+ return session;
556
+ }
557
+
558
+ function jsonResponse(res, statusCode, payload) {
559
+ res.writeHead(statusCode, {
560
+ 'content-type': 'application/json; charset=utf-8',
561
+ 'cache-control': 'no-store',
562
+ 'x-content-type-options': 'nosniff',
563
+ });
564
+ res.end(JSON.stringify(payload, null, 2));
565
+ }
566
+
567
+ function textResponse(res, statusCode, text, contentType = 'text/plain; charset=utf-8') {
568
+ res.writeHead(statusCode, {
569
+ 'content-type': contentType,
570
+ 'cache-control': 'no-store',
571
+ 'x-content-type-options': 'nosniff',
572
+ });
573
+ res.end(text);
574
+ }
575
+
576
+ async function readRequestJson(req, limitBytes = BODY_LIMIT_BYTES) {
577
+ const chunks = [];
578
+ let size = 0;
579
+ for await (const chunk of req) {
580
+ size += chunk.length;
581
+ if (size > limitBytes) {
582
+ const error = new Error('Request body is too large.');
583
+ error.statusCode = 413;
584
+ throw error;
585
+ }
586
+ chunks.push(chunk);
587
+ }
588
+ const text = Buffer.concat(chunks).toString('utf8');
589
+ if (!text.trim()) {
590
+ return {};
591
+ }
592
+ return JSON.parse(text);
593
+ }
594
+
595
+ async function readOps(paths) {
596
+ const ops = await readJson(paths.opsFile).catch(() => null);
597
+ if (!ops || !Array.isArray(ops.ops)) {
598
+ return { type: 'openprd.canvas.ops.v1', nextSeq: 0, ops: [] };
599
+ }
600
+ return ops;
601
+ }
602
+
603
+ async function appendOperation(paths, operation) {
604
+ const ops = await readOps(paths);
605
+ const seq = Number(ops.nextSeq ?? ops.ops.length) + 1;
606
+ const nextOperation = {
607
+ id: operation.id ?? `op-${seq}-${shortHash(JSON.stringify(operation))}`,
608
+ seq,
609
+ createdAt: timestamp(),
610
+ ...operation,
611
+ };
612
+ const nextOps = {
613
+ ...ops,
614
+ nextSeq: seq,
615
+ ops: [...ops.ops, nextOperation],
616
+ };
617
+ await writeJson(paths.opsFile, nextOps);
618
+ return nextOperation;
619
+ }
620
+
621
+ async function updateOperation(paths, operationId, patch) {
622
+ const ops = await readOps(paths);
623
+ let updatedOperation = null;
624
+ const nextOperations = ops.ops.map((op) => {
625
+ if (op.id !== operationId) {
626
+ return op;
627
+ }
628
+ updatedOperation = {
629
+ ...op,
630
+ ...patch,
631
+ updatedAt: timestamp(),
632
+ };
633
+ return updatedOperation;
634
+ });
635
+ if (!updatedOperation) {
636
+ const error = new Error(`Canvas operation not found: ${operationId}`);
637
+ error.statusCode = 404;
638
+ throw error;
639
+ }
640
+ await writeJson(paths.opsFile, {
641
+ ...ops,
642
+ ops: nextOperations,
643
+ });
644
+ return updatedOperation;
645
+ }
646
+
647
+ async function readLibrary(paths) {
648
+ return normalizeCanvasLibrary(await readJson(paths.libraryFile).catch(() => ({
649
+ type: 'openprd.canvas.library.v1',
650
+ items: [],
651
+ })));
652
+ }
653
+
654
+ async function writeLibrary(paths, library) {
655
+ const nextLibrary = normalizeCanvasLibrary(library);
656
+ await writeJson(paths.libraryFile, nextLibrary);
657
+ return nextLibrary;
658
+ }
659
+
660
+ async function appendLibraryItem(session, input) {
661
+ const elements = Array.isArray(input.elements) ? input.elements : [];
662
+ if (elements.length === 0) {
663
+ const error = new Error('Expected at least one element for a library item.');
664
+ error.statusCode = 400;
665
+ throw error;
666
+ }
667
+ const library = await readLibrary(session.paths);
668
+ const id = `lib-${Date.now()}-${shortHash(JSON.stringify(elements))}`;
669
+ const item = {
670
+ id,
671
+ status: input.status === 'published' ? 'published' : 'unpublished',
672
+ created: Date.now(),
673
+ elements,
674
+ };
675
+ if (input.name) {
676
+ item.name = trimString(input.name, 120);
677
+ }
678
+ const nextItems = [item, ...library.libraryItems].slice(0, 80);
679
+ const nextLibrary = await writeLibrary(session.paths, {
680
+ ...library,
681
+ libraryItems: nextItems,
682
+ });
683
+ return { item, library: nextLibrary };
684
+ }
685
+
686
+ function dataUrlToBuffer(dataUrl) {
687
+ const match = /^data:([^;,]+)?(;base64)?,([\s\S]*)$/i.exec(String(dataUrl ?? ''));
688
+ if (!match) {
689
+ throw new Error('Expected a data URL.');
690
+ }
691
+ const mimeType = match[1] || 'application/octet-stream';
692
+ const isBase64 = Boolean(match[2]);
693
+ const raw = match[3] ?? '';
694
+ const buffer = isBase64 ? Buffer.from(raw, 'base64') : Buffer.from(decodeURIComponent(raw), 'utf8');
695
+ return { buffer, mimeType };
696
+ }
697
+
698
+ function extensionFromMime(mimeType, fallback = '.bin') {
699
+ const normalized = String(mimeType ?? '').split(';')[0].trim().toLowerCase();
700
+ if (normalized === 'image/png') return '.png';
701
+ if (normalized === 'image/jpeg' || normalized === 'image/jpg') return '.jpg';
702
+ if (normalized === 'image/webp') return '.webp';
703
+ if (normalized === 'image/gif') return '.gif';
704
+ if (normalized === 'image/svg+xml') return '.svg';
705
+ if (normalized === 'application/json') return '.json';
706
+ return fallback;
707
+ }
708
+
709
+ function mimeFromPath(filePath) {
710
+ return MIME_TYPES[path.extname(filePath).toLowerCase()] ?? 'application/octet-stream';
711
+ }
712
+
713
+ function trimString(value, maxLength = HANDOFF_TEXT_MAX) {
714
+ const text = String(value ?? '');
715
+ return text.length > maxLength ? `${text.slice(0, maxLength)}\n...[truncated]` : text;
716
+ }
717
+
718
+ function escapeCodexDelegationXml(value) {
719
+ return String(value ?? '')
720
+ .replace(/&/g, '&amp;')
721
+ .replace(/</g, '&lt;')
722
+ .replace(/>/g, '&gt;');
723
+ }
724
+
725
+ function buildCodexDelegationText(sourceThreadId, inputText) {
726
+ const sourceId = trimString(sourceThreadId || 'openprd-canvas', 200);
727
+ const text = trimString(inputText || 'Please continue from this OpenPrd Canvas handoff.');
728
+ return [
729
+ '<codex_delegation>',
730
+ ` <source_thread_id>${escapeCodexDelegationXml(sourceId)}</source_thread_id>`,
731
+ ` <input>${escapeCodexDelegationXml(text)}</input>`,
732
+ '</codex_delegation>',
733
+ ].join('\n');
734
+ }
735
+
736
+ function parsePositiveInteger(value, fallback = 0) {
737
+ const parsed = Number(value);
738
+ return Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : fallback;
739
+ }
740
+
741
+ function normalizeStringArray(value) {
742
+ if (!Array.isArray(value)) {
743
+ return [];
744
+ }
745
+ return value.map((item) => String(item)).filter(Boolean);
746
+ }
747
+
748
+ function downloadsDir(env = process.env) {
749
+ return env.OPENPRD_DOWNLOADS_DIR || path.join(os.homedir(), 'Downloads');
750
+ }
751
+
752
+ function clipboardCommandCandidates(platform = process.platform) {
753
+ if (platform === 'darwin') {
754
+ return [{ command: 'pbcopy', args: [] }];
755
+ }
756
+ if (platform === 'win32') {
757
+ return [{ command: 'clip.exe', args: [] }];
758
+ }
759
+ return [
760
+ { command: 'wl-copy', args: [] },
761
+ { command: 'xclip', args: ['-selection', 'clipboard'] },
762
+ { command: 'xsel', args: ['--clipboard', '--input'] },
763
+ ];
764
+ }
765
+
766
+ async function writeTextToSystemClipboard(text, options = {}) {
767
+ const value = trimString(text, HANDOFF_TEXT_MAX);
768
+ const candidates = clipboardCommandCandidates(options.platform);
769
+ if (options.dryRun) {
770
+ return {
771
+ ok: true,
772
+ command: candidates[0]?.command ?? null,
773
+ platform: options.platform ?? process.platform,
774
+ dryRun: true,
775
+ };
776
+ }
777
+ let lastError = null;
778
+ for (const candidate of candidates) {
779
+ try {
780
+ await new Promise((resolve, reject) => {
781
+ const child = spawn(candidate.command, candidate.args, { stdio: ['pipe', 'ignore', 'ignore'] });
782
+ child.once('error', reject);
783
+ child.once('close', (code) => {
784
+ if (code === 0) {
785
+ resolve();
786
+ return;
787
+ }
788
+ reject(new Error(`${candidate.command} exited with code ${code}`));
789
+ });
790
+ child.stdin.end(value);
791
+ });
792
+ return {
793
+ ok: true,
794
+ command: candidate.command,
795
+ platform: options.platform ?? process.platform,
796
+ dryRun: false,
797
+ };
798
+ } catch (error) {
799
+ lastError = error;
800
+ }
801
+ }
802
+ const error = new Error(`Could not write to system clipboard: ${lastError?.message ?? 'no clipboard command available'}`);
803
+ error.statusCode = 500;
804
+ throw error;
805
+ }
806
+
807
+ function relayTimeoutMs(env = process.env) {
808
+ return parsePositiveInteger(env.OPENPRD_CANVAS_CODEX_RELAY_TIMEOUT_MS, CODEX_APP_SERVER_RELAY_TIMEOUT_MS);
809
+ }
810
+
811
+ function relayStartWaitMs(options = {}, env = process.env) {
812
+ if (options.relayWaitMs != null) {
813
+ return parsePositiveInteger(options.relayWaitMs, 0);
814
+ }
815
+ return parsePositiveInteger(env.OPENPRD_CANVAS_CODEX_RELAY_START_WAIT_MS, CODEX_APP_SERVER_RELAY_START_WAIT_MS);
816
+ }
817
+
818
+ async function sleep(ms) {
819
+ await new Promise((resolve) => setTimeout(resolve, ms));
820
+ }
821
+
822
+ async function waitForCodexRelayStart(statusPath, waitMs) {
823
+ const timeoutMs = parsePositiveInteger(waitMs, 0);
824
+ if (!timeoutMs) {
825
+ return null;
826
+ }
827
+ const deadline = Date.now() + timeoutMs;
828
+ let latest = null;
829
+ while (Date.now() <= deadline) {
830
+ latest = await readJson(statusPath).catch(() => latest);
831
+ if (latest?.status && CODEX_RELAY_WAIT_READY_STATUSES.has(latest.status)) {
832
+ return latest;
833
+ }
834
+ await sleep(350);
835
+ }
836
+ latest = await readJson(statusPath).catch(() => latest);
837
+ return latest ? { ...latest, waitTimedOut: true } : {
838
+ status: 'queued',
839
+ waitTimedOut: true,
840
+ };
841
+ }
842
+
843
+ function relayStatusSummary(statusDoc) {
844
+ if (!statusDoc) {
845
+ return {};
846
+ }
847
+ const status = statusDoc.status ?? 'queued';
848
+ return {
849
+ status,
850
+ phase: statusDoc.phase ?? null,
851
+ turn: statusDoc.turn ?? null,
852
+ resolvedThread: statusDoc.resolvedThread ?? null,
853
+ error: statusDoc.error ?? null,
854
+ waitTimedOut: Boolean(statusDoc.waitTimedOut),
855
+ delivered: CODEX_RELAY_DELIVERED_STATUSES.has(status),
856
+ pending: Boolean(statusDoc.waitTimedOut) || CODEX_RELAY_PENDING_STATUSES.has(status),
857
+ accepted: CODEX_RELAY_ACCEPTED_STATUSES.has(status),
858
+ };
859
+ }
860
+
861
+ async function resolveCodexAppServerBin(env = process.env) {
862
+ if (env.OPENPRD_CODEX_APP_SERVER_BIN) {
863
+ return env.OPENPRD_CODEX_APP_SERVER_BIN;
864
+ }
865
+ if (process.platform === 'darwin' && await exists(CODEX_APP_BUNDLE_BIN_PATH)) {
866
+ return CODEX_APP_BUNDLE_BIN_PATH;
867
+ }
868
+ return 'codex';
869
+ }
870
+
871
+ function codexRelaySkipReason(session, op, options = {}, env = process.env) {
872
+ if (options.relay === false || options.disableRelay === true) {
873
+ return 'disabled-by-request';
874
+ }
875
+ if (env.OPENPRD_CANVAS_CODEX_RELAY === '0') {
876
+ return 'disabled-by-env';
877
+ }
878
+ if (String(op.target ?? '').toLowerCase() !== 'codex') {
879
+ return 'unsupported-target';
880
+ }
881
+ if (session.binding?.kind !== 'thread' || !session.binding?.id) {
882
+ return 'not-thread-bound';
883
+ }
884
+ return null;
885
+ }
886
+
887
+ function buildCodexRelayInput(session, op) {
888
+ const input = [];
889
+ const text = trimString(op.promptText || op.instruction || 'Please continue from this OpenPrd Canvas handoff.');
890
+ if (text.trim()) {
891
+ const sourceThreadId = session.binding?.id || op.binding?.id || op.threadId || 'openprd-canvas';
892
+ input.push({ type: 'text', text: buildCodexDelegationText(sourceThreadId, text) });
893
+ }
894
+ const exportPath = String(op.exportPath ?? '').trim();
895
+ if (exportPath) {
896
+ input.push({ type: 'localImage', path: exportPath });
897
+ }
898
+ return input;
899
+ }
900
+
901
+ function buildCodexAgentBridgePrompt(session, op) {
902
+ const text = trimString(op.promptText || op.instruction || 'Please continue from this OpenPrd Canvas handoff.');
903
+ const sourceThreadId = session.binding?.id || op.binding?.id || op.threadId || 'openprd-canvas';
904
+ return buildCodexDelegationText(sourceThreadId, text);
905
+ }
906
+
907
+ function summarizeAgentBridgeOperation(op) {
908
+ if (!op) {
909
+ return null;
910
+ }
911
+ return {
912
+ id: op.id,
913
+ seq: op.seq ?? null,
914
+ status: op.status ?? 'pending',
915
+ target: op.target ?? 'codex',
916
+ transport: op.transport ?? CODEX_AGENT_BRIDGE_TRANSPORT,
917
+ handoffOperationId: op.handoffOperationId ?? null,
918
+ threadId: op.threadId ?? null,
919
+ threadTitle: op.threadTitle ?? null,
920
+ prompt: op.prompt ?? null,
921
+ promptText: op.promptText ?? null,
922
+ exportPath: op.exportPath ?? null,
923
+ exportUrl: op.exportUrl ?? null,
924
+ selection: op.selection ?? null,
925
+ placeholderId: op.placeholderId ?? null,
926
+ elementCount: op.elementCount ?? null,
927
+ selectedElementIds: Array.isArray(op.selectedElementIds) ? op.selectedElementIds : [],
928
+ createdAt: op.createdAt ?? null,
929
+ updatedAt: op.updatedAt ?? null,
930
+ claimedAt: op.claimedAt ?? null,
931
+ sentAt: op.sentAt ?? null,
932
+ failedAt: op.failedAt ?? null,
933
+ reason: op.reason ?? null,
934
+ requiresAgentTool: op.requiresAgentTool ?? 'codex_app.send_message_to_thread',
935
+ };
936
+ }
937
+
938
+ function codexRelayNeedsAgentBridgeFallback(relay) {
939
+ if (!relay || relay.status === 'skipped') {
940
+ return true;
941
+ }
942
+ const status = String(relay.status ?? '');
943
+ if (CODEX_RELAY_AGENT_FALLBACK_STATUSES.has(status)) {
944
+ return true;
945
+ }
946
+ return Boolean(relay.waitTimedOut && ['queued', 'starting', 'resuming'].includes(status));
947
+ }
948
+
949
+ async function queueCodexAgentBridge(session, op, options = {}) {
950
+ const requested = Boolean(options.agentBridge || options.foregroundBridge);
951
+ const base = {
952
+ target: op.target ?? 'codex',
953
+ transport: CODEX_AGENT_BRIDGE_TRANSPORT,
954
+ supportsForegroundMessage: false,
955
+ requiresAgentTool: 'codex_app.send_message_to_thread',
956
+ };
957
+ if (options.agentBridgeSkipReason) {
958
+ return {
959
+ ...base,
960
+ status: 'skipped',
961
+ reason: options.agentBridgeSkipReason,
962
+ directRelayStatus: options.directRelay?.status ?? null,
963
+ directRelayTransport: options.directRelay?.transport ?? CODEX_DELEGATION_RELAY_TRANSPORT,
964
+ };
965
+ }
966
+ if (!requested) {
967
+ return {
968
+ ...base,
969
+ status: 'skipped',
970
+ reason: 'not-requested',
971
+ };
972
+ }
973
+ if (String(op.target ?? '').toLowerCase() !== 'codex') {
974
+ return {
975
+ ...base,
976
+ status: 'skipped',
977
+ reason: 'unsupported-target',
978
+ };
979
+ }
980
+ if (session.binding?.kind !== 'thread' || !session.binding?.id) {
981
+ return {
982
+ ...base,
983
+ status: 'skipped',
984
+ reason: 'not-thread-bound',
985
+ };
986
+ }
987
+ const bridgeOp = await appendOperation(session.paths, {
988
+ type: 'agent-foreground-relay',
989
+ target: 'codex',
990
+ transport: CODEX_AGENT_BRIDGE_TRANSPORT,
991
+ status: 'pending',
992
+ handoffOperationId: op.id,
993
+ threadId: session.binding.id,
994
+ threadTitle: session.binding.title || session.binding.threadTitle || null,
995
+ prompt: buildCodexAgentBridgePrompt(session, op),
996
+ promptText: op.promptText ?? null,
997
+ exportPath: op.exportPath ?? null,
998
+ exportUrl: op.exportUrl ?? null,
999
+ selection: op.selection ?? null,
1000
+ placeholderId: op.placeholderId ?? null,
1001
+ elementCount: op.elementCount ?? null,
1002
+ selectedElementIds: Array.isArray(op.selectedElementIds) ? op.selectedElementIds : [],
1003
+ locale: op.locale ?? null,
1004
+ note: op.note ?? null,
1005
+ requiresAgentTool: 'codex_app.send_message_to_thread',
1006
+ supportsForegroundMessage: true,
1007
+ });
1008
+ return {
1009
+ ...base,
1010
+ ...summarizeAgentBridgeOperation(bridgeOp),
1011
+ status: 'pending',
1012
+ supportsForegroundMessage: true,
1013
+ };
1014
+ }
1015
+
1016
+ async function listCodexAgentBridgeOutbox(session, options = {}) {
1017
+ const includeAll = Boolean(options.all);
1018
+ const ops = await readOps(session.paths);
1019
+ const items = ops.ops
1020
+ .filter((op) => op.type === 'agent-foreground-relay')
1021
+ .filter((op) => includeAll || ['pending', 'claimed'].includes(op.status ?? 'pending'))
1022
+ .map(summarizeAgentBridgeOperation)
1023
+ .filter(Boolean);
1024
+ return items;
1025
+ }
1026
+
1027
+ async function claimCodexAgentBridgeItem(session, claim = 'next') {
1028
+ const items = await listCodexAgentBridgeOutbox(session);
1029
+ const value = String(claim || 'next');
1030
+ const item = value === 'next'
1031
+ ? items.find((candidate) => candidate.status === 'pending')
1032
+ : items.find((candidate) => candidate.id === value);
1033
+ if (!item) {
1034
+ return null;
1035
+ }
1036
+ const updated = await updateOperation(session.paths, item.id, {
1037
+ status: 'claimed',
1038
+ claimedAt: timestamp(),
1039
+ });
1040
+ await appendOperation(session.paths, {
1041
+ type: 'agent-foreground-relay-status',
1042
+ bridgeOperationId: item.id,
1043
+ status: 'claimed',
1044
+ });
1045
+ return summarizeAgentBridgeOperation(updated);
1046
+ }
1047
+
1048
+ async function markCodexAgentBridgeItem(session, id, status, options = {}) {
1049
+ if (!id) {
1050
+ const error = new Error('Expected --id for canvas bridge status update.');
1051
+ error.statusCode = 400;
1052
+ throw error;
1053
+ }
1054
+ const normalizedStatus = status === 'failed' ? 'failed' : 'sent';
1055
+ const timestampField = normalizedStatus === 'failed' ? 'failedAt' : 'sentAt';
1056
+ const updated = await updateOperation(session.paths, id, {
1057
+ status: normalizedStatus,
1058
+ [timestampField]: timestamp(),
1059
+ reason: options.reason ?? null,
1060
+ sentThreadId: options.threadId ?? session.binding?.id ?? null,
1061
+ });
1062
+ await appendOperation(session.paths, {
1063
+ type: 'agent-foreground-relay-status',
1064
+ bridgeOperationId: id,
1065
+ status: normalizedStatus,
1066
+ reason: options.reason ?? null,
1067
+ threadId: options.threadId ?? session.binding?.id ?? null,
1068
+ });
1069
+ return summarizeAgentBridgeOperation(updated);
1070
+ }
1071
+
1072
+ async function queueCodexThreadRelay(session, op, options = {}, env = process.env) {
1073
+ const skipReason = codexRelaySkipReason(session, op, options, env);
1074
+ const input = buildCodexRelayInput(session, op);
1075
+ const supportsDirectAttachment = input.some((item) => item.type === 'localImage');
1076
+ const base = {
1077
+ target: op.target ?? 'codex',
1078
+ transport: CODEX_DELEGATION_RELAY_TRANSPORT,
1079
+ supportsDirectMessage: !skipReason,
1080
+ supportsDirectAttachment: !skipReason && supportsDirectAttachment,
1081
+ inputTypes: input.map((item) => item.type),
1082
+ promptText: op.promptText,
1083
+ delivered: false,
1084
+ pending: false,
1085
+ accepted: false,
1086
+ };
1087
+ if (skipReason) {
1088
+ return {
1089
+ ...base,
1090
+ status: 'skipped',
1091
+ reason: skipReason,
1092
+ supportsDirectMessage: false,
1093
+ supportsDirectAttachment: false,
1094
+ };
1095
+ }
1096
+
1097
+ await fs.mkdir(session.paths.relaysDir, { recursive: true });
1098
+ const relayName = sanitizeSessionSegment(op.id || `handoff-${Date.now()}`, 'handoff');
1099
+ const payloadPath = cjoin(session.paths.relaysDir, `${relayName}.payload.json`);
1100
+ const statusPath = cjoin(session.paths.relaysDir, `${relayName}.status.json`);
1101
+ const logPath = cjoin(session.paths.relaysDir, `${relayName}.log`);
1102
+ const payload = {
1103
+ schema: 'openprd.codex.app-server-relay.v1',
1104
+ createdAt: timestamp(),
1105
+ handoffOperationId: op.id,
1106
+ threadId: session.binding.id,
1107
+ threadTitle: session.binding.title || session.binding.threadTitle || null,
1108
+ cwd: session.projectRoot,
1109
+ input,
1110
+ payloadPath,
1111
+ statusPath,
1112
+ logPath,
1113
+ codexBin: await resolveCodexAppServerBin(env),
1114
+ timeoutMs: relayTimeoutMs(env),
1115
+ };
1116
+ await writeJson(payloadPath, payload);
1117
+ await writeJson(statusPath, {
1118
+ schema: 'openprd.codex.app-server-relay.status.v1',
1119
+ status: 'queued',
1120
+ createdAt: payload.createdAt,
1121
+ updatedAt: timestamp(),
1122
+ handoffOperationId: op.id,
1123
+ threadId: session.binding.id,
1124
+ });
1125
+
1126
+ if (options.relayDryRun || env.OPENPRD_CANVAS_CODEX_RELAY_DRY_RUN === '1') {
1127
+ return {
1128
+ ...base,
1129
+ ...relayStatusSummary({ status: 'queued' }),
1130
+ status: 'queued',
1131
+ dryRun: true,
1132
+ payloadPath,
1133
+ statusPath,
1134
+ logPath,
1135
+ };
1136
+ }
1137
+
1138
+ const child = spawn(process.execPath, [CODEX_APP_SERVER_RELAY_WORKER_PATH, payloadPath], {
1139
+ cwd: session.projectRoot,
1140
+ detached: true,
1141
+ stdio: 'ignore',
1142
+ });
1143
+ child.unref();
1144
+ const waitedStatus = await waitForCodexRelayStart(statusPath, relayStartWaitMs(options, env));
1145
+ const statusSummary = relayStatusSummary(waitedStatus || { status: 'queued' });
1146
+ return {
1147
+ ...base,
1148
+ status: statusSummary.status ?? 'queued',
1149
+ payloadPath,
1150
+ statusPath,
1151
+ logPath,
1152
+ pid: child.pid ?? null,
1153
+ ...statusSummary,
1154
+ };
1155
+ }
1156
+
1157
+ async function materializeImage(session, input) {
1158
+ const paths = session.paths;
1159
+ let buffer = null;
1160
+ let mimeType = null;
1161
+ let sourcePath = null;
1162
+ let sourceUrl = null;
1163
+ let extension = '.png';
1164
+
1165
+ if (input.dataUrl) {
1166
+ const parsed = dataUrlToBuffer(input.dataUrl);
1167
+ buffer = parsed.buffer;
1168
+ mimeType = parsed.mimeType;
1169
+ extension = extensionFromMime(mimeType, '.png');
1170
+ } else if (input.path) {
1171
+ sourcePath = path.isAbsolute(input.path) ? input.path : path.resolve(session.projectRoot, input.path);
1172
+ buffer = await fs.readFile(sourcePath);
1173
+ extension = path.extname(sourcePath) || '.png';
1174
+ mimeType = mimeFromPath(sourcePath);
1175
+ } else if (input.url) {
1176
+ sourceUrl = input.url;
1177
+ const response = await fetch(input.url);
1178
+ if (!response.ok) {
1179
+ throw new Error(`Unable to fetch image URL: ${response.status} ${response.statusText}`);
1180
+ }
1181
+ buffer = Buffer.from(await response.arrayBuffer());
1182
+ mimeType = response.headers.get('content-type') ?? 'application/octet-stream';
1183
+ extension = extensionFromMime(mimeType, path.extname(decodeURIComponent(new URL(input.url).pathname)) || '.png');
1184
+ } else {
1185
+ throw new Error('Expected one of: dataUrl, path, url.');
1186
+ }
1187
+
1188
+ const safeExt = extension.startsWith('.') ? extension : `.${extension}`;
1189
+ const digest = shortHash(buffer);
1190
+ const fileName = `image-${Date.now()}-${digest}${safeExt.toLowerCase()}`;
1191
+ const assetPath = cjoin(paths.assetsDir, fileName);
1192
+ await fs.writeFile(assetPath, buffer);
1193
+ return {
1194
+ fileName,
1195
+ assetPath,
1196
+ assetUrl: `/assets/${encodeURIComponent(fileName)}`,
1197
+ mimeType,
1198
+ sourcePath,
1199
+ sourceUrl,
1200
+ digest,
1201
+ };
1202
+ }
1203
+
1204
+ async function materializeExport(session, input) {
1205
+ if (!input.dataUrl) {
1206
+ throw new Error('Expected dataUrl for export.');
1207
+ }
1208
+ const parsed = dataUrlToBuffer(input.dataUrl);
1209
+ const extension = extensionFromMime(parsed.mimeType, '.png');
1210
+ const baseName = sanitizeSessionSegment(input.filename || `canvas-export-${Date.now()}`, 'canvas-export');
1211
+ const fileName = baseName.endsWith(extension) ? baseName : `${baseName}${extension}`;
1212
+ const exportPath = cjoin(session.paths.exportsDir, fileName);
1213
+ await fs.writeFile(exportPath, parsed.buffer);
1214
+ return {
1215
+ fileName,
1216
+ exportPath,
1217
+ exportUrl: `/exports/${encodeURIComponent(fileName)}`,
1218
+ mimeType: parsed.mimeType,
1219
+ };
1220
+ }
1221
+
1222
+ function safeDownloadFileName(value, fallback = `openprd-canvas-${Date.now()}.excalidraw`) {
1223
+ const requestedName = String(value ?? fallback).trim() || fallback;
1224
+ const baseName = path.basename(requestedName);
1225
+ if (baseName !== requestedName || baseName.includes('/') || baseName.includes('\\') || baseName.includes('..')) {
1226
+ const error = new Error('Invalid download filename.');
1227
+ error.statusCode = 400;
1228
+ throw error;
1229
+ }
1230
+ return baseName;
1231
+ }
1232
+
1233
+ async function materializeDownloadFile(input) {
1234
+ const content = String(input.content ?? '');
1235
+ if (!content.trim()) {
1236
+ throw new Error('Expected content for file save.');
1237
+ }
1238
+ const targetDir = downloadsDir();
1239
+ await fs.mkdir(targetDir, { recursive: true });
1240
+ const fileName = safeDownloadFileName(input.filename);
1241
+ const filePath = path.join(targetDir, fileName);
1242
+ await fs.writeFile(filePath, content, 'utf8');
1243
+ return {
1244
+ fileName,
1245
+ filePath,
1246
+ folderPath: targetDir,
1247
+ mimeType: input.mimeType || 'application/json; charset=utf-8',
1248
+ };
1249
+ }
1250
+
1251
+ function isInsideDirectory(parentDir, targetPath) {
1252
+ const relative = path.relative(path.resolve(parentDir), path.resolve(targetPath));
1253
+ return !relative.startsWith('..') && !path.isAbsolute(relative);
1254
+ }
1255
+
1256
+ async function resolveCanvasSessionFile(session, requestedPath, allowedDirs = []) {
1257
+ if (!requestedPath) {
1258
+ const error = new Error('Expected source path.');
1259
+ error.statusCode = 400;
1260
+ throw error;
1261
+ }
1262
+ const targetPath = path.resolve(String(requestedPath));
1263
+ const allowed = allowedDirs.some((dir) => isInsideDirectory(dir, targetPath));
1264
+ if (!allowed) {
1265
+ const error = new Error('Path is outside this canvas session.');
1266
+ error.statusCode = 403;
1267
+ throw error;
1268
+ }
1269
+ const stat = await fs.stat(targetPath).catch(() => null);
1270
+ if (!stat?.isFile()) {
1271
+ const error = new Error('Path does not exist.');
1272
+ error.statusCode = 404;
1273
+ throw error;
1274
+ }
1275
+ return targetPath;
1276
+ }
1277
+
1278
+ async function materializeDownloadImage(session, input) {
1279
+ const sourcePath = await resolveCanvasSessionFile(
1280
+ session,
1281
+ input.path || input.exportPath || input.assetPath,
1282
+ [session.paths.exportsDir, session.paths.assetsDir],
1283
+ );
1284
+ const targetDir = downloadsDir();
1285
+ await fs.mkdir(targetDir, { recursive: true });
1286
+ const extension = path.extname(sourcePath) || extensionFromMime(input.mimeType || mimeFromPath(sourcePath), '.png');
1287
+ const fallback = `openprd-canvas-image-${Date.now()}${extension}`;
1288
+ const requestedName = safeDownloadFileName(input.filename || input.fileName, fallback);
1289
+ const fileName = path.extname(requestedName) ? requestedName : `${requestedName}${extension}`;
1290
+ const filePath = path.join(targetDir, fileName);
1291
+ await fs.copyFile(sourcePath, filePath);
1292
+ return {
1293
+ fileName,
1294
+ filePath,
1295
+ folderPath: targetDir,
1296
+ sourcePath,
1297
+ mimeType: mimeFromPath(filePath),
1298
+ };
1299
+ }
1300
+
1301
+ async function resolveOpenFolderTarget(session, requestedPath) {
1302
+ if (!requestedPath) {
1303
+ const error = new Error('Expected path.');
1304
+ error.statusCode = 400;
1305
+ throw error;
1306
+ }
1307
+ const sessionRoot = path.resolve(session.paths.sessionDir);
1308
+ const targetPath = path.resolve(String(requestedPath));
1309
+ const relative = path.relative(sessionRoot, targetPath);
1310
+ if (relative.startsWith('..') || path.isAbsolute(relative)) {
1311
+ const error = new Error('Path is outside this canvas session.');
1312
+ error.statusCode = 403;
1313
+ throw error;
1314
+ }
1315
+ const stat = await fs.stat(targetPath).catch(() => null);
1316
+ if (!stat) {
1317
+ const error = new Error('Path does not exist.');
1318
+ error.statusCode = 404;
1319
+ throw error;
1320
+ }
1321
+ const isFile = stat.isFile();
1322
+ return {
1323
+ targetPath,
1324
+ folderPath: isFile ? path.dirname(targetPath) : targetPath,
1325
+ isFile,
1326
+ };
1327
+ }
1328
+
1329
+ async function resolveDownloadsFolderTarget(fileName) {
1330
+ const targetDir = downloadsDir();
1331
+ const requestedName = String(fileName ?? '').trim();
1332
+ if (!requestedName) {
1333
+ return {
1334
+ targetPath: targetDir,
1335
+ folderPath: targetDir,
1336
+ isFile: false,
1337
+ fileName: null,
1338
+ };
1339
+ }
1340
+ const safeName = safeDownloadFileName(requestedName);
1341
+ const targetPath = path.join(targetDir, safeName);
1342
+ const stat = await fs.stat(targetPath).catch(() => null);
1343
+ if (stat?.isFile()) {
1344
+ return {
1345
+ targetPath,
1346
+ folderPath: targetDir,
1347
+ isFile: true,
1348
+ fileName: safeName,
1349
+ };
1350
+ }
1351
+ return {
1352
+ targetPath: targetDir,
1353
+ folderPath: targetDir,
1354
+ isFile: false,
1355
+ fileName: safeName,
1356
+ missingFile: true,
1357
+ };
1358
+ }
1359
+
1360
+ function safeStaticName(urlPathname, prefix) {
1361
+ const raw = decodeURIComponent(urlPathname.slice(prefix.length));
1362
+ if (!raw || raw.includes('/') || raw.includes('\\') || raw.includes('..')) {
1363
+ return null;
1364
+ }
1365
+ return raw;
1366
+ }
1367
+
1368
+ async function serveStaticFile(res, filePath) {
1369
+ const stat = await fs.stat(filePath).catch(() => null);
1370
+ if (!stat?.isFile()) {
1371
+ textResponse(res, 404, 'Not found');
1372
+ return;
1373
+ }
1374
+ res.writeHead(200, {
1375
+ 'content-type': mimeFromPath(filePath),
1376
+ 'content-length': stat.size,
1377
+ 'cache-control': 'no-store',
1378
+ 'x-content-type-options': 'nosniff',
1379
+ });
1380
+ createReadStream(filePath).pipe(res);
1381
+ }
1382
+
1383
+ async function buildReviewDirectionsPayload(projectRoot) {
1384
+ const versionIndexPath = cjoin(projectRoot, '.openprd', 'state', 'version-index.json');
1385
+ const versionIndex = await readJson(versionIndexPath).catch(() => []);
1386
+ const latestVersionId = Array.isArray(versionIndex) ? versionIndex.at(-1)?.versionId : null;
1387
+ if (!latestVersionId) {
1388
+ return { ok: false, reason: 'no-version', directions: [] };
1389
+ }
1390
+ const snapshot = await readJson(cjoin(projectRoot, '.openprd', 'state', 'versions', `${latestVersionId}.json`)).catch(() => null);
1391
+ const block = snapshot?.reviewPresentation?.designDirections;
1392
+ const rawDirections = Array.isArray(block?.directions) ? block.directions : [];
1393
+ if (rawDirections.length === 0) {
1394
+ return { ok: false, reason: 'no-directions', versionId: latestVersionId, directions: [] };
1395
+ }
1396
+ const resolvedRoot = path.resolve(projectRoot);
1397
+ const directions = [];
1398
+ for (const direction of rawDirections) {
1399
+ if (!direction || typeof direction !== 'object') {
1400
+ continue;
1401
+ }
1402
+ const entry = {
1403
+ id: String(direction.id || ''),
1404
+ title: String(direction.title || ''),
1405
+ summary: String(direction.summary || ''),
1406
+ tradeoff: String(direction.tradeoff || ''),
1407
+ image: null,
1408
+ };
1409
+ const imagePath = typeof direction.image === 'string' ? direction.image.trim() : '';
1410
+ if (imagePath) {
1411
+ const resolvedImage = path.resolve(resolvedRoot, imagePath);
1412
+ if (resolvedImage.startsWith(`${resolvedRoot}${path.sep}`) && await exists(resolvedImage)) {
1413
+ const buffer = await fs.readFile(resolvedImage).catch(() => null);
1414
+ if (buffer) {
1415
+ const mimeType = mimeFromPath(resolvedImage);
1416
+ entry.image = {
1417
+ path: imagePath,
1418
+ mimeType,
1419
+ dataUrl: `data:${mimeType};base64,${buffer.toString('base64')}`,
1420
+ };
1421
+ }
1422
+ }
1423
+ }
1424
+ directions.push(entry);
1425
+ }
1426
+ return {
1427
+ ok: directions.length > 0,
1428
+ versionId: latestVersionId,
1429
+ title: snapshot?.title || '',
1430
+ selectedDirectionId: (await readJson(cjoin(projectRoot, '.openprd', 'state', 'current.json')).catch(() => null))?.reviewStatus?.designDirection?.id
1431
+ || null,
1432
+ directions,
1433
+ };
1434
+ }
1435
+
1436
+ async function handleCanvasRequest(session, req, res) {
1437
+ const url = new URL(req.url ?? '/', `http://${req.headers.host || 'localhost'}`);
1438
+ const pathname = url.pathname;
1439
+
1440
+ if ((req.method === 'GET' || req.method === 'HEAD') && pathname === '/') {
1441
+ if (req.method === 'HEAD') {
1442
+ textResponse(res, 200, '', 'text/html; charset=utf-8');
1443
+ return;
1444
+ }
1445
+ textResponse(res, 200, renderCanvasAppHtml(session), 'text/html; charset=utf-8');
1446
+ return;
1447
+ }
1448
+
1449
+ if (req.method === 'GET' && pathname === '/api/session') {
1450
+ jsonResponse(res, 200, session);
1451
+ return;
1452
+ }
1453
+
1454
+ if (req.method === 'GET' && pathname === '/api/guide') {
1455
+ try {
1456
+ const { guideWorkspace } = await import('./guide.js');
1457
+ jsonResponse(res, 200, await guideWorkspace(session.projectRoot));
1458
+ } catch (error) {
1459
+ jsonResponse(res, 200, { ok: false, error: error?.message || String(error) });
1460
+ }
1461
+ return;
1462
+ }
1463
+
1464
+ if (req.method === 'GET' && pathname === '/api/review-directions') {
1465
+ try {
1466
+ jsonResponse(res, 200, await buildReviewDirectionsPayload(session.projectRoot));
1467
+ } catch (error) {
1468
+ jsonResponse(res, 200, { ok: false, error: error?.message || String(error) });
1469
+ }
1470
+ return;
1471
+ }
1472
+
1473
+ if (req.method === 'GET' && pathname === '/api/scene') {
1474
+ const scene = normalizeCanvasScene(await readJson(session.paths.sceneFile).catch(() => ({
1475
+ type: 'openprd.canvas.scene.v1',
1476
+ elements: [],
1477
+ appState: { viewBackgroundColor: CANVAS_SURFACE_BACKGROUND },
1478
+ files: {},
1479
+ })));
1480
+ jsonResponse(res, 200, scene);
1481
+ return;
1482
+ }
1483
+
1484
+ if (req.method === 'POST' && pathname === '/api/scene') {
1485
+ const body = await readRequestJson(req);
1486
+ await writeJson(session.paths.sceneFile, normalizeCanvasScene({
1487
+ type: 'openprd.canvas.scene.v1',
1488
+ elements: Array.isArray(body.elements) ? body.elements : [],
1489
+ appState: isPlainObject(body.appState) ? body.appState : {},
1490
+ files: isPlainObject(body.files) ? body.files : {},
1491
+ savedAt: timestamp(),
1492
+ }));
1493
+ jsonResponse(res, 200, { ok: true, scenePath: session.paths.sceneFile });
1494
+ return;
1495
+ }
1496
+
1497
+ if (req.method === 'GET' && pathname === '/api/ops') {
1498
+ const after = Number(url.searchParams.get('after') ?? -1);
1499
+ const ops = await readOps(session.paths);
1500
+ jsonResponse(res, 200, {
1501
+ ok: true,
1502
+ nextSeq: ops.nextSeq,
1503
+ ops: ops.ops.filter((op) => Number(op.seq ?? 0) > after),
1504
+ });
1505
+ return;
1506
+ }
1507
+
1508
+ if (req.method === 'GET' && pathname === '/api/agent-bridge') {
1509
+ const pending = await listCodexAgentBridgeOutbox(session);
1510
+ jsonResponse(res, 200, {
1511
+ ok: true,
1512
+ transport: CODEX_AGENT_BRIDGE_TRANSPORT,
1513
+ requiresAgentTool: 'codex_app.send_message_to_thread',
1514
+ pending,
1515
+ });
1516
+ return;
1517
+ }
1518
+
1519
+ if (req.method === 'GET' && pathname === '/api/library') {
1520
+ const library = await readLibrary(session.paths);
1521
+ jsonResponse(res, 200, {
1522
+ ok: true,
1523
+ ...library,
1524
+ });
1525
+ return;
1526
+ }
1527
+
1528
+ if (req.method === 'POST' && pathname === '/api/library') {
1529
+ const body = await readRequestJson(req);
1530
+ if (Array.isArray(body.libraryItems) || Array.isArray(body.items)) {
1531
+ const library = await writeLibrary(session.paths, body);
1532
+ const op = await appendOperation(session.paths, {
1533
+ type: 'library',
1534
+ intent: body.intent ?? 'sync-excalidraw-library',
1535
+ itemCount: library.libraryItems.length,
1536
+ locale: body.locale ?? null,
1537
+ });
1538
+ jsonResponse(res, 200, {
1539
+ ok: true,
1540
+ operation: op,
1541
+ ...library,
1542
+ });
1543
+ return;
1544
+ }
1545
+ const result = await appendLibraryItem(session, body);
1546
+ const op = await appendOperation(session.paths, {
1547
+ type: 'library',
1548
+ intent: body.intent ?? 'add-selection-to-library',
1549
+ libraryItemId: result.item.id,
1550
+ elementCount: result.item.elements.length,
1551
+ locale: body.locale ?? null,
1552
+ });
1553
+ jsonResponse(res, 200, {
1554
+ ok: true,
1555
+ operation: op,
1556
+ item: result.item,
1557
+ ...result.library,
1558
+ });
1559
+ return;
1560
+ }
1561
+
1562
+ if (req.method === 'POST' && pathname === '/api/images') {
1563
+ const body = await readRequestJson(req);
1564
+ const asset = await materializeImage(session, body);
1565
+ const op = await appendOperation(session.paths, {
1566
+ type: 'image',
1567
+ mode: body.mode ?? 'replace',
1568
+ aspectRatio: body.aspectRatio ?? null,
1569
+ placeholderId: body.placeholderId ?? null,
1570
+ note: body.note ?? null,
1571
+ assetUrl: asset.assetUrl,
1572
+ assetPath: asset.assetPath,
1573
+ sourcePath: asset.sourcePath,
1574
+ sourceUrl: asset.sourceUrl,
1575
+ mimeType: asset.mimeType,
1576
+ fileId: `openprd-${asset.digest}`,
1577
+ });
1578
+ jsonResponse(res, 200, {
1579
+ ok: true,
1580
+ operation: op,
1581
+ asset,
1582
+ });
1583
+ return;
1584
+ }
1585
+
1586
+ if (req.method === 'POST' && pathname === '/api/cards') {
1587
+ const body = await readRequestJson(req).catch(() => ({}));
1588
+ const op = await appendOperation(session.paths, {
1589
+ type: 'card',
1590
+ source: body.source ?? 'agent',
1591
+ note: body.note ?? null,
1592
+ });
1593
+ jsonResponse(res, 200, { ok: true, operation: op });
1594
+ return;
1595
+ }
1596
+
1597
+ if (req.method === 'POST' && pathname === '/api/exports') {
1598
+ const body = await readRequestJson(req);
1599
+ const exported = await materializeExport(session, body);
1600
+ const op = await appendOperation(session.paths, {
1601
+ type: 'export',
1602
+ intent: body.intent ?? 'manual-export',
1603
+ exportPath: exported.exportPath,
1604
+ exportUrl: exported.exportUrl,
1605
+ selection: body.selection ?? null,
1606
+ elementCount: Number.isFinite(Number(body.elementCount)) ? Number(body.elementCount) : null,
1607
+ selectedElementIds: normalizeStringArray(body.selectedElementIds),
1608
+ locale: body.locale ?? null,
1609
+ });
1610
+ jsonResponse(res, 200, {
1611
+ ok: true,
1612
+ operation: op,
1613
+ export: exported,
1614
+ });
1615
+ return;
1616
+ }
1617
+
1618
+ if (req.method === 'POST' && pathname === '/api/download-file') {
1619
+ const body = await readRequestJson(req);
1620
+ const file = await materializeDownloadFile(body);
1621
+ jsonResponse(res, 200, {
1622
+ ok: true,
1623
+ file,
1624
+ });
1625
+ return;
1626
+ }
1627
+
1628
+ if (req.method === 'POST' && pathname === '/api/download-image') {
1629
+ const body = await readRequestJson(req);
1630
+ const file = await materializeDownloadImage(session, body);
1631
+ jsonResponse(res, 200, {
1632
+ ok: true,
1633
+ file,
1634
+ });
1635
+ return;
1636
+ }
1637
+
1638
+ if (req.method === 'POST' && pathname === '/api/open-folder') {
1639
+ const body = await readRequestJson(req);
1640
+ const target = await resolveOpenFolderTarget(session, body.path || body.targetPath);
1641
+ openPathInFileManager(target);
1642
+ jsonResponse(res, 200, {
1643
+ ok: true,
1644
+ opened: true,
1645
+ platform: process.platform,
1646
+ ...target,
1647
+ });
1648
+ return;
1649
+ }
1650
+
1651
+ if (req.method === 'POST' && pathname === '/api/open-download') {
1652
+ const body = await readRequestJson(req);
1653
+ const target = await resolveDownloadsFolderTarget(body.filename || body.fileName);
1654
+ openPathInFileManager(target);
1655
+ jsonResponse(res, 200, {
1656
+ ok: true,
1657
+ opened: true,
1658
+ platform: process.platform,
1659
+ ...target,
1660
+ });
1661
+ return;
1662
+ }
1663
+
1664
+ if (req.method === 'POST' && pathname === '/api/clipboard') {
1665
+ const body = await readRequestJson(req, 256 * 1024);
1666
+ const result = await writeTextToSystemClipboard(body.text ?? body.value ?? '', {
1667
+ dryRun: Boolean(body.dryRun),
1668
+ platform: body.platform,
1669
+ });
1670
+ jsonResponse(res, 200, {
1671
+ ok: true,
1672
+ clipboard: result,
1673
+ });
1674
+ return;
1675
+ }
1676
+
1677
+ if (req.method === 'POST' && pathname === '/api/handoffs') {
1678
+ const body = await readRequestJson(req);
1679
+ const target = body.target ?? body.targetClient ?? 'codex';
1680
+ const requestedTransport = body.transport ?? CODEX_DELEGATION_RELAY_TRANSPORT;
1681
+ const transport = String(target).toLowerCase() === 'codex'
1682
+ ? CODEX_DELEGATION_RELAY_TRANSPORT
1683
+ : requestedTransport;
1684
+ const op = await appendOperation(session.paths, {
1685
+ type: 'handoff',
1686
+ target,
1687
+ transport,
1688
+ status: 'prepared',
1689
+ exportOperationId: body.exportOperationId ?? null,
1690
+ exportPath: body.exportPath ?? null,
1691
+ exportUrl: body.exportUrl ?? null,
1692
+ promptText: trimString(body.promptText),
1693
+ instruction: trimString(body.instruction, 4000),
1694
+ selection: body.selection ?? null,
1695
+ placeholderId: body.placeholderId ?? null,
1696
+ aspectRatio: body.aspectRatio ?? null,
1697
+ mode: body.mode ?? null,
1698
+ elementCount: Number.isFinite(Number(body.elementCount)) ? Number(body.elementCount) : null,
1699
+ selectedElementIds: normalizeStringArray(body.selectedElementIds),
1700
+ locale: body.locale ?? null,
1701
+ binding: {
1702
+ kind: session.binding?.kind ?? null,
1703
+ id: session.binding?.id ?? null,
1704
+ displayId: session.binding?.displayId ?? null,
1705
+ title: session.binding?.title ?? null,
1706
+ threadTitle: session.binding?.threadTitle ?? null,
1707
+ sessionTitle: session.binding?.sessionTitle ?? null,
1708
+ source: session.binding?.source ?? null,
1709
+ },
1710
+ note: body.note ?? null,
1711
+ });
1712
+ const relay = await queueCodexThreadRelay(session, op, body);
1713
+ if (relay.status !== 'skipped') {
1714
+ await appendOperation(session.paths, {
1715
+ type: 'handoff-relay',
1716
+ handoffOperationId: op.id,
1717
+ target,
1718
+ transport: relay.transport,
1719
+ status: relay.status,
1720
+ dryRun: Boolean(relay.dryRun),
1721
+ payloadPath: relay.payloadPath ?? null,
1722
+ statusPath: relay.statusPath ?? null,
1723
+ logPath: relay.logPath ?? null,
1724
+ pid: relay.pid ?? null,
1725
+ inputTypes: relay.inputTypes ?? [],
1726
+ });
1727
+ }
1728
+ const agentBridgeRequested = Boolean(body.agentBridge || body.foregroundBridge);
1729
+ const agentBridge = await queueCodexAgentBridge(
1730
+ session,
1731
+ op,
1732
+ agentBridgeRequested && !codexRelayNeedsAgentBridgeFallback(relay)
1733
+ ? {
1734
+ ...body,
1735
+ agentBridgeSkipReason: 'direct-relay-active',
1736
+ directRelay: {
1737
+ status: relay.status,
1738
+ transport: relay.transport,
1739
+ },
1740
+ }
1741
+ : body,
1742
+ );
1743
+ jsonResponse(res, 200, {
1744
+ ok: true,
1745
+ operation: op,
1746
+ agentBridge,
1747
+ relay,
1748
+ });
1749
+ return;
1750
+ }
1751
+
1752
+ if (req.method === 'GET' && pathname.startsWith('/assets/')) {
1753
+ const name = safeStaticName(pathname, '/assets/');
1754
+ if (!name) {
1755
+ textResponse(res, 400, 'Invalid asset path');
1756
+ return;
1757
+ }
1758
+ await serveStaticFile(res, cjoin(session.paths.assetsDir, name));
1759
+ return;
1760
+ }
1761
+
1762
+ if (req.method === 'GET' && pathname.startsWith('/exports/')) {
1763
+ const name = safeStaticName(pathname, '/exports/');
1764
+ if (!name) {
1765
+ textResponse(res, 400, 'Invalid export path');
1766
+ return;
1767
+ }
1768
+ await serveStaticFile(res, cjoin(session.paths.exportsDir, name));
1769
+ return;
1770
+ }
1771
+
1772
+ textResponse(res, 404, 'Not found');
1773
+ }
1774
+
1775
+ function normalizeCanvasOpenTarget(value, env = process.env) {
1776
+ const requested = firstPresent(value, env.OPENPRD_CANVAS_OPEN_TARGET, env.OPENPRD_OPEN_TARGET, 'auto');
1777
+ const normalized = String(requested).trim().toLowerCase();
1778
+ if (!CANVAS_OPEN_TARGETS.has(normalized)) {
1779
+ throw new Error(`Invalid canvas open target: ${requested}. Expected auto, system, or codex-browser.`);
1780
+ }
1781
+ return normalized;
1782
+ }
1783
+
1784
+ function bindingHasCodexBrowserEvidence(binding = {}) {
1785
+ const activeClient = String(binding.activeClient ?? '').toLowerCase();
1786
+ const surface = String(binding.surface ?? '').toLowerCase();
1787
+ const observedCapabilities = Array.isArray(binding.observedCapabilities) ? binding.observedCapabilities : [];
1788
+ return (
1789
+ activeClient === 'codex'
1790
+ && (surface.includes('desktop') || surface.includes('browser') || surface.includes('app'))
1791
+ && observedCapabilities.some((capability) => String(capability).toLowerCase().includes('browser'))
1792
+ );
1793
+ }
1794
+
1795
+ function resolveCanvasOpenTarget(session, options = {}, env = process.env) {
1796
+ const requestedTarget = normalizeCanvasOpenTarget(options.openTarget, env);
1797
+ if (requestedTarget === 'codex-browser') {
1798
+ return {
1799
+ requestedTarget,
1800
+ target: 'codex-browser',
1801
+ reason: 'explicit-open-target',
1802
+ };
1803
+ }
1804
+ if (requestedTarget === 'system') {
1805
+ return {
1806
+ requestedTarget,
1807
+ target: 'system',
1808
+ reason: 'explicit-open-target',
1809
+ };
1810
+ }
1811
+ if (bindingHasCodexBrowserEvidence(session?.binding)) {
1812
+ return {
1813
+ requestedTarget,
1814
+ target: 'codex-browser',
1815
+ reason: 'runtime-evidence',
1816
+ };
1817
+ }
1818
+ return {
1819
+ requestedTarget,
1820
+ target: 'system',
1821
+ reason: 'no-codex-browser-evidence',
1822
+ };
1823
+ }
1824
+
1825
+ function openSystemUrl(url) {
1826
+ const platform = process.platform;
1827
+ if (platform === 'darwin') {
1828
+ const child = spawn('open', [url], { detached: true, stdio: 'ignore' });
1829
+ child.unref();
1830
+ return true;
1831
+ }
1832
+ if (platform === 'win32') {
1833
+ const child = spawn('cmd', ['/d', '/s', '/c', 'start', '', url], { detached: true, stdio: 'ignore' });
1834
+ child.unref();
1835
+ return true;
1836
+ }
1837
+ const child = spawn('xdg-open', [url], { detached: true, stdio: 'ignore' });
1838
+ child.unref();
1839
+ return true;
1840
+ }
1841
+
1842
+ function openPathInFileManager(target) {
1843
+ const platform = process.platform;
1844
+ if (platform === 'darwin') {
1845
+ const args = target.isFile ? ['-R', target.targetPath] : [target.folderPath];
1846
+ const child = spawn('open', args, { detached: true, stdio: 'ignore' });
1847
+ child.unref();
1848
+ return true;
1849
+ }
1850
+ if (platform === 'win32') {
1851
+ const args = target.isFile ? [`/select,${target.targetPath}`] : [target.folderPath];
1852
+ const child = spawn('explorer.exe', args, { detached: true, stdio: 'ignore' });
1853
+ child.unref();
1854
+ return true;
1855
+ }
1856
+ const child = spawn('xdg-open', [target.folderPath], { detached: true, stdio: 'ignore' });
1857
+ child.unref();
1858
+ return true;
1859
+ }
1860
+
1861
+ function openCanvasUrl(url, session, options = {}, env = process.env) {
1862
+ const target = resolveCanvasOpenTarget(session, options, env);
1863
+ if (target.target === 'codex-browser') {
1864
+ return {
1865
+ ...target,
1866
+ status: 'requires-agent-browser',
1867
+ opened: false,
1868
+ url,
1869
+ message: 'Use the Codex in-app Browser tool to open this URL; the CLI will not launch the system browser for this target.',
1870
+ };
1871
+ }
1872
+ openSystemUrl(url);
1873
+ return {
1874
+ ...target,
1875
+ status: 'opened',
1876
+ opened: true,
1877
+ url,
1878
+ };
1879
+ }
1880
+
1881
+ async function listen(server, host, port) {
1882
+ await new Promise((resolve, reject) => {
1883
+ const onError = (error) => {
1884
+ server.off('listening', onListening);
1885
+ reject(error);
1886
+ };
1887
+ const onListening = () => {
1888
+ server.off('error', onError);
1889
+ resolve();
1890
+ };
1891
+ server.once('error', onError);
1892
+ server.once('listening', onListening);
1893
+ server.listen(port, host);
1894
+ });
1895
+ return server.address();
1896
+ }
1897
+
1898
+ function normalizePort(port, fallback = DEFAULT_PORT) {
1899
+ if (port === null || port === undefined || port === '') {
1900
+ return fallback;
1901
+ }
1902
+ const parsed = Number(port);
1903
+ if (!Number.isInteger(parsed) || parsed < 0 || parsed > 65535) {
1904
+ throw new Error(`Invalid canvas port: ${port}`);
1905
+ }
1906
+ return parsed;
1907
+ }
1908
+
1909
+ function canvasUrl(host, port) {
1910
+ const urlHost = host === '::' ? '127.0.0.1' : host;
1911
+ return `http://${urlHost}:${port}/`;
1912
+ }
1913
+
1914
+ function processIsAlive(pid) {
1915
+ if (!pid || !Number.isInteger(Number(pid))) {
1916
+ return false;
1917
+ }
1918
+ try {
1919
+ process.kill(Number(pid), 0);
1920
+ return true;
1921
+ } catch {
1922
+ return false;
1923
+ }
1924
+ }
1925
+
1926
+ async function fetchCanvasSession(url) {
1927
+ const response = await fetch(new URL('/api/session', url));
1928
+ if (!response.ok) {
1929
+ return null;
1930
+ }
1931
+ return response.json();
1932
+ }
1933
+
1934
+ function sameBinding(left, right) {
1935
+ return Boolean(left?.kind && right?.kind && left.kind === right.kind && left.id === right.id);
1936
+ }
1937
+
1938
+ async function waitForCanvasServer(url, expectedBinding, timeoutMs = DAEMON_READY_TIMEOUT_MS) {
1939
+ const startedAt = Date.now();
1940
+ let lastError = null;
1941
+ while (Date.now() - startedAt < timeoutMs) {
1942
+ try {
1943
+ const remoteSession = await fetchCanvasSession(url);
1944
+ if (sameBinding(remoteSession?.binding, expectedBinding)) {
1945
+ return remoteSession;
1946
+ }
1947
+ lastError = new Error('Canvas server responded with a different session binding.');
1948
+ } catch (error) {
1949
+ lastError = error;
1950
+ }
1951
+ await new Promise((resolve) => setTimeout(resolve, 200));
1952
+ }
1953
+ throw new Error(`Canvas daemon did not become ready at ${url}: ${lastError?.message ?? 'timeout'}`);
1954
+ }
1955
+
1956
+ async function writeServerMetadata(session, metadata) {
1957
+ await writeJson(session.paths.serverFile, {
1958
+ type: 'openprd.canvas.server.v1',
1959
+ sessionKey: session.sessionKey,
1960
+ binding: session.binding,
1961
+ updatedAt: timestamp(),
1962
+ ...metadata,
1963
+ });
1964
+ }
1965
+
1966
+ async function removeServerMetadata(session) {
1967
+ await fs.rm(session.paths.serverFile, { force: true }).catch(() => {});
1968
+ }
1969
+
1970
+ function canvasBindingArgs(session) {
1971
+ if (session.binding?.kind === 'thread') {
1972
+ const args = ['--thread', session.binding.id];
1973
+ const title = normalizeBindingTitle(session.binding.title, session.binding.threadTitle, session.binding.name);
1974
+ if (title) {
1975
+ args.push('--thread-title', title);
1976
+ }
1977
+ return args;
1978
+ }
1979
+ if (session.binding?.kind === 'session') {
1980
+ const args = ['--session', session.binding.id];
1981
+ const title = normalizeBindingTitle(session.binding.title, session.binding.sessionTitle, session.binding.name);
1982
+ if (title) {
1983
+ args.push('--session-title', title);
1984
+ }
1985
+ return args;
1986
+ }
1987
+ throw new Error('Persistent canvas requires thread/session evidence. Pass --thread <id> or --session <id>.');
1988
+ }
1989
+
1990
+ function canvasDaemonArgs(projectRoot, session, options = {}) {
1991
+ const args = [
1992
+ CLI_BIN_PATH,
1993
+ 'canvas',
1994
+ projectRoot,
1995
+ '--host',
1996
+ options.host,
1997
+ '--port',
1998
+ String(options.port),
1999
+ ...canvasBindingArgs(session),
2000
+ ];
2001
+ if (options.json) {
2002
+ args.push('--json');
2003
+ }
2004
+ return args;
2005
+ }
2006
+
2007
+ async function existingCanvasServer(session, url) {
2008
+ const metadata = await readJson(session.paths.serverFile).catch(() => null);
2009
+ if (!metadata?.pid || !processIsAlive(metadata.pid)) {
2010
+ return null;
2011
+ }
2012
+ try {
2013
+ const remoteSession = await fetchCanvasSession(metadata.url ?? url);
2014
+ if (sameBinding(remoteSession?.binding, session.binding)) {
2015
+ return metadata;
2016
+ }
2017
+ } catch {
2018
+ return null;
2019
+ }
2020
+ return null;
2021
+ }
2022
+
2023
+ async function startCanvasDaemon(projectRoot, session, options = {}) {
2024
+ const host = options.host || DEFAULT_HOST;
2025
+ const port = normalizePort(options.port, DEFAULT_DAEMON_PORT);
2026
+ if (port === 0) {
2027
+ throw new Error('Persistent canvas requires a fixed port. Pass --port <number>.');
2028
+ }
2029
+ const url = canvasUrl(host, port);
2030
+ const existing = await existingCanvasServer(session, url);
2031
+ if (existing) {
2032
+ const openResult = options.open ? openCanvasUrl(existing.url ?? url, session, options) : null;
2033
+ if (options.open) {
2034
+ await writeServerMetadata(session, {
2035
+ ...existing,
2036
+ openResult,
2037
+ }).catch(() => {});
2038
+ }
2039
+ return {
2040
+ ok: true,
2041
+ action: 'canvas',
2042
+ daemon: true,
2043
+ alreadyRunning: true,
2044
+ dryRun: false,
2045
+ projectRoot,
2046
+ session,
2047
+ paths: session.paths,
2048
+ url: existing.url ?? url,
2049
+ host,
2050
+ port,
2051
+ pid: existing.pid,
2052
+ logPath: session.paths.logFile,
2053
+ openResult,
2054
+ };
2055
+ }
2056
+
2057
+ await fs.mkdir(session.paths.sessionDir, { recursive: true });
2058
+ const logHandle = await fs.open(session.paths.logFile, 'a');
2059
+ const child = spawn(process.execPath, canvasDaemonArgs(projectRoot, session, { ...options, host, port, json: false }), {
2060
+ cwd: projectRoot,
2061
+ detached: true,
2062
+ stdio: ['ignore', logHandle.fd, logHandle.fd],
2063
+ env: {
2064
+ ...process.env,
2065
+ OPENPRD_CANVAS_DAEMON: '1',
2066
+ },
2067
+ });
2068
+ await logHandle.close().catch(() => {});
2069
+ child.unref();
2070
+
2071
+ await waitForCanvasServer(url, session.binding);
2072
+ await writeServerMetadata(session, {
2073
+ mode: 'daemon',
2074
+ pid: child.pid,
2075
+ url,
2076
+ host,
2077
+ port,
2078
+ logPath: session.paths.logFile,
2079
+ startedAt: timestamp(),
2080
+ openTarget: normalizeCanvasOpenTarget(options.openTarget),
2081
+ });
2082
+
2083
+ const openResult = options.open ? openCanvasUrl(url, session, options) : null;
2084
+ if (options.open) {
2085
+ await writeServerMetadata(session, {
2086
+ mode: 'daemon',
2087
+ pid: child.pid,
2088
+ url,
2089
+ host,
2090
+ port,
2091
+ logPath: session.paths.logFile,
2092
+ startedAt: timestamp(),
2093
+ openTarget: normalizeCanvasOpenTarget(options.openTarget),
2094
+ openResult,
2095
+ }).catch(() => {});
2096
+ }
2097
+
2098
+ return {
2099
+ ok: true,
2100
+ action: 'canvas',
2101
+ daemon: true,
2102
+ alreadyRunning: false,
2103
+ dryRun: false,
2104
+ projectRoot,
2105
+ session,
2106
+ paths: session.paths,
2107
+ url,
2108
+ host,
2109
+ port,
2110
+ pid: child.pid,
2111
+ logPath: session.paths.logFile,
2112
+ openResult,
2113
+ };
2114
+ }
2115
+
2116
+ async function stopCanvasDaemon(session) {
2117
+ const metadata = await readJson(session.paths.serverFile).catch(() => null);
2118
+ if (!metadata?.pid) {
2119
+ return {
2120
+ ok: true,
2121
+ action: 'canvas-stop',
2122
+ stopped: false,
2123
+ reason: 'not-running',
2124
+ session,
2125
+ paths: session.paths,
2126
+ };
2127
+ }
2128
+ const pid = Number(metadata.pid);
2129
+ if (processIsAlive(pid)) {
2130
+ process.kill(pid, 'SIGTERM');
2131
+ const startedAt = Date.now();
2132
+ while (processIsAlive(pid) && Date.now() - startedAt < 3000) {
2133
+ await new Promise((resolve) => setTimeout(resolve, 100));
2134
+ }
2135
+ }
2136
+ await removeServerMetadata(session);
2137
+ return {
2138
+ ok: true,
2139
+ action: 'canvas-stop',
2140
+ stopped: true,
2141
+ session,
2142
+ paths: session.paths,
2143
+ pid,
2144
+ url: metadata.url ?? null,
2145
+ };
2146
+ }
2147
+
2148
+ async function canvasWorkspace(projectRoot, options = {}) {
2149
+ const session = await ensureCanvasSession(projectRoot, options);
2150
+ const host = options.host || DEFAULT_HOST;
2151
+ const port = normalizePort(options.port, DEFAULT_PORT);
2152
+ const baseResult = {
2153
+ ok: true,
2154
+ action: 'canvas',
2155
+ dryRun: Boolean(options.dryRun),
2156
+ projectRoot,
2157
+ session,
2158
+ paths: session.paths,
2159
+ };
2160
+
2161
+ if (options.bridgeOutbox || options.bridgeSent || options.bridgeFailed) {
2162
+ if (options.bridgeSent || options.bridgeFailed) {
2163
+ const item = await markCodexAgentBridgeItem(session, options.id, options.bridgeFailed ? 'failed' : 'sent', {
2164
+ reason: options.reason,
2165
+ threadId: session.binding?.id ?? null,
2166
+ });
2167
+ return {
2168
+ ...baseResult,
2169
+ action: 'canvas-agent-bridge',
2170
+ bridgeAction: options.bridgeFailed ? 'failed' : 'sent',
2171
+ item,
2172
+ };
2173
+ }
2174
+ if (options.claim) {
2175
+ const item = await claimCodexAgentBridgeItem(session, options.claim);
2176
+ return {
2177
+ ...baseResult,
2178
+ action: 'canvas-agent-bridge',
2179
+ bridgeAction: 'claim',
2180
+ item,
2181
+ pending: item ? [item] : [],
2182
+ };
2183
+ }
2184
+ const pending = await listCodexAgentBridgeOutbox(session);
2185
+ return {
2186
+ ...baseResult,
2187
+ action: 'canvas-agent-bridge',
2188
+ bridgeAction: 'outbox',
2189
+ pending,
2190
+ };
2191
+ }
2192
+
2193
+ if (options.stop) {
2194
+ return stopCanvasDaemon(session);
2195
+ }
2196
+
2197
+ if (options.dryRun) {
2198
+ return {
2199
+ ...baseResult,
2200
+ url: null,
2201
+ host,
2202
+ port,
2203
+ };
2204
+ }
2205
+
2206
+ if (options.daemon) {
2207
+ return startCanvasDaemon(projectRoot, session, { ...options, host });
2208
+ }
2209
+
2210
+ const server = http.createServer((req, res) => {
2211
+ handleCanvasRequest(session, req, res).catch((error) => {
2212
+ const statusCode = Number(error.statusCode ?? 500);
2213
+ jsonResponse(res, statusCode, {
2214
+ ok: false,
2215
+ error: error instanceof Error ? error.message : String(error),
2216
+ });
2217
+ });
2218
+ });
2219
+ const address = await listen(server, host, port);
2220
+ const actualHost = typeof address === 'object' && address ? address.address : host;
2221
+ const actualPort = typeof address === 'object' && address ? address.port : port;
2222
+ const url = canvasUrl(actualHost, actualPort);
2223
+ await writeServerMetadata(session, {
2224
+ mode: process.env.OPENPRD_CANVAS_DAEMON === '1' ? 'daemon-child' : 'foreground',
2225
+ pid: process.pid,
2226
+ url,
2227
+ host: actualHost,
2228
+ port: actualPort,
2229
+ logPath: session.paths.logFile,
2230
+ startedAt: timestamp(),
2231
+ openTarget: normalizeCanvasOpenTarget(options.openTarget),
2232
+ });
2233
+ server.once('close', () => {
2234
+ removeServerMetadata(session);
2235
+ });
2236
+ const result = {
2237
+ ...baseResult,
2238
+ url,
2239
+ host: actualHost,
2240
+ port: actualPort,
2241
+ };
2242
+ Object.defineProperty(result, 'server', {
2243
+ value: server,
2244
+ enumerable: false,
2245
+ });
2246
+
2247
+ result.openResult = options.open ? openCanvasUrl(url, session, options) : null;
2248
+ return result;
2249
+ }
2250
+
2251
+ function toPrintableCanvasResult(result) {
2252
+ if (!result) {
2253
+ return result;
2254
+ }
2255
+ const { server, ...printable } = result;
2256
+ return printable;
2257
+ }
2258
+
2259
+ export {
2260
+ buildCanvasSessionPaths,
2261
+ buildReviewDirectionsPayload,
2262
+ canvasWorkspace,
2263
+ ensureCanvasSession,
2264
+ resolveCanvasOpenTarget,
2265
+ resolveDownloadsFolderTarget,
2266
+ resolveOpenFolderTarget,
2267
+ resolveCanvasSessionBinding,
2268
+ sanitizeSessionSegment,
2269
+ stopCanvasDaemon,
2270
+ toPrintableCanvasResult,
2271
+ };