clankerbend 0.1.0

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.
@@ -0,0 +1,1177 @@
1
+ import { createReadStream, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import http from "node:http";
3
+ import { randomBytes } from "node:crypto";
4
+ import { extname, join, relative, resolve } from "node:path";
5
+
6
+ export const ONEWHACK_VERSION = "0.1";
7
+
8
+ export class OneWhackHttpError extends Error {
9
+ constructor(status, code, message, detail) {
10
+ super(message);
11
+ this.status = status;
12
+ this.code = code;
13
+ this.detail = detail;
14
+ }
15
+ }
16
+
17
+ export function httpError(status, code, message, detail) {
18
+ return new OneWhackHttpError(status, code, message, detail);
19
+ }
20
+
21
+ export function createMockTranscriptAdapter(options = {}) {
22
+ const anchors = options.anchors || [
23
+ mockAnchor("mock-1:user", 1, "user", "What changed in the current branch?"),
24
+ mockAnchor("mock-2:assistant", 2, "assistant", "I inspected the diff and found two changed files."),
25
+ mockAnchor("mock-3:user", 3, "user", "Jump to the latest assistant answer."),
26
+ mockAnchor("mock-4:assistant", 4, "assistant", "Use G to jump to the last visible transcript item.")
27
+ ];
28
+
29
+ return {
30
+ name: "mock",
31
+ cdp: false,
32
+ rendererInjection: false,
33
+ providers: options.providers || undefined,
34
+ async start(host) {
35
+ host.setDesktopStatus({
36
+ cdpStatus: "connected",
37
+ target: { type: "mock", title: "Mock Codex Desktop", url: "app://-/index.html" },
38
+ error: null
39
+ });
40
+ host.updateTranscript({
41
+ anchors,
42
+ visibleCount: anchors.filter((anchor) => anchor.visible).length,
43
+ annotationCount: anchors.length,
44
+ scroll: { top: 0, height: 1200, clientHeight: 700 },
45
+ updatedAt: new Date().toISOString()
46
+ }, { broadcast: false });
47
+ host.acceptSelection({
48
+ selectionId: "mock-selected",
49
+ source: "adapter",
50
+ appId: options.defaultAppId,
51
+ anchorId: anchors[1]?.anchorId || anchors[0]?.anchorId,
52
+ entryId: anchors[1] ? `nav:${anchors[1].anchorId}` : undefined,
53
+ selectedAt: new Date().toISOString()
54
+ }, { broadcast: false });
55
+ },
56
+ async openPanel() {
57
+ return { ok: true, mode: "focused" };
58
+ },
59
+ async scrollToAnchor(anchorId) {
60
+ return { ok: true, anchorId };
61
+ },
62
+ async highlightAnchor(anchorId) {
63
+ return { ok: true, anchorId };
64
+ },
65
+ async highlightRange(range) {
66
+ return { ok: true, anchorId: range?.anchorId, range };
67
+ },
68
+ async setComposerDraft(draft) {
69
+ return { ok: true, draft };
70
+ },
71
+ async submitComposer(draft) {
72
+ return { ok: true, draft, submitted: true };
73
+ },
74
+ async attachFiles(files) {
75
+ return { ok: true, files, attached: true, mode: "mock" };
76
+ },
77
+ async stop() {}
78
+ };
79
+ }
80
+
81
+ export function mockAnchor(anchorId, order, inferredRole, textPreview) {
82
+ return {
83
+ anchorId,
84
+ kind: "content-search-unit",
85
+ visible: true,
86
+ top: order * 120,
87
+ height: 96,
88
+ textPreview,
89
+ order,
90
+ inferredRole
91
+ };
92
+ }
93
+
94
+ export class OneWhackHost {
95
+ constructor(options = {}) {
96
+ this.protocolVersion = options.protocolVersion || ONEWHACK_VERSION;
97
+ this.hostId = options.hostId || "onewill.onewhack.host";
98
+ this.hostName = options.hostName || "OneWhack Host";
99
+ this.localDevInsecure = options.localDevInsecure === true;
100
+ this.token = this.localDevInsecure ? "" : (isNonEmptyString(options.token) ? options.token : createSessionToken());
101
+ this.runDir = options.runDir || null;
102
+ this.transcriptAdapter = options.transcriptAdapter || createMockTranscriptAdapter();
103
+ this.apps = new Map();
104
+ this.sseClients = new Set();
105
+ this.actionResults = new Map();
106
+ this.server = null;
107
+ this.heartbeat = null;
108
+
109
+ this.state = {
110
+ sequence: 1,
111
+ generatedAt: new Date().toISOString(),
112
+ host: {
113
+ status: "starting",
114
+ hostId: this.hostId,
115
+ url: null,
116
+ launchedAt: new Date().toISOString(),
117
+ error: null
118
+ },
119
+ desktop: {
120
+ cdpStatus: "starting",
121
+ cdpPort: null,
122
+ desktopPid: null,
123
+ target: null,
124
+ error: null
125
+ },
126
+ panel: {
127
+ status: "closed",
128
+ activeAppId: null,
129
+ url: null,
130
+ preferredWidth: null,
131
+ lastOpenedAt: null,
132
+ error: null
133
+ },
134
+ transcript: {
135
+ anchors: [],
136
+ visibleCount: 0,
137
+ annotationCount: 0,
138
+ scroll: null,
139
+ updatedAt: new Date().toISOString()
140
+ },
141
+ selection: null,
142
+ overlay: null,
143
+ composer: {
144
+ contextItems: [],
145
+ attachments: [],
146
+ draft: {
147
+ text: "",
148
+ mode: "replace",
149
+ contextItemIds: [],
150
+ updatedAt: null
151
+ },
152
+ lastSubmittedAt: null
153
+ },
154
+ appServer: {
155
+ status: "disabled",
156
+ pid: null,
157
+ version: null,
158
+ error: "not started"
159
+ },
160
+ lastAction: null
161
+ };
162
+ }
163
+
164
+ registerApp(app) {
165
+ if (!app?.appId) throw new Error("appId is required");
166
+ if (this.apps.has(app.appId)) throw httpError(409, "conflict", `duplicate app id: ${app.appId}`);
167
+ this.apps.set(app.appId, app);
168
+ if (!this.state.panel.activeAppId) {
169
+ this.state.panel.activeAppId = app.appId;
170
+ this.state.panel.preferredWidth = app.panel?.preferredWidth || null;
171
+ }
172
+ return this;
173
+ }
174
+
175
+ setActivePanelApp(appId) {
176
+ const app = this.requireApp(appId);
177
+ this.state.panel.activeAppId = app.appId;
178
+ this.state.panel.preferredWidth = app.panel?.preferredWidth || null;
179
+ this.state.panel.url = this.appEntryUrl(app.appId);
180
+ return this;
181
+ }
182
+
183
+ async start() {
184
+ if (this.runDir) mkdirSync(this.runDir, { recursive: true });
185
+ await this.listen();
186
+ await this.transcriptAdapter.start?.(this);
187
+ this.touchAndBroadcast();
188
+ this.heartbeat = setInterval(() => {
189
+ this.broadcast("heartbeat", {});
190
+ }, 15000);
191
+ this.heartbeat.unref();
192
+ return this;
193
+ }
194
+
195
+ async stop() {
196
+ this.heartbeat?.[Symbol.dispose]?.();
197
+ if (this.heartbeat) clearInterval(this.heartbeat);
198
+ await this.transcriptAdapter.stop?.();
199
+ for (const res of this.sseClients) {
200
+ try {
201
+ res.end();
202
+ } catch {}
203
+ }
204
+ this.sseClients.clear();
205
+ if (this.server) {
206
+ await new Promise((resolvePromise) => this.server.close(resolvePromise));
207
+ this.server = null;
208
+ }
209
+ this.state.host.status = "exited";
210
+ }
211
+
212
+ listen() {
213
+ this.server = http.createServer((req, res) => {
214
+ this.handleRequest(req, res).catch((err) => {
215
+ this.fail(res, err.status || 500, err.code || "internal_error", err.message, err.detail);
216
+ });
217
+ });
218
+
219
+ return new Promise((resolvePromise) => {
220
+ this.server.listen(0, "127.0.0.1", () => {
221
+ const address = this.server.address();
222
+ this.state.host.url = `http://127.0.0.1:${address.port}`;
223
+ this.state.host.status = "running";
224
+ this.state.panel.url = this.appEntryUrl(this.state.panel.activeAppId);
225
+ if (this.runDir) writeFileSync(resolve(this.runDir, "host-url"), `${this.state.host.url}\n`);
226
+ resolvePromise();
227
+ });
228
+ });
229
+ }
230
+
231
+ capabilities() {
232
+ return {
233
+ protocolVersion: this.protocolVersion,
234
+ host: {
235
+ apps: true,
236
+ actions: true,
237
+ appState: true,
238
+ sidePanel: true
239
+ },
240
+ transcript: {
241
+ read: true,
242
+ annotate: true,
243
+ navigate: true,
244
+ select: true,
245
+ rangeSelect: true,
246
+ rangeHighlight: true
247
+ },
248
+ overlay: {
249
+ anchored: true,
250
+ forms: true
251
+ },
252
+ composer: {
253
+ contextItems: true,
254
+ draft: true,
255
+ submit: Boolean(this.transcriptAdapter.submitComposer)
256
+ },
257
+ adapter: {
258
+ name: this.transcriptAdapter.name || "unknown",
259
+ cdp: Boolean(this.transcriptAdapter.cdp),
260
+ rendererInjection: Boolean(this.transcriptAdapter.rendererInjection),
261
+ rendererFetchToLoopback: Boolean(this.transcriptAdapter.rendererFetchToLoopback),
262
+ providers: this.transcriptAdapter.providers || undefined
263
+ },
264
+ appServer: {
265
+ available: this.state.appServer.status === "connected",
266
+ correlateItems: false,
267
+ approvals: false,
268
+ rollback: false
269
+ }
270
+ };
271
+ }
272
+
273
+ appEntryUrl(appId) {
274
+ const app = this.apps.get(appId);
275
+ if (!app || !this.state.host.url) return null;
276
+ const base = `${this.state.host.url.replace(/\/$/, "")}/apps/${encodeURIComponent(app.appId)}/`;
277
+ return this.token ? `${base}#onewhack_token=${encodeURIComponent(this.token)}` : base;
278
+ }
279
+
280
+ hostManifest() {
281
+ return {
282
+ oneWhackVersion: this.protocolVersion,
283
+ hostId: this.hostId,
284
+ hostName: this.hostName,
285
+ capabilities: this.capabilities(),
286
+ security: {
287
+ auth: this.token ? "bearer" : "none",
288
+ localDevInsecure: this.localDevInsecure
289
+ },
290
+ apps: [...this.apps.values()].map((app) => this.appManifest(app.appId))
291
+ };
292
+ }
293
+
294
+ appManifest(appId) {
295
+ const app = this.requireApp(appId);
296
+ const loadedManifest = app.manifest || {};
297
+ if (typeof app.getManifest === "function") {
298
+ return cleanObject({
299
+ ...loadedManifest,
300
+ ...app.getManifest(this.appContext(app)),
301
+ version: app.version || loadedManifest.version,
302
+ distribution: loadedManifest.distribution,
303
+ entrypoint: loadedManifest.entrypoint,
304
+ rendererBridge: loadedManifest.rendererBridge,
305
+ lifecycle: loadedManifest.lifecycle
306
+ });
307
+ }
308
+ return cleanObject({
309
+ ...loadedManifest,
310
+ oneWhackVersion: this.protocolVersion,
311
+ appId: app.appId,
312
+ name: app.name || app.appId,
313
+ version: app.version || loadedManifest.version,
314
+ entry: this.appEntryUrl(app.appId),
315
+ contributes: app.contributes || {},
316
+ permissions: app.permissions || {},
317
+ panel: app.panel,
318
+ distribution: loadedManifest.distribution,
319
+ entrypoint: loadedManifest.entrypoint,
320
+ rendererBridge: loadedManifest.rendererBridge,
321
+ lifecycle: loadedManifest.lifecycle
322
+ });
323
+ }
324
+
325
+ publicState() {
326
+ return {
327
+ protocolName: "onewhack",
328
+ protocolVersion: this.protocolVersion,
329
+ sequence: this.state.sequence,
330
+ generatedAt: this.state.generatedAt,
331
+ capabilities: this.capabilities(),
332
+ host: cleanObject({
333
+ status: this.state.host.status,
334
+ hostId: this.state.host.hostId,
335
+ url: this.state.host.url,
336
+ launchedAt: this.state.host.launchedAt,
337
+ error: this.state.host.error || undefined
338
+ }),
339
+ desktop: cleanObject({
340
+ cdpStatus: this.state.desktop.cdpStatus,
341
+ cdpPort: this.state.desktop.cdpPort || undefined,
342
+ desktopPid: this.state.desktop.desktopPid || undefined,
343
+ target: this.state.desktop.target || undefined,
344
+ error: this.state.desktop.error || undefined
345
+ }),
346
+ panel: cleanObject({
347
+ status: this.state.panel.status,
348
+ activeAppId: this.state.panel.activeAppId,
349
+ url: this.state.panel.url,
350
+ preferredWidth: this.state.panel.preferredWidth || undefined,
351
+ lastOpenedAt: this.state.panel.lastOpenedAt || undefined,
352
+ error: this.state.panel.error || undefined
353
+ }),
354
+ transcript: this.state.transcript,
355
+ selection: this.state.selection,
356
+ selectionActions: this.selectionActions(),
357
+ overlay: this.state.overlay,
358
+ composer: this.state.composer,
359
+ apps: [...this.apps.values()].map((app) => ({
360
+ entry: this.appEntryUrl(app.appId),
361
+ ...this.appState(app.appId)
362
+ })),
363
+ appServer: this.state.appServer,
364
+ lastAction: this.state.lastAction || undefined
365
+ };
366
+ }
367
+
368
+ appState(appId) {
369
+ const app = this.requireApp(appId);
370
+ if (typeof app.getState !== "function") {
371
+ return {
372
+ appId: app.appId,
373
+ status: "ready",
374
+ source: "static",
375
+ connected: true,
376
+ entries: [],
377
+ updatedAt: this.state.generatedAt
378
+ };
379
+ }
380
+ return this.sanitizeAppState(app, app.getState(this.appContext(app)));
381
+ }
382
+
383
+ sanitizeAppState(app, appState) {
384
+ const permissions = app.permissions || {};
385
+ if (!appState || typeof appState !== "object") return appState;
386
+ return cleanObject({
387
+ ...appState,
388
+ annotations: permissions.transcriptAnnotate === false ? undefined : appState.annotations
389
+ });
390
+ }
391
+
392
+ appContext(app) {
393
+ const permissions = app.permissions || {};
394
+ const transcript = permissions.transcriptRead === false
395
+ ? {
396
+ anchors: [],
397
+ visibleCount: 0,
398
+ annotationCount: 0,
399
+ scroll: null,
400
+ updatedAt: this.state.transcript.updatedAt
401
+ }
402
+ : this.state.transcript;
403
+ const appServer = permissions.appServerRead === false ? { status: "disabled", error: "permission denied" } : this.state.appServer;
404
+ const scopedState = {
405
+ ...this.state,
406
+ transcript,
407
+ appServer
408
+ };
409
+ return {
410
+ protocolVersion: this.protocolVersion,
411
+ appId: app.appId,
412
+ entry: this.appEntryUrl(app.appId),
413
+ state: scopedState,
414
+ transcript,
415
+ selection: this.state.selection,
416
+ desktop: this.state.desktop,
417
+ appServer,
418
+ findAnchor: (anchorId) => {
419
+ requirePermission(app, "transcriptRead");
420
+ return this.findAnchor(anchorId);
421
+ },
422
+ anchorIndex: (anchorId) => {
423
+ requirePermission(app, "transcriptRead");
424
+ return this.anchorIndex(anchorId);
425
+ },
426
+ anchorExists: (anchorId) => {
427
+ requirePermission(app, "transcriptRead");
428
+ return this.anchorExists(anchorId);
429
+ },
430
+ scrollToAnchor: (anchorId, options) => {
431
+ requirePermission(app, "transcriptNavigate");
432
+ return this.scrollToAnchor(anchorId, options);
433
+ },
434
+ highlightAnchor: (anchorId, options) => {
435
+ requirePermission(app, "transcriptNavigate");
436
+ return this.highlightAnchor(anchorId, options);
437
+ },
438
+ highlightRange: (range, options) => {
439
+ requirePermission(app, "transcriptNavigate");
440
+ return this.highlightRange(range, options);
441
+ },
442
+ acceptSelection: (selection, options) => this.acceptSelection(selection, options),
443
+ openOverlay: (overlay, options) => {
444
+ requirePermission(app, "overlayWrite");
445
+ return this.openOverlay({ ...overlay, appId: overlay?.appId || app.appId }, options);
446
+ },
447
+ closeOverlay: (overlayId, options) => {
448
+ requirePermission(app, "overlayWrite");
449
+ return this.closeOverlay(overlayId, options);
450
+ },
451
+ addComposerContext: (item, options) => {
452
+ requirePermission(app, "composerWrite");
453
+ return this.addComposerContext({ ...item, appId: item?.appId || app.appId }, options);
454
+ },
455
+ removeComposerContext: (itemId, options) => {
456
+ requirePermission(app, "composerWrite");
457
+ return this.removeComposerContext(itemId, options);
458
+ },
459
+ setComposerDraft: (draft, options) => {
460
+ requirePermission(app, "composerWrite");
461
+ return this.setComposerDraft(draft, options);
462
+ },
463
+ submitComposer: (draft, options) => {
464
+ requirePermission(app, "composerWrite");
465
+ return this.submitComposer(draft, options);
466
+ },
467
+ writeRuntimeFile: (file) => this.writeRuntimeFile(app, file),
468
+ attachRuntimeFiles: (files, options) => {
469
+ requirePermission(app, "composerWrite");
470
+ return this.attachRuntimeFiles(app, files, options);
471
+ },
472
+ requestStateBroadcast: () => this.touchAndBroadcast(),
473
+ httpError
474
+ };
475
+ }
476
+
477
+ async handleRequest(req, res) {
478
+ const url = new URL(req.url, "http://127.0.0.1");
479
+ this.applyCors(res);
480
+ if (req.method === "OPTIONS") {
481
+ res.writeHead(204).end();
482
+ return;
483
+ }
484
+ if (url.pathname.startsWith("/onewhack/") && !this.authorized(req)) {
485
+ this.fail(res, 401, "unauthorized", "missing or invalid OneWhack token");
486
+ return;
487
+ }
488
+
489
+ if (url.pathname === "/onewhack/manifest" && req.method === "GET") return this.ok(res, this.hostManifest());
490
+ if (url.pathname === "/onewhack/state" && req.method === "GET") return this.ok(res, this.publicState());
491
+ if (url.pathname === "/onewhack/events" && req.method === "GET") return this.handleEvents(req, res);
492
+ if (url.pathname === "/onewhack/apps" && req.method === "GET") return this.ok(res, { apps: this.appSummaries() });
493
+ if (url.pathname === "/onewhack/panel/open" && req.method === "POST") return this.openPanelEndpoint(req, res);
494
+ if (url.pathname === "/onewhack/transcript/scroll" && req.method === "POST") return this.scrollEndpoint(req, res);
495
+ if (url.pathname === "/onewhack/transcript/highlight" && req.method === "POST") return this.highlightEndpoint(req, res);
496
+ if (url.pathname === "/onewhack/transcript/highlight-range" && req.method === "POST") return this.highlightRangeEndpoint(req, res);
497
+ if (url.pathname === "/onewhack/selection" && req.method === "POST") return this.selectionEndpoint(req, res);
498
+ if (url.pathname === "/onewhack/overlay/open" && req.method === "POST") return this.overlayOpenEndpoint(req, res);
499
+ if (url.pathname === "/onewhack/overlay/close" && req.method === "POST") return this.overlayCloseEndpoint(req, res);
500
+ if (url.pathname === "/onewhack/composer/context" && req.method === "POST") return this.composerContextEndpoint(req, res);
501
+ if (url.pathname === "/onewhack/composer/context/remove" && req.method === "POST") return this.composerContextRemoveEndpoint(req, res);
502
+ if (url.pathname === "/onewhack/composer/draft" && req.method === "POST") return this.composerDraftEndpoint(req, res);
503
+ if (url.pathname === "/onewhack/composer/submit" && req.method === "POST") return this.composerSubmitEndpoint(req, res);
504
+
505
+ const appRoute = this.parseAppRoute(url.pathname);
506
+ if (appRoute && req.method === "GET" && appRoute.tail === "/manifest") return this.ok(res, this.appManifest(appRoute.appId));
507
+ if (appRoute && req.method === "GET" && appRoute.tail === "/state") return this.ok(res, this.appState(appRoute.appId));
508
+ if (appRoute && req.method === "POST" && appRoute.tail === "/actions") return this.actionEndpoint(appRoute.appId, req, res);
509
+ if (appRoute && req.method === "GET" && appRoute.tail.startsWith("/actions/")) return this.actionResultEndpoint(appRoute.appId, appRoute.tail, res);
510
+
511
+ return this.serveStatic(url, res);
512
+ }
513
+
514
+ appSummaries() {
515
+ return [...this.apps.values()].map((app) => {
516
+ const appState = this.appState(app.appId);
517
+ return {
518
+ appId: app.appId,
519
+ name: app.name || app.appId,
520
+ status: appState.status,
521
+ entry: this.appEntryUrl(app.appId)
522
+ };
523
+ });
524
+ }
525
+
526
+ parseAppRoute(pathname) {
527
+ if (!pathname.startsWith("/onewhack/apps/")) return null;
528
+ const parts = pathname.split("/");
529
+ const appId = decodeURIComponent(parts[3] || "");
530
+ if (!appId) return null;
531
+ this.requireApp(appId);
532
+ return { appId, tail: `/${parts.slice(4).join("/")}` };
533
+ }
534
+
535
+ async actionEndpoint(appId, req, res) {
536
+ const app = this.requireApp(appId);
537
+ const body = await readJsonObject(req, "action request body");
538
+ if (!isPlainObject(body.action)) throw httpError(400, "bad_request", "action object is required");
539
+ const result = await this.handleAction(app, body.action);
540
+ return this.ok(res, result);
541
+ }
542
+
543
+ actionResultEndpoint(appId, tail, res) {
544
+ this.requireApp(appId);
545
+ const actionId = decodeURIComponent(tail.slice("/actions/".length));
546
+ const result = this.actionResults.get(`${appId}:${actionId}`);
547
+ if (!result) return this.fail(res, 404, "not_found", "unknown action result");
548
+ return this.ok(res, result);
549
+ }
550
+
551
+ async handleAction(app, action) {
552
+ if (!isNonEmptyString(action.appId)) throw httpError(400, "bad_request", "action.appId is required");
553
+ if (action.appId !== app.appId) throw httpError(400, "bad_request", "action.appId must match route appId");
554
+ if (!isNonEmptyString(action.actionId)) throw httpError(400, "bad_request", "action.actionId is required");
555
+ if (!isNonEmptyString(action.type)) throw httpError(400, "bad_request", "action.type is required");
556
+ if (action.requestedAt !== undefined && !Number.isFinite(Date.parse(action.requestedAt))) {
557
+ throw httpError(400, "bad_request", "action.requestedAt must be an ISO timestamp");
558
+ }
559
+ const resultKey = `${action.appId}:${action.actionId}`;
560
+ if (this.actionResults.has(resultKey)) return this.actionResults.get(resultKey);
561
+ if (typeof app.handleAction !== "function") throw httpError(404, "not_found", `unknown action: ${action.type}`);
562
+
563
+ const result = await app.handleAction(action, this.appContext(app));
564
+ if (!result || typeof result.ok !== "boolean") {
565
+ throw httpError(500, "action_failed", `action handler returned an invalid result: ${action.type}`);
566
+ }
567
+ const normalized = {
568
+ actionId: action.actionId,
569
+ appId: app.appId,
570
+ ok: Boolean(result?.ok),
571
+ status: result?.status || (result?.ok ? "applied" : "failed"),
572
+ ...(result?.error ? { error: result.error } : {}),
573
+ ...(result?.data === undefined ? {} : { data: result.data }),
574
+ completedAt: result?.completedAt || new Date().toISOString()
575
+ };
576
+ this.actionResults.set(resultKey, normalized);
577
+ this.state.lastAction = normalized;
578
+ this.touchAndBroadcast("action", normalized);
579
+ return normalized;
580
+ }
581
+
582
+ async openPanelEndpoint(req, res) {
583
+ try {
584
+ const body = await readJsonObject(req, "panel open request body");
585
+ if (body.appId !== undefined) {
586
+ if (!isNonEmptyString(body.appId)) throw httpError(400, "bad_request", "appId must be a non-empty string");
587
+ this.setActivePanelApp(body.appId);
588
+ }
589
+ this.state.panel.status = "opening";
590
+ const result = await this.transcriptAdapter.openPanel?.(this);
591
+ if (result?.ok) {
592
+ this.state.panel.status = result.mode === "focused" ? "focused" : "open";
593
+ this.state.panel.lastOpenedAt = new Date().toISOString();
594
+ this.state.panel.error = null;
595
+ } else {
596
+ this.state.panel.status = "waiting";
597
+ this.state.panel.error = result?.error || "Open a Codex thread, then try opening the OneWhack panel again.";
598
+ }
599
+ this.touchAndBroadcast();
600
+ return this.ok(res, result || { ok: false, error: "panel unavailable" });
601
+ } catch (err) {
602
+ this.state.panel.status = "error";
603
+ this.state.panel.error = err.message;
604
+ this.touchAndBroadcast();
605
+ throw err;
606
+ }
607
+ }
608
+
609
+ async scrollEndpoint(req, res) {
610
+ const body = await readJsonObject(req, "scroll request body");
611
+ validateAnchorRequest(body);
612
+ if (body.behavior !== undefined && !["auto", "smooth"].includes(body.behavior)) {
613
+ throw httpError(400, "bad_request", "behavior must be auto or smooth");
614
+ }
615
+ if (body.block !== undefined && !["start", "center", "end", "nearest"].includes(body.block)) {
616
+ throw httpError(400, "bad_request", "block must be start, center, end, or nearest");
617
+ }
618
+ const result = await this.scrollToAnchor(body.anchorId, body);
619
+ if (!result?.ok) return this.fail(res, 404, "not_found", result?.error || "anchor not found", { anchorId: body.anchorId });
620
+ return this.ok(res, result);
621
+ }
622
+
623
+ async highlightEndpoint(req, res) {
624
+ const body = await readJsonObject(req, "highlight request body");
625
+ validateAnchorRequest(body);
626
+ if (body.durationMs !== undefined && (!Number.isFinite(Number(body.durationMs)) || Number(body.durationMs) < 0)) {
627
+ throw httpError(400, "bad_request", "durationMs must be a non-negative number");
628
+ }
629
+ const result = await this.highlightAnchor(body.anchorId, body);
630
+ if (!result?.ok) return this.fail(res, 404, "not_found", result?.error || "anchor not found", { anchorId: body.anchorId });
631
+ return this.ok(res, result);
632
+ }
633
+
634
+ async highlightRangeEndpoint(req, res) {
635
+ const body = await readJsonObject(req, "range highlight request body");
636
+ validateRangeRequest(body.range || body);
637
+ if (body.durationMs !== undefined && (!Number.isFinite(Number(body.durationMs)) || Number(body.durationMs) < 0)) {
638
+ throw httpError(400, "bad_request", "durationMs must be a non-negative number");
639
+ }
640
+ const result = await this.highlightRange(body.range || body, body);
641
+ if (!result?.ok) return this.fail(res, 404, "not_found", result?.error || "range not found", { anchorId: (body.range || body).anchorId });
642
+ return this.ok(res, result);
643
+ }
644
+
645
+ async selectionEndpoint(req, res) {
646
+ const body = await readJsonObject(req, "selection request body");
647
+ if (body.selection !== undefined && !isPlainObject(body.selection)) {
648
+ throw httpError(400, "bad_request", "selection must be an object");
649
+ }
650
+ if (body.selection?.source !== undefined && !["panel", "transcript", "adapter"].includes(body.selection.source)) {
651
+ throw httpError(400, "bad_request", "selection.source is invalid");
652
+ }
653
+ if (body.selection?.range !== undefined) validateRangeRequest({ ...body.selection.range, anchorId: body.selection.anchorId || body.selection.range.anchorId });
654
+ if (body.selection?.selectedAt !== undefined && !Number.isFinite(Date.parse(body.selection.selectedAt))) {
655
+ throw httpError(400, "bad_request", "selection.selectedAt must be an ISO timestamp");
656
+ }
657
+ return this.ok(res, this.setSelection(body.selection));
658
+ }
659
+
660
+ async overlayOpenEndpoint(req, res) {
661
+ const body = await readJsonObject(req, "overlay open request body");
662
+ const overlay = body.overlay || body;
663
+ return this.ok(res, this.openOverlay(overlay));
664
+ }
665
+
666
+ async overlayCloseEndpoint(req, res) {
667
+ const body = await readJsonObject(req, "overlay close request body");
668
+ if (body.overlayId !== undefined && !isNonEmptyString(body.overlayId)) {
669
+ throw httpError(400, "bad_request", "overlayId must be a non-empty string when present");
670
+ }
671
+ return this.ok(res, this.closeOverlay(body.overlayId));
672
+ }
673
+
674
+ async composerContextEndpoint(req, res) {
675
+ const body = await readJsonObject(req, "composer context request body");
676
+ return this.ok(res, this.addComposerContext(body.item || body));
677
+ }
678
+
679
+ async composerContextRemoveEndpoint(req, res) {
680
+ const body = await readJsonObject(req, "composer context remove request body");
681
+ if (!isNonEmptyString(body.itemId)) throw httpError(400, "bad_request", "itemId is required");
682
+ return this.ok(res, this.removeComposerContext(body.itemId));
683
+ }
684
+
685
+ async composerDraftEndpoint(req, res) {
686
+ const body = await readJsonObject(req, "composer draft request body");
687
+ return this.ok(res, await this.setComposerDraft(body.draft || body));
688
+ }
689
+
690
+ async composerSubmitEndpoint(req, res) {
691
+ const body = await readJsonObject(req, "composer submit request body");
692
+ return this.ok(res, await this.submitComposer(body.draft || body));
693
+ }
694
+
695
+ async scrollToAnchor(anchorId, options = {}) {
696
+ if (!this.anchorExists(anchorId)) return { ok: false, error: "anchor not found", anchorId };
697
+ const result = await this.transcriptAdapter.scrollToAnchor?.(anchorId, options, this);
698
+ return result || { ok: true, anchorId };
699
+ }
700
+
701
+ async highlightAnchor(anchorId, options = {}) {
702
+ if (!this.anchorExists(anchorId)) return { ok: false, error: "anchor not found", anchorId };
703
+ const result = await this.transcriptAdapter.highlightAnchor?.(anchorId, options, this);
704
+ return result || { ok: true, anchorId };
705
+ }
706
+
707
+ async highlightRange(range, options = {}) {
708
+ validateRangeRequest(range);
709
+ if (!this.anchorExists(range.anchorId)) return { ok: false, error: "anchor not found", anchorId: range.anchorId };
710
+ const normalized = normalizeRange(range);
711
+ const result = await this.transcriptAdapter.highlightRange?.(normalized, options, this);
712
+ if (result) return result;
713
+ const fallback = await this.highlightAnchor(normalized.anchorId, options);
714
+ return fallback?.ok ? { ...fallback, range: normalized } : fallback;
715
+ }
716
+
717
+ setSelection(selection) {
718
+ if (!selection?.anchorId && !selection?.entryId) return { stale: false, selection: this.state.selection };
719
+ if (!this.isCandidateSelectionNewer(selection, this.state.selection)) {
720
+ return { stale: true, selection: this.state.selection };
721
+ }
722
+ return { stale: false, selection: this.acceptSelection(selection) };
723
+ }
724
+
725
+ acceptSelection(selection, options = {}) {
726
+ this.touch();
727
+ this.state.selection = {
728
+ selectionId: selection.selectionId || `sel_${this.state.sequence}`,
729
+ sequence: this.state.sequence,
730
+ source: selection.source || "adapter",
731
+ appId: selection.appId || this.state.panel.activeAppId || undefined,
732
+ anchorId: selection.anchorId || null,
733
+ quote: selection.quote || selection.range?.text || null,
734
+ range: selection.range ? normalizeRange({ ...selection.range, anchorId: selection.anchorId || selection.range.anchorId }) : null,
735
+ rect: selection.rect ? normalizeRect(selection.rect) : null,
736
+ markerId: selection.markerId || null,
737
+ entryId: selection.entryId || (selection.anchorId ? `nav:${selection.anchorId}` : null),
738
+ appServer: selection.appServer || null,
739
+ correlationId: selection.correlationId || null,
740
+ selectedAt: selection.selectedAt || new Date().toISOString(),
741
+ acceptedAt: this.state.generatedAt
742
+ };
743
+ if (options.broadcast !== false) this.broadcast("state", this.publicState());
744
+ return this.state.selection;
745
+ }
746
+
747
+ openOverlay(overlay, options = {}) {
748
+ validateOverlay(overlay);
749
+ this.touch();
750
+ this.state.overlay = cleanObject({
751
+ overlayId: overlay.overlayId || `overlay_${this.state.sequence}`,
752
+ appId: overlay.appId,
753
+ kind: overlay.kind || "form",
754
+ title: overlay.title || null,
755
+ anchorId: overlay.anchorId || overlay.range?.anchorId || this.state.selection?.anchorId || null,
756
+ range: overlay.range ? normalizeRange(overlay.range) : this.state.selection?.range || null,
757
+ anchorRect: overlay.anchorRect ? normalizeRect(overlay.anchorRect) : this.state.selection?.rect || null,
758
+ fields: overlay.fields || [],
759
+ actions: overlay.actions || [],
760
+ openedAt: this.state.generatedAt
761
+ });
762
+ if (options.broadcast !== false) this.broadcast("state", this.publicState());
763
+ return { ok: true, overlay: this.state.overlay };
764
+ }
765
+
766
+ closeOverlay(overlayId, options = {}) {
767
+ if (overlayId && this.state.overlay?.overlayId !== overlayId) {
768
+ return { ok: false, error: "overlay not found", overlayId };
769
+ }
770
+ this.touch();
771
+ const closed = this.state.overlay;
772
+ this.state.overlay = null;
773
+ if (options.broadcast !== false) this.broadcast("state", this.publicState());
774
+ return { ok: true, overlay: closed };
775
+ }
776
+
777
+ writeRuntimeFile(app, file) {
778
+ if (!this.runDir) throw httpError(500, "runtime_unavailable", "runtime directory is unavailable");
779
+ if (!isPlainObject(file)) throw httpError(400, "bad_request", "runtime file must be an object");
780
+ const content = String(file.content || "");
781
+ const directory = safePathSegment(file.directory || "files");
782
+ const filename = safeFilename(file.filename || `file-${this.state.sequence}.txt`);
783
+ const root = resolve(this.runDir, "apps", safePathSegment(app.appId), directory);
784
+ const filePath = resolve(root, filename);
785
+ if (filePath !== root && !filePath.startsWith(`${root}/`)) {
786
+ throw httpError(400, "bad_request", "runtime file path escapes app directory");
787
+ }
788
+ mkdirSync(root, { recursive: true });
789
+ writeFileSync(filePath, content);
790
+ return cleanObject({
791
+ fileId: file.fileId || `${app.appId}:${directory}/${filename}`,
792
+ appId: app.appId,
793
+ path: filePath,
794
+ relativePath: relative(this.runDir, filePath),
795
+ mimeType: file.mimeType || mimeType(filePath),
796
+ bytes: Buffer.byteLength(content),
797
+ createdAt: this.state.generatedAt
798
+ });
799
+ }
800
+
801
+ async attachRuntimeFiles(app, files = [], options = {}) {
802
+ if (!this.runDir) throw httpError(500, "runtime_unavailable", "runtime directory is unavailable");
803
+ if (!Array.isArray(files)) throw httpError(400, "bad_request", "files must be an array");
804
+ const appRoot = resolve(this.runDir, "apps", safePathSegment(app.appId));
805
+ const normalized = files.map((file) => {
806
+ const path = resolve(typeof file === "string" ? file : file?.path || "");
807
+ if (!path || (path !== appRoot && !path.startsWith(`${appRoot}/`))) {
808
+ throw httpError(403, "forbidden", "apps may only attach their own runtime files");
809
+ }
810
+ if (!existsSync(path)) throw httpError(404, "not_found", `attachment file not found: ${path}`);
811
+ return cleanObject({
812
+ fileId: typeof file === "object" ? file.fileId : undefined,
813
+ appId: app.appId,
814
+ path,
815
+ relativePath: relative(this.runDir, path),
816
+ name: typeof file === "object" && file.name ? file.name : path.split("/").pop(),
817
+ mimeType: typeof file === "object" ? file.mimeType : mimeType(path),
818
+ body: readFileSync(path, "utf8"),
819
+ status: "queued"
820
+ });
821
+ });
822
+ const adapterResult = await this.transcriptAdapter.attachFiles?.(normalized, options, this);
823
+ if (!adapterResult || adapterResult.ok === false) {
824
+ return adapterResult || { ok: false, error: "file attachment unavailable" };
825
+ }
826
+ this.touch();
827
+ if (shouldTrackHostComposerAttachment(adapterResult)) {
828
+ const attachedAt = this.state.generatedAt;
829
+ this.state.composer.attachments = [
830
+ ...this.state.composer.attachments,
831
+ ...normalized.map((file) => ({ ...file, attachedAt }))
832
+ ];
833
+ }
834
+ if (options.broadcast !== false) this.broadcast("state", this.publicState());
835
+ return { ok: true, files: normalized, adapter: adapterResult };
836
+ }
837
+
838
+ addComposerContext(item, options = {}) {
839
+ validateComposerContextItem(item);
840
+ this.touch();
841
+ const normalized = cleanObject({
842
+ itemId: item.itemId || `ctx_${this.state.sequence}`,
843
+ appId: item.appId,
844
+ label: item.label,
845
+ body: item.body || item.quote || "",
846
+ anchorId: item.anchorId || item.range?.anchorId || null,
847
+ range: item.range ? normalizeRange(item.range) : null,
848
+ status: item.status || "queued",
849
+ createdAt: item.createdAt || this.state.generatedAt,
850
+ updatedAt: this.state.generatedAt
851
+ });
852
+ const index = this.state.composer.contextItems.findIndex((candidate) => candidate.itemId === normalized.itemId);
853
+ if (index >= 0) this.state.composer.contextItems[index] = normalized;
854
+ else this.state.composer.contextItems.push(normalized);
855
+ if (options.broadcast !== false) this.broadcast("state", this.publicState());
856
+ return { ok: true, item: normalized, contextItems: this.state.composer.contextItems };
857
+ }
858
+
859
+ removeComposerContext(itemId, options = {}) {
860
+ this.touch();
861
+ const before = this.state.composer.contextItems.length;
862
+ this.state.composer.contextItems = this.state.composer.contextItems.filter((item) => item.itemId !== itemId);
863
+ const removed = before !== this.state.composer.contextItems.length;
864
+ if (options.broadcast !== false) this.broadcast("state", this.publicState());
865
+ return { ok: true, removed, itemId, contextItems: this.state.composer.contextItems };
866
+ }
867
+
868
+ removeComposerAttachment(path, options = {}) {
869
+ this.touch();
870
+ const targetPath = resolve(String(path || ""));
871
+ const before = this.state.composer.attachments.length;
872
+ this.state.composer.attachments = this.state.composer.attachments.filter((item) => resolve(item.path) !== targetPath);
873
+ const removed = before !== this.state.composer.attachments.length;
874
+ if (options.broadcast !== false) this.broadcast("state", this.publicState());
875
+ return { ok: true, removed, path: targetPath, attachments: this.state.composer.attachments };
876
+ }
877
+
878
+ async setComposerDraft(draft, options = {}) {
879
+ validateComposerDraft(draft);
880
+ this.touch();
881
+ const mode = draft.mode || "replace";
882
+ const nextText = mergeComposerDraftText(this.state.composer.draft.text, draft.text || "", mode);
883
+ this.state.composer.draft = {
884
+ text: nextText,
885
+ mode,
886
+ contextItemIds: draft.contextItemIds || this.state.composer.contextItems.map((item) => item.itemId),
887
+ updatedAt: this.state.generatedAt
888
+ };
889
+ const adapterResult = await this.transcriptAdapter.setComposerDraft?.(this.state.composer.draft, options, this);
890
+ if (options.broadcast !== false) this.broadcast("state", this.publicState());
891
+ return adapterResult || { ok: true, draft: this.state.composer.draft };
892
+ }
893
+
894
+ async submitComposer(draft = {}, options = {}) {
895
+ const nextDraft = draft.text !== undefined ? await this.setComposerDraft(draft, { ...options, broadcast: false }) : { ok: true };
896
+ if (!nextDraft?.ok) return nextDraft;
897
+ this.touch();
898
+ const adapterResult = await this.transcriptAdapter.submitComposer?.(this.state.composer.draft, options, this);
899
+ if (adapterResult?.ok !== false) this.state.composer.lastSubmittedAt = this.state.generatedAt;
900
+ if (options.broadcast !== false) this.broadcast("state", this.publicState());
901
+ return adapterResult || { ok: false, error: "composer submit unavailable", draft: this.state.composer.draft };
902
+ }
903
+
904
+ selectionActions() {
905
+ return [...this.apps.values()].flatMap((app) => {
906
+ const state = this.appState(app.appId);
907
+ return (state.selectionActions || []).map((action) => ({
908
+ ...action,
909
+ appId: action.appId || app.appId
910
+ }));
911
+ });
912
+ }
913
+
914
+ isCandidateSelectionNewer(candidate, current) {
915
+ if (!candidate?.anchorId && !candidate?.entryId) return false;
916
+ const candidateTime = Date.parse(candidate.selectedAt || "");
917
+ const currentTime = Date.parse(current?.selectedAt || "");
918
+ return (Number.isFinite(candidateTime) ? candidateTime : 0) >= (Number.isFinite(currentTime) ? currentTime : 0);
919
+ }
920
+
921
+ setDesktopStatus(next) {
922
+ this.state.desktop = cleanObject({ ...this.state.desktop, ...next });
923
+ }
924
+
925
+ updateTranscript(transcript, options = {}) {
926
+ this.state.transcript = {
927
+ anchors: transcript.anchors || [],
928
+ visibleCount: transcript.visibleCount || 0,
929
+ annotationCount: transcript.annotationCount || 0,
930
+ scroll: transcript.scroll || null,
931
+ updatedAt: transcript.updatedAt || new Date().toISOString()
932
+ };
933
+ if (options.broadcast !== false) this.touchAndBroadcast();
934
+ }
935
+
936
+ findAnchor(anchorId) {
937
+ return this.state.transcript.anchors.find((anchor) => anchor.anchorId === anchorId) || null;
938
+ }
939
+
940
+ anchorExists(anchorId) {
941
+ return Boolean(this.findAnchor(anchorId));
942
+ }
943
+
944
+ anchorIndex(anchorId) {
945
+ return this.state.transcript.anchors.findIndex((anchor) => anchor.anchorId === anchorId);
946
+ }
947
+
948
+ requireApp(appId) {
949
+ const app = this.apps.get(appId);
950
+ if (!app) throw httpError(404, "not_found", "unknown app");
951
+ return app;
952
+ }
953
+
954
+ serveStatic(url, res) {
955
+ let pathname = url.pathname;
956
+ if (pathname === "/") pathname = `/apps/${this.state.panel.activeAppId || ""}/`;
957
+ if (!pathname.startsWith("/apps/")) {
958
+ res.writeHead(404).end("not found");
959
+ return;
960
+ }
961
+ const parts = pathname.split("/");
962
+ const appId = decodeURIComponent(parts[2] || "");
963
+ const app = this.apps.get(appId);
964
+ if (!app?.publicDir) {
965
+ res.writeHead(404).end("not found");
966
+ return;
967
+ }
968
+ let relativePath = parts.slice(3).join("/");
969
+ if (!relativePath) relativePath = "index.html";
970
+ const rootPath = resolve(app.publicDir);
971
+ const filePath = resolve(rootPath, `./${relativePath}`);
972
+ if ((filePath !== rootPath && !filePath.startsWith(`${rootPath}/`)) || !existsSync(filePath)) {
973
+ res.writeHead(404).end("not found");
974
+ return;
975
+ }
976
+ res.writeHead(200, {
977
+ "content-type": mimeType(filePath),
978
+ "cache-control": "no-store"
979
+ });
980
+ createReadStream(filePath).pipe(res);
981
+ }
982
+
983
+ handleEvents(req, res) {
984
+ res.writeHead(200, {
985
+ "content-type": "text/event-stream",
986
+ "cache-control": "no-store",
987
+ connection: "keep-alive"
988
+ });
989
+ this.sseClients.add(res);
990
+ res.write(`event: state\nid: ${this.state.sequence}\ndata: ${JSON.stringify(this.publicState())}\n\n`);
991
+ req.on("close", () => this.sseClients.delete(res));
992
+ }
993
+
994
+ touch() {
995
+ this.state.sequence += 1;
996
+ this.state.generatedAt = new Date().toISOString();
997
+ }
998
+
999
+ touchAndBroadcast(event = "state", data = this.publicState()) {
1000
+ this.touch();
1001
+ this.broadcast(event, typeof data === "function" ? data() : data);
1002
+ }
1003
+
1004
+ broadcast(event = "state", data = this.publicState()) {
1005
+ const payload = `event: ${event}\nid: ${this.state.sequence}\ndata: ${JSON.stringify(data)}\n\n`;
1006
+ for (const res of this.sseClients) res.write(payload);
1007
+ }
1008
+
1009
+ authorized(req) {
1010
+ if (this.localDevInsecure) return true;
1011
+ return req.headers.authorization === `Bearer ${this.token}`;
1012
+ }
1013
+
1014
+ ok(res, data, status = 200) {
1015
+ res.writeHead(status, { "content-type": "application/json", "cache-control": "no-store" });
1016
+ res.end(JSON.stringify({ ok: true, data }));
1017
+ }
1018
+
1019
+ fail(res, status, code, message, detail) {
1020
+ res.writeHead(status, { "content-type": "application/json", "cache-control": "no-store" });
1021
+ res.end(JSON.stringify({ ok: false, error: { code, message, ...(detail === undefined ? {} : { detail }) } }));
1022
+ }
1023
+
1024
+ applyCors(res) {
1025
+ res.setHeader("access-control-allow-origin", "*");
1026
+ res.setHeader("access-control-allow-methods", "GET,POST,OPTIONS");
1027
+ res.setHeader("access-control-allow-headers", "authorization,content-type");
1028
+ }
1029
+ }
1030
+
1031
+ async function readJsonObject(req, label) {
1032
+ const chunks = [];
1033
+ let size = 0;
1034
+ for await (const chunk of req) {
1035
+ size += chunk.length;
1036
+ if (size > 1024 * 1024) throw httpError(400, "bad_request", "request body exceeds 1 MiB");
1037
+ chunks.push(chunk);
1038
+ }
1039
+ let parsed;
1040
+ try {
1041
+ parsed = JSON.parse(Buffer.concat(chunks).toString("utf8") || "{}");
1042
+ } catch {
1043
+ throw httpError(400, "bad_request", "invalid JSON request body");
1044
+ }
1045
+ if (!isPlainObject(parsed)) throw httpError(400, "bad_request", `${label} must be a JSON object`);
1046
+ return parsed;
1047
+ }
1048
+
1049
+ function validateAnchorRequest(body) {
1050
+ if (!isNonEmptyString(body.anchorId)) throw httpError(400, "bad_request", "anchorId is required");
1051
+ }
1052
+
1053
+ function validateRangeRequest(range) {
1054
+ if (!isPlainObject(range)) throw httpError(400, "bad_request", "range must be an object");
1055
+ if (!isNonEmptyString(range.anchorId)) throw httpError(400, "bad_request", "range.anchorId is required");
1056
+ if (range.text !== undefined && typeof range.text !== "string") throw httpError(400, "bad_request", "range.text must be a string");
1057
+ if (range.quote !== undefined && typeof range.quote !== "string") throw httpError(400, "bad_request", "range.quote must be a string");
1058
+ for (const key of ["prefix", "suffix"]) {
1059
+ if (range[key] !== undefined && typeof range[key] !== "string") throw httpError(400, "bad_request", `range.${key} must be a string`);
1060
+ }
1061
+ for (const key of ["startOffset", "endOffset"]) {
1062
+ if (range[key] !== undefined && (!Number.isInteger(Number(range[key])) || Number(range[key]) < 0)) {
1063
+ throw httpError(400, "bad_request", `range.${key} must be a non-negative integer`);
1064
+ }
1065
+ }
1066
+ }
1067
+
1068
+ function normalizeRange(range) {
1069
+ validateRangeRequest(range);
1070
+ return cleanObject({
1071
+ anchorId: range.anchorId,
1072
+ text: range.text || range.quote || "",
1073
+ quote: range.quote || range.text || "",
1074
+ prefix: range.prefix || "",
1075
+ suffix: range.suffix || "",
1076
+ startOffset: range.startOffset === undefined ? undefined : Number(range.startOffset),
1077
+ endOffset: range.endOffset === undefined ? undefined : Number(range.endOffset),
1078
+ fingerprint: range.fingerprint || range.text || range.quote || ""
1079
+ });
1080
+ }
1081
+
1082
+ function normalizeRect(rect) {
1083
+ if (!isPlainObject(rect)) return null;
1084
+ const normalized = {};
1085
+ for (const key of ["left", "top", "right", "bottom", "width", "height"]) {
1086
+ if (rect[key] !== undefined && Number.isFinite(Number(rect[key]))) normalized[key] = Number(rect[key]);
1087
+ }
1088
+ return Object.keys(normalized).length ? normalized : null;
1089
+ }
1090
+
1091
+ function validateOverlay(overlay) {
1092
+ if (!isPlainObject(overlay)) throw httpError(400, "bad_request", "overlay must be an object");
1093
+ if (!isNonEmptyString(overlay.appId)) throw httpError(400, "bad_request", "overlay.appId is required");
1094
+ if (overlay.overlayId !== undefined && !isNonEmptyString(overlay.overlayId)) throw httpError(400, "bad_request", "overlay.overlayId must be a non-empty string");
1095
+ if (overlay.kind !== undefined && !["form", "menu", "notice"].includes(overlay.kind)) throw httpError(400, "bad_request", "overlay.kind is invalid");
1096
+ if (overlay.anchorId !== undefined && !isNonEmptyString(overlay.anchorId)) throw httpError(400, "bad_request", "overlay.anchorId must be a non-empty string");
1097
+ if (overlay.range !== undefined) validateRangeRequest(overlay.range);
1098
+ if (overlay.fields !== undefined && !Array.isArray(overlay.fields)) throw httpError(400, "bad_request", "overlay.fields must be an array");
1099
+ if (overlay.actions !== undefined && !Array.isArray(overlay.actions)) throw httpError(400, "bad_request", "overlay.actions must be an array");
1100
+ }
1101
+
1102
+ function validateComposerContextItem(item) {
1103
+ if (!isPlainObject(item)) throw httpError(400, "bad_request", "composer context item must be an object");
1104
+ if (!isNonEmptyString(item.appId)) throw httpError(400, "bad_request", "item.appId is required");
1105
+ if (item.itemId !== undefined && !isNonEmptyString(item.itemId)) throw httpError(400, "bad_request", "item.itemId must be a non-empty string");
1106
+ if (!isNonEmptyString(item.label)) throw httpError(400, "bad_request", "item.label is required");
1107
+ if (item.body !== undefined && typeof item.body !== "string") throw httpError(400, "bad_request", "item.body must be a string");
1108
+ if (item.quote !== undefined && typeof item.quote !== "string") throw httpError(400, "bad_request", "item.quote must be a string");
1109
+ if (item.anchorId !== undefined && !isNonEmptyString(item.anchorId)) throw httpError(400, "bad_request", "item.anchorId must be a non-empty string");
1110
+ if (item.range !== undefined) validateRangeRequest(item.range);
1111
+ if (item.status !== undefined && !["queued", "sent", "resolved"].includes(item.status)) throw httpError(400, "bad_request", "item.status is invalid");
1112
+ }
1113
+
1114
+ function validateComposerDraft(draft) {
1115
+ if (!isPlainObject(draft)) throw httpError(400, "bad_request", "composer draft must be an object");
1116
+ if (draft.text !== undefined && typeof draft.text !== "string") throw httpError(400, "bad_request", "draft.text must be a string");
1117
+ if (draft.mode !== undefined && !["replace", "append", "prepend"].includes(draft.mode)) throw httpError(400, "bad_request", "draft.mode is invalid");
1118
+ if (draft.contextItemIds !== undefined && (!Array.isArray(draft.contextItemIds) || draft.contextItemIds.some((id) => !isNonEmptyString(id)))) {
1119
+ throw httpError(400, "bad_request", "draft.contextItemIds must be an array of non-empty strings");
1120
+ }
1121
+ }
1122
+
1123
+ function mergeComposerDraftText(current, next, mode) {
1124
+ if (mode === "append") return current ? `${current}\n${next}` : next;
1125
+ if (mode === "prepend") return current ? `${next}\n${current}` : next;
1126
+ return next;
1127
+ }
1128
+
1129
+ function shouldTrackHostComposerAttachment(adapterResult) {
1130
+ return adapterResult?.mode === "mock" || adapterResult?.trackHostComposerAttachment === true;
1131
+ }
1132
+
1133
+ export function createSessionToken() {
1134
+ return randomBytes(32).toString("base64url");
1135
+ }
1136
+
1137
+ function requirePermission(app, permission) {
1138
+ if (app.permissions?.[permission] === false) {
1139
+ throw httpError(403, "forbidden", `${app.appId} lacks ${permission}`);
1140
+ }
1141
+ }
1142
+
1143
+ function isNonEmptyString(value) {
1144
+ return typeof value === "string" && value.trim().length > 0;
1145
+ }
1146
+
1147
+ function isPlainObject(value) {
1148
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
1149
+ }
1150
+
1151
+ function safePathSegment(value) {
1152
+ return String(value || "files")
1153
+ .replace(/[^a-zA-Z0-9._-]+/g, "-")
1154
+ .replace(/^-+|-+$/g, "")
1155
+ .slice(0, 80) || "files";
1156
+ }
1157
+
1158
+ function safeFilename(value) {
1159
+ const filename = safePathSegment(value);
1160
+ return filename.includes(".") ? filename : `${filename}.txt`;
1161
+ }
1162
+
1163
+ function mimeType(filePath) {
1164
+ switch (extname(filePath)) {
1165
+ case ".html": return "text/html; charset=utf-8";
1166
+ case ".css": return "text/css; charset=utf-8";
1167
+ case ".js": return "text/javascript; charset=utf-8";
1168
+ case ".json": return "application/json; charset=utf-8";
1169
+ case ".md": return "text/markdown; charset=utf-8";
1170
+ case ".txt": return "text/plain; charset=utf-8";
1171
+ default: return "application/octet-stream";
1172
+ }
1173
+ }
1174
+
1175
+ function cleanObject(value) {
1176
+ return Object.fromEntries(Object.entries(value).filter(([, entry]) => entry !== undefined));
1177
+ }