@openprd/cli 0.1.11 → 0.1.19
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/.openprd/changes/adaptive-project-framing/.openprd.yaml +2 -0
- package/.openprd/changes/adaptive-project-framing/design.md +47 -0
- package/.openprd/changes/adaptive-project-framing/proposal.md +35 -0
- package/.openprd/changes/adaptive-project-framing/specs/agent-requirements/spec.md +16 -0
- package/.openprd/changes/adaptive-project-framing/task-events.jsonl +20 -0
- package/.openprd/changes/adaptive-project-framing/tasks.md +340 -0
- package/.openprd/changes/docs-anti-pollution-and-design-direction-review/.openprd.yaml +2 -0
- package/.openprd/changes/docs-anti-pollution-and-design-direction-review/design.md +34 -0
- package/.openprd/changes/docs-anti-pollution-and-design-direction-review/proposal.md +23 -0
- package/.openprd/changes/docs-anti-pollution-and-design-direction-review/specs/agent-requirements/spec.md +16 -0
- package/.openprd/changes/docs-anti-pollution-and-design-direction-review/task-events.jsonl +14 -0
- package/.openprd/changes/docs-anti-pollution-and-design-direction-review/tasks.md +238 -0
- package/.openprd/changes/loop-isolated-worktree-commit/.openprd.yaml +2 -0
- package/.openprd/changes/loop-isolated-worktree-commit/design.md +53 -0
- package/.openprd/changes/loop-isolated-worktree-commit/proposal.md +38 -0
- package/.openprd/changes/loop-isolated-worktree-commit/specs/agent-requirements/spec.md +16 -0
- package/.openprd/changes/loop-isolated-worktree-commit/task-events.jsonl +22 -0
- package/.openprd/changes/loop-isolated-worktree-commit/tasks.md +357 -0
- package/.openprd/changes/openprd/.openprd.yaml +2 -0
- package/.openprd/changes/openprd/design.md +46 -0
- package/.openprd/changes/openprd/proposal.md +36 -0
- package/.openprd/changes/openprd/specs/agent-requirements/spec.md +16 -0
- package/.openprd/changes/openprd/task-events.jsonl +23 -0
- package/.openprd/changes/openprd/tasks.md +242 -0
- package/.openprd/changes/openprd-generated-change/.openprd.yaml +2 -0
- package/.openprd/changes/openprd-generated-change/design.md +50 -0
- package/.openprd/changes/openprd-generated-change/proposal.md +37 -0
- package/.openprd/changes/openprd-generated-change/specs/agent-requirements/spec.md +16 -0
- package/.openprd/changes/openprd-generated-change/task-events.jsonl +21 -0
- package/.openprd/changes/openprd-generated-change/tasks.md +357 -0
- package/.openprd/changes/pm-human-in-the-loop/.openprd.yaml +2 -0
- package/.openprd/changes/pm-human-in-the-loop/design.md +38 -0
- package/.openprd/changes/pm-human-in-the-loop/proposal.md +29 -0
- package/.openprd/changes/pm-human-in-the-loop/specs/agent-requirements/spec.md +16 -0
- package/.openprd/changes/pm-human-in-the-loop/task-events.jsonl +18 -0
- package/.openprd/changes/pm-human-in-the-loop/tasks.md +306 -0
- package/.openprd/changes/test-strategy-router/.openprd.yaml +2 -0
- package/.openprd/changes/test-strategy-router/design.md +65 -0
- package/.openprd/changes/test-strategy-router/proposal.md +45 -0
- package/.openprd/changes/test-strategy-router/specs/agent-requirements/spec.md +16 -0
- package/.openprd/changes/test-strategy-router/task-events.jsonl +1 -0
- package/.openprd/changes/test-strategy-router/tasks.md +132 -0
- package/.openprd/changes/verify-boundaries-and-historical-refresh/.openprd.yaml +2 -0
- package/.openprd/changes/verify-boundaries-and-historical-refresh/design.md +28 -0
- package/.openprd/changes/verify-boundaries-and-historical-refresh/proposal.md +23 -0
- package/.openprd/changes/verify-boundaries-and-historical-refresh/specs/agent-requirements/spec.md +16 -0
- package/.openprd/changes/verify-boundaries-and-historical-refresh/task-events.jsonl +11 -0
- package/.openprd/changes/verify-boundaries-and-historical-refresh/tasks.md +66 -0
- package/.openprd/changes/vibe-coding-dev-check/.openprd.yaml +2 -0
- package/.openprd/changes/vibe-coding-dev-check/design.md +37 -0
- package/.openprd/changes/vibe-coding-dev-check/proposal.md +28 -0
- package/.openprd/changes/vibe-coding-dev-check/specs/agent-requirements/spec.md +16 -0
- package/.openprd/changes/vibe-coding-dev-check/task-events.jsonl +18 -0
- package/.openprd/changes/vibe-coding-dev-check/tasks.md +306 -0
- package/.openprd/design/README.md +11 -6
- package/.openprd/design/README_EN.md +4 -1
- package/.openprd/design/active/asset-spec.md +6 -0
- package/.openprd/design/active/direction-plan.md +5 -5
- package/.openprd/design/active/selected-direction.md +2 -0
- package/.openprd/design/anti-slop.md +26 -1
- package/.openprd/design/assets/surface-presets.md +6 -0
- package/.openprd/design/checklists/ui-quality-gate.md +7 -0
- package/.openprd/design/lenses/frontend-lenses.md +28 -0
- package/.openprd/design/recipes/content-experience.md +8 -1
- package/.openprd/design/recipes/operational-tool.md +7 -0
- package/.openprd/design/recipes/product-launch.md +8 -1
- package/.openprd/design/templates/README.md +6 -3
- package/.openprd/design/templates/README_EN.md +6 -3
- package/.openprd/design/templates/ops-dashboard.html +8 -1
- package/.openprd/design/templates/product-launch.html +2 -1
- package/.openprd/design/themes/theme-catalog.json +23 -5
- package/.openprd/engagements/active/prd.md +137 -112
- package/.openprd/standards/config.json +7 -1
- package/AGENTS.md +13 -3
- package/README.md +127 -14
- package/README_EN.md +156 -13
- package/package.json +1 -1
- package/scripts/dev-check-wrapup-copy.mjs +46 -21
- package/skills/openprd-diagram-review/SKILL.md +27 -2
- package/skills/openprd-diagram-review/references/explanation-svg-patterns.md +131 -0
- package/skills/openprd-discovery-loop/SKILL.md +2 -2
- package/skills/openprd-frontend-design/SKILL.md +44 -20
- package/skills/openprd-frontend-design/references/design-asset-contract.md +6 -0
- package/skills/openprd-frontend-design/references/direction-engine.md +29 -4
- package/skills/openprd-harness/SKILL.md +22 -16
- package/skills/openprd-quality/SKILL.md +10 -8
- package/skills/openprd-requirement-intake/SKILL.md +2 -0
- package/skills/openprd-router/SKILL.md +2 -1
- package/skills/openprd-shared/SKILL.md +26 -16
- package/skills/openprd-standards/SKILL.md +4 -2
- package/src/agent-canonical-content.js +810 -0
- package/src/agent-integration.js +258 -777
- package/src/canvas-app-client-app.js +838 -0
- package/src/canvas-app-client-helpers.js +1138 -0
- package/src/canvas-app-styles.js +898 -0
- package/src/canvas-app.html.js +135 -0
- package/src/canvas-binding.js +414 -0
- package/src/canvas-codex-relay.js +430 -0
- package/src/canvas-i18n.js +282 -0
- package/src/canvas-scene.js +377 -0
- package/src/canvas-session-store.js +148 -0
- package/src/canvas-workspace.js +1264 -0
- package/src/cli/args.js +72 -4
- package/src/cli/basic-print.js +3 -0
- package/src/cli/canvas-print.js +106 -0
- package/src/cli/doctor-print.js +20 -0
- package/src/cli/print.js +2 -0
- package/src/cli/quality-commands.js +222 -0
- package/src/cli/quality-print.js +28 -2
- package/src/cli/run-print.js +5 -0
- package/src/cli/workflow-print.js +3 -0
- package/src/codex-app-server-relay.js +251 -0
- package/src/codex-hook-runner-template.mjs +978 -58
- package/src/design-starter-support.js +2 -2
- package/src/design-starter.js +39 -3
- package/src/dev-standards.js +125 -10
- package/src/diagram-core.js +6 -20
- package/src/discovery.js +2 -8
- package/src/docs-compact.js +125 -0
- package/src/execution-strategy.js +1 -1
- package/src/fleet.js +9 -3
- package/src/guide.js +227 -0
- package/src/html-artifacts.js +428 -7
- package/src/knowledge.js +45 -11
- package/src/language-policy.js +63 -3
- package/src/learning-review.js +1 -1
- package/src/loop.js +1 -1
- package/src/openprd.js +68 -131
- package/src/openspec/change-validate.js +2 -2
- package/src/openspec/constants.js +6 -7
- package/src/openspec/execute.js +2 -2
- package/src/openspec/generate.js +318 -102
- package/src/openspec/migration.js +168 -0
- package/src/openspec/paths.js +1 -22
- package/src/prd-core.js +1 -1
- package/src/quality-visual-review.js +257 -14
- package/src/quality.js +46 -8
- package/src/review-presentation.js +60 -0
- package/src/run-harness.js +41 -3
- package/src/session-evidence.js +363 -0
- package/src/standards.js +64 -0
- package/src/test-strategy.js +7 -4
- package/src/visual-compare-centering.js +622 -0
- package/src/visual-compare-core.js +1123 -0
- package/src/visual-compare.js +620 -698
- package/src/workspace-core.js +94 -4
- package/src/workspace-workflow.js +46 -1
|
@@ -0,0 +1,1264 @@
|
|
|
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 fs from 'node:fs/promises';
|
|
25
|
+
import { createReadStream } from 'node:fs';
|
|
26
|
+
import http from 'node:http';
|
|
27
|
+
import os from 'node:os';
|
|
28
|
+
import path from 'node:path';
|
|
29
|
+
import { spawn } from 'node:child_process';
|
|
30
|
+
import { fileURLToPath } from 'node:url';
|
|
31
|
+
import { renderCanvasAppHtml } from './canvas-app.html.js';
|
|
32
|
+
import { cjoin, exists, readJson, writeJson } from './fs-utils.js';
|
|
33
|
+
import { timestamp } from './time.js';
|
|
34
|
+
import {
|
|
35
|
+
CANVAS_SURFACE_BACKGROUND,
|
|
36
|
+
HANDOFF_TEXT_MAX,
|
|
37
|
+
appendOperation,
|
|
38
|
+
firstPresent,
|
|
39
|
+
isPlainObject,
|
|
40
|
+
normalizeBindingTitle,
|
|
41
|
+
normalizeStringArray,
|
|
42
|
+
readOps,
|
|
43
|
+
sanitizeSessionSegment,
|
|
44
|
+
trimString,
|
|
45
|
+
} from './canvas-session-store.js';
|
|
46
|
+
import {
|
|
47
|
+
buildCanvasSessionPaths,
|
|
48
|
+
ensureCanvasSession,
|
|
49
|
+
resolveCanvasSessionBinding,
|
|
50
|
+
} from './canvas-binding.js';
|
|
51
|
+
import {
|
|
52
|
+
CODEX_AGENT_BRIDGE_TRANSPORT,
|
|
53
|
+
CODEX_DELEGATION_RELAY_TRANSPORT,
|
|
54
|
+
claimCodexAgentBridgeItem,
|
|
55
|
+
codexRelayNeedsAgentBridgeFallback,
|
|
56
|
+
listCodexAgentBridgeOutbox,
|
|
57
|
+
markCodexAgentBridgeItem,
|
|
58
|
+
queueCodexAgentBridge,
|
|
59
|
+
queueCodexThreadRelay,
|
|
60
|
+
} from './canvas-codex-relay.js';
|
|
61
|
+
import {
|
|
62
|
+
buildCanvasSelectionPayload,
|
|
63
|
+
dataUrlToBuffer,
|
|
64
|
+
extensionFromMime,
|
|
65
|
+
insertAnchoredCanvasImage,
|
|
66
|
+
materializeImage,
|
|
67
|
+
mimeFromPath,
|
|
68
|
+
normalizeCanvasScene,
|
|
69
|
+
normalizeCanvasSizeContract,
|
|
70
|
+
appendLibraryItem,
|
|
71
|
+
readLibrary,
|
|
72
|
+
writeLibrary,
|
|
73
|
+
} from './canvas-scene.js';
|
|
74
|
+
|
|
75
|
+
const DEFAULT_HOST = '127.0.0.1';
|
|
76
|
+
const DEFAULT_PORT = 0;
|
|
77
|
+
const DEFAULT_DAEMON_PORT = 4767;
|
|
78
|
+
const BODY_LIMIT_BYTES = 35 * 1024 * 1024;
|
|
79
|
+
const DAEMON_READY_TIMEOUT_MS = 7000;
|
|
80
|
+
const CLI_BIN_PATH = cjoin(path.dirname(fileURLToPath(import.meta.url)), '..', 'bin', 'openprd.js');
|
|
81
|
+
const CANVAS_OPEN_TARGETS = new Set(['auto', 'system', 'codex-browser']);
|
|
82
|
+
function jsonResponse(res, statusCode, payload) {
|
|
83
|
+
res.writeHead(statusCode, {
|
|
84
|
+
'content-type': 'application/json; charset=utf-8',
|
|
85
|
+
'cache-control': 'no-store',
|
|
86
|
+
'x-content-type-options': 'nosniff',
|
|
87
|
+
});
|
|
88
|
+
res.end(JSON.stringify(payload, null, 2));
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function textResponse(res, statusCode, text, contentType = 'text/plain; charset=utf-8') {
|
|
92
|
+
res.writeHead(statusCode, {
|
|
93
|
+
'content-type': contentType,
|
|
94
|
+
'cache-control': 'no-store',
|
|
95
|
+
'x-content-type-options': 'nosniff',
|
|
96
|
+
});
|
|
97
|
+
res.end(text);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async function readRequestJson(req, limitBytes = BODY_LIMIT_BYTES) {
|
|
101
|
+
const chunks = [];
|
|
102
|
+
let size = 0;
|
|
103
|
+
for await (const chunk of req) {
|
|
104
|
+
size += chunk.length;
|
|
105
|
+
if (size > limitBytes) {
|
|
106
|
+
const error = new Error('Request body is too large.');
|
|
107
|
+
error.statusCode = 413;
|
|
108
|
+
throw error;
|
|
109
|
+
}
|
|
110
|
+
chunks.push(chunk);
|
|
111
|
+
}
|
|
112
|
+
const text = Buffer.concat(chunks).toString('utf8');
|
|
113
|
+
if (!text.trim()) {
|
|
114
|
+
return {};
|
|
115
|
+
}
|
|
116
|
+
return JSON.parse(text);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function downloadsDir(env = process.env) {
|
|
120
|
+
return env.OPENPRD_DOWNLOADS_DIR || path.join(os.homedir(), 'Downloads');
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function clipboardCommandCandidates(platform = process.platform) {
|
|
124
|
+
if (platform === 'darwin') {
|
|
125
|
+
return [{ command: 'pbcopy', args: [] }];
|
|
126
|
+
}
|
|
127
|
+
if (platform === 'win32') {
|
|
128
|
+
return [{ command: 'clip.exe', args: [] }];
|
|
129
|
+
}
|
|
130
|
+
return [
|
|
131
|
+
{ command: 'wl-copy', args: [] },
|
|
132
|
+
{ command: 'xclip', args: ['-selection', 'clipboard'] },
|
|
133
|
+
{ command: 'xsel', args: ['--clipboard', '--input'] },
|
|
134
|
+
];
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
async function writeTextToSystemClipboard(text, options = {}) {
|
|
138
|
+
const value = trimString(text, HANDOFF_TEXT_MAX);
|
|
139
|
+
const candidates = clipboardCommandCandidates(options.platform);
|
|
140
|
+
if (options.dryRun) {
|
|
141
|
+
return {
|
|
142
|
+
ok: true,
|
|
143
|
+
command: candidates[0]?.command ?? null,
|
|
144
|
+
platform: options.platform ?? process.platform,
|
|
145
|
+
dryRun: true,
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
let lastError = null;
|
|
149
|
+
for (const candidate of candidates) {
|
|
150
|
+
try {
|
|
151
|
+
await new Promise((resolve, reject) => {
|
|
152
|
+
const child = spawn(candidate.command, candidate.args, { stdio: ['pipe', 'ignore', 'ignore'] });
|
|
153
|
+
child.once('error', reject);
|
|
154
|
+
child.once('close', (code) => {
|
|
155
|
+
if (code === 0) {
|
|
156
|
+
resolve();
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
reject(new Error(`${candidate.command} exited with code ${code}`));
|
|
160
|
+
});
|
|
161
|
+
child.stdin.end(value);
|
|
162
|
+
});
|
|
163
|
+
return {
|
|
164
|
+
ok: true,
|
|
165
|
+
command: candidate.command,
|
|
166
|
+
platform: options.platform ?? process.platform,
|
|
167
|
+
dryRun: false,
|
|
168
|
+
};
|
|
169
|
+
} catch (error) {
|
|
170
|
+
lastError = error;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
const error = new Error(`Could not write to system clipboard: ${lastError?.message ?? 'no clipboard command available'}`);
|
|
174
|
+
error.statusCode = 500;
|
|
175
|
+
throw error;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
async function materializeExport(session, input) {
|
|
179
|
+
if (!input.dataUrl) {
|
|
180
|
+
throw new Error('Expected dataUrl for export.');
|
|
181
|
+
}
|
|
182
|
+
const parsed = dataUrlToBuffer(input.dataUrl);
|
|
183
|
+
const extension = extensionFromMime(parsed.mimeType, '.png');
|
|
184
|
+
const baseName = sanitizeSessionSegment(input.filename || `canvas-export-${Date.now()}`, 'canvas-export');
|
|
185
|
+
const fileName = baseName.endsWith(extension) ? baseName : `${baseName}${extension}`;
|
|
186
|
+
const exportPath = cjoin(session.paths.exportsDir, fileName);
|
|
187
|
+
await fs.writeFile(exportPath, parsed.buffer);
|
|
188
|
+
return {
|
|
189
|
+
fileName,
|
|
190
|
+
exportPath,
|
|
191
|
+
exportUrl: `/exports/${encodeURIComponent(fileName)}`,
|
|
192
|
+
mimeType: parsed.mimeType,
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function safeDownloadFileName(value, fallback = `openprd-canvas-${Date.now()}.excalidraw`) {
|
|
197
|
+
const requestedName = String(value ?? fallback).trim() || fallback;
|
|
198
|
+
const baseName = path.basename(requestedName);
|
|
199
|
+
if (baseName !== requestedName || baseName.includes('/') || baseName.includes('\\') || baseName.includes('..')) {
|
|
200
|
+
const error = new Error('Invalid download filename.');
|
|
201
|
+
error.statusCode = 400;
|
|
202
|
+
throw error;
|
|
203
|
+
}
|
|
204
|
+
return baseName;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
async function materializeDownloadFile(input) {
|
|
208
|
+
const content = String(input.content ?? '');
|
|
209
|
+
if (!content.trim()) {
|
|
210
|
+
throw new Error('Expected content for file save.');
|
|
211
|
+
}
|
|
212
|
+
const targetDir = downloadsDir();
|
|
213
|
+
await fs.mkdir(targetDir, { recursive: true });
|
|
214
|
+
const fileName = safeDownloadFileName(input.filename);
|
|
215
|
+
const filePath = path.join(targetDir, fileName);
|
|
216
|
+
await fs.writeFile(filePath, content, 'utf8');
|
|
217
|
+
return {
|
|
218
|
+
fileName,
|
|
219
|
+
filePath,
|
|
220
|
+
folderPath: targetDir,
|
|
221
|
+
mimeType: input.mimeType || 'application/json; charset=utf-8',
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function isInsideDirectory(parentDir, targetPath) {
|
|
226
|
+
const relative = path.relative(path.resolve(parentDir), path.resolve(targetPath));
|
|
227
|
+
return !relative.startsWith('..') && !path.isAbsolute(relative);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
async function resolveCanvasSessionFile(session, requestedPath, allowedDirs = []) {
|
|
231
|
+
if (!requestedPath) {
|
|
232
|
+
const error = new Error('Expected source path.');
|
|
233
|
+
error.statusCode = 400;
|
|
234
|
+
throw error;
|
|
235
|
+
}
|
|
236
|
+
const targetPath = path.resolve(String(requestedPath));
|
|
237
|
+
const allowed = allowedDirs.some((dir) => isInsideDirectory(dir, targetPath));
|
|
238
|
+
if (!allowed) {
|
|
239
|
+
const error = new Error('Path is outside this canvas session.');
|
|
240
|
+
error.statusCode = 403;
|
|
241
|
+
throw error;
|
|
242
|
+
}
|
|
243
|
+
const stat = await fs.stat(targetPath).catch(() => null);
|
|
244
|
+
if (!stat?.isFile()) {
|
|
245
|
+
const error = new Error('Path does not exist.');
|
|
246
|
+
error.statusCode = 404;
|
|
247
|
+
throw error;
|
|
248
|
+
}
|
|
249
|
+
return targetPath;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
async function materializeDownloadImage(session, input) {
|
|
253
|
+
const sourcePath = await resolveCanvasSessionFile(
|
|
254
|
+
session,
|
|
255
|
+
input.path || input.exportPath || input.assetPath,
|
|
256
|
+
[session.paths.exportsDir, session.paths.assetsDir],
|
|
257
|
+
);
|
|
258
|
+
const targetDir = downloadsDir();
|
|
259
|
+
await fs.mkdir(targetDir, { recursive: true });
|
|
260
|
+
const extension = path.extname(sourcePath) || extensionFromMime(input.mimeType || mimeFromPath(sourcePath), '.png');
|
|
261
|
+
const fallback = `openprd-canvas-image-${Date.now()}${extension}`;
|
|
262
|
+
const requestedName = safeDownloadFileName(input.filename || input.fileName, fallback);
|
|
263
|
+
const fileName = path.extname(requestedName) ? requestedName : `${requestedName}${extension}`;
|
|
264
|
+
const filePath = path.join(targetDir, fileName);
|
|
265
|
+
await fs.copyFile(sourcePath, filePath);
|
|
266
|
+
return {
|
|
267
|
+
fileName,
|
|
268
|
+
filePath,
|
|
269
|
+
folderPath: targetDir,
|
|
270
|
+
sourcePath,
|
|
271
|
+
mimeType: mimeFromPath(filePath),
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
async function resolveOpenFolderTarget(session, requestedPath) {
|
|
276
|
+
if (!requestedPath) {
|
|
277
|
+
const error = new Error('Expected path.');
|
|
278
|
+
error.statusCode = 400;
|
|
279
|
+
throw error;
|
|
280
|
+
}
|
|
281
|
+
const sessionRoot = path.resolve(session.paths.sessionDir);
|
|
282
|
+
const targetPath = path.resolve(String(requestedPath));
|
|
283
|
+
const relative = path.relative(sessionRoot, targetPath);
|
|
284
|
+
if (relative.startsWith('..') || path.isAbsolute(relative)) {
|
|
285
|
+
const error = new Error('Path is outside this canvas session.');
|
|
286
|
+
error.statusCode = 403;
|
|
287
|
+
throw error;
|
|
288
|
+
}
|
|
289
|
+
const stat = await fs.stat(targetPath).catch(() => null);
|
|
290
|
+
if (!stat) {
|
|
291
|
+
const error = new Error('Path does not exist.');
|
|
292
|
+
error.statusCode = 404;
|
|
293
|
+
throw error;
|
|
294
|
+
}
|
|
295
|
+
const isFile = stat.isFile();
|
|
296
|
+
return {
|
|
297
|
+
targetPath,
|
|
298
|
+
folderPath: isFile ? path.dirname(targetPath) : targetPath,
|
|
299
|
+
isFile,
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
async function resolveDownloadsFolderTarget(fileName) {
|
|
304
|
+
const targetDir = downloadsDir();
|
|
305
|
+
const requestedName = String(fileName ?? '').trim();
|
|
306
|
+
if (!requestedName) {
|
|
307
|
+
return {
|
|
308
|
+
targetPath: targetDir,
|
|
309
|
+
folderPath: targetDir,
|
|
310
|
+
isFile: false,
|
|
311
|
+
fileName: null,
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
const safeName = safeDownloadFileName(requestedName);
|
|
315
|
+
const targetPath = path.join(targetDir, safeName);
|
|
316
|
+
const stat = await fs.stat(targetPath).catch(() => null);
|
|
317
|
+
if (stat?.isFile()) {
|
|
318
|
+
return {
|
|
319
|
+
targetPath,
|
|
320
|
+
folderPath: targetDir,
|
|
321
|
+
isFile: true,
|
|
322
|
+
fileName: safeName,
|
|
323
|
+
};
|
|
324
|
+
}
|
|
325
|
+
return {
|
|
326
|
+
targetPath: targetDir,
|
|
327
|
+
folderPath: targetDir,
|
|
328
|
+
isFile: false,
|
|
329
|
+
fileName: safeName,
|
|
330
|
+
missingFile: true,
|
|
331
|
+
};
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function safeStaticName(urlPathname, prefix) {
|
|
335
|
+
const raw = decodeURIComponent(urlPathname.slice(prefix.length));
|
|
336
|
+
if (!raw || raw.includes('/') || raw.includes('\\') || raw.includes('..')) {
|
|
337
|
+
return null;
|
|
338
|
+
}
|
|
339
|
+
return raw;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
async function serveStaticFile(res, filePath) {
|
|
343
|
+
const stat = await fs.stat(filePath).catch(() => null);
|
|
344
|
+
if (!stat?.isFile()) {
|
|
345
|
+
textResponse(res, 404, 'Not found');
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
res.writeHead(200, {
|
|
349
|
+
'content-type': mimeFromPath(filePath),
|
|
350
|
+
'content-length': stat.size,
|
|
351
|
+
'cache-control': 'no-store',
|
|
352
|
+
'x-content-type-options': 'nosniff',
|
|
353
|
+
});
|
|
354
|
+
createReadStream(filePath).pipe(res);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
async function buildReviewDirectionsPayload(projectRoot) {
|
|
358
|
+
const versionIndexPath = cjoin(projectRoot, '.openprd', 'state', 'version-index.json');
|
|
359
|
+
const versionIndex = await readJson(versionIndexPath).catch(() => []);
|
|
360
|
+
const latestVersionId = Array.isArray(versionIndex) ? versionIndex.at(-1)?.versionId : null;
|
|
361
|
+
if (!latestVersionId) {
|
|
362
|
+
return { ok: false, reason: 'no-version', directions: [] };
|
|
363
|
+
}
|
|
364
|
+
const snapshot = await readJson(cjoin(projectRoot, '.openprd', 'state', 'versions', `${latestVersionId}.json`)).catch(() => null);
|
|
365
|
+
const block = snapshot?.reviewPresentation?.designDirections;
|
|
366
|
+
const rawDirections = Array.isArray(block?.directions) ? block.directions : [];
|
|
367
|
+
if (rawDirections.length === 0) {
|
|
368
|
+
return { ok: false, reason: 'no-directions', versionId: latestVersionId, directions: [] };
|
|
369
|
+
}
|
|
370
|
+
const resolvedRoot = path.resolve(projectRoot);
|
|
371
|
+
const directions = [];
|
|
372
|
+
for (const direction of rawDirections) {
|
|
373
|
+
if (!direction || typeof direction !== 'object') {
|
|
374
|
+
continue;
|
|
375
|
+
}
|
|
376
|
+
const entry = {
|
|
377
|
+
id: String(direction.id || ''),
|
|
378
|
+
title: String(direction.title || ''),
|
|
379
|
+
summary: String(direction.summary || ''),
|
|
380
|
+
tradeoff: String(direction.tradeoff || ''),
|
|
381
|
+
image: null,
|
|
382
|
+
};
|
|
383
|
+
const imagePath = typeof direction.image === 'string' ? direction.image.trim() : '';
|
|
384
|
+
if (imagePath) {
|
|
385
|
+
const resolvedImage = path.resolve(resolvedRoot, imagePath);
|
|
386
|
+
if (resolvedImage.startsWith(`${resolvedRoot}${path.sep}`) && await exists(resolvedImage)) {
|
|
387
|
+
const buffer = await fs.readFile(resolvedImage).catch(() => null);
|
|
388
|
+
if (buffer) {
|
|
389
|
+
const mimeType = mimeFromPath(resolvedImage);
|
|
390
|
+
entry.image = {
|
|
391
|
+
path: imagePath,
|
|
392
|
+
mimeType,
|
|
393
|
+
dataUrl: `data:${mimeType};base64,${buffer.toString('base64')}`,
|
|
394
|
+
};
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
directions.push(entry);
|
|
399
|
+
}
|
|
400
|
+
return {
|
|
401
|
+
ok: directions.length > 0,
|
|
402
|
+
versionId: latestVersionId,
|
|
403
|
+
title: snapshot?.title || '',
|
|
404
|
+
selectedDirectionId: (await readJson(cjoin(projectRoot, '.openprd', 'state', 'current.json')).catch(() => null))?.reviewStatus?.designDirection?.id
|
|
405
|
+
|| null,
|
|
406
|
+
directions,
|
|
407
|
+
};
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
async function handleCanvasRequest(session, req, res) {
|
|
411
|
+
const url = new URL(req.url ?? '/', `http://${req.headers.host || 'localhost'}`);
|
|
412
|
+
const pathname = url.pathname;
|
|
413
|
+
|
|
414
|
+
if ((req.method === 'GET' || req.method === 'HEAD') && pathname === '/') {
|
|
415
|
+
if (req.method === 'HEAD') {
|
|
416
|
+
textResponse(res, 200, '', 'text/html; charset=utf-8');
|
|
417
|
+
return;
|
|
418
|
+
}
|
|
419
|
+
textResponse(res, 200, renderCanvasAppHtml(session), 'text/html; charset=utf-8');
|
|
420
|
+
return;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
if (req.method === 'GET' && pathname === '/api/session') {
|
|
424
|
+
jsonResponse(res, 200, session);
|
|
425
|
+
return;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
if (req.method === 'GET' && pathname === '/api/guide') {
|
|
429
|
+
try {
|
|
430
|
+
const { guideWorkspace } = await import('./guide.js');
|
|
431
|
+
jsonResponse(res, 200, await guideWorkspace(session.projectRoot));
|
|
432
|
+
} catch (error) {
|
|
433
|
+
jsonResponse(res, 200, { ok: false, error: error?.message || String(error) });
|
|
434
|
+
}
|
|
435
|
+
return;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
if (req.method === 'GET' && pathname === '/api/review-directions') {
|
|
439
|
+
try {
|
|
440
|
+
jsonResponse(res, 200, await buildReviewDirectionsPayload(session.projectRoot));
|
|
441
|
+
} catch (error) {
|
|
442
|
+
jsonResponse(res, 200, { ok: false, error: error?.message || String(error) });
|
|
443
|
+
}
|
|
444
|
+
return;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
if (req.method === 'GET' && pathname === '/api/scene') {
|
|
448
|
+
const scene = normalizeCanvasScene(await readJson(session.paths.sceneFile).catch(() => ({
|
|
449
|
+
type: 'openprd.canvas.scene.v1',
|
|
450
|
+
elements: [],
|
|
451
|
+
appState: { viewBackgroundColor: CANVAS_SURFACE_BACKGROUND },
|
|
452
|
+
files: {},
|
|
453
|
+
})));
|
|
454
|
+
jsonResponse(res, 200, scene);
|
|
455
|
+
return;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
if (req.method === 'POST' && pathname === '/api/scene') {
|
|
459
|
+
const body = await readRequestJson(req);
|
|
460
|
+
await writeJson(session.paths.sceneFile, normalizeCanvasScene({
|
|
461
|
+
type: 'openprd.canvas.scene.v1',
|
|
462
|
+
elements: Array.isArray(body.elements) ? body.elements : [],
|
|
463
|
+
appState: isPlainObject(body.appState) ? body.appState : {},
|
|
464
|
+
files: isPlainObject(body.files) ? body.files : {},
|
|
465
|
+
savedAt: timestamp(),
|
|
466
|
+
}));
|
|
467
|
+
jsonResponse(res, 200, { ok: true, scenePath: session.paths.sceneFile });
|
|
468
|
+
return;
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
if (req.method === 'GET' && pathname === '/api/selection') {
|
|
472
|
+
const scene = normalizeCanvasScene(await readJson(session.paths.sceneFile).catch(() => null));
|
|
473
|
+
jsonResponse(res, 200, buildCanvasSelectionPayload(scene));
|
|
474
|
+
return;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
if (req.method === 'POST' && pathname === '/api/insert-image') {
|
|
478
|
+
const body = await readRequestJson(req);
|
|
479
|
+
const inserted = await insertAnchoredCanvasImage(session, body);
|
|
480
|
+
jsonResponse(res, 200, {
|
|
481
|
+
ok: true,
|
|
482
|
+
...inserted,
|
|
483
|
+
});
|
|
484
|
+
return;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
if (req.method === 'GET' && pathname === '/api/ops') {
|
|
488
|
+
const after = Number(url.searchParams.get('after') ?? -1);
|
|
489
|
+
const ops = await readOps(session.paths);
|
|
490
|
+
jsonResponse(res, 200, {
|
|
491
|
+
ok: true,
|
|
492
|
+
nextSeq: ops.nextSeq,
|
|
493
|
+
ops: ops.ops.filter((op) => Number(op.seq ?? 0) > after),
|
|
494
|
+
});
|
|
495
|
+
return;
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
if (req.method === 'GET' && pathname === '/api/agent-bridge') {
|
|
499
|
+
const pending = await listCodexAgentBridgeOutbox(session);
|
|
500
|
+
jsonResponse(res, 200, {
|
|
501
|
+
ok: true,
|
|
502
|
+
transport: CODEX_AGENT_BRIDGE_TRANSPORT,
|
|
503
|
+
requiresAgentTool: 'codex_app.send_message_to_thread',
|
|
504
|
+
pending,
|
|
505
|
+
});
|
|
506
|
+
return;
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
if (req.method === 'GET' && pathname === '/api/library') {
|
|
510
|
+
const library = await readLibrary(session.paths);
|
|
511
|
+
jsonResponse(res, 200, {
|
|
512
|
+
ok: true,
|
|
513
|
+
...library,
|
|
514
|
+
});
|
|
515
|
+
return;
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
if (req.method === 'POST' && pathname === '/api/library') {
|
|
519
|
+
const body = await readRequestJson(req);
|
|
520
|
+
if (Array.isArray(body.libraryItems) || Array.isArray(body.items)) {
|
|
521
|
+
const library = await writeLibrary(session.paths, body);
|
|
522
|
+
const op = await appendOperation(session.paths, {
|
|
523
|
+
type: 'library',
|
|
524
|
+
intent: body.intent ?? 'sync-excalidraw-library',
|
|
525
|
+
itemCount: library.libraryItems.length,
|
|
526
|
+
locale: body.locale ?? null,
|
|
527
|
+
});
|
|
528
|
+
jsonResponse(res, 200, {
|
|
529
|
+
ok: true,
|
|
530
|
+
operation: op,
|
|
531
|
+
...library,
|
|
532
|
+
});
|
|
533
|
+
return;
|
|
534
|
+
}
|
|
535
|
+
const result = await appendLibraryItem(session, body);
|
|
536
|
+
const op = await appendOperation(session.paths, {
|
|
537
|
+
type: 'library',
|
|
538
|
+
intent: body.intent ?? 'add-selection-to-library',
|
|
539
|
+
libraryItemId: result.item.id,
|
|
540
|
+
elementCount: result.item.elements.length,
|
|
541
|
+
locale: body.locale ?? null,
|
|
542
|
+
});
|
|
543
|
+
jsonResponse(res, 200, {
|
|
544
|
+
ok: true,
|
|
545
|
+
operation: op,
|
|
546
|
+
item: result.item,
|
|
547
|
+
...result.library,
|
|
548
|
+
});
|
|
549
|
+
return;
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
if (req.method === 'POST' && pathname === '/api/images') {
|
|
553
|
+
const body = await readRequestJson(req);
|
|
554
|
+
const asset = await materializeImage(session, body);
|
|
555
|
+
const op = await appendOperation(session.paths, {
|
|
556
|
+
type: 'image',
|
|
557
|
+
mode: body.mode ?? 'replace',
|
|
558
|
+
aspectRatio: body.aspectRatio ?? null,
|
|
559
|
+
placeholderId: body.placeholderId ?? null,
|
|
560
|
+
note: body.note ?? null,
|
|
561
|
+
assetUrl: asset.assetUrl,
|
|
562
|
+
assetPath: asset.assetPath,
|
|
563
|
+
sourcePath: asset.sourcePath,
|
|
564
|
+
sourceUrl: asset.sourceUrl,
|
|
565
|
+
mimeType: asset.mimeType,
|
|
566
|
+
fileId: `openprd-${asset.digest}`,
|
|
567
|
+
});
|
|
568
|
+
jsonResponse(res, 200, {
|
|
569
|
+
ok: true,
|
|
570
|
+
operation: op,
|
|
571
|
+
asset,
|
|
572
|
+
});
|
|
573
|
+
return;
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
if (req.method === 'POST' && pathname === '/api/cards') {
|
|
577
|
+
const body = await readRequestJson(req).catch(() => ({}));
|
|
578
|
+
const op = await appendOperation(session.paths, {
|
|
579
|
+
type: 'card',
|
|
580
|
+
source: body.source ?? 'agent',
|
|
581
|
+
note: body.note ?? null,
|
|
582
|
+
});
|
|
583
|
+
jsonResponse(res, 200, { ok: true, operation: op });
|
|
584
|
+
return;
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
if (req.method === 'POST' && pathname === '/api/exports') {
|
|
588
|
+
const body = await readRequestJson(req);
|
|
589
|
+
const exported = await materializeExport(session, body);
|
|
590
|
+
const op = await appendOperation(session.paths, {
|
|
591
|
+
type: 'export',
|
|
592
|
+
intent: body.intent ?? 'manual-export',
|
|
593
|
+
exportPath: exported.exportPath,
|
|
594
|
+
exportUrl: exported.exportUrl,
|
|
595
|
+
selection: body.selection ?? null,
|
|
596
|
+
elementCount: Number.isFinite(Number(body.elementCount)) ? Number(body.elementCount) : null,
|
|
597
|
+
selectedElementIds: normalizeStringArray(body.selectedElementIds),
|
|
598
|
+
locale: body.locale ?? null,
|
|
599
|
+
});
|
|
600
|
+
jsonResponse(res, 200, {
|
|
601
|
+
ok: true,
|
|
602
|
+
operation: op,
|
|
603
|
+
export: exported,
|
|
604
|
+
});
|
|
605
|
+
return;
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
if (req.method === 'POST' && pathname === '/api/download-file') {
|
|
609
|
+
const body = await readRequestJson(req);
|
|
610
|
+
const file = await materializeDownloadFile(body);
|
|
611
|
+
jsonResponse(res, 200, {
|
|
612
|
+
ok: true,
|
|
613
|
+
file,
|
|
614
|
+
});
|
|
615
|
+
return;
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
if (req.method === 'POST' && pathname === '/api/download-image') {
|
|
619
|
+
const body = await readRequestJson(req);
|
|
620
|
+
const file = await materializeDownloadImage(session, body);
|
|
621
|
+
jsonResponse(res, 200, {
|
|
622
|
+
ok: true,
|
|
623
|
+
file,
|
|
624
|
+
});
|
|
625
|
+
return;
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
if (req.method === 'POST' && pathname === '/api/open-folder') {
|
|
629
|
+
const body = await readRequestJson(req);
|
|
630
|
+
const target = await resolveOpenFolderTarget(session, body.path || body.targetPath);
|
|
631
|
+
openPathInFileManager(target);
|
|
632
|
+
jsonResponse(res, 200, {
|
|
633
|
+
ok: true,
|
|
634
|
+
opened: true,
|
|
635
|
+
platform: process.platform,
|
|
636
|
+
...target,
|
|
637
|
+
});
|
|
638
|
+
return;
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
if (req.method === 'POST' && pathname === '/api/open-download') {
|
|
642
|
+
const body = await readRequestJson(req);
|
|
643
|
+
const target = await resolveDownloadsFolderTarget(body.filename || body.fileName);
|
|
644
|
+
openPathInFileManager(target);
|
|
645
|
+
jsonResponse(res, 200, {
|
|
646
|
+
ok: true,
|
|
647
|
+
opened: true,
|
|
648
|
+
platform: process.platform,
|
|
649
|
+
...target,
|
|
650
|
+
});
|
|
651
|
+
return;
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
if (req.method === 'POST' && pathname === '/api/clipboard') {
|
|
655
|
+
const body = await readRequestJson(req, 256 * 1024);
|
|
656
|
+
const result = await writeTextToSystemClipboard(body.text ?? body.value ?? '', {
|
|
657
|
+
dryRun: Boolean(body.dryRun),
|
|
658
|
+
platform: body.platform,
|
|
659
|
+
});
|
|
660
|
+
jsonResponse(res, 200, {
|
|
661
|
+
ok: true,
|
|
662
|
+
clipboard: result,
|
|
663
|
+
});
|
|
664
|
+
return;
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
if (req.method === 'POST' && pathname === '/api/handoffs') {
|
|
668
|
+
const body = await readRequestJson(req);
|
|
669
|
+
const target = body.target ?? body.targetClient ?? 'codex';
|
|
670
|
+
const requestedTransport = body.transport ?? CODEX_DELEGATION_RELAY_TRANSPORT;
|
|
671
|
+
const transport = String(target).toLowerCase() === 'codex'
|
|
672
|
+
? CODEX_DELEGATION_RELAY_TRANSPORT
|
|
673
|
+
: requestedTransport;
|
|
674
|
+
const op = await appendOperation(session.paths, {
|
|
675
|
+
type: 'handoff',
|
|
676
|
+
target,
|
|
677
|
+
transport,
|
|
678
|
+
status: 'prepared',
|
|
679
|
+
exportOperationId: body.exportOperationId ?? null,
|
|
680
|
+
exportPath: body.exportPath ?? null,
|
|
681
|
+
exportUrl: body.exportUrl ?? null,
|
|
682
|
+
promptText: trimString(body.promptText),
|
|
683
|
+
instruction: trimString(body.instruction, 4000),
|
|
684
|
+
selection: body.selection ?? null,
|
|
685
|
+
placeholderId: body.placeholderId ?? null,
|
|
686
|
+
aspectRatio: body.aspectRatio ?? null,
|
|
687
|
+
mode: body.mode ?? null,
|
|
688
|
+
elementCount: Number.isFinite(Number(body.elementCount)) ? Number(body.elementCount) : null,
|
|
689
|
+
selectedElementIds: normalizeStringArray(body.selectedElementIds),
|
|
690
|
+
sourceImageElementIds: normalizeStringArray(body.sourceImageElementIds),
|
|
691
|
+
annotationElementIds: normalizeStringArray(body.annotationElementIds),
|
|
692
|
+
sizeContract: normalizeCanvasSizeContract(body.sizeContract),
|
|
693
|
+
locale: body.locale ?? null,
|
|
694
|
+
binding: {
|
|
695
|
+
kind: session.binding?.kind ?? null,
|
|
696
|
+
id: session.binding?.id ?? null,
|
|
697
|
+
displayId: session.binding?.displayId ?? null,
|
|
698
|
+
title: session.binding?.title ?? null,
|
|
699
|
+
threadTitle: session.binding?.threadTitle ?? null,
|
|
700
|
+
sessionTitle: session.binding?.sessionTitle ?? null,
|
|
701
|
+
source: session.binding?.source ?? null,
|
|
702
|
+
},
|
|
703
|
+
note: body.note ?? null,
|
|
704
|
+
});
|
|
705
|
+
const relay = await queueCodexThreadRelay(session, op, body);
|
|
706
|
+
if (relay.status !== 'skipped') {
|
|
707
|
+
await appendOperation(session.paths, {
|
|
708
|
+
type: 'handoff-relay',
|
|
709
|
+
handoffOperationId: op.id,
|
|
710
|
+
target,
|
|
711
|
+
transport: relay.transport,
|
|
712
|
+
status: relay.status,
|
|
713
|
+
dryRun: Boolean(relay.dryRun),
|
|
714
|
+
payloadPath: relay.payloadPath ?? null,
|
|
715
|
+
statusPath: relay.statusPath ?? null,
|
|
716
|
+
logPath: relay.logPath ?? null,
|
|
717
|
+
pid: relay.pid ?? null,
|
|
718
|
+
inputTypes: relay.inputTypes ?? [],
|
|
719
|
+
});
|
|
720
|
+
}
|
|
721
|
+
const agentBridgeRequested = Boolean(body.agentBridge || body.foregroundBridge);
|
|
722
|
+
const agentBridge = await queueCodexAgentBridge(
|
|
723
|
+
session,
|
|
724
|
+
op,
|
|
725
|
+
agentBridgeRequested && !codexRelayNeedsAgentBridgeFallback(relay)
|
|
726
|
+
? {
|
|
727
|
+
...body,
|
|
728
|
+
agentBridgeSkipReason: 'direct-relay-active',
|
|
729
|
+
directRelay: {
|
|
730
|
+
status: relay.status,
|
|
731
|
+
transport: relay.transport,
|
|
732
|
+
},
|
|
733
|
+
}
|
|
734
|
+
: body,
|
|
735
|
+
);
|
|
736
|
+
jsonResponse(res, 200, {
|
|
737
|
+
ok: true,
|
|
738
|
+
operation: op,
|
|
739
|
+
agentBridge,
|
|
740
|
+
relay,
|
|
741
|
+
});
|
|
742
|
+
return;
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
if (req.method === 'GET' && pathname.startsWith('/assets/')) {
|
|
746
|
+
const name = safeStaticName(pathname, '/assets/');
|
|
747
|
+
if (!name) {
|
|
748
|
+
textResponse(res, 400, 'Invalid asset path');
|
|
749
|
+
return;
|
|
750
|
+
}
|
|
751
|
+
await serveStaticFile(res, cjoin(session.paths.assetsDir, name));
|
|
752
|
+
return;
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
if (req.method === 'GET' && pathname.startsWith('/exports/')) {
|
|
756
|
+
const name = safeStaticName(pathname, '/exports/');
|
|
757
|
+
if (!name) {
|
|
758
|
+
textResponse(res, 400, 'Invalid export path');
|
|
759
|
+
return;
|
|
760
|
+
}
|
|
761
|
+
await serveStaticFile(res, cjoin(session.paths.exportsDir, name));
|
|
762
|
+
return;
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
textResponse(res, 404, 'Not found');
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
function normalizeCanvasOpenTarget(value, env = process.env) {
|
|
769
|
+
const requested = firstPresent(value, env.OPENPRD_CANVAS_OPEN_TARGET, env.OPENPRD_OPEN_TARGET, 'auto');
|
|
770
|
+
const normalized = String(requested).trim().toLowerCase();
|
|
771
|
+
if (!CANVAS_OPEN_TARGETS.has(normalized)) {
|
|
772
|
+
throw new Error(`Invalid canvas open target: ${requested}. Expected auto, system, or codex-browser.`);
|
|
773
|
+
}
|
|
774
|
+
return normalized;
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
function bindingHasCodexBrowserEvidence(binding = {}) {
|
|
778
|
+
const activeClient = String(binding.activeClient ?? '').toLowerCase();
|
|
779
|
+
const surface = String(binding.surface ?? '').toLowerCase();
|
|
780
|
+
const observedCapabilities = Array.isArray(binding.observedCapabilities) ? binding.observedCapabilities : [];
|
|
781
|
+
return (
|
|
782
|
+
activeClient === 'codex'
|
|
783
|
+
&& (surface.includes('desktop') || surface.includes('browser') || surface.includes('app'))
|
|
784
|
+
&& observedCapabilities.some((capability) => String(capability).toLowerCase().includes('browser'))
|
|
785
|
+
);
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
function resolveCanvasOpenTarget(session, options = {}, env = process.env) {
|
|
789
|
+
const requestedTarget = normalizeCanvasOpenTarget(options.openTarget, env);
|
|
790
|
+
if (requestedTarget === 'codex-browser') {
|
|
791
|
+
return {
|
|
792
|
+
requestedTarget,
|
|
793
|
+
target: 'codex-browser',
|
|
794
|
+
reason: 'explicit-open-target',
|
|
795
|
+
};
|
|
796
|
+
}
|
|
797
|
+
if (requestedTarget === 'system') {
|
|
798
|
+
return {
|
|
799
|
+
requestedTarget,
|
|
800
|
+
target: 'system',
|
|
801
|
+
reason: 'explicit-open-target',
|
|
802
|
+
};
|
|
803
|
+
}
|
|
804
|
+
if (bindingHasCodexBrowserEvidence(session?.binding)) {
|
|
805
|
+
return {
|
|
806
|
+
requestedTarget,
|
|
807
|
+
target: 'codex-browser',
|
|
808
|
+
reason: 'runtime-evidence',
|
|
809
|
+
};
|
|
810
|
+
}
|
|
811
|
+
return {
|
|
812
|
+
requestedTarget,
|
|
813
|
+
target: 'system',
|
|
814
|
+
reason: 'no-codex-browser-evidence',
|
|
815
|
+
};
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
function openSystemUrl(url) {
|
|
819
|
+
const platform = process.platform;
|
|
820
|
+
if (platform === 'darwin') {
|
|
821
|
+
const child = spawn('open', [url], { detached: true, stdio: 'ignore' });
|
|
822
|
+
child.unref();
|
|
823
|
+
return true;
|
|
824
|
+
}
|
|
825
|
+
if (platform === 'win32') {
|
|
826
|
+
const child = spawn('cmd', ['/d', '/s', '/c', 'start', '', url], { detached: true, stdio: 'ignore' });
|
|
827
|
+
child.unref();
|
|
828
|
+
return true;
|
|
829
|
+
}
|
|
830
|
+
const child = spawn('xdg-open', [url], { detached: true, stdio: 'ignore' });
|
|
831
|
+
child.unref();
|
|
832
|
+
return true;
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
function openPathInFileManager(target) {
|
|
836
|
+
const platform = process.platform;
|
|
837
|
+
if (platform === 'darwin') {
|
|
838
|
+
const args = target.isFile ? ['-R', target.targetPath] : [target.folderPath];
|
|
839
|
+
const child = spawn('open', args, { detached: true, stdio: 'ignore' });
|
|
840
|
+
child.unref();
|
|
841
|
+
return true;
|
|
842
|
+
}
|
|
843
|
+
if (platform === 'win32') {
|
|
844
|
+
const args = target.isFile ? [`/select,${target.targetPath}`] : [target.folderPath];
|
|
845
|
+
const child = spawn('explorer.exe', args, { detached: true, stdio: 'ignore' });
|
|
846
|
+
child.unref();
|
|
847
|
+
return true;
|
|
848
|
+
}
|
|
849
|
+
const child = spawn('xdg-open', [target.folderPath], { detached: true, stdio: 'ignore' });
|
|
850
|
+
child.unref();
|
|
851
|
+
return true;
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
function openCanvasUrl(url, session, options = {}, env = process.env) {
|
|
855
|
+
const target = resolveCanvasOpenTarget(session, options, env);
|
|
856
|
+
if (target.target === 'codex-browser') {
|
|
857
|
+
return {
|
|
858
|
+
...target,
|
|
859
|
+
status: 'requires-agent-browser',
|
|
860
|
+
opened: false,
|
|
861
|
+
url,
|
|
862
|
+
message: 'Use the Codex in-app Browser tool to open this URL; the CLI will not launch the system browser for this target.',
|
|
863
|
+
};
|
|
864
|
+
}
|
|
865
|
+
openSystemUrl(url);
|
|
866
|
+
return {
|
|
867
|
+
...target,
|
|
868
|
+
status: 'opened',
|
|
869
|
+
opened: true,
|
|
870
|
+
url,
|
|
871
|
+
};
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
async function listen(server, host, port) {
|
|
875
|
+
await new Promise((resolve, reject) => {
|
|
876
|
+
const onError = (error) => {
|
|
877
|
+
server.off('listening', onListening);
|
|
878
|
+
reject(error);
|
|
879
|
+
};
|
|
880
|
+
const onListening = () => {
|
|
881
|
+
server.off('error', onError);
|
|
882
|
+
resolve();
|
|
883
|
+
};
|
|
884
|
+
server.once('error', onError);
|
|
885
|
+
server.once('listening', onListening);
|
|
886
|
+
server.listen(port, host);
|
|
887
|
+
});
|
|
888
|
+
return server.address();
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
function normalizePort(port, fallback = DEFAULT_PORT) {
|
|
892
|
+
if (port === null || port === undefined || port === '') {
|
|
893
|
+
return fallback;
|
|
894
|
+
}
|
|
895
|
+
const parsed = Number(port);
|
|
896
|
+
if (!Number.isInteger(parsed) || parsed < 0 || parsed > 65535) {
|
|
897
|
+
throw new Error(`Invalid canvas port: ${port}`);
|
|
898
|
+
}
|
|
899
|
+
return parsed;
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
function canvasUrl(host, port) {
|
|
903
|
+
const urlHost = host === '::' ? '127.0.0.1' : host;
|
|
904
|
+
return `http://${urlHost}:${port}/`;
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
function processIsAlive(pid) {
|
|
908
|
+
if (!pid || !Number.isInteger(Number(pid))) {
|
|
909
|
+
return false;
|
|
910
|
+
}
|
|
911
|
+
try {
|
|
912
|
+
process.kill(Number(pid), 0);
|
|
913
|
+
return true;
|
|
914
|
+
} catch {
|
|
915
|
+
return false;
|
|
916
|
+
}
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
async function fetchCanvasSession(url) {
|
|
920
|
+
const response = await fetch(new URL('/api/session', url));
|
|
921
|
+
if (!response.ok) {
|
|
922
|
+
return null;
|
|
923
|
+
}
|
|
924
|
+
return response.json();
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
function sameBinding(left, right) {
|
|
928
|
+
return Boolean(left?.kind && right?.kind && left.kind === right.kind && left.id === right.id);
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
async function waitForCanvasServer(url, expectedBinding, timeoutMs = DAEMON_READY_TIMEOUT_MS) {
|
|
932
|
+
const startedAt = Date.now();
|
|
933
|
+
let lastError = null;
|
|
934
|
+
while (Date.now() - startedAt < timeoutMs) {
|
|
935
|
+
try {
|
|
936
|
+
const remoteSession = await fetchCanvasSession(url);
|
|
937
|
+
if (sameBinding(remoteSession?.binding, expectedBinding)) {
|
|
938
|
+
return remoteSession;
|
|
939
|
+
}
|
|
940
|
+
lastError = new Error('Canvas server responded with a different session binding.');
|
|
941
|
+
} catch (error) {
|
|
942
|
+
lastError = error;
|
|
943
|
+
}
|
|
944
|
+
await new Promise((resolve) => setTimeout(resolve, 200));
|
|
945
|
+
}
|
|
946
|
+
throw new Error(`Canvas daemon did not become ready at ${url}: ${lastError?.message ?? 'timeout'}`);
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
async function writeServerMetadata(session, metadata) {
|
|
950
|
+
await writeJson(session.paths.serverFile, {
|
|
951
|
+
type: 'openprd.canvas.server.v1',
|
|
952
|
+
sessionKey: session.sessionKey,
|
|
953
|
+
binding: session.binding,
|
|
954
|
+
updatedAt: timestamp(),
|
|
955
|
+
...metadata,
|
|
956
|
+
});
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
async function removeServerMetadata(session) {
|
|
960
|
+
await fs.rm(session.paths.serverFile, { force: true }).catch(() => {});
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
function canvasBindingArgs(session) {
|
|
964
|
+
if (session.binding?.kind === 'thread') {
|
|
965
|
+
const args = ['--thread', session.binding.id];
|
|
966
|
+
const title = normalizeBindingTitle(session.binding.title, session.binding.threadTitle, session.binding.name);
|
|
967
|
+
if (title) {
|
|
968
|
+
args.push('--thread-title', title);
|
|
969
|
+
}
|
|
970
|
+
return args;
|
|
971
|
+
}
|
|
972
|
+
if (session.binding?.kind === 'session') {
|
|
973
|
+
const args = ['--session', session.binding.id];
|
|
974
|
+
const title = normalizeBindingTitle(session.binding.title, session.binding.sessionTitle, session.binding.name);
|
|
975
|
+
if (title) {
|
|
976
|
+
args.push('--session-title', title);
|
|
977
|
+
}
|
|
978
|
+
return args;
|
|
979
|
+
}
|
|
980
|
+
throw new Error('Persistent canvas requires thread/session evidence. Pass --thread <id> or --session <id>.');
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
function canvasDaemonArgs(projectRoot, session, options = {}) {
|
|
984
|
+
const args = [
|
|
985
|
+
CLI_BIN_PATH,
|
|
986
|
+
'canvas',
|
|
987
|
+
projectRoot,
|
|
988
|
+
'--host',
|
|
989
|
+
options.host,
|
|
990
|
+
'--port',
|
|
991
|
+
String(options.port),
|
|
992
|
+
...canvasBindingArgs(session),
|
|
993
|
+
];
|
|
994
|
+
if (options.json) {
|
|
995
|
+
args.push('--json');
|
|
996
|
+
}
|
|
997
|
+
return args;
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
async function existingCanvasServer(session, url) {
|
|
1001
|
+
const metadata = await readJson(session.paths.serverFile).catch(() => null);
|
|
1002
|
+
if (!metadata?.pid || !processIsAlive(metadata.pid)) {
|
|
1003
|
+
return null;
|
|
1004
|
+
}
|
|
1005
|
+
try {
|
|
1006
|
+
const remoteSession = await fetchCanvasSession(metadata.url ?? url);
|
|
1007
|
+
if (sameBinding(remoteSession?.binding, session.binding)) {
|
|
1008
|
+
return metadata;
|
|
1009
|
+
}
|
|
1010
|
+
} catch {
|
|
1011
|
+
return null;
|
|
1012
|
+
}
|
|
1013
|
+
return null;
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
async function startCanvasDaemon(projectRoot, session, options = {}) {
|
|
1017
|
+
const host = options.host || DEFAULT_HOST;
|
|
1018
|
+
const port = normalizePort(options.port, DEFAULT_DAEMON_PORT);
|
|
1019
|
+
if (port === 0) {
|
|
1020
|
+
throw new Error('Persistent canvas requires a fixed port. Pass --port <number>.');
|
|
1021
|
+
}
|
|
1022
|
+
const url = canvasUrl(host, port);
|
|
1023
|
+
const existing = await existingCanvasServer(session, url);
|
|
1024
|
+
if (existing) {
|
|
1025
|
+
const openResult = options.open ? openCanvasUrl(existing.url ?? url, session, options) : null;
|
|
1026
|
+
if (options.open) {
|
|
1027
|
+
await writeServerMetadata(session, {
|
|
1028
|
+
...existing,
|
|
1029
|
+
openResult,
|
|
1030
|
+
}).catch(() => {});
|
|
1031
|
+
}
|
|
1032
|
+
return {
|
|
1033
|
+
ok: true,
|
|
1034
|
+
action: 'canvas',
|
|
1035
|
+
daemon: true,
|
|
1036
|
+
alreadyRunning: true,
|
|
1037
|
+
dryRun: false,
|
|
1038
|
+
projectRoot,
|
|
1039
|
+
session,
|
|
1040
|
+
paths: session.paths,
|
|
1041
|
+
url: existing.url ?? url,
|
|
1042
|
+
host,
|
|
1043
|
+
port,
|
|
1044
|
+
pid: existing.pid,
|
|
1045
|
+
logPath: session.paths.logFile,
|
|
1046
|
+
openResult,
|
|
1047
|
+
};
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
await fs.mkdir(session.paths.sessionDir, { recursive: true });
|
|
1051
|
+
const logHandle = await fs.open(session.paths.logFile, 'a');
|
|
1052
|
+
const child = spawn(process.execPath, canvasDaemonArgs(projectRoot, session, { ...options, host, port, json: false }), {
|
|
1053
|
+
cwd: projectRoot,
|
|
1054
|
+
detached: true,
|
|
1055
|
+
stdio: ['ignore', logHandle.fd, logHandle.fd],
|
|
1056
|
+
env: {
|
|
1057
|
+
...process.env,
|
|
1058
|
+
OPENPRD_CANVAS_DAEMON: '1',
|
|
1059
|
+
},
|
|
1060
|
+
});
|
|
1061
|
+
await logHandle.close().catch(() => {});
|
|
1062
|
+
child.unref();
|
|
1063
|
+
|
|
1064
|
+
await waitForCanvasServer(url, session.binding);
|
|
1065
|
+
await writeServerMetadata(session, {
|
|
1066
|
+
mode: 'daemon',
|
|
1067
|
+
pid: child.pid,
|
|
1068
|
+
url,
|
|
1069
|
+
host,
|
|
1070
|
+
port,
|
|
1071
|
+
logPath: session.paths.logFile,
|
|
1072
|
+
startedAt: timestamp(),
|
|
1073
|
+
openTarget: normalizeCanvasOpenTarget(options.openTarget),
|
|
1074
|
+
});
|
|
1075
|
+
|
|
1076
|
+
const openResult = options.open ? openCanvasUrl(url, session, options) : null;
|
|
1077
|
+
if (options.open) {
|
|
1078
|
+
await writeServerMetadata(session, {
|
|
1079
|
+
mode: 'daemon',
|
|
1080
|
+
pid: child.pid,
|
|
1081
|
+
url,
|
|
1082
|
+
host,
|
|
1083
|
+
port,
|
|
1084
|
+
logPath: session.paths.logFile,
|
|
1085
|
+
startedAt: timestamp(),
|
|
1086
|
+
openTarget: normalizeCanvasOpenTarget(options.openTarget),
|
|
1087
|
+
openResult,
|
|
1088
|
+
}).catch(() => {});
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
return {
|
|
1092
|
+
ok: true,
|
|
1093
|
+
action: 'canvas',
|
|
1094
|
+
daemon: true,
|
|
1095
|
+
alreadyRunning: false,
|
|
1096
|
+
dryRun: false,
|
|
1097
|
+
projectRoot,
|
|
1098
|
+
session,
|
|
1099
|
+
paths: session.paths,
|
|
1100
|
+
url,
|
|
1101
|
+
host,
|
|
1102
|
+
port,
|
|
1103
|
+
pid: child.pid,
|
|
1104
|
+
logPath: session.paths.logFile,
|
|
1105
|
+
openResult,
|
|
1106
|
+
};
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1109
|
+
async function stopCanvasDaemon(session) {
|
|
1110
|
+
const metadata = await readJson(session.paths.serverFile).catch(() => null);
|
|
1111
|
+
if (!metadata?.pid) {
|
|
1112
|
+
return {
|
|
1113
|
+
ok: true,
|
|
1114
|
+
action: 'canvas-stop',
|
|
1115
|
+
stopped: false,
|
|
1116
|
+
reason: 'not-running',
|
|
1117
|
+
session,
|
|
1118
|
+
paths: session.paths,
|
|
1119
|
+
};
|
|
1120
|
+
}
|
|
1121
|
+
const pid = Number(metadata.pid);
|
|
1122
|
+
if (processIsAlive(pid)) {
|
|
1123
|
+
process.kill(pid, 'SIGTERM');
|
|
1124
|
+
const startedAt = Date.now();
|
|
1125
|
+
while (processIsAlive(pid) && Date.now() - startedAt < 3000) {
|
|
1126
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
1127
|
+
}
|
|
1128
|
+
}
|
|
1129
|
+
await removeServerMetadata(session);
|
|
1130
|
+
return {
|
|
1131
|
+
ok: true,
|
|
1132
|
+
action: 'canvas-stop',
|
|
1133
|
+
stopped: true,
|
|
1134
|
+
session,
|
|
1135
|
+
paths: session.paths,
|
|
1136
|
+
pid,
|
|
1137
|
+
url: metadata.url ?? null,
|
|
1138
|
+
};
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
async function canvasWorkspace(projectRoot, options = {}) {
|
|
1142
|
+
const session = await ensureCanvasSession(projectRoot, options);
|
|
1143
|
+
const host = options.host || DEFAULT_HOST;
|
|
1144
|
+
const port = normalizePort(options.port, DEFAULT_PORT);
|
|
1145
|
+
const baseResult = {
|
|
1146
|
+
ok: true,
|
|
1147
|
+
action: 'canvas',
|
|
1148
|
+
dryRun: Boolean(options.dryRun),
|
|
1149
|
+
projectRoot,
|
|
1150
|
+
session,
|
|
1151
|
+
paths: session.paths,
|
|
1152
|
+
};
|
|
1153
|
+
|
|
1154
|
+
if (options.bridgeOutbox || options.bridgeSent || options.bridgeFailed) {
|
|
1155
|
+
if (options.bridgeSent || options.bridgeFailed) {
|
|
1156
|
+
const item = await markCodexAgentBridgeItem(session, options.id, options.bridgeFailed ? 'failed' : 'sent', {
|
|
1157
|
+
reason: options.reason,
|
|
1158
|
+
threadId: session.binding?.id ?? null,
|
|
1159
|
+
});
|
|
1160
|
+
return {
|
|
1161
|
+
...baseResult,
|
|
1162
|
+
action: 'canvas-agent-bridge',
|
|
1163
|
+
bridgeAction: options.bridgeFailed ? 'failed' : 'sent',
|
|
1164
|
+
item,
|
|
1165
|
+
};
|
|
1166
|
+
}
|
|
1167
|
+
if (options.claim) {
|
|
1168
|
+
const item = await claimCodexAgentBridgeItem(session, options.claim);
|
|
1169
|
+
return {
|
|
1170
|
+
...baseResult,
|
|
1171
|
+
action: 'canvas-agent-bridge',
|
|
1172
|
+
bridgeAction: 'claim',
|
|
1173
|
+
item,
|
|
1174
|
+
pending: item ? [item] : [],
|
|
1175
|
+
};
|
|
1176
|
+
}
|
|
1177
|
+
const pending = await listCodexAgentBridgeOutbox(session);
|
|
1178
|
+
return {
|
|
1179
|
+
...baseResult,
|
|
1180
|
+
action: 'canvas-agent-bridge',
|
|
1181
|
+
bridgeAction: 'outbox',
|
|
1182
|
+
pending,
|
|
1183
|
+
};
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1186
|
+
if (options.stop) {
|
|
1187
|
+
return stopCanvasDaemon(session);
|
|
1188
|
+
}
|
|
1189
|
+
|
|
1190
|
+
if (options.dryRun) {
|
|
1191
|
+
return {
|
|
1192
|
+
...baseResult,
|
|
1193
|
+
url: null,
|
|
1194
|
+
host,
|
|
1195
|
+
port,
|
|
1196
|
+
};
|
|
1197
|
+
}
|
|
1198
|
+
|
|
1199
|
+
if (options.daemon) {
|
|
1200
|
+
return startCanvasDaemon(projectRoot, session, { ...options, host });
|
|
1201
|
+
}
|
|
1202
|
+
|
|
1203
|
+
const server = http.createServer((req, res) => {
|
|
1204
|
+
handleCanvasRequest(session, req, res).catch((error) => {
|
|
1205
|
+
const statusCode = Number(error.statusCode ?? 500);
|
|
1206
|
+
jsonResponse(res, statusCode, {
|
|
1207
|
+
ok: false,
|
|
1208
|
+
error: error instanceof Error ? error.message : String(error),
|
|
1209
|
+
});
|
|
1210
|
+
});
|
|
1211
|
+
});
|
|
1212
|
+
const address = await listen(server, host, port);
|
|
1213
|
+
const actualHost = typeof address === 'object' && address ? address.address : host;
|
|
1214
|
+
const actualPort = typeof address === 'object' && address ? address.port : port;
|
|
1215
|
+
const url = canvasUrl(actualHost, actualPort);
|
|
1216
|
+
await writeServerMetadata(session, {
|
|
1217
|
+
mode: process.env.OPENPRD_CANVAS_DAEMON === '1' ? 'daemon-child' : 'foreground',
|
|
1218
|
+
pid: process.pid,
|
|
1219
|
+
url,
|
|
1220
|
+
host: actualHost,
|
|
1221
|
+
port: actualPort,
|
|
1222
|
+
logPath: session.paths.logFile,
|
|
1223
|
+
startedAt: timestamp(),
|
|
1224
|
+
openTarget: normalizeCanvasOpenTarget(options.openTarget),
|
|
1225
|
+
});
|
|
1226
|
+
server.once('close', () => {
|
|
1227
|
+
removeServerMetadata(session);
|
|
1228
|
+
});
|
|
1229
|
+
const result = {
|
|
1230
|
+
...baseResult,
|
|
1231
|
+
url,
|
|
1232
|
+
host: actualHost,
|
|
1233
|
+
port: actualPort,
|
|
1234
|
+
};
|
|
1235
|
+
Object.defineProperty(result, 'server', {
|
|
1236
|
+
value: server,
|
|
1237
|
+
enumerable: false,
|
|
1238
|
+
});
|
|
1239
|
+
|
|
1240
|
+
result.openResult = options.open ? openCanvasUrl(url, session, options) : null;
|
|
1241
|
+
return result;
|
|
1242
|
+
}
|
|
1243
|
+
|
|
1244
|
+
function toPrintableCanvasResult(result) {
|
|
1245
|
+
if (!result) {
|
|
1246
|
+
return result;
|
|
1247
|
+
}
|
|
1248
|
+
const { server, ...printable } = result;
|
|
1249
|
+
return printable;
|
|
1250
|
+
}
|
|
1251
|
+
|
|
1252
|
+
export {
|
|
1253
|
+
buildCanvasSessionPaths,
|
|
1254
|
+
buildReviewDirectionsPayload,
|
|
1255
|
+
canvasWorkspace,
|
|
1256
|
+
ensureCanvasSession,
|
|
1257
|
+
resolveCanvasOpenTarget,
|
|
1258
|
+
resolveDownloadsFolderTarget,
|
|
1259
|
+
resolveOpenFolderTarget,
|
|
1260
|
+
resolveCanvasSessionBinding,
|
|
1261
|
+
sanitizeSessionSegment,
|
|
1262
|
+
stopCanvasDaemon,
|
|
1263
|
+
toPrintableCanvasResult,
|
|
1264
|
+
};
|