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.
- package/DEVELOPERS.md +63 -0
- package/LICENSE +21 -0
- package/README.md +49 -0
- package/apps/README.md +11 -0
- package/apps/sticky-notes/README.md +11 -0
- package/apps/sticky-notes/onewhack.manifest.json +74 -0
- package/apps/sticky-notes/package.json +14 -0
- package/apps/sticky-notes/public/app.js +117 -0
- package/apps/sticky-notes/public/index.html +23 -0
- package/apps/sticky-notes/public/styles.css +84 -0
- package/apps/sticky-notes/src/sticky-notes-app.js +413 -0
- package/apps/vim-nav/README.md +126 -0
- package/apps/vim-nav/onewhack.manifest.json +69 -0
- package/apps/vim-nav/package.json +16 -0
- package/apps/vim-nav/public/app.js +276 -0
- package/apps/vim-nav/public/index.html +53 -0
- package/apps/vim-nav/public/styles.css +211 -0
- package/apps/vim-nav/server.mjs +10 -0
- package/apps/vim-nav/src/vim-nav-app.js +221 -0
- package/assets/onewhack.jpg +0 -0
- package/cli.mjs +91 -0
- package/docs/app-lifecycle.md +63 -0
- package/docs/app-manifest.md +130 -0
- package/docs/author-guide.md +126 -0
- package/docs/launcher-profiles.md +50 -0
- package/docs/protocol.md +1935 -0
- package/host/README.md +18 -0
- package/host/src/app-registry.js +315 -0
- package/host/src/codex-desktop-cdp-adapter.js +1826 -0
- package/host/src/codex-desktop-renderer-bridge.js +3536 -0
- package/host/src/index.js +1177 -0
- package/launch/profiles.mjs +93 -0
- package/launch/runtime-paths.mjs +21 -0
- package/package.json +66 -0
- package/scripts/release-npm.mjs +202 -0
- package/server.mjs +58 -0
|
@@ -0,0 +1,1826 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import net from "node:net";
|
|
4
|
+
import { basename, join, resolve } from "node:path";
|
|
5
|
+
|
|
6
|
+
const DEFAULT_CODEX_APP = "/Applications/Codex.app/Contents/MacOS/Codex";
|
|
7
|
+
const DEFAULT_CODEX_CLI = "/Applications/Codex.app/Contents/Resources/codex";
|
|
8
|
+
|
|
9
|
+
export function createCodexDesktopCdpAdapter(options = {}) {
|
|
10
|
+
return new CodexDesktopCdpAdapter(options);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function normalizeRendererBridges(options = {}) {
|
|
14
|
+
const bridges = options.rendererBridges?.length
|
|
15
|
+
? options.rendererBridges
|
|
16
|
+
: [{
|
|
17
|
+
appId: options.appId || null,
|
|
18
|
+
injectedScriptPath: options.injectedScriptPath,
|
|
19
|
+
openPanelMethod: options.openPanelMethod || "openPanel",
|
|
20
|
+
scrollMethod: options.scrollMethod || "scrollToAnchor",
|
|
21
|
+
highlightMethod: options.highlightMethod || "highlightAnchor",
|
|
22
|
+
primary: true
|
|
23
|
+
}];
|
|
24
|
+
const hasPrimaryBridge = bridges.some((candidate) => candidate.primary);
|
|
25
|
+
return bridges.map((bridge, index) => ({
|
|
26
|
+
appId: bridge.appId || null,
|
|
27
|
+
injectedScriptPath: bridge.injectedScriptPath,
|
|
28
|
+
openPanelMethod: bridge.openPanelMethod || "openPanel",
|
|
29
|
+
scrollMethod: bridge.scrollMethod || "scrollToAnchor",
|
|
30
|
+
highlightMethod: bridge.highlightMethod || "highlightAnchor",
|
|
31
|
+
primary: bridge.primary === true || (!hasPrimaryBridge && index === 0),
|
|
32
|
+
injectedSource: null
|
|
33
|
+
}));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function normalizeProviders(options = {}, rendererBridges = []) {
|
|
37
|
+
const fallbackAppId = rendererBridges.find((bridge) => bridge.primary)?.appId ||
|
|
38
|
+
rendererBridges[0]?.appId ||
|
|
39
|
+
options.appId ||
|
|
40
|
+
null;
|
|
41
|
+
const providers = options.providers || {};
|
|
42
|
+
return {
|
|
43
|
+
transcriptSnapshot: providers.transcriptSnapshot || fallbackAppId,
|
|
44
|
+
transcriptOrder: providers.transcriptOrder || providers.transcriptSnapshot || fallbackAppId,
|
|
45
|
+
transcriptNavigation: providers.transcriptNavigation || providers.transcriptSnapshot || fallbackAppId,
|
|
46
|
+
transcriptHighlight: providers.transcriptHighlight || providers.transcriptNavigation || providers.transcriptSnapshot || fallbackAppId,
|
|
47
|
+
transcriptTextSelection: providers.transcriptTextSelection || providers.transcriptSnapshot || fallbackAppId,
|
|
48
|
+
composerDraft: providers.composerDraft || fallbackAppId,
|
|
49
|
+
composerContext: providers.composerContext || fallbackAppId
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function userPanelError(error) {
|
|
54
|
+
if (/native Browser panel controls not found|panel app did not load|panel unavailable/i.test(String(error || ""))) {
|
|
55
|
+
return "Waiting for Codex Desktop to expose the Browser side panel. Open a Codex thread, then OneWhack will load the selected app automatically.";
|
|
56
|
+
}
|
|
57
|
+
return error || "Waiting for Codex Desktop to expose the Browser side panel.";
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
class CodexDesktopCdpAdapter {
|
|
61
|
+
constructor(options = {}) {
|
|
62
|
+
this.name = "codex-desktop-cdp";
|
|
63
|
+
this.cdp = true;
|
|
64
|
+
this.rendererInjection = true;
|
|
65
|
+
this.rendererFetchToLoopback = false;
|
|
66
|
+
this.codexApp = options.codexApp || DEFAULT_CODEX_APP;
|
|
67
|
+
this.codexCli = options.codexCli || DEFAULT_CODEX_CLI;
|
|
68
|
+
this.runDir = options.runDir ? resolve(options.runDir) : null;
|
|
69
|
+
this.profileDir = options.profileDir || (this.runDir ? join(this.runDir, "codex-profile") : null);
|
|
70
|
+
this.rendererBridges = normalizeRendererBridges(options);
|
|
71
|
+
this.providers = normalizeProviders(options, this.rendererBridges);
|
|
72
|
+
this.snapshotToTranscript = options.snapshotToTranscript || defaultSnapshotToTranscript;
|
|
73
|
+
this.appServerOrder = options.appServerOrder !== false;
|
|
74
|
+
this.pollIntervalMs = options.pollIntervalMs || 700;
|
|
75
|
+
this.autoOpenPanel = options.autoOpenPanel !== false;
|
|
76
|
+
this.child = null;
|
|
77
|
+
this.host = null;
|
|
78
|
+
this.cdpPort = null;
|
|
79
|
+
this.browser = null;
|
|
80
|
+
this.sessionId = null;
|
|
81
|
+
this.pollTimer = null;
|
|
82
|
+
this.appServerOrderPromise = null;
|
|
83
|
+
this.lastAppServerOrderAt = 0;
|
|
84
|
+
this.lastPanelSelectionId = null;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async start(host) {
|
|
88
|
+
this.host = host;
|
|
89
|
+
if (!this.rendererBridges.length) throw new Error("at least one renderer bridge is required");
|
|
90
|
+
for (const bridge of this.rendererBridges) {
|
|
91
|
+
if (!bridge.injectedScriptPath) throw new Error(`injectedScriptPath is required for ${bridge.appId || "renderer bridge"}`);
|
|
92
|
+
bridge.injectedSource = readFileSync(bridge.injectedScriptPath, "utf8");
|
|
93
|
+
}
|
|
94
|
+
if (this.runDir) mkdirSync(this.runDir, { recursive: true });
|
|
95
|
+
if (this.profileDir) {
|
|
96
|
+
rmSync(this.profileDir, { recursive: true, force: true });
|
|
97
|
+
mkdirSync(this.profileDir, { recursive: true });
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
this.cdpPort = await freePort();
|
|
101
|
+
this.child = spawn(this.codexApp, [
|
|
102
|
+
"--remote-debugging-address=127.0.0.1",
|
|
103
|
+
`--remote-debugging-port=${this.cdpPort}`,
|
|
104
|
+
...(this.profileDir ? [`--user-data-dir=${this.profileDir}`] : [])
|
|
105
|
+
], {
|
|
106
|
+
env: {
|
|
107
|
+
...process.env,
|
|
108
|
+
...(this.profileDir ? { CODEX_ELECTRON_USER_DATA_PATH: this.profileDir } : {})
|
|
109
|
+
},
|
|
110
|
+
stdio: ["ignore", "pipe", "pipe"]
|
|
111
|
+
});
|
|
112
|
+
this.child.stdout.on("data", (chunk) => this.appendLog(chunk));
|
|
113
|
+
this.child.stderr.on("data", (chunk) => this.appendLog(chunk));
|
|
114
|
+
|
|
115
|
+
host.setDesktopStatus({
|
|
116
|
+
cdpStatus: "waiting-for-renderer",
|
|
117
|
+
cdpPort: this.cdpPort,
|
|
118
|
+
desktopPid: this.child.pid,
|
|
119
|
+
error: null
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
const version = await pollJson(`http://127.0.0.1:${this.cdpPort}/json/version`, 15000);
|
|
123
|
+
if (!version?.webSocketDebuggerUrl) throw new Error("CDP browser websocket did not become available");
|
|
124
|
+
this.browser = await CdpConnection.connect(version.webSocketDebuggerUrl);
|
|
125
|
+
const target = await this.discoverRendererTarget();
|
|
126
|
+
this.sessionId = await this.attachToTarget(target.targetId);
|
|
127
|
+
host.setDesktopStatus({
|
|
128
|
+
cdpStatus: "connected",
|
|
129
|
+
target: summarizeTarget(target),
|
|
130
|
+
error: null
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
await this.inject();
|
|
134
|
+
const firstSnapshot = await this.refreshSnapshot();
|
|
135
|
+
if (this.appServerOrder && firstSnapshot?.anchors?.length) {
|
|
136
|
+
await this.applyAppServerTranscriptOrder(firstSnapshot).catch((err) => {
|
|
137
|
+
host.state.appServer = {
|
|
138
|
+
status: "error",
|
|
139
|
+
pid: null,
|
|
140
|
+
version: null,
|
|
141
|
+
error: err.message
|
|
142
|
+
};
|
|
143
|
+
host.touchAndBroadcast();
|
|
144
|
+
});
|
|
145
|
+
await this.refreshSnapshot();
|
|
146
|
+
}
|
|
147
|
+
if (this.autoOpenPanel) {
|
|
148
|
+
const panelResult = await this.openPanel();
|
|
149
|
+
this.applyPanelOpenResult(panelResult);
|
|
150
|
+
}
|
|
151
|
+
this.pollTimer = setInterval(() => {
|
|
152
|
+
this.refreshSnapshot().catch((err) => {
|
|
153
|
+
host.setDesktopStatus({ cdpStatus: "disconnected", error: err.message });
|
|
154
|
+
host.touchAndBroadcast();
|
|
155
|
+
});
|
|
156
|
+
}, this.pollIntervalMs);
|
|
157
|
+
this.pollTimer.unref();
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
async stop() {
|
|
161
|
+
if (this.pollTimer) clearInterval(this.pollTimer);
|
|
162
|
+
this.pollTimer = null;
|
|
163
|
+
if (this.browser) {
|
|
164
|
+
try {
|
|
165
|
+
if (this.sessionId) {
|
|
166
|
+
await Promise.race([
|
|
167
|
+
this.browser.call("Target.detachFromTarget", { sessionId: this.sessionId }),
|
|
168
|
+
wait(1000)
|
|
169
|
+
]);
|
|
170
|
+
}
|
|
171
|
+
} catch {}
|
|
172
|
+
this.browser.close();
|
|
173
|
+
}
|
|
174
|
+
this.browser = null;
|
|
175
|
+
this.sessionId = null;
|
|
176
|
+
if (this.child && this.child.exitCode === null) {
|
|
177
|
+
this.child.kill("SIGTERM");
|
|
178
|
+
await wait(800);
|
|
179
|
+
if (this.child.exitCode === null) this.child.kill("SIGKILL");
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
async openPanel() {
|
|
184
|
+
const bridge = this.bridgeForApp(this.host.state.panel.activeAppId) || this.primaryBridge();
|
|
185
|
+
const bridgeResult = await this.evalBridge(`${bridge.openPanelMethod}()`, bridge).catch((err) => ({
|
|
186
|
+
ok: false,
|
|
187
|
+
error: err.message
|
|
188
|
+
}));
|
|
189
|
+
const loaded = await this.ensurePanelAppLoaded().catch((err) => ({
|
|
190
|
+
ok: false,
|
|
191
|
+
error: err.message
|
|
192
|
+
}));
|
|
193
|
+
return loaded?.ok ? loaded : bridgeResult;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
applyPanelOpenResult(result) {
|
|
197
|
+
if (result?.ok) {
|
|
198
|
+
this.host.state.panel.status = result.mode === "focused" ? "focused" : "open";
|
|
199
|
+
this.host.state.panel.lastOpenedAt = new Date().toISOString();
|
|
200
|
+
this.host.state.panel.error = null;
|
|
201
|
+
} else {
|
|
202
|
+
this.host.state.panel.status = "waiting";
|
|
203
|
+
this.host.state.panel.error = userPanelError(result?.error);
|
|
204
|
+
}
|
|
205
|
+
this.host.touchAndBroadcast();
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
async scrollToAnchor(anchorId, options = {}) {
|
|
209
|
+
const bridge = this.bridgeForProvider("transcriptNavigation");
|
|
210
|
+
return this.evalBridge(`${bridge.scrollMethod}(${JSON.stringify(anchorId)}, ${JSON.stringify(options)})`, bridge);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
async highlightAnchor(anchorId, options = {}) {
|
|
214
|
+
const bridge = this.bridgeForProvider("transcriptHighlight");
|
|
215
|
+
return this.evalBridge(`${bridge.highlightMethod}(${JSON.stringify(anchorId)}, ${JSON.stringify(options)})`, bridge);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
async highlightRange(range, options = {}) {
|
|
219
|
+
const bridge = this.bridgeForProvider("transcriptHighlight");
|
|
220
|
+
const appId = this.bridgeAppId(bridge);
|
|
221
|
+
return this.evaluate(`(() => {
|
|
222
|
+
const runtime = window.__oneWhackRuntime;
|
|
223
|
+
const bridge = runtime?.getBridge?.(${JSON.stringify(appId)});
|
|
224
|
+
if (bridge?.highlightRange) return bridge.highlightRange(${JSON.stringify(range)}, ${JSON.stringify(options)});
|
|
225
|
+
if (runtime?.highlightRange) return runtime.highlightRange(${JSON.stringify(range)}, ${JSON.stringify(options)});
|
|
226
|
+
if (bridge?.${bridge.highlightMethod}) return bridge.${bridge.highlightMethod}(${JSON.stringify(range.anchorId)}, ${JSON.stringify(options)});
|
|
227
|
+
return { ok: false, error: "range highlight unavailable" };
|
|
228
|
+
})()`);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
async setComposerDraft(draft, options = {}) {
|
|
232
|
+
return this.evaluate(`(() => {
|
|
233
|
+
const runtime = window.__oneWhackRuntime;
|
|
234
|
+
if (!runtime?.setComposerDraft) return { ok: false, error: "composer draft unavailable" };
|
|
235
|
+
return runtime.setComposerDraft(${JSON.stringify(draft)}, ${JSON.stringify(options)});
|
|
236
|
+
})()`);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
async submitComposer(draft, options = {}) {
|
|
240
|
+
return this.evaluate(`(() => {
|
|
241
|
+
const runtime = window.__oneWhackRuntime;
|
|
242
|
+
if (!runtime?.submitComposer) return { ok: false, error: "composer submit unavailable" };
|
|
243
|
+
return runtime.submitComposer(${JSON.stringify(draft)}, ${JSON.stringify(options)});
|
|
244
|
+
})()`);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
async attachFiles(files = [], options = {}) {
|
|
248
|
+
const normalized = files.map((file) => {
|
|
249
|
+
const filePath = resolve(typeof file === "string" ? file : file?.path || "");
|
|
250
|
+
return {
|
|
251
|
+
...(typeof file === "object" && file ? file : {}),
|
|
252
|
+
path: filePath,
|
|
253
|
+
name: typeof file === "object" && file?.name ? file.name : basename(filePath)
|
|
254
|
+
};
|
|
255
|
+
}).filter((file) => file.path);
|
|
256
|
+
const paths = normalized.map((file) => file.path);
|
|
257
|
+
if (!normalized.length) return { ok: false, error: "no files to attach" };
|
|
258
|
+
for (const filePath of paths) {
|
|
259
|
+
if (!existsSync(filePath)) return { ok: false, error: `attachment file does not exist: ${filePath}` };
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const nativeResult = await this.attachFilesThroughNativeComposer(normalized, options).catch((err) => ({
|
|
263
|
+
ok: false,
|
|
264
|
+
error: err.message,
|
|
265
|
+
mode: "codex-native-composer"
|
|
266
|
+
}));
|
|
267
|
+
if (nativeResult?.ok || options.nativeOnly) return nativeResult;
|
|
268
|
+
|
|
269
|
+
const appServerResult = await this.attachFilesThroughAppServer(normalized, options).catch((err) => ({
|
|
270
|
+
ok: false,
|
|
271
|
+
error: err.message,
|
|
272
|
+
mode: "app-server-inject-items",
|
|
273
|
+
nativeComposerError: nativeResult
|
|
274
|
+
}));
|
|
275
|
+
if (appServerResult?.ok || options.appServerOnly) return appServerResult;
|
|
276
|
+
|
|
277
|
+
if (options.tryNativeComposerAttachment) {
|
|
278
|
+
const dropResult = await this.attachFilesThroughComposerDrop(normalized, options).catch((err) => ({
|
|
279
|
+
ok: false,
|
|
280
|
+
error: err.message,
|
|
281
|
+
mode: "composer-drop"
|
|
282
|
+
}));
|
|
283
|
+
if (dropResult?.ok) return dropResult;
|
|
284
|
+
|
|
285
|
+
const composerResult = await this.attachFilesThroughComposerFilePicker(normalized, options, dropResult).catch((err) => ({
|
|
286
|
+
ok: false,
|
|
287
|
+
error: err.message,
|
|
288
|
+
mode: "composer-file-picker"
|
|
289
|
+
}));
|
|
290
|
+
if (composerResult?.ok) return composerResult;
|
|
291
|
+
return { ...appServerResult, diagnostic: { ...(appServerResult?.diagnostic || {}), nativeComposerError: nativeResult, composerDropError: dropResult, composerFilePickerError: composerResult } };
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
return { ...appServerResult, diagnostic: { ...(appServerResult?.diagnostic || {}), nativeComposerError: nativeResult } };
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
async attachFilesThroughNativeComposer(files = [], options = {}) {
|
|
298
|
+
const attached = [];
|
|
299
|
+
for (const file of files) {
|
|
300
|
+
const result = await this.evaluate(nativeAddContextFileExpression({
|
|
301
|
+
label: file.name || basename(file.path),
|
|
302
|
+
path: file.path,
|
|
303
|
+
fsPath: file.path
|
|
304
|
+
}));
|
|
305
|
+
if (!result?.ok) {
|
|
306
|
+
return {
|
|
307
|
+
ok: false,
|
|
308
|
+
mode: "codex-native-composer",
|
|
309
|
+
files: files.map((candidate) => candidate.path),
|
|
310
|
+
attached,
|
|
311
|
+
diagnostic: result
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
attached.push(result.file);
|
|
315
|
+
await wait(80);
|
|
316
|
+
}
|
|
317
|
+
const paths = files.map((file) => file.path);
|
|
318
|
+
const diagnostic = await waitForEvaluate(this, nativeAttachmentDiagnosticExpression(paths), options.timeoutMs || 8000);
|
|
319
|
+
if (!diagnostic?.ok) {
|
|
320
|
+
return {
|
|
321
|
+
ok: false,
|
|
322
|
+
mode: "codex-native-composer",
|
|
323
|
+
files: paths,
|
|
324
|
+
attached,
|
|
325
|
+
diagnostic
|
|
326
|
+
};
|
|
327
|
+
}
|
|
328
|
+
return {
|
|
329
|
+
ok: true,
|
|
330
|
+
mode: "codex-native-composer",
|
|
331
|
+
files: paths,
|
|
332
|
+
attached,
|
|
333
|
+
diagnostic
|
|
334
|
+
};
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
async attachFilesThroughAppServer(files = [], options = {}) {
|
|
338
|
+
const client = await AppServerClient.start(this.codexCli, { cwd: process.cwd() });
|
|
339
|
+
const diagnostics = {
|
|
340
|
+
composerDropError: options.composerDropError || null,
|
|
341
|
+
composerFilePickerError: options.composerFilePickerError || null
|
|
342
|
+
};
|
|
343
|
+
try {
|
|
344
|
+
const init = await client.initialize();
|
|
345
|
+
const thread = await this.resolveAppServerThreadForCurrentDesktop(client, diagnostics);
|
|
346
|
+
if (!thread?.threadId) {
|
|
347
|
+
return { ok: false, error: "app-server active thread is unknown", mode: "app-server-inject-items", diagnostic: diagnostics };
|
|
348
|
+
}
|
|
349
|
+
const items = buildAttachmentResponseItems(files, {
|
|
350
|
+
appName: options.appName || "OneWhack",
|
|
351
|
+
createdAt: new Date().toISOString()
|
|
352
|
+
});
|
|
353
|
+
const response = await client.threadInjectItems(thread.threadId, items);
|
|
354
|
+
return {
|
|
355
|
+
ok: true,
|
|
356
|
+
mode: "app-server-inject-items",
|
|
357
|
+
threadId: thread.threadId,
|
|
358
|
+
files: files.map((file) => file.path),
|
|
359
|
+
itemCount: items.length,
|
|
360
|
+
version: init?.userAgent || null,
|
|
361
|
+
diagnostic: diagnostics,
|
|
362
|
+
response
|
|
363
|
+
};
|
|
364
|
+
} finally {
|
|
365
|
+
await client.stop();
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
async resolveAppServerThreadForCurrentDesktop(client, diagnostics = {}) {
|
|
370
|
+
const cachedThreadId = this.host?.state?.appServer?.threadId;
|
|
371
|
+
if (cachedThreadId) {
|
|
372
|
+
try {
|
|
373
|
+
await client.threadResume(cachedThreadId);
|
|
374
|
+
diagnostics.resume = { ok: true, threadId: cachedThreadId, source: "cached" };
|
|
375
|
+
return { threadId: cachedThreadId, source: "cached" };
|
|
376
|
+
} catch (err) {
|
|
377
|
+
diagnostics.resume = { ok: false, threadId: cachedThreadId, error: err.message };
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
const snapshot = await this.evalBridge("snapshot()", this.bridgeForProvider("transcriptSnapshot"));
|
|
382
|
+
const match = await this.findMatchingAppServerThread(client, snapshot);
|
|
383
|
+
diagnostics.match = match;
|
|
384
|
+
if (!match?.threadId) return null;
|
|
385
|
+
await client.threadResume(match.threadId);
|
|
386
|
+
diagnostics.resume = { ok: true, threadId: match.threadId, source: "matched" };
|
|
387
|
+
this.host.state.appServer = {
|
|
388
|
+
...this.host.state.appServer,
|
|
389
|
+
status: "connected",
|
|
390
|
+
pid: client.pid,
|
|
391
|
+
threadId: match.threadId,
|
|
392
|
+
threadName: match.threadName || null,
|
|
393
|
+
error: null
|
|
394
|
+
};
|
|
395
|
+
this.host.touchAndBroadcast();
|
|
396
|
+
return { threadId: match.threadId, source: "matched" };
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
async attachFilesThroughComposerDrop(files = [], options = {}) {
|
|
400
|
+
await this.applyHostStateToRenderer().catch(() => {});
|
|
401
|
+
await wait(150);
|
|
402
|
+
const payload = files.map((file) => ({
|
|
403
|
+
name: file.name || basename(file.path),
|
|
404
|
+
mimeType: file.mimeType || "text/markdown",
|
|
405
|
+
body: typeof file.body === "string" ? file.body : readFileSync(file.path, "utf8")
|
|
406
|
+
}));
|
|
407
|
+
const dropped = await this.evaluate(composerFileDropExpression(payload));
|
|
408
|
+
if (!dropped?.ok) return { ok: false, error: "composer drop target not found", mode: "composer-drop", diagnostic: dropped };
|
|
409
|
+
const paths = files.map((file) => file.path);
|
|
410
|
+
const attached = await waitForEvaluate(this, attachmentDiagnosticExpression(paths), options.timeoutMs || 8000);
|
|
411
|
+
return { ok: attached?.ok !== false, mode: "composer-drop", files: paths, diagnostic: { dropped, attached } };
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
async attachFilesThroughComposerFilePicker(files = [], options = {}, priorError = null) {
|
|
415
|
+
const paths = files.map((file) => file.path);
|
|
416
|
+
await this.applyHostStateToRenderer().catch(() => {});
|
|
417
|
+
await wait(150);
|
|
418
|
+
await this.browser.call("Page.enable", {}, this.sessionId).catch(() => {});
|
|
419
|
+
await this.browser.call("DOM.enable", {}, this.sessionId).catch(() => {});
|
|
420
|
+
await this.browser.call("Page.setInterceptFileChooserDialog", { enabled: true }, this.sessionId);
|
|
421
|
+
const diagnostics = { priorError };
|
|
422
|
+
try {
|
|
423
|
+
const plus = await this.evaluate(findComposerAddButtonExpression());
|
|
424
|
+
if (!plus?.ok) return { ok: false, error: "composer add button not found", diagnostic: plus };
|
|
425
|
+
diagnostics.plus = plus;
|
|
426
|
+
await this.dispatchMouseClick(center(plus.rect));
|
|
427
|
+
await wait(250);
|
|
428
|
+
|
|
429
|
+
const item = await waitForEvaluate(this, findFilesAndFoldersItemExpression(), 2500);
|
|
430
|
+
if (!item?.ok) return { ok: false, error: "Files and folders menu item not found", diagnostic: item };
|
|
431
|
+
diagnostics.item = item;
|
|
432
|
+
|
|
433
|
+
const chooserPromise = this.browser.waitForEvent("Page.fileChooserOpened", {
|
|
434
|
+
timeoutMs: options.timeoutMs || 8000
|
|
435
|
+
});
|
|
436
|
+
await this.dispatchMouseClick(center(item.rect));
|
|
437
|
+
const chooser = await chooserPromise;
|
|
438
|
+
diagnostics.chooser = chooser;
|
|
439
|
+
if (!chooser?.backendNodeId) return { ok: false, error: "file chooser opened without backendNodeId", diagnostic: chooser };
|
|
440
|
+
await this.browser.call("DOM.setFileInputFiles", {
|
|
441
|
+
files: paths,
|
|
442
|
+
backendNodeId: chooser.backendNodeId
|
|
443
|
+
}, this.sessionId);
|
|
444
|
+
await wait(600);
|
|
445
|
+
const attached = await this.evaluate(attachmentDiagnosticExpression(paths));
|
|
446
|
+
return { ok: attached?.ok !== false, mode: "composer-file-picker", files: paths, diagnostic: { ...diagnostics, attached } };
|
|
447
|
+
} catch (err) {
|
|
448
|
+
return { ok: false, error: err.message, mode: "composer-file-picker", files: paths, diagnostic: diagnostics };
|
|
449
|
+
} finally {
|
|
450
|
+
await this.browser.call("Page.setInterceptFileChooserDialog", { enabled: false }, this.sessionId).catch(() => {});
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
async discoverRendererTarget() {
|
|
455
|
+
await this.browser.call("Target.setDiscoverTargets", { discover: true });
|
|
456
|
+
const deadline = Date.now() + 20000;
|
|
457
|
+
let lastTargets = [];
|
|
458
|
+
while (Date.now() < deadline) {
|
|
459
|
+
const result = await this.browser.call("Target.getTargets");
|
|
460
|
+
lastTargets = result.targetInfos || [];
|
|
461
|
+
const target = lastTargets.find((candidate) =>
|
|
462
|
+
candidate.type === "page" && candidate.url?.startsWith("app://-/index.html")
|
|
463
|
+
) || lastTargets.find((candidate) =>
|
|
464
|
+
candidate.type === "page" && candidate.url?.startsWith("app://-")
|
|
465
|
+
) || lastTargets.find((candidate) => candidate.type === "page");
|
|
466
|
+
if (target) return target;
|
|
467
|
+
await wait(300);
|
|
468
|
+
}
|
|
469
|
+
throw new Error(`No attachable renderer target found: ${JSON.stringify(lastTargets.map(summarizeTarget))}`);
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
async attachToTarget(targetId) {
|
|
473
|
+
const attached = await this.browser.call("Target.attachToTarget", {
|
|
474
|
+
targetId,
|
|
475
|
+
flatten: true
|
|
476
|
+
});
|
|
477
|
+
await this.browser.call("Runtime.enable", {}, attached.sessionId);
|
|
478
|
+
return attached.sessionId;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
async inject() {
|
|
482
|
+
const apps = {};
|
|
483
|
+
for (const app of this.host.apps.values()) {
|
|
484
|
+
apps[app.appId] = {
|
|
485
|
+
appId: app.appId,
|
|
486
|
+
entryUrl: this.host.appEntryUrl(app.appId),
|
|
487
|
+
capabilities: app.contributes || {}
|
|
488
|
+
};
|
|
489
|
+
}
|
|
490
|
+
await this.evaluate(`(() => {
|
|
491
|
+
const protocolVersion = ${JSON.stringify(this.host.protocolVersion)};
|
|
492
|
+
const hostUrl = ${JSON.stringify(this.host.state.host.url)};
|
|
493
|
+
const seededApps = ${JSON.stringify(apps)};
|
|
494
|
+
const previous = window.__oneWhackRuntime;
|
|
495
|
+
const slots = previous && typeof previous.apps === "object" ? previous.apps : {};
|
|
496
|
+
const cssEscape = (value) => window.CSS?.escape ? window.CSS.escape(String(value)) : String(value).replace(/["\\\\]/g, "\\\\$&");
|
|
497
|
+
const ensureAnnotationLayoutStyle = () => {
|
|
498
|
+
const styleId = "onewhack-annotation-layout-style";
|
|
499
|
+
if (document.getElementById(styleId)) return;
|
|
500
|
+
const style = document.createElement("style");
|
|
501
|
+
style.id = styleId;
|
|
502
|
+
style.textContent = \`
|
|
503
|
+
.onewhack-transcript-anchor {
|
|
504
|
+
position: relative !important;
|
|
505
|
+
overflow: visible !important;
|
|
506
|
+
}
|
|
507
|
+
.onewhack-transcript-annotations {
|
|
508
|
+
position: absolute !important;
|
|
509
|
+
left: -62px !important;
|
|
510
|
+
top: 4px !important;
|
|
511
|
+
z-index: 2147483002 !important;
|
|
512
|
+
display: flex !important;
|
|
513
|
+
flex-direction: column !important;
|
|
514
|
+
align-items: center !important;
|
|
515
|
+
gap: 6px !important;
|
|
516
|
+
pointer-events: none !important;
|
|
517
|
+
}
|
|
518
|
+
.onewhack-transcript-annotation-slot {
|
|
519
|
+
display: flex !important;
|
|
520
|
+
align-items: center !important;
|
|
521
|
+
justify-content: center !important;
|
|
522
|
+
pointer-events: auto !important;
|
|
523
|
+
}
|
|
524
|
+
@media (max-width: 900px) {
|
|
525
|
+
.onewhack-transcript-annotations {
|
|
526
|
+
position: static !important;
|
|
527
|
+
flex-direction: row !important;
|
|
528
|
+
justify-content: flex-start !important;
|
|
529
|
+
align-items: center !important;
|
|
530
|
+
margin: 0 0 6px 0 !important;
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
\`;
|
|
534
|
+
document.head.appendChild(style);
|
|
535
|
+
};
|
|
536
|
+
const annotationHost = (anchor, anchorId) => {
|
|
537
|
+
if (!(anchor instanceof HTMLElement)) throw new Error("OneWhack annotation anchor must be an HTMLElement");
|
|
538
|
+
ensureAnnotationLayoutStyle();
|
|
539
|
+
anchor.classList.add("onewhack-transcript-anchor");
|
|
540
|
+
let host = Array.from(anchor.children).find((child) =>
|
|
541
|
+
child instanceof HTMLElement &&
|
|
542
|
+
child.classList.contains("onewhack-transcript-annotations") &&
|
|
543
|
+
child.dataset.onewhackAnchorId === anchorId
|
|
544
|
+
);
|
|
545
|
+
if (!host) {
|
|
546
|
+
host = document.createElement("div");
|
|
547
|
+
host.className = "onewhack-transcript-annotations";
|
|
548
|
+
host.dataset.onewhackAnchorId = anchorId;
|
|
549
|
+
anchor.insertAdjacentElement("afterbegin", host);
|
|
550
|
+
}
|
|
551
|
+
return host;
|
|
552
|
+
};
|
|
553
|
+
const positionAnnotationHost = (host, anchor) => {
|
|
554
|
+
if (!(host instanceof HTMLElement) || !(anchor instanceof HTMLElement)) return;
|
|
555
|
+
const rect = anchor.getBoundingClientRect();
|
|
556
|
+
const hostWidth = Math.max(host.getBoundingClientRect().width || 0, 32);
|
|
557
|
+
const desiredLeft = -hostWidth - 12;
|
|
558
|
+
const clippingLeft = (() => {
|
|
559
|
+
let left = 8;
|
|
560
|
+
for (let node = anchor.parentElement; node; node = node.parentElement) {
|
|
561
|
+
const style = getComputedStyle(node);
|
|
562
|
+
if (!/(hidden|auto|scroll|clip)/.test([style.overflow, style.overflowX, style.overflowY].join(" "))) continue;
|
|
563
|
+
const nodeRect = node.getBoundingClientRect();
|
|
564
|
+
if (nodeRect.width > 0 && nodeRect.height > 0) left = Math.max(left, nodeRect.left + 8);
|
|
565
|
+
}
|
|
566
|
+
return left;
|
|
567
|
+
})();
|
|
568
|
+
const visibleLeft = clippingLeft - rect.left;
|
|
569
|
+
host.style.setProperty("left", Math.max(visibleLeft, desiredLeft) + "px", "important");
|
|
570
|
+
};
|
|
571
|
+
const sortAnnotationSlots = (host) => {
|
|
572
|
+
[...host.children]
|
|
573
|
+
.sort((a, b) =>
|
|
574
|
+
Number(a.dataset.onewhackPriority || 100) - Number(b.dataset.onewhackPriority || 100) ||
|
|
575
|
+
String(a.dataset.onewhackAppId || "").localeCompare(String(b.dataset.onewhackAppId || "")) ||
|
|
576
|
+
String(a.dataset.onewhackMarkerId || "").localeCompare(String(b.dataset.onewhackMarkerId || ""))
|
|
577
|
+
)
|
|
578
|
+
.forEach((child) => host.appendChild(child));
|
|
579
|
+
};
|
|
580
|
+
const transcriptAnchorSelector = (anchorId) => {
|
|
581
|
+
const escaped = cssEscape(anchorId);
|
|
582
|
+
return [
|
|
583
|
+
'[data-content-search-unit-key="' + escaped + '"]',
|
|
584
|
+
'[data-turn-key="' + escaped + '"]',
|
|
585
|
+
'[data-content-search-turn-key="' + escaped + '"]',
|
|
586
|
+
'[data-thread-user-message-navigation-item-id="' + escaped + '"]'
|
|
587
|
+
].join(",");
|
|
588
|
+
};
|
|
589
|
+
const findAnchorElement = (anchorId) => document.querySelector(transcriptAnchorSelector(anchorId));
|
|
590
|
+
const isHostUiElement = (el) => {
|
|
591
|
+
for (let node = el; node; node = node.parentElement) {
|
|
592
|
+
if (node.classList?.contains?.("onewhack-host-ui")) return true;
|
|
593
|
+
if (/^onewhack-/.test(String(node.id || ""))) return true;
|
|
594
|
+
}
|
|
595
|
+
return false;
|
|
596
|
+
};
|
|
597
|
+
const composerAnchorForInput = (input) => {
|
|
598
|
+
const inputRect = input.getBoundingClientRect();
|
|
599
|
+
let anchor = input;
|
|
600
|
+
for (let node = input.parentElement; node && node !== document.body && node !== document.documentElement; node = node.parentElement) {
|
|
601
|
+
const rect = node.getBoundingClientRect();
|
|
602
|
+
if (rect.width < inputRect.width || rect.height < inputRect.height) continue;
|
|
603
|
+
if (rect.bottom < inputRect.bottom - 4 || rect.top > inputRect.top + 4) continue;
|
|
604
|
+
anchor = node;
|
|
605
|
+
if (node.matches?.("form")) break;
|
|
606
|
+
}
|
|
607
|
+
return anchor;
|
|
608
|
+
};
|
|
609
|
+
const findComposer = () => {
|
|
610
|
+
const selectors = [
|
|
611
|
+
"textarea",
|
|
612
|
+
'[contenteditable="true"]',
|
|
613
|
+
'[role="textbox"]'
|
|
614
|
+
];
|
|
615
|
+
const candidates = selectors.map((selector) => [...document.querySelectorAll(selector)])
|
|
616
|
+
.flat()
|
|
617
|
+
.filter((el) => el instanceof HTMLElement && !isHostUiElement(el))
|
|
618
|
+
.map((el) => {
|
|
619
|
+
const rect = el.getBoundingClientRect();
|
|
620
|
+
if (rect.width < 180 || rect.height < 18) return null;
|
|
621
|
+
if (rect.bottom < window.innerHeight * 0.45) return null;
|
|
622
|
+
if (rect.top > window.innerHeight || rect.bottom < 0) return null;
|
|
623
|
+
if (rect.right < window.innerWidth * 0.35) return null;
|
|
624
|
+
const anchor = composerAnchorForInput(el);
|
|
625
|
+
const anchorRect = anchor.getBoundingClientRect();
|
|
626
|
+
return { el, rect, anchorRect };
|
|
627
|
+
})
|
|
628
|
+
.filter(Boolean)
|
|
629
|
+
.sort((a, b) =>
|
|
630
|
+
b.rect.bottom - a.rect.bottom ||
|
|
631
|
+
b.rect.width - a.rect.width ||
|
|
632
|
+
(b.anchorRect.width * b.anchorRect.height) - (a.anchorRect.width * a.anchorRect.height)
|
|
633
|
+
);
|
|
634
|
+
return candidates[0]?.el || null;
|
|
635
|
+
};
|
|
636
|
+
const ONEWHACK_CONTEXT_START = "--- OneWhack context ---";
|
|
637
|
+
const ONEWHACK_CONTEXT_END = "--- End OneWhack context ---";
|
|
638
|
+
const stripOneWhackContext = (text) => {
|
|
639
|
+
let output = String(text || "");
|
|
640
|
+
while (true) {
|
|
641
|
+
const start = output.indexOf(ONEWHACK_CONTEXT_START);
|
|
642
|
+
if (start < 0) return output.trimStart();
|
|
643
|
+
const end = output.indexOf(ONEWHACK_CONTEXT_END, start + ONEWHACK_CONTEXT_START.length);
|
|
644
|
+
if (end < 0) return output.slice(0, start).trimEnd();
|
|
645
|
+
output = output.slice(0, start) + output.slice(end + ONEWHACK_CONTEXT_END.length);
|
|
646
|
+
}
|
|
647
|
+
};
|
|
648
|
+
const setComposerValue = (el, value) => {
|
|
649
|
+
if (!el) return false;
|
|
650
|
+
try {
|
|
651
|
+
el.focus({ preventScroll: true });
|
|
652
|
+
} catch {
|
|
653
|
+
el.focus();
|
|
654
|
+
}
|
|
655
|
+
if ("value" in el) {
|
|
656
|
+
const setter = Object.getOwnPropertyDescriptor(el.constructor?.prototype || HTMLTextAreaElement.prototype, "value")?.set;
|
|
657
|
+
if (setter) setter.call(el, value);
|
|
658
|
+
else el.value = value;
|
|
659
|
+
el.dispatchEvent(new InputEvent("input", { bubbles: true, inputType: "insertText", data: value }));
|
|
660
|
+
el.dispatchEvent(new Event("change", { bubbles: true }));
|
|
661
|
+
return true;
|
|
662
|
+
}
|
|
663
|
+
el.textContent = value;
|
|
664
|
+
el.dispatchEvent(new InputEvent("input", { bubbles: true, inputType: "insertText", data: value }));
|
|
665
|
+
return true;
|
|
666
|
+
};
|
|
667
|
+
const composerText = (el) => {
|
|
668
|
+
if (!el) return "";
|
|
669
|
+
return "value" in el ? String(el.value || "") : String(el.innerText || el.textContent || "");
|
|
670
|
+
};
|
|
671
|
+
const mergeDraftText = (current, next, mode) => {
|
|
672
|
+
if (mode === "append") return current ? current + "\\n" + next : next;
|
|
673
|
+
if (mode === "prepend") return current ? next + "\\n" + current : next;
|
|
674
|
+
return next;
|
|
675
|
+
};
|
|
676
|
+
const runtime = {
|
|
677
|
+
protocolVersion,
|
|
678
|
+
hostUrl,
|
|
679
|
+
apps: slots,
|
|
680
|
+
registerApp(app) {
|
|
681
|
+
if (!app || !app.appId) throw new Error("OneWhack appId is required");
|
|
682
|
+
const seeded = seededApps[app.appId] || {};
|
|
683
|
+
const current = this.apps[app.appId] || {};
|
|
684
|
+
const currentVersion = Number(current.bridge?.version);
|
|
685
|
+
const nextVersion = Number(app.bridge?.version);
|
|
686
|
+
if (
|
|
687
|
+
current.bridge &&
|
|
688
|
+
app.bridge &&
|
|
689
|
+
Number.isFinite(currentVersion) &&
|
|
690
|
+
Number.isFinite(nextVersion) &&
|
|
691
|
+
nextVersion < currentVersion
|
|
692
|
+
) {
|
|
693
|
+
throw new Error("OneWhack app bridge version regressed for " + app.appId);
|
|
694
|
+
}
|
|
695
|
+
const slot = {
|
|
696
|
+
...current,
|
|
697
|
+
...app,
|
|
698
|
+
appId: app.appId,
|
|
699
|
+
entryUrl: app.entryUrl || current.entryUrl || seeded.entryUrl || null,
|
|
700
|
+
capabilities: app.capabilities || current.capabilities || seeded.capabilities || {},
|
|
701
|
+
injectedAt: app.injectedAt || current.injectedAt || new Date().toISOString()
|
|
702
|
+
};
|
|
703
|
+
this.apps[app.appId] = slot;
|
|
704
|
+
return slot;
|
|
705
|
+
},
|
|
706
|
+
getApp(appId) {
|
|
707
|
+
return this.apps[appId] || null;
|
|
708
|
+
},
|
|
709
|
+
getBridge(appId) {
|
|
710
|
+
return this.getApp(appId)?.bridge || null;
|
|
711
|
+
},
|
|
712
|
+
getEntryUrl(appId) {
|
|
713
|
+
return this.getApp(appId)?.entryUrl || null;
|
|
714
|
+
},
|
|
715
|
+
placeAnnotation(anchor, annotation) {
|
|
716
|
+
if (!annotation?.appId) throw new Error("OneWhack annotation appId is required");
|
|
717
|
+
if (!annotation?.anchorId) throw new Error("OneWhack annotation anchorId is required");
|
|
718
|
+
if (!(annotation.element instanceof HTMLElement)) throw new Error("OneWhack annotation element is required");
|
|
719
|
+
const markerId = annotation.markerId || annotation.anchorId;
|
|
720
|
+
const host = annotationHost(anchor, annotation.anchorId);
|
|
721
|
+
const selector = '.onewhack-transcript-annotation-slot[data-onewhack-app-id="' + cssEscape(annotation.appId) + '"][data-onewhack-marker-id="' + cssEscape(markerId) + '"]';
|
|
722
|
+
let slot = host.querySelector(selector);
|
|
723
|
+
if (!slot) {
|
|
724
|
+
slot = document.createElement("div");
|
|
725
|
+
slot.className = "onewhack-transcript-annotation-slot";
|
|
726
|
+
slot.dataset.onewhackAppId = annotation.appId;
|
|
727
|
+
slot.dataset.onewhackMarkerId = markerId;
|
|
728
|
+
host.appendChild(slot);
|
|
729
|
+
}
|
|
730
|
+
slot.dataset.onewhackAnchorId = annotation.anchorId;
|
|
731
|
+
slot.dataset.onewhackPriority = String(annotation.priority ?? 100);
|
|
732
|
+
if (slot.firstElementChild !== annotation.element) slot.replaceChildren(annotation.element);
|
|
733
|
+
sortAnnotationSlots(host);
|
|
734
|
+
positionAnnotationHost(host, anchor);
|
|
735
|
+
return slot;
|
|
736
|
+
},
|
|
737
|
+
removeAnnotations(appId, liveAnchorIds = null) {
|
|
738
|
+
const live = liveAnchorIds ? new Set([...liveAnchorIds].map(String)) : null;
|
|
739
|
+
document.querySelectorAll('.onewhack-transcript-annotation-slot[data-onewhack-app-id="' + cssEscape(appId) + '"]').forEach((slot) => {
|
|
740
|
+
if (!live || !live.has(slot.dataset.onewhackAnchorId || "")) slot.remove();
|
|
741
|
+
});
|
|
742
|
+
document.querySelectorAll(".onewhack-transcript-annotations").forEach((host) => {
|
|
743
|
+
if (!host.children.length) host.remove();
|
|
744
|
+
});
|
|
745
|
+
},
|
|
746
|
+
highlightRange(range, options = {}) {
|
|
747
|
+
if (!range?.anchorId) return { ok: false, error: "range.anchorId is required" };
|
|
748
|
+
const anchor = findAnchorElement(range.anchorId);
|
|
749
|
+
if (!anchor) return { ok: false, error: "anchor not found", anchorId: range.anchorId };
|
|
750
|
+
anchor.classList.add("onewhack-range-highlight");
|
|
751
|
+
const previousOutline = anchor.style.outline;
|
|
752
|
+
const previousOffset = anchor.style.outlineOffset;
|
|
753
|
+
anchor.style.outline = "3px solid #f6c945";
|
|
754
|
+
anchor.style.outlineOffset = "3px";
|
|
755
|
+
anchor.scrollIntoView({ block: options.block || "center", behavior: options.behavior || "smooth" });
|
|
756
|
+
setTimeout(() => {
|
|
757
|
+
anchor.classList.remove("onewhack-range-highlight");
|
|
758
|
+
anchor.style.outline = previousOutline;
|
|
759
|
+
anchor.style.outlineOffset = previousOffset;
|
|
760
|
+
}, Number(options.durationMs || 1200));
|
|
761
|
+
return { ok: true, anchorId: range.anchorId, range };
|
|
762
|
+
},
|
|
763
|
+
setComposerDraft(draft = {}) {
|
|
764
|
+
const el = findComposer();
|
|
765
|
+
if (!el) return { ok: false, error: "composer not found" };
|
|
766
|
+
const currentText = composerText(el);
|
|
767
|
+
const text = draft.oneWhackContext
|
|
768
|
+
? (draft.text ? mergeDraftText(stripOneWhackContext(currentText), String(draft.text || ""), "prepend") : stripOneWhackContext(currentText))
|
|
769
|
+
: mergeDraftText(currentText, String(draft.text || ""), draft.mode || "replace");
|
|
770
|
+
if (!setComposerValue(el, text)) return { ok: false, error: "composer cannot be updated" };
|
|
771
|
+
return { ok: true, draft: { ...draft, text } };
|
|
772
|
+
},
|
|
773
|
+
submitComposer(draft = {}) {
|
|
774
|
+
const setResult = draft.text !== undefined ? this.setComposerDraft(draft) : { ok: true };
|
|
775
|
+
if (!setResult.ok) return setResult;
|
|
776
|
+
const composer = findComposer();
|
|
777
|
+
const button = [...document.querySelectorAll("button")]
|
|
778
|
+
.filter((el) => el instanceof HTMLButtonElement && !el.disabled)
|
|
779
|
+
.find((el) => /send|submit|arrow/i.test([el.ariaLabel, el.title, el.textContent].join(" ")));
|
|
780
|
+
if (!button) return { ok: false, error: "composer submit button not found" };
|
|
781
|
+
button.click();
|
|
782
|
+
return { ok: true, submitted: true, draft: setResult.draft || draft };
|
|
783
|
+
}
|
|
784
|
+
};
|
|
785
|
+
for (const [appId, seeded] of Object.entries(seededApps)) {
|
|
786
|
+
runtime.apps[appId] = {
|
|
787
|
+
...(runtime.apps[appId] || {}),
|
|
788
|
+
...seeded,
|
|
789
|
+
bridge: runtime.apps[appId]?.bridge || null,
|
|
790
|
+
injectedAt: runtime.apps[appId]?.injectedAt || null
|
|
791
|
+
};
|
|
792
|
+
}
|
|
793
|
+
window.__oneWhackRuntime = runtime;
|
|
794
|
+
})()`);
|
|
795
|
+
for (const bridge of this.rendererBridges) {
|
|
796
|
+
await this.evaluate(bridge.injectedSource);
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
async refreshSnapshot() {
|
|
801
|
+
const primary = this.bridgeForProvider("transcriptSnapshot");
|
|
802
|
+
const snapshot = await this.evalBridge("snapshot()", primary);
|
|
803
|
+
const hostEvents = await this.evalBridge("drainHostEvents()", primary).catch(() => []);
|
|
804
|
+
const secondarySnapshots = [];
|
|
805
|
+
for (const bridge of this.rendererBridges) {
|
|
806
|
+
if (bridge === primary) continue;
|
|
807
|
+
const secondary = await this.evalBridge("snapshot()", bridge).catch((err) => ({
|
|
808
|
+
ok: false,
|
|
809
|
+
error: err.message,
|
|
810
|
+
appId: this.bridgeAppId(bridge)
|
|
811
|
+
}));
|
|
812
|
+
secondarySnapshots.push(secondary);
|
|
813
|
+
}
|
|
814
|
+
const transcript = this.snapshotToTranscript(snapshot);
|
|
815
|
+
this.host.updateTranscript(transcript, { broadcast: false });
|
|
816
|
+
const selectionSnapshot = [snapshot, ...secondarySnapshots].find((candidate) => candidate?.selection);
|
|
817
|
+
if (selectionSnapshot?.selection) {
|
|
818
|
+
const selection = this.host.setSelection(selectionSnapshot.selection);
|
|
819
|
+
if (
|
|
820
|
+
this.autoOpenPanel &&
|
|
821
|
+
selection?.selectionId &&
|
|
822
|
+
selection.selectionId !== this.lastPanelSelectionId
|
|
823
|
+
) {
|
|
824
|
+
this.lastPanelSelectionId = selection.selectionId;
|
|
825
|
+
this.openPanel()
|
|
826
|
+
.then((result) => this.applyPanelOpenResult(result))
|
|
827
|
+
.catch((err) => {
|
|
828
|
+
this.host.state.panel.status = "error";
|
|
829
|
+
this.host.state.panel.error = err.message;
|
|
830
|
+
this.host.touchAndBroadcast();
|
|
831
|
+
});
|
|
832
|
+
}
|
|
833
|
+
} else {
|
|
834
|
+
this.host.touchAndBroadcast();
|
|
835
|
+
}
|
|
836
|
+
if (Array.isArray(hostEvents) && hostEvents.length) {
|
|
837
|
+
await this.processRendererHostEvents(hostEvents);
|
|
838
|
+
}
|
|
839
|
+
const needsAppServerOrder = this.appServerOrder &&
|
|
840
|
+
snapshot?.anchors?.length &&
|
|
841
|
+
!this.appServerOrderPromise &&
|
|
842
|
+
(
|
|
843
|
+
snapshot.transcriptOrderSource !== "app-server" ||
|
|
844
|
+
(
|
|
845
|
+
snapshot.unknownMountedAnchorCount > 0 &&
|
|
846
|
+
Date.now() - this.lastAppServerOrderAt > 2500
|
|
847
|
+
)
|
|
848
|
+
);
|
|
849
|
+
if (needsAppServerOrder) {
|
|
850
|
+
this.appServerOrderPromise = this.applyAppServerTranscriptOrder(snapshot)
|
|
851
|
+
.then(() => this.refreshSnapshot())
|
|
852
|
+
.catch((err) => {
|
|
853
|
+
this.host.state.appServer = {
|
|
854
|
+
status: "error",
|
|
855
|
+
pid: null,
|
|
856
|
+
version: null,
|
|
857
|
+
error: err.message
|
|
858
|
+
};
|
|
859
|
+
this.host.touchAndBroadcast();
|
|
860
|
+
})
|
|
861
|
+
.finally(() => {
|
|
862
|
+
this.appServerOrderPromise = null;
|
|
863
|
+
});
|
|
864
|
+
}
|
|
865
|
+
await this.applyHostStateToRenderer().catch(() => {});
|
|
866
|
+
return snapshot;
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
async processRendererHostEvents(events) {
|
|
870
|
+
for (const event of events) {
|
|
871
|
+
if (!event || typeof event !== "object") continue;
|
|
872
|
+
if (event.kind === "selection" && event.selection) {
|
|
873
|
+
this.host.setSelection(event.selection);
|
|
874
|
+
} else if (event.kind === "appAction") {
|
|
875
|
+
await this.processRendererAppAction(event);
|
|
876
|
+
} else if (event.kind === "overlayClose") {
|
|
877
|
+
this.host.closeOverlay(event.overlayId);
|
|
878
|
+
} else if (event.kind === "composerContextRemove") {
|
|
879
|
+
this.host.removeComposerContext(event.itemId);
|
|
880
|
+
} else if (event.kind === "composerAttachmentRemove") {
|
|
881
|
+
this.host.removeComposerAttachment(event.path);
|
|
882
|
+
} else if (event.kind === "composerContextSubmitted") {
|
|
883
|
+
this.markComposerContextSubmitted(event.itemIds || []);
|
|
884
|
+
} else if (event.kind === "composerAttachmentsSubmitted") {
|
|
885
|
+
this.markComposerAttachmentsSubmitted(event.paths || []);
|
|
886
|
+
} else if (event.kind === "highlightRange" && event.range) {
|
|
887
|
+
await this.host.highlightRange(event.range, { durationMs: 1200 });
|
|
888
|
+
} else if (event.kind === "highlightAnchor" && event.anchorId) {
|
|
889
|
+
await this.host.highlightAnchor(event.anchorId, { durationMs: 1200 });
|
|
890
|
+
}
|
|
891
|
+
}
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
async processRendererAppAction(event) {
|
|
895
|
+
const app = this.host.requireApp(event.appId);
|
|
896
|
+
const action = {
|
|
897
|
+
actionId: event.actionId || `${event.type}:${event.eventId || Date.now()}`,
|
|
898
|
+
appId: event.appId,
|
|
899
|
+
type: event.type,
|
|
900
|
+
payload: event.payload || {},
|
|
901
|
+
requestedAt: event.requestedAt || new Date().toISOString()
|
|
902
|
+
};
|
|
903
|
+
await this.host.handleAction(app, action);
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
markComposerContextSubmitted(itemIds) {
|
|
907
|
+
const ids = new Set((Array.isArray(itemIds) ? itemIds : []).map(String));
|
|
908
|
+
if (!ids.size) return { ok: true, updated: 0 };
|
|
909
|
+
let updated = 0;
|
|
910
|
+
for (const item of this.host.state.composer.contextItems) {
|
|
911
|
+
if (!ids.has(item.itemId)) continue;
|
|
912
|
+
item.status = "sent";
|
|
913
|
+
item.updatedAt = new Date().toISOString();
|
|
914
|
+
updated += 1;
|
|
915
|
+
}
|
|
916
|
+
if (updated) this.host.touchAndBroadcast();
|
|
917
|
+
return { ok: true, updated };
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
markComposerAttachmentsSubmitted(paths) {
|
|
921
|
+
const ids = new Set((Array.isArray(paths) ? paths : []).map(String));
|
|
922
|
+
if (!ids.size) return { ok: true, updated: 0 };
|
|
923
|
+
let updated = 0;
|
|
924
|
+
for (const item of this.host.state.composer.attachments) {
|
|
925
|
+
if (!ids.has(item.path)) continue;
|
|
926
|
+
item.status = "sent";
|
|
927
|
+
item.updatedAt = new Date().toISOString();
|
|
928
|
+
updated += 1;
|
|
929
|
+
}
|
|
930
|
+
if (updated) this.host.touchAndBroadcast();
|
|
931
|
+
return { ok: true, updated };
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
async applyHostStateToRenderer() {
|
|
935
|
+
const primary = this.bridgeForProvider("transcriptSnapshot");
|
|
936
|
+
const state = this.host.publicState();
|
|
937
|
+
await this.evalBridge(`applyHostState(${JSON.stringify(state)})`, primary);
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
async applyAppServerTranscriptOrder(snapshot) {
|
|
941
|
+
this.lastAppServerOrderAt = Date.now();
|
|
942
|
+
const mountedTurnSearchKeys = mountedTurnSearchKeysFromSnapshot(snapshot);
|
|
943
|
+
const mountedContentAnchorIds = mountedContentAnchorIdsFromSnapshot(snapshot);
|
|
944
|
+
if (!mountedTurnSearchKeys.size) return null;
|
|
945
|
+
|
|
946
|
+
const client = await AppServerClient.start(this.codexCli, { cwd: process.cwd() });
|
|
947
|
+
this.host.state.appServer = {
|
|
948
|
+
status: "starting",
|
|
949
|
+
pid: client.pid,
|
|
950
|
+
version: null,
|
|
951
|
+
error: null
|
|
952
|
+
};
|
|
953
|
+
this.host.touchAndBroadcast();
|
|
954
|
+
try {
|
|
955
|
+
const init = await client.initialize();
|
|
956
|
+
const threads = await client.threadList({ limit: 100 });
|
|
957
|
+
const bestMatch = await this.findMatchingAppServerThread(client, snapshot, threads);
|
|
958
|
+
if (bestMatch) {
|
|
959
|
+
const result = await this.evalBridge(`setTranscriptOrder(${JSON.stringify(bestMatch.projected.anchorIds)}, ${JSON.stringify({
|
|
960
|
+
source: "app-server",
|
|
961
|
+
threadId: bestMatch.threadId,
|
|
962
|
+
threadName: bestMatch.threadName
|
|
963
|
+
})})`, this.bridgeForProvider("transcriptOrder"));
|
|
964
|
+
this.host.state.appServer = {
|
|
965
|
+
status: "connected",
|
|
966
|
+
pid: client.pid,
|
|
967
|
+
version: init?.userAgent || null,
|
|
968
|
+
error: null,
|
|
969
|
+
threadId: bestMatch.threadId,
|
|
970
|
+
threadName: bestMatch.threadName,
|
|
971
|
+
mountedContentHits: bestMatch.mountedContentHits,
|
|
972
|
+
mountedContentCount: mountedContentAnchorIds.size
|
|
973
|
+
};
|
|
974
|
+
this.host.touchAndBroadcast();
|
|
975
|
+
this.lastAppServerOrderAt = Date.now();
|
|
976
|
+
return { threadId: bestMatch.threadId, count: bestMatch.projected.anchorIds.length, result };
|
|
977
|
+
}
|
|
978
|
+
throw new Error("No app-server thread matched mounted Desktop turn search keys");
|
|
979
|
+
} finally {
|
|
980
|
+
await client.stop();
|
|
981
|
+
}
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
async findMatchingAppServerThread(client, snapshot, threadSummaries = null) {
|
|
985
|
+
const mountedTurnSearchKeys = mountedTurnSearchKeysFromSnapshot(snapshot);
|
|
986
|
+
const mountedContentAnchorIds = mountedContentAnchorIdsFromSnapshot(snapshot);
|
|
987
|
+
if (!mountedTurnSearchKeys.size) return null;
|
|
988
|
+
|
|
989
|
+
const threads = threadSummaries || await client.threadList({ limit: 100 });
|
|
990
|
+
let bestMatch = null;
|
|
991
|
+
for (const threadSummary of threads) {
|
|
992
|
+
const threadId = threadSummary?.id;
|
|
993
|
+
if (!threadId) continue;
|
|
994
|
+
const turns = await client.threadTurnsListAll(threadId, {
|
|
995
|
+
sortDirection: "asc",
|
|
996
|
+
itemsView: "full",
|
|
997
|
+
limit: 100
|
|
998
|
+
}).catch(() => []);
|
|
999
|
+
if (!turns.length) continue;
|
|
1000
|
+
const projected = projectDesktopTranscriptAnchors(turns);
|
|
1001
|
+
if (!projected.anchorIds.length) continue;
|
|
1002
|
+
const projectedIds = new Set(projected.anchorIds);
|
|
1003
|
+
const mountedContentHits = [...mountedContentAnchorIds].filter((id) => projectedIds.has(id)).length;
|
|
1004
|
+
const mountedTurnHits = projected.turnSearchKeys.filter((key) => mountedTurnSearchKeys.has(key)).length;
|
|
1005
|
+
if (!mountedContentHits && !mountedTurnHits) continue;
|
|
1006
|
+
const candidate = {
|
|
1007
|
+
threadId,
|
|
1008
|
+
threadName: threadSummary.name || null,
|
|
1009
|
+
projected,
|
|
1010
|
+
mountedContentHits,
|
|
1011
|
+
mountedTurnHits,
|
|
1012
|
+
score: mountedContentHits * 1000 + mountedTurnHits
|
|
1013
|
+
};
|
|
1014
|
+
if (!bestMatch || candidate.score > bestMatch.score) bestMatch = candidate;
|
|
1015
|
+
if (mountedContentAnchorIds.size && mountedContentHits === mountedContentAnchorIds.size) break;
|
|
1016
|
+
}
|
|
1017
|
+
return bestMatch;
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
primaryBridge() {
|
|
1021
|
+
return this.bridgeForProvider("transcriptSnapshot");
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
bridgeForProvider(name) {
|
|
1025
|
+
const appId = this.providers[name];
|
|
1026
|
+
const bridge = this.bridgeForApp(appId);
|
|
1027
|
+
if (bridge) return bridge;
|
|
1028
|
+
return this.rendererBridges.find((candidate) => candidate.primary) || this.rendererBridges[0];
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
bridgeForApp(appId) {
|
|
1032
|
+
if (!appId) return null;
|
|
1033
|
+
return this.rendererBridges.find((bridge) => this.bridgeAppId(bridge) === appId) || null;
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
bridgeAppId(bridge) {
|
|
1037
|
+
if (bridge?.appId) return bridge.appId;
|
|
1038
|
+
if (this.host.apps.size === 1) return this.host.apps.keys().next().value;
|
|
1039
|
+
return this.host.state.panel.activeAppId;
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
async evalBridge(expression, bridge = this.primaryBridge()) {
|
|
1043
|
+
const appId = this.bridgeAppId(bridge);
|
|
1044
|
+
return this.evaluate(`(() => {
|
|
1045
|
+
const runtime = window.__oneWhackRuntime;
|
|
1046
|
+
const bridge = runtime?.getBridge?.(${JSON.stringify(appId)});
|
|
1047
|
+
if (!bridge) return { ok: false, error: "OneWhack bridge is not installed for " + ${JSON.stringify(appId)} };
|
|
1048
|
+
return bridge.${expression};
|
|
1049
|
+
})()`);
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
async evaluate(expression) {
|
|
1053
|
+
const output = await this.browser.call("Runtime.evaluate", {
|
|
1054
|
+
expression,
|
|
1055
|
+
awaitPromise: true,
|
|
1056
|
+
returnByValue: true
|
|
1057
|
+
}, this.sessionId);
|
|
1058
|
+
if (output.exceptionDetails) {
|
|
1059
|
+
throw new Error(output.exceptionDetails.exception?.description || output.exceptionDetails.text || "Runtime.evaluate failed");
|
|
1060
|
+
}
|
|
1061
|
+
return output.result?.value;
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
async ensurePanelAppLoaded() {
|
|
1065
|
+
const appUrl = this.host.appEntryUrl(this.host.state.panel.activeAppId);
|
|
1066
|
+
if (!appUrl) return { ok: false, error: "panel app URL missing" };
|
|
1067
|
+
|
|
1068
|
+
let last = null;
|
|
1069
|
+
for (let attempt = 0; attempt < 8; attempt += 1) {
|
|
1070
|
+
last = await this.evaluate(panelDiagnosticExpression(appUrl));
|
|
1071
|
+
if (last?.frame) return { ok: true, mode: attempt ? "cdp-repaired" : "focused", frame: last.frame };
|
|
1072
|
+
|
|
1073
|
+
if (last?.localRect) {
|
|
1074
|
+
await this.dispatchMouseClick(center(last.localRect));
|
|
1075
|
+
await wait(900);
|
|
1076
|
+
continue;
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
if (last?.inputRect) {
|
|
1080
|
+
await this.dispatchMouseClick(center(last.inputRect));
|
|
1081
|
+
await this.evaluate(setBrowserUrlInputExpression(appUrl));
|
|
1082
|
+
await this.dispatchEnter();
|
|
1083
|
+
await wait(900);
|
|
1084
|
+
continue;
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
await wait(300);
|
|
1088
|
+
}
|
|
1089
|
+
return { ok: false, error: "panel app did not load", diagnostic: last };
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
async dispatchMouseClick(point) {
|
|
1093
|
+
await this.browser.call("Input.dispatchMouseEvent", {
|
|
1094
|
+
type: "mouseMoved",
|
|
1095
|
+
x: point.x,
|
|
1096
|
+
y: point.y
|
|
1097
|
+
}, this.sessionId);
|
|
1098
|
+
await this.browser.call("Input.dispatchMouseEvent", {
|
|
1099
|
+
type: "mousePressed",
|
|
1100
|
+
x: point.x,
|
|
1101
|
+
y: point.y,
|
|
1102
|
+
button: "left",
|
|
1103
|
+
clickCount: 1
|
|
1104
|
+
}, this.sessionId);
|
|
1105
|
+
await this.browser.call("Input.dispatchMouseEvent", {
|
|
1106
|
+
type: "mouseReleased",
|
|
1107
|
+
x: point.x,
|
|
1108
|
+
y: point.y,
|
|
1109
|
+
button: "left",
|
|
1110
|
+
clickCount: 1
|
|
1111
|
+
}, this.sessionId);
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
async dispatchEnter() {
|
|
1115
|
+
await this.browser.call("Input.dispatchKeyEvent", {
|
|
1116
|
+
type: "keyDown",
|
|
1117
|
+
key: "Enter",
|
|
1118
|
+
code: "Enter",
|
|
1119
|
+
windowsVirtualKeyCode: 13,
|
|
1120
|
+
nativeVirtualKeyCode: 13
|
|
1121
|
+
}, this.sessionId);
|
|
1122
|
+
await this.browser.call("Input.dispatchKeyEvent", {
|
|
1123
|
+
type: "keyUp",
|
|
1124
|
+
key: "Enter",
|
|
1125
|
+
code: "Enter",
|
|
1126
|
+
windowsVirtualKeyCode: 13,
|
|
1127
|
+
nativeVirtualKeyCode: 13
|
|
1128
|
+
}, this.sessionId);
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1131
|
+
appendLog(chunk) {
|
|
1132
|
+
if (!this.runDir) return;
|
|
1133
|
+
mkdirSync(this.runDir, { recursive: true });
|
|
1134
|
+
writeFileSync(join(this.runDir, "codex-desktop.log"), chunk, { flag: "a" });
|
|
1135
|
+
}
|
|
1136
|
+
}
|
|
1137
|
+
|
|
1138
|
+
class CdpConnection {
|
|
1139
|
+
static async connect(wsUrl) {
|
|
1140
|
+
const socket = new WebSocket(wsUrl);
|
|
1141
|
+
const connection = new CdpConnection(socket);
|
|
1142
|
+
await new Promise((resolvePromise, reject) => {
|
|
1143
|
+
socket.addEventListener("open", resolvePromise, { once: true });
|
|
1144
|
+
socket.addEventListener("error", () => reject(new Error("CDP websocket error")), { once: true });
|
|
1145
|
+
});
|
|
1146
|
+
socket.addEventListener("message", (event) => connection.handleMessage(event));
|
|
1147
|
+
return connection;
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
constructor(socket) {
|
|
1151
|
+
this.socket = socket;
|
|
1152
|
+
this.nextId = 1;
|
|
1153
|
+
this.pending = new Map();
|
|
1154
|
+
this.eventWaiters = [];
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1157
|
+
call(method, params = {}, sessionId) {
|
|
1158
|
+
const id = this.nextId++;
|
|
1159
|
+
this.socket.send(JSON.stringify({ id, method, params, ...(sessionId ? { sessionId } : {}) }));
|
|
1160
|
+
return new Promise((resolvePromise, reject) => {
|
|
1161
|
+
this.pending.set(id, { resolve: resolvePromise, reject });
|
|
1162
|
+
setTimeout(() => {
|
|
1163
|
+
if (!this.pending.has(id)) return;
|
|
1164
|
+
this.pending.delete(id);
|
|
1165
|
+
reject(new Error(`${method} timed out`));
|
|
1166
|
+
}, 30000);
|
|
1167
|
+
});
|
|
1168
|
+
}
|
|
1169
|
+
|
|
1170
|
+
handleMessage(event) {
|
|
1171
|
+
const message = JSON.parse(event.data);
|
|
1172
|
+
if (message.method) {
|
|
1173
|
+
const index = this.eventWaiters.findIndex((waiter) =>
|
|
1174
|
+
waiter.method === message.method &&
|
|
1175
|
+
(!waiter.sessionId || waiter.sessionId === message.sessionId) &&
|
|
1176
|
+
(!waiter.predicate || waiter.predicate(message.params || {}))
|
|
1177
|
+
);
|
|
1178
|
+
if (index >= 0) {
|
|
1179
|
+
const [waiter] = this.eventWaiters.splice(index, 1);
|
|
1180
|
+
clearTimeout(waiter.timer);
|
|
1181
|
+
waiter.resolve(message.params || {});
|
|
1182
|
+
}
|
|
1183
|
+
}
|
|
1184
|
+
const slot = this.pending.get(message.id);
|
|
1185
|
+
if (!slot) return;
|
|
1186
|
+
this.pending.delete(message.id);
|
|
1187
|
+
if (message.error) slot.reject(new Error(message.error.message || JSON.stringify(message.error)));
|
|
1188
|
+
else slot.resolve(message.result);
|
|
1189
|
+
}
|
|
1190
|
+
|
|
1191
|
+
waitForEvent(method, options = {}) {
|
|
1192
|
+
return new Promise((resolvePromise, reject) => {
|
|
1193
|
+
const waiter = {
|
|
1194
|
+
method,
|
|
1195
|
+
sessionId: options.sessionId,
|
|
1196
|
+
predicate: options.predicate,
|
|
1197
|
+
resolve: resolvePromise,
|
|
1198
|
+
reject,
|
|
1199
|
+
timer: setTimeout(() => {
|
|
1200
|
+
const index = this.eventWaiters.indexOf(waiter);
|
|
1201
|
+
if (index >= 0) this.eventWaiters.splice(index, 1);
|
|
1202
|
+
reject(new Error(`${method} timed out`));
|
|
1203
|
+
}, options.timeoutMs || 10000)
|
|
1204
|
+
};
|
|
1205
|
+
this.eventWaiters.push(waiter);
|
|
1206
|
+
});
|
|
1207
|
+
}
|
|
1208
|
+
|
|
1209
|
+
close() {
|
|
1210
|
+
try {
|
|
1211
|
+
this.socket.close();
|
|
1212
|
+
} catch {}
|
|
1213
|
+
}
|
|
1214
|
+
}
|
|
1215
|
+
|
|
1216
|
+
class AppServerClient {
|
|
1217
|
+
static async start(codexCli, options = {}) {
|
|
1218
|
+
const child = spawn(codexCli, ["app-server", "--listen", "stdio://"], {
|
|
1219
|
+
cwd: options.cwd || process.cwd(),
|
|
1220
|
+
stdio: ["pipe", "pipe", "pipe"]
|
|
1221
|
+
});
|
|
1222
|
+
const client = new AppServerClient(child);
|
|
1223
|
+
child.stdout.setEncoding("utf8");
|
|
1224
|
+
child.stdout.on("data", (chunk) => client.handleStdout(chunk));
|
|
1225
|
+
child.stderr.on("data", (chunk) => {
|
|
1226
|
+
client.stderr += chunk.toString();
|
|
1227
|
+
});
|
|
1228
|
+
return client;
|
|
1229
|
+
}
|
|
1230
|
+
|
|
1231
|
+
constructor(child) {
|
|
1232
|
+
this.child = child;
|
|
1233
|
+
this.pid = child.pid;
|
|
1234
|
+
this.nextId = 1;
|
|
1235
|
+
this.pending = new Map();
|
|
1236
|
+
this.buffer = "";
|
|
1237
|
+
this.stderr = "";
|
|
1238
|
+
}
|
|
1239
|
+
|
|
1240
|
+
async initialize() {
|
|
1241
|
+
const response = await this.call("initialize", {
|
|
1242
|
+
clientInfo: {
|
|
1243
|
+
name: "onewhack-host",
|
|
1244
|
+
title: "OneWhack Host",
|
|
1245
|
+
version: "0.1"
|
|
1246
|
+
},
|
|
1247
|
+
capabilities: {
|
|
1248
|
+
experimentalApi: true,
|
|
1249
|
+
requestAttestation: false,
|
|
1250
|
+
optOutNotificationMethods: []
|
|
1251
|
+
}
|
|
1252
|
+
});
|
|
1253
|
+
this.notify("initialized", {});
|
|
1254
|
+
return response;
|
|
1255
|
+
}
|
|
1256
|
+
|
|
1257
|
+
async threadList(params = {}) {
|
|
1258
|
+
const response = await this.call("thread/list", params);
|
|
1259
|
+
return response?.data || response?.threads || [];
|
|
1260
|
+
}
|
|
1261
|
+
|
|
1262
|
+
async threadRead(threadId, params = {}) {
|
|
1263
|
+
const response = await this.call("thread/read", { threadId, ...params });
|
|
1264
|
+
return response?.thread || null;
|
|
1265
|
+
}
|
|
1266
|
+
|
|
1267
|
+
async threadResume(threadId, params = {}) {
|
|
1268
|
+
return this.call("thread/resume", { threadId, ...params });
|
|
1269
|
+
}
|
|
1270
|
+
|
|
1271
|
+
async threadTurnsList(threadId, params = {}) {
|
|
1272
|
+
const response = await this.call("thread/turns/list", { threadId, ...params });
|
|
1273
|
+
return {
|
|
1274
|
+
data: response?.data || [],
|
|
1275
|
+
nextCursor: response?.nextCursor ?? response?.next_cursor ?? null,
|
|
1276
|
+
backwardsCursor: response?.backwardsCursor ?? response?.backwards_cursor ?? null
|
|
1277
|
+
};
|
|
1278
|
+
}
|
|
1279
|
+
|
|
1280
|
+
async threadTurnsListAll(threadId, params = {}) {
|
|
1281
|
+
const turns = [];
|
|
1282
|
+
let cursor = params.cursor ?? null;
|
|
1283
|
+
for (let page = 0; page < 200; page += 1) {
|
|
1284
|
+
const response = await this.threadTurnsList(threadId, { ...params, cursor });
|
|
1285
|
+
turns.push(...response.data);
|
|
1286
|
+
if (!response.nextCursor) break;
|
|
1287
|
+
cursor = response.nextCursor;
|
|
1288
|
+
}
|
|
1289
|
+
return turns;
|
|
1290
|
+
}
|
|
1291
|
+
|
|
1292
|
+
async threadInjectItems(threadId, items = []) {
|
|
1293
|
+
return this.call("thread/inject_items", { threadId, items });
|
|
1294
|
+
}
|
|
1295
|
+
|
|
1296
|
+
call(method, params = {}, timeoutMs = 20000) {
|
|
1297
|
+
const id = this.nextId++;
|
|
1298
|
+
this.child.stdin.write(`${JSON.stringify({ jsonrpc: "2.0", id, method, params })}\n`);
|
|
1299
|
+
return new Promise((resolvePromise, reject) => {
|
|
1300
|
+
const timer = setTimeout(() => {
|
|
1301
|
+
this.pending.delete(id);
|
|
1302
|
+
reject(new Error(`${method} timed out: ${this.stderr.slice(-1200)}`));
|
|
1303
|
+
}, timeoutMs);
|
|
1304
|
+
this.pending.set(id, {
|
|
1305
|
+
resolve: (message) => {
|
|
1306
|
+
clearTimeout(timer);
|
|
1307
|
+
if (message.error) {
|
|
1308
|
+
reject(new Error(message.error.message || JSON.stringify(message.error)));
|
|
1309
|
+
return;
|
|
1310
|
+
}
|
|
1311
|
+
resolvePromise(message.result);
|
|
1312
|
+
}
|
|
1313
|
+
});
|
|
1314
|
+
});
|
|
1315
|
+
}
|
|
1316
|
+
|
|
1317
|
+
notify(method, params = {}) {
|
|
1318
|
+
this.child.stdin.write(`${JSON.stringify({ jsonrpc: "2.0", method, params })}\n`);
|
|
1319
|
+
}
|
|
1320
|
+
|
|
1321
|
+
handleStdout(chunk) {
|
|
1322
|
+
this.buffer += chunk;
|
|
1323
|
+
let newline;
|
|
1324
|
+
while ((newline = this.buffer.indexOf("\n")) >= 0) {
|
|
1325
|
+
const line = this.buffer.slice(0, newline);
|
|
1326
|
+
this.buffer = this.buffer.slice(newline + 1);
|
|
1327
|
+
if (!line.trim()) continue;
|
|
1328
|
+
let message;
|
|
1329
|
+
try {
|
|
1330
|
+
message = JSON.parse(line);
|
|
1331
|
+
} catch {
|
|
1332
|
+
continue;
|
|
1333
|
+
}
|
|
1334
|
+
if (message.id === undefined || !this.pending.has(message.id)) continue;
|
|
1335
|
+
const slot = this.pending.get(message.id);
|
|
1336
|
+
this.pending.delete(message.id);
|
|
1337
|
+
slot.resolve(message);
|
|
1338
|
+
}
|
|
1339
|
+
}
|
|
1340
|
+
|
|
1341
|
+
async stop() {
|
|
1342
|
+
try {
|
|
1343
|
+
this.child.stdin.end();
|
|
1344
|
+
} catch {}
|
|
1345
|
+
if (this.child.exitCode === null) {
|
|
1346
|
+
this.child.kill("SIGTERM");
|
|
1347
|
+
await wait(500);
|
|
1348
|
+
if (this.child.exitCode === null) this.child.kill("SIGKILL");
|
|
1349
|
+
}
|
|
1350
|
+
}
|
|
1351
|
+
}
|
|
1352
|
+
|
|
1353
|
+
function mountedTurnSearchKeysFromSnapshot(snapshot = {}) {
|
|
1354
|
+
const keys = new Set(snapshot.mountedTurnSearchKeys || []);
|
|
1355
|
+
for (const anchor of snapshot.anchors || []) {
|
|
1356
|
+
const id = String(anchor?.anchorId || "");
|
|
1357
|
+
const parsed = parseLocalContentSearchUnitKey(id);
|
|
1358
|
+
if (parsed) keys.add(parsed.turnSearchKey);
|
|
1359
|
+
if (anchor?.kind === "turn" || anchor?.kind === "content-search-turn") {
|
|
1360
|
+
keys.add(id);
|
|
1361
|
+
}
|
|
1362
|
+
}
|
|
1363
|
+
return keys;
|
|
1364
|
+
}
|
|
1365
|
+
|
|
1366
|
+
function mountedContentAnchorIdsFromSnapshot(snapshot = {}) {
|
|
1367
|
+
const ids = new Set(snapshot.mountedContentAnchorIds || []);
|
|
1368
|
+
for (const anchor of snapshot.anchors || []) {
|
|
1369
|
+
const id = String(anchor?.anchorId || "");
|
|
1370
|
+
if (parseLocalContentSearchUnitKey(id)) ids.add(id);
|
|
1371
|
+
}
|
|
1372
|
+
return ids;
|
|
1373
|
+
}
|
|
1374
|
+
|
|
1375
|
+
function parseLocalContentSearchUnitKey(id) {
|
|
1376
|
+
const match = String(id || "").match(/^(.*):(\d+):(user|assistant)$/);
|
|
1377
|
+
if (!match) return null;
|
|
1378
|
+
return {
|
|
1379
|
+
turnSearchKey: match[1],
|
|
1380
|
+
itemIndex: Number(match[2]),
|
|
1381
|
+
role: match[3]
|
|
1382
|
+
};
|
|
1383
|
+
}
|
|
1384
|
+
|
|
1385
|
+
function projectDesktopTranscriptAnchors(turns) {
|
|
1386
|
+
const anchorIds = [];
|
|
1387
|
+
const turnSearchKeys = [];
|
|
1388
|
+
turns.forEach((turn, turnIndex) => {
|
|
1389
|
+
const turnSearchKey = turn?.id || `turn-index-${turnIndex}`;
|
|
1390
|
+
if (!isRenderableDesktopTurn(turn)) return;
|
|
1391
|
+
turnSearchKeys.push(turnSearchKey);
|
|
1392
|
+
const items = Array.isArray(turn?.items) ? turn.items : [];
|
|
1393
|
+
const lastAssistantIndex = findLastIndex(items, (item) => isItemType(item, "agentMessage", "assistant-message"));
|
|
1394
|
+
items.forEach((item, itemIndex) => {
|
|
1395
|
+
if (isItemType(item, "userMessage", "user-message")) {
|
|
1396
|
+
const text = userMessageText(item).trim();
|
|
1397
|
+
if (text) anchorIds.push(`${turnSearchKey}:${itemIndex}:user`);
|
|
1398
|
+
} else if (itemIndex === lastAssistantIndex && isItemType(item, "agentMessage", "assistant-message")) {
|
|
1399
|
+
const text = agentMessageText(item).trim();
|
|
1400
|
+
if (text) anchorIds.push(`${turnSearchKey}:${itemIndex}:assistant`);
|
|
1401
|
+
}
|
|
1402
|
+
});
|
|
1403
|
+
});
|
|
1404
|
+
return { anchorIds, turnSearchKeys };
|
|
1405
|
+
}
|
|
1406
|
+
|
|
1407
|
+
function isRenderableDesktopTurn(turn) {
|
|
1408
|
+
const items = Array.isArray(turn?.items) ? turn.items : [];
|
|
1409
|
+
return items.some((item) =>
|
|
1410
|
+
isItemType(item, "userMessage", "user-message") ||
|
|
1411
|
+
isItemType(item, "agentMessage", "assistant-message")
|
|
1412
|
+
);
|
|
1413
|
+
}
|
|
1414
|
+
|
|
1415
|
+
function isItemType(item, ...types) {
|
|
1416
|
+
return types.includes(item?.type);
|
|
1417
|
+
}
|
|
1418
|
+
|
|
1419
|
+
function findLastIndex(items, predicate) {
|
|
1420
|
+
for (let i = items.length - 1; i >= 0; i -= 1) {
|
|
1421
|
+
if (predicate(items[i], i)) return i;
|
|
1422
|
+
}
|
|
1423
|
+
return -1;
|
|
1424
|
+
}
|
|
1425
|
+
|
|
1426
|
+
function userMessageText(item) {
|
|
1427
|
+
if (typeof item?.message === "string") return item.message;
|
|
1428
|
+
if (typeof item?.text === "string") return item.text;
|
|
1429
|
+
if (typeof item?.content === "string") return item.content;
|
|
1430
|
+
if (Array.isArray(item?.content)) {
|
|
1431
|
+
return item.content.map((part) => {
|
|
1432
|
+
if (typeof part === "string") return part;
|
|
1433
|
+
if (typeof part?.text === "string") return part.text;
|
|
1434
|
+
return "";
|
|
1435
|
+
}).join("");
|
|
1436
|
+
}
|
|
1437
|
+
return "";
|
|
1438
|
+
}
|
|
1439
|
+
|
|
1440
|
+
function agentMessageText(item) {
|
|
1441
|
+
if (typeof item?.content === "string") return item.content;
|
|
1442
|
+
if (typeof item?.text === "string") return item.text;
|
|
1443
|
+
if (typeof item?.message === "string") return item.message;
|
|
1444
|
+
return "";
|
|
1445
|
+
}
|
|
1446
|
+
|
|
1447
|
+
function buildAttachmentResponseItems(files = [], options = {}) {
|
|
1448
|
+
const createdAt = options.createdAt || new Date().toISOString();
|
|
1449
|
+
return files.map((file, index) => {
|
|
1450
|
+
const text = [
|
|
1451
|
+
"OneWhack attached runtime file context.",
|
|
1452
|
+
"",
|
|
1453
|
+
`Attached at: ${createdAt}`,
|
|
1454
|
+
`File ${index + 1}: ${file.name || basename(file.path)}`,
|
|
1455
|
+
`Path: ${file.path}`,
|
|
1456
|
+
file.relativePath ? `Runtime path: ${file.relativePath}` : null,
|
|
1457
|
+
file.mimeType ? `MIME type: ${file.mimeType}` : null,
|
|
1458
|
+
"",
|
|
1459
|
+
"The following file was created by a OneWhack app and should be treated as attached context for the user's next prompt.",
|
|
1460
|
+
"",
|
|
1461
|
+
"```markdown",
|
|
1462
|
+
readFileSync(file.path, "utf8"),
|
|
1463
|
+
"```"
|
|
1464
|
+
].filter((line) => line !== null && line !== undefined).join("\n");
|
|
1465
|
+
return {
|
|
1466
|
+
type: "message",
|
|
1467
|
+
role: "user",
|
|
1468
|
+
content: [{
|
|
1469
|
+
type: "input_text",
|
|
1470
|
+
text
|
|
1471
|
+
}]
|
|
1472
|
+
};
|
|
1473
|
+
});
|
|
1474
|
+
}
|
|
1475
|
+
|
|
1476
|
+
export const __testHooks = {
|
|
1477
|
+
mountedContentAnchorIdsFromSnapshot,
|
|
1478
|
+
mountedTurnSearchKeysFromSnapshot,
|
|
1479
|
+
projectDesktopTranscriptAnchors,
|
|
1480
|
+
buildAttachmentResponseItems
|
|
1481
|
+
};
|
|
1482
|
+
|
|
1483
|
+
function defaultSnapshotToTranscript(snapshot = {}) {
|
|
1484
|
+
const anchors = (snapshot.anchors || snapshot.markers || []).map((item, index) => ({
|
|
1485
|
+
anchorId: item.anchorId || item.key,
|
|
1486
|
+
kind: item.kind || "unknown",
|
|
1487
|
+
visible: Boolean(item.visible),
|
|
1488
|
+
top: item.top,
|
|
1489
|
+
height: item.height,
|
|
1490
|
+
textPreview: item.textPreview || item.text || "",
|
|
1491
|
+
order: item.order || index + 1,
|
|
1492
|
+
inferredRole: item.inferredRole
|
|
1493
|
+
})).filter((anchor) => anchor.anchorId);
|
|
1494
|
+
return {
|
|
1495
|
+
anchors,
|
|
1496
|
+
visibleCount: snapshot.visibleCount ?? anchors.filter((anchor) => anchor.visible).length,
|
|
1497
|
+
annotationCount: snapshot.annotationCount ?? snapshot.annotationRails ?? 0,
|
|
1498
|
+
scroll: snapshot.scroll || null,
|
|
1499
|
+
updatedAt: new Date().toISOString()
|
|
1500
|
+
};
|
|
1501
|
+
}
|
|
1502
|
+
|
|
1503
|
+
function summarizeTarget(target) {
|
|
1504
|
+
return {
|
|
1505
|
+
targetId: target.targetId,
|
|
1506
|
+
type: target.type,
|
|
1507
|
+
title: target.title,
|
|
1508
|
+
url: target.url
|
|
1509
|
+
};
|
|
1510
|
+
}
|
|
1511
|
+
|
|
1512
|
+
function center(rect) {
|
|
1513
|
+
return {
|
|
1514
|
+
x: rect.x + rect.width / 2,
|
|
1515
|
+
y: rect.y + rect.height / 2
|
|
1516
|
+
};
|
|
1517
|
+
}
|
|
1518
|
+
|
|
1519
|
+
function panelDiagnosticExpression(appUrl) {
|
|
1520
|
+
return `
|
|
1521
|
+
(() => {
|
|
1522
|
+
const appUrl = ${JSON.stringify(appUrl)};
|
|
1523
|
+
const appId = (() => {
|
|
1524
|
+
try {
|
|
1525
|
+
const match = new URL(appUrl).pathname.match(/\\/apps\\/([^/]+)\\/?/);
|
|
1526
|
+
return match ? decodeURIComponent(match[1]) : "";
|
|
1527
|
+
} catch {
|
|
1528
|
+
return "";
|
|
1529
|
+
}
|
|
1530
|
+
})();
|
|
1531
|
+
const normalize = (value) => String(value || "").replace("localhost", "127.0.0.1").replace(/\\/(?=#|$)/, "");
|
|
1532
|
+
const rectOf = (el) => {
|
|
1533
|
+
const rect = el.getBoundingClientRect();
|
|
1534
|
+
return { x: rect.x, y: rect.y, width: rect.width, height: rect.height };
|
|
1535
|
+
};
|
|
1536
|
+
const appPath = appId ? "/apps/" + appId + "/" : "";
|
|
1537
|
+
const encodedAppPath = appId ? "/apps/" + encodeURIComponent(appId) + "/" : "";
|
|
1538
|
+
const frame = [...document.querySelectorAll("webview, iframe")]
|
|
1539
|
+
.map((el) => String(el.getAttribute("src") || ""))
|
|
1540
|
+
.find((src) => normalize(src) === normalize(appUrl) || (appPath && (src.includes(appPath) || src.includes(encodedAppPath))));
|
|
1541
|
+
const buttons = [...document.querySelectorAll("button, [role='button'], a")]
|
|
1542
|
+
.map((el) => {
|
|
1543
|
+
const haystack = [
|
|
1544
|
+
el.getAttribute("aria-label") || "",
|
|
1545
|
+
el.getAttribute("title") || "",
|
|
1546
|
+
el.innerText || el.textContent || ""
|
|
1547
|
+
].join(" ").replace(/\\s+/g, " ").trim();
|
|
1548
|
+
const rect = el.getBoundingClientRect();
|
|
1549
|
+
const match = normalize(haystack).includes(normalize(appUrl)) ||
|
|
1550
|
+
(appId && haystack.includes(appId)) ||
|
|
1551
|
+
(appPath && (haystack.includes(appPath) || haystack.includes(encodedAppPath)));
|
|
1552
|
+
const score = (/\\b127\\.0\\.0\\.1:\\d+\\b/.test(haystack) ? 4 : 0) +
|
|
1553
|
+
((appPath && (haystack.includes(appPath) || haystack.includes(encodedAppPath))) ? 4 : 0) +
|
|
1554
|
+
(rect.top > 80 ? 2 : -4);
|
|
1555
|
+
return { el, match, score, rect, haystack };
|
|
1556
|
+
})
|
|
1557
|
+
.filter((item) => item.match && item.rect.width > 0 && item.rect.height > 0)
|
|
1558
|
+
.sort((a, b) => b.score - a.score);
|
|
1559
|
+
const input = [...document.querySelectorAll("input")]
|
|
1560
|
+
.find((el) => /url/i.test(el.placeholder || "") && el.getBoundingClientRect().width > 0);
|
|
1561
|
+
return {
|
|
1562
|
+
frame: frame || null,
|
|
1563
|
+
frames: [...document.querySelectorAll("webview, iframe")].map((el) => String(el.getAttribute("src") || "")),
|
|
1564
|
+
localRect: buttons[0] ? rectOf(buttons[0].el) : null,
|
|
1565
|
+
localText: buttons[0]?.haystack || null,
|
|
1566
|
+
inputRect: input ? rectOf(input) : null,
|
|
1567
|
+
inputValue: input?.value || null
|
|
1568
|
+
};
|
|
1569
|
+
})()
|
|
1570
|
+
`;
|
|
1571
|
+
}
|
|
1572
|
+
|
|
1573
|
+
function findComposerAddButtonExpression() {
|
|
1574
|
+
return `
|
|
1575
|
+
(() => {
|
|
1576
|
+
const rectOf = (el) => {
|
|
1577
|
+
const rect = el.getBoundingClientRect();
|
|
1578
|
+
return { x: rect.x, y: rect.y, width: rect.width, height: rect.height, left: rect.left, top: rect.top, right: rect.right, bottom: rect.bottom };
|
|
1579
|
+
};
|
|
1580
|
+
const composerCandidates = [...document.querySelectorAll("textarea,[contenteditable='true'],[role='textbox']")]
|
|
1581
|
+
.map((el) => ({ el, rect: el.getBoundingClientRect() }))
|
|
1582
|
+
.filter((item) => item.rect.width > 240 && item.rect.height > 24 && item.rect.top > window.innerHeight / 2)
|
|
1583
|
+
.sort((a, b) => b.rect.top - a.rect.top);
|
|
1584
|
+
const composer = composerCandidates[0];
|
|
1585
|
+
if (!composer) return { ok: false, error: "composer not found" };
|
|
1586
|
+
const composerRect = composer.rect;
|
|
1587
|
+
const buttons = [...document.querySelectorAll("button,[role='button']")]
|
|
1588
|
+
.map((el) => {
|
|
1589
|
+
const text = [el.getAttribute("aria-label") || "", el.getAttribute("title") || "", el.innerText || el.textContent || ""]
|
|
1590
|
+
.join(" ")
|
|
1591
|
+
.replace(/\\s+/g, " ")
|
|
1592
|
+
.trim();
|
|
1593
|
+
const rect = el.getBoundingClientRect();
|
|
1594
|
+
const nearComposer = rect.top >= composerRect.top - 80 &&
|
|
1595
|
+
rect.bottom <= composerRect.bottom + 80 &&
|
|
1596
|
+
rect.left <= composerRect.left + 120;
|
|
1597
|
+
const score = (/^\\+$/.test(text) || /\\b(add|attach|files?|folders?)\\b/i.test(text) ? 8 : 0) +
|
|
1598
|
+
(nearComposer ? 8 : 0) +
|
|
1599
|
+
(rect.width >= 24 && rect.width <= 72 && rect.height >= 24 && rect.height <= 72 ? 4 : 0);
|
|
1600
|
+
return { el, text, rect, score };
|
|
1601
|
+
})
|
|
1602
|
+
.filter((item) => item.score >= 12 && item.rect.width > 0 && item.rect.height > 0)
|
|
1603
|
+
.sort((a, b) => b.score - a.score || a.rect.left - b.rect.left);
|
|
1604
|
+
const item = buttons[0];
|
|
1605
|
+
return item ? { ok: true, text: item.text, rect: rectOf(item.el), composerRect: rectOf(composer.el) } : { ok: false, error: "add button not found" };
|
|
1606
|
+
})()
|
|
1607
|
+
`;
|
|
1608
|
+
}
|
|
1609
|
+
|
|
1610
|
+
function composerFileDropExpression(files) {
|
|
1611
|
+
return `
|
|
1612
|
+
(async () => {
|
|
1613
|
+
const files = ${JSON.stringify(files)};
|
|
1614
|
+
const rectOf = (el) => {
|
|
1615
|
+
const rect = el.getBoundingClientRect();
|
|
1616
|
+
return { x: rect.x, y: rect.y, width: rect.width, height: rect.height, left: rect.left, top: rect.top, right: rect.right, bottom: rect.bottom };
|
|
1617
|
+
};
|
|
1618
|
+
const composerCandidates = [...document.querySelectorAll("textarea,[contenteditable='true'],[role='textbox']")]
|
|
1619
|
+
.map((input) => {
|
|
1620
|
+
const inputRect = input.getBoundingClientRect();
|
|
1621
|
+
if (inputRect.width < 240 || inputRect.height < 24 || inputRect.top < window.innerHeight / 2) return null;
|
|
1622
|
+
let anchor = input;
|
|
1623
|
+
for (let node = input.parentElement; node && node !== document.body && node !== document.documentElement; node = node.parentElement) {
|
|
1624
|
+
const rect = node.getBoundingClientRect();
|
|
1625
|
+
if (rect.width < inputRect.width || rect.height < inputRect.height) continue;
|
|
1626
|
+
if (rect.bottom < inputRect.bottom - 4 || rect.top > inputRect.top + 4) continue;
|
|
1627
|
+
if (rect.height > Math.max(260, inputRect.height + 180)) break;
|
|
1628
|
+
anchor = node;
|
|
1629
|
+
if (node.matches?.("form")) break;
|
|
1630
|
+
}
|
|
1631
|
+
return { input, anchor, inputRect, anchorRect: anchor.getBoundingClientRect() };
|
|
1632
|
+
})
|
|
1633
|
+
.filter(Boolean)
|
|
1634
|
+
.sort((a, b) => b.inputRect.bottom - a.inputRect.bottom || b.inputRect.width - a.inputRect.width);
|
|
1635
|
+
const composer = composerCandidates[0];
|
|
1636
|
+
if (!composer) return { ok: false, error: "composer not found" };
|
|
1637
|
+
const dt = new DataTransfer();
|
|
1638
|
+
for (const file of files) {
|
|
1639
|
+
dt.items.add(new File([file.body || ""], file.name, { type: file.mimeType || "text/plain" }));
|
|
1640
|
+
}
|
|
1641
|
+
const targets = [composer.input, composer.anchor].filter(Boolean);
|
|
1642
|
+
const eventInit = {
|
|
1643
|
+
bubbles: true,
|
|
1644
|
+
cancelable: true,
|
|
1645
|
+
composed: true,
|
|
1646
|
+
dataTransfer: dt,
|
|
1647
|
+
clientX: composer.inputRect.left + Math.min(40, composer.inputRect.width / 2),
|
|
1648
|
+
clientY: composer.inputRect.top + Math.min(20, composer.inputRect.height / 2)
|
|
1649
|
+
};
|
|
1650
|
+
const dispatched = [];
|
|
1651
|
+
for (const target of targets) {
|
|
1652
|
+
try { target.focus?.({ preventScroll: true }); } catch { target.focus?.(); }
|
|
1653
|
+
for (const type of ["dragenter", "dragover", "drop"]) {
|
|
1654
|
+
const event = new DragEvent(type, eventInit);
|
|
1655
|
+
dispatched.push({ type, target: target.tagName, defaultPrevented: !target.dispatchEvent(event) || event.defaultPrevented });
|
|
1656
|
+
await new Promise((resolve) => setTimeout(resolve, 40));
|
|
1657
|
+
}
|
|
1658
|
+
}
|
|
1659
|
+
return {
|
|
1660
|
+
ok: true,
|
|
1661
|
+
files: files.map((file) => file.name),
|
|
1662
|
+
inputRect: rectOf(composer.input),
|
|
1663
|
+
anchorRect: rectOf(composer.anchor),
|
|
1664
|
+
dispatched
|
|
1665
|
+
};
|
|
1666
|
+
})()
|
|
1667
|
+
`;
|
|
1668
|
+
}
|
|
1669
|
+
|
|
1670
|
+
function findFilesAndFoldersItemExpression() {
|
|
1671
|
+
return `
|
|
1672
|
+
(() => {
|
|
1673
|
+
const rectOf = (el) => {
|
|
1674
|
+
const rect = el.getBoundingClientRect();
|
|
1675
|
+
return { x: rect.x, y: rect.y, width: rect.width, height: rect.height, left: rect.left, top: rect.top, right: rect.right, bottom: rect.bottom };
|
|
1676
|
+
};
|
|
1677
|
+
const candidates = [...document.querySelectorAll("button,[role='button'],[role='menuitem'],li,div")]
|
|
1678
|
+
.map((el) => {
|
|
1679
|
+
const text = (el.innerText || el.textContent || "").replace(/\\s+/g, " ").trim();
|
|
1680
|
+
const rect = el.getBoundingClientRect();
|
|
1681
|
+
const compact = rect.width > 160 && rect.height >= 28 && rect.height <= 72;
|
|
1682
|
+
const exactish = /^Files and folders\\b/i.test(text);
|
|
1683
|
+
const score = (exactish ? 30 : 0) +
|
|
1684
|
+
(/\\bFiles and folders\\b/i.test(text) ? 20 : 0) +
|
|
1685
|
+
(/\\bFiles?\\b/i.test(text) && /\\bFolders?\\b/i.test(text) ? 8 : 0) +
|
|
1686
|
+
(compact ? 8 : -20);
|
|
1687
|
+
return { el, text, rect, score, compact };
|
|
1688
|
+
})
|
|
1689
|
+
.filter((item) => item.compact && item.score >= 20 && item.rect.width > 0 && item.rect.height > 0 && item.rect.top > window.innerHeight / 3)
|
|
1690
|
+
.sort((a, b) => b.score - a.score || (a.rect.width * a.rect.height) - (b.rect.width * b.rect.height));
|
|
1691
|
+
const item = candidates[0];
|
|
1692
|
+
return item ? { ok: true, text: item.text, rect: rectOf(item.el) } : { ok: false, error: "Files and folders not found" };
|
|
1693
|
+
})()
|
|
1694
|
+
`;
|
|
1695
|
+
}
|
|
1696
|
+
|
|
1697
|
+
function attachmentDiagnosticExpression(paths) {
|
|
1698
|
+
return `
|
|
1699
|
+
(() => {
|
|
1700
|
+
const paths = ${JSON.stringify(paths)};
|
|
1701
|
+
const text = document.body?.innerText || "";
|
|
1702
|
+
const basenames = paths.map((path) => path.split(/[\\\\/]/).pop());
|
|
1703
|
+
const matched = basenames.filter((name) => text.includes(name));
|
|
1704
|
+
return { ok: matched.length > 0, matched, basenames };
|
|
1705
|
+
})()
|
|
1706
|
+
`;
|
|
1707
|
+
}
|
|
1708
|
+
|
|
1709
|
+
function nativeAddContextFileExpression(file) {
|
|
1710
|
+
return `
|
|
1711
|
+
(() => {
|
|
1712
|
+
const file = ${JSON.stringify(file)};
|
|
1713
|
+
if (!file?.path) return { ok: false, error: "file path missing" };
|
|
1714
|
+
const label = file.label || file.path.split(/[\\\\/]/).pop();
|
|
1715
|
+
const payload = {
|
|
1716
|
+
type: "add-context-file",
|
|
1717
|
+
file: {
|
|
1718
|
+
label,
|
|
1719
|
+
path: file.path,
|
|
1720
|
+
fsPath: file.fsPath || file.path
|
|
1721
|
+
}
|
|
1722
|
+
};
|
|
1723
|
+
window.dispatchEvent(new MessageEvent("message", { data: payload }));
|
|
1724
|
+
return { ok: true, file: payload.file };
|
|
1725
|
+
})()
|
|
1726
|
+
`;
|
|
1727
|
+
}
|
|
1728
|
+
|
|
1729
|
+
function nativeAttachmentDiagnosticExpression(paths) {
|
|
1730
|
+
return `
|
|
1731
|
+
(() => {
|
|
1732
|
+
const paths = ${JSON.stringify(paths)};
|
|
1733
|
+
const basenames = paths.map((path) => String(path).split(/[\\\\/]/).pop()).filter(Boolean);
|
|
1734
|
+
const oneWhackChipCount = document.querySelectorAll("#onewhack-composer-chips .onewhack-context-chip").length;
|
|
1735
|
+
const composerInputs = [...document.querySelectorAll("textarea,[contenteditable='true'],[role='textbox']")]
|
|
1736
|
+
.map((el) => ({ el, rect: el.getBoundingClientRect() }))
|
|
1737
|
+
.filter((item) => item.rect.width > 180 && item.rect.height > 18 && item.rect.bottom > window.innerHeight * 0.45)
|
|
1738
|
+
.sort((a, b) => b.rect.bottom - a.rect.bottom);
|
|
1739
|
+
const composerRect = composerInputs[0]?.rect || null;
|
|
1740
|
+
const candidates = [...document.querySelectorAll("button,[role='button'],span,div")]
|
|
1741
|
+
.filter((el) => !el.closest("#onewhack-composer-chips"))
|
|
1742
|
+
.map((el) => {
|
|
1743
|
+
const text = (el.innerText || el.textContent || "").replace(/\\s+/g, " ").trim();
|
|
1744
|
+
const rect = el.getBoundingClientRect();
|
|
1745
|
+
const nearComposer = !composerRect || (
|
|
1746
|
+
rect.bottom >= composerRect.top - 120 &&
|
|
1747
|
+
rect.top <= composerRect.bottom + 80 &&
|
|
1748
|
+
rect.right >= composerRect.left - 40 &&
|
|
1749
|
+
rect.left <= composerRect.right + 40
|
|
1750
|
+
);
|
|
1751
|
+
return { text, rect: { left: rect.left, top: rect.top, right: rect.right, bottom: rect.bottom, width: rect.width, height: rect.height }, nearComposer };
|
|
1752
|
+
})
|
|
1753
|
+
.filter((item) =>
|
|
1754
|
+
item.nearComposer &&
|
|
1755
|
+
item.rect.width > 20 &&
|
|
1756
|
+
item.rect.width < 420 &&
|
|
1757
|
+
item.rect.height >= 12 &&
|
|
1758
|
+
item.rect.height < 80 &&
|
|
1759
|
+
basenames.some((name) => item.text.includes(name))
|
|
1760
|
+
);
|
|
1761
|
+
const matched = basenames.filter((name) => candidates.some((item) => item.text.includes(name)));
|
|
1762
|
+
return {
|
|
1763
|
+
ok: matched.length === basenames.length && oneWhackChipCount === 0,
|
|
1764
|
+
matched,
|
|
1765
|
+
basenames,
|
|
1766
|
+
oneWhackChipCount,
|
|
1767
|
+
candidates: candidates.slice(0, 12)
|
|
1768
|
+
};
|
|
1769
|
+
})()
|
|
1770
|
+
`;
|
|
1771
|
+
}
|
|
1772
|
+
|
|
1773
|
+
function setBrowserUrlInputExpression(appUrl) {
|
|
1774
|
+
return `
|
|
1775
|
+
(() => {
|
|
1776
|
+
const input = [...document.querySelectorAll("input")]
|
|
1777
|
+
.find((el) => /url/i.test(el.placeholder || "") && el.getBoundingClientRect().width > 0);
|
|
1778
|
+
if (!input) return false;
|
|
1779
|
+
input.focus();
|
|
1780
|
+
const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set;
|
|
1781
|
+
if (setter) setter.call(input, ${JSON.stringify(appUrl)});
|
|
1782
|
+
else input.value = ${JSON.stringify(appUrl)};
|
|
1783
|
+
input.dispatchEvent(new InputEvent("input", { bubbles: true, inputType: "insertText", data: ${JSON.stringify(appUrl)} }));
|
|
1784
|
+
input.dispatchEvent(new Event("change", { bubbles: true }));
|
|
1785
|
+
return true;
|
|
1786
|
+
})()
|
|
1787
|
+
`;
|
|
1788
|
+
}
|
|
1789
|
+
|
|
1790
|
+
function freePort() {
|
|
1791
|
+
return new Promise((resolvePromise, reject) => {
|
|
1792
|
+
const server = net.createServer();
|
|
1793
|
+
server.on("error", reject);
|
|
1794
|
+
server.listen(0, "127.0.0.1", () => {
|
|
1795
|
+
const address = server.address();
|
|
1796
|
+
server.close(() => resolvePromise(address.port));
|
|
1797
|
+
});
|
|
1798
|
+
});
|
|
1799
|
+
}
|
|
1800
|
+
|
|
1801
|
+
async function pollJson(url, timeoutMs) {
|
|
1802
|
+
const deadline = Date.now() + timeoutMs;
|
|
1803
|
+
while (Date.now() < deadline) {
|
|
1804
|
+
try {
|
|
1805
|
+
const res = await fetch(url);
|
|
1806
|
+
if (res.ok) return res.json();
|
|
1807
|
+
} catch {}
|
|
1808
|
+
await wait(300);
|
|
1809
|
+
}
|
|
1810
|
+
return null;
|
|
1811
|
+
}
|
|
1812
|
+
|
|
1813
|
+
function wait(ms) {
|
|
1814
|
+
return new Promise((resolvePromise) => setTimeout(resolvePromise, ms));
|
|
1815
|
+
}
|
|
1816
|
+
|
|
1817
|
+
async function waitForEvaluate(adapter, expression, timeoutMs) {
|
|
1818
|
+
const deadline = Date.now() + timeoutMs;
|
|
1819
|
+
let last = null;
|
|
1820
|
+
while (Date.now() < deadline) {
|
|
1821
|
+
last = await adapter.evaluate(expression).catch((err) => ({ ok: false, error: err.message }));
|
|
1822
|
+
if (last?.ok) return last;
|
|
1823
|
+
await wait(150);
|
|
1824
|
+
}
|
|
1825
|
+
return last;
|
|
1826
|
+
}
|