@openprd/cli 0.1.18 → 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.
@@ -21,7 +21,6 @@
21
21
  * Keep the HTTP API small and file-backed. If realtime collaboration is added,
22
22
  * preserve this file-backed session format as the review/archive source.
23
23
  */
24
- import crypto from 'node:crypto';
25
24
  import fs from 'node:fs/promises';
26
25
  import { createReadStream } from 'node:fs';
27
26
  import http from 'node:http';
@@ -32,529 +31,54 @@ import { fileURLToPath } from 'node:url';
32
31
  import { renderCanvasAppHtml } from './canvas-app.html.js';
33
32
  import { cjoin, exists, readJson, writeJson } from './fs-utils.js';
34
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';
35
74
 
36
- const CANVAS_SCHEMA_VERSION = 1;
37
75
  const DEFAULT_HOST = '127.0.0.1';
38
76
  const DEFAULT_PORT = 0;
39
77
  const DEFAULT_DAEMON_PORT = 4767;
40
78
  const BODY_LIMIT_BYTES = 35 * 1024 * 1024;
41
- const SESSION_KEY_MAX = 84;
42
79
  const DAEMON_READY_TIMEOUT_MS = 7000;
43
- const HANDOFF_TEXT_MAX = 24000;
44
80
  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
81
  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
82
  function jsonResponse(res, statusCode, payload) {
559
83
  res.writeHead(statusCode, {
560
84
  'content-type': 'application/json; charset=utf-8',
@@ -592,159 +116,6 @@ async function readRequestJson(req, limitBytes = BODY_LIMIT_BYTES) {
592
116
  return JSON.parse(text);
593
117
  }
594
118
 
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, '&')
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
119
  function downloadsDir(env = process.env) {
749
120
  return env.OPENPRD_DOWNLOADS_DIR || path.join(os.homedir(), 'Downloads');
750
121
  }
@@ -804,403 +175,6 @@ async function writeTextToSystemClipboard(text, options = {}) {
804
175
  throw error;
805
176
  }
806
177
 
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
178
  async function materializeExport(session, input) {
1205
179
  if (!input.dataUrl) {
1206
180
  throw new Error('Expected dataUrl for export.');
@@ -1494,6 +468,22 @@ async function handleCanvasRequest(session, req, res) {
1494
468
  return;
1495
469
  }
1496
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
+
1497
487
  if (req.method === 'GET' && pathname === '/api/ops') {
1498
488
  const after = Number(url.searchParams.get('after') ?? -1);
1499
489
  const ops = await readOps(session.paths);
@@ -1697,6 +687,9 @@ async function handleCanvasRequest(session, req, res) {
1697
687
  mode: body.mode ?? null,
1698
688
  elementCount: Number.isFinite(Number(body.elementCount)) ? Number(body.elementCount) : null,
1699
689
  selectedElementIds: normalizeStringArray(body.selectedElementIds),
690
+ sourceImageElementIds: normalizeStringArray(body.sourceImageElementIds),
691
+ annotationElementIds: normalizeStringArray(body.annotationElementIds),
692
+ sizeContract: normalizeCanvasSizeContract(body.sizeContract),
1700
693
  locale: body.locale ?? null,
1701
694
  binding: {
1702
695
  kind: session.binding?.kind ?? null,