@xingular/harness 1.0.0 → 1.1.1
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/CHANGELOG.md +33 -5
- package/README.md +70 -19
- package/dist/cli.js +10321 -5785
- package/dist/sdk/agents/builtin.d.ts +2 -0
- package/dist/sdk/agents/builtin.js +34 -0
- package/dist/sdk/agents/narrow.d.ts +13 -0
- package/dist/sdk/agents/narrow.js +29 -0
- package/dist/sdk/agents/profiles.d.ts +80 -0
- package/dist/sdk/agents/profiles.js +221 -0
- package/dist/sdk/annotations/bridge.d.ts +42 -0
- package/dist/sdk/annotations/bridge.js +264 -0
- package/dist/sdk/annotations/capture.d.ts +116 -0
- package/dist/sdk/annotations/capture.js +308 -0
- package/dist/sdk/annotations/delivery.d.ts +72 -0
- package/dist/sdk/annotations/delivery.js +130 -0
- package/dist/sdk/annotations/inspect.d.ts +10 -0
- package/dist/sdk/annotations/inspect.js +33 -0
- package/dist/sdk/annotations/isolated.d.ts +6 -0
- package/dist/sdk/annotations/isolated.js +51 -0
- package/dist/sdk/annotations/pageScript.d.ts +28 -0
- package/dist/sdk/annotations/pageScript.js +570 -0
- package/dist/sdk/annotations/part.d.ts +10 -0
- package/dist/sdk/annotations/part.js +144 -0
- package/dist/sdk/annotations/queue.d.ts +4 -0
- package/dist/sdk/annotations/queue.js +40 -0
- package/dist/sdk/annotations/serverMethods.d.ts +3 -0
- package/dist/sdk/annotations/serverMethods.js +62 -0
- package/dist/sdk/annotations/store.d.ts +160 -0
- package/dist/sdk/annotations/store.js +386 -0
- package/dist/sdk/browser/index.d.ts +14 -3
- package/dist/sdk/browser/index.js +11 -4
- package/dist/sdk/browser/serverMethods.d.ts +3 -0
- package/dist/sdk/browser/serverMethods.js +7 -0
- package/dist/sdk/browser/session.d.ts +1 -0
- package/dist/sdk/browser/session.js +45 -9
- package/dist/sdk/cli.js +19 -11
- package/dist/sdk/core/bus.js +1 -1
- package/dist/sdk/core/env.d.ts +1 -1
- package/dist/sdk/core/env.js +1 -1
- package/dist/sdk/doctor/serverMethods.d.ts +3 -0
- package/dist/sdk/doctor/serverMethods.js +8 -0
- package/dist/sdk/harness.d.ts +15 -0
- package/dist/sdk/harness.js +44 -5
- package/dist/sdk/history/recall.js +8 -8
- package/dist/sdk/history/serverMethods.d.ts +3 -0
- package/dist/sdk/history/serverMethods.js +8 -0
- package/dist/sdk/hub/cli.d.ts +1 -1
- package/dist/sdk/hub/cli.js +1 -1
- package/dist/sdk/hub/schema.d.ts +1 -1
- package/dist/sdk/hub/serverMethods.d.ts +3 -0
- package/dist/sdk/hub/serverMethods.js +11 -0
- package/dist/sdk/index.d.ts +10 -0
- package/dist/sdk/index.js +7 -0
- package/dist/sdk/mcp/connections.js +14 -7
- package/dist/sdk/mcp/serverMethods.d.ts +3 -0
- package/dist/sdk/mcp/serverMethods.js +22 -0
- package/dist/sdk/plugins/builtin.d.ts +1 -0
- package/dist/sdk/plugins/builtin.js +128 -33
- package/dist/sdk/plugins/builtinRegistry.d.ts +53 -0
- package/dist/sdk/plugins/builtinRegistry.js +49 -0
- package/dist/sdk/plugins/cli.d.ts +1 -1
- package/dist/sdk/plugins/cli.js +4 -3
- package/dist/sdk/prompt/compile.d.ts +5 -2
- package/dist/sdk/prompt/compile.js +25 -8
- package/dist/sdk/refine/cli.d.ts +1 -1
- package/dist/sdk/refine/cli.js +2 -2
- package/dist/sdk/refine/serverMethods.d.ts +3 -0
- package/dist/sdk/refine/serverMethods.js +20 -0
- package/dist/sdk/server/methods.js +9 -86
- package/dist/sdk/server/params.d.ts +4 -0
- package/dist/sdk/server/params.js +11 -0
- package/dist/sdk/sessions/contracts.d.ts +17 -0
- package/dist/sdk/sessions/contracts.js +42 -3
- package/dist/sdk/sessions/planMode.d.ts +5 -2
- package/dist/sdk/sessions/planMode.js +12 -3
- package/dist/sdk/sessions/reasoningSummaries.d.ts +2 -2
- package/dist/sdk/sessions/reasoningSummaries.js +3 -3
- package/dist/sdk/sessions/registry.d.ts +1 -0
- package/dist/sdk/sessions/registry.js +4 -1
- package/dist/sdk/sessions/runIndex.js +7 -7
- package/dist/sdk/sessions/runStore.js +3 -1
- package/dist/sdk/sessions/runner.js +2 -2
- package/dist/sdk/sessions/sessions.d.ts +17 -5
- package/dist/sdk/sessions/sessions.js +79 -12
- package/dist/sdk/sessions/sideQuestionServerMethods.d.ts +3 -0
- package/dist/sdk/sessions/sideQuestionServerMethods.js +19 -0
- package/dist/sdk/sessions/sideQuestions.d.ts +66 -0
- package/dist/sdk/sessions/sideQuestions.js +290 -0
- package/dist/sdk/sessions/subagentServerMethods.d.ts +3 -0
- package/dist/sdk/sessions/subagentServerMethods.js +41 -0
- package/dist/sdk/sessions/subagentService.d.ts +13 -2
- package/dist/sdk/sessions/subagentService.js +34 -16
- package/dist/sdk/sessions/subagents.d.ts +9 -1
- package/dist/sdk/sessions/subagents.js +19 -0
- package/dist/sdk/terminal/browser.d.ts +62 -0
- package/dist/sdk/terminal/browser.js +41 -0
- package/dist/sdk/terminal/browserControl.d.ts +29 -0
- package/dist/sdk/terminal/browserControl.js +186 -0
- package/dist/sdk/terminal/commandHelp.d.ts +17 -0
- package/dist/sdk/terminal/commandHelp.js +20 -0
- package/dist/sdk/terminal/controller.d.ts +7 -1
- package/dist/sdk/terminal/controller.js +34 -2
- package/dist/sdk/terminal/opentui/subagentsWidget.d.ts +28 -0
- package/dist/sdk/terminal/opentui/subagentsWidget.js +52 -0
- package/dist/sdk/terminal/piExtension.js +33 -48
- package/dist/sdk/terminal/socket.d.ts +6 -3
- package/dist/sdk/terminal/socket.js +40 -9
- package/dist/sdk/terminal/standalone.d.ts +2 -0
- package/dist/sdk/terminal/standalone.js +50 -10
- package/dist/sdk/terminal/subagentMonitor.d.ts +38 -0
- package/dist/sdk/terminal/subagentMonitor.js +121 -0
- package/dist/sdk/terminal/terminals.d.ts +42 -6
- package/dist/sdk/terminal/terminals.js +359 -18
- package/dist/sdk/terminal/trajectory.js +2 -2
- package/dist/sdk/terminal/tui/annotateCommand.d.ts +3 -0
- package/dist/sdk/terminal/tui/annotateCommand.js +22 -0
- package/dist/sdk/terminal/tui/commandPages.d.ts +5 -1
- package/dist/sdk/terminal/tui/commandPages.js +54 -6
- package/dist/sdk/terminal/tui/commands.d.ts +8 -4
- package/dist/sdk/terminal/tui/commands.js +181 -58
- package/dist/sdk/terminal/tui/dialogGeometry.d.ts +2 -2
- package/dist/sdk/terminal/tui/dialogGeometry.js +4 -2
- package/dist/sdk/terminal/tui/dialogs.d.ts +6 -1
- package/dist/sdk/terminal/tui/dialogs.js +9 -2
- package/dist/sdk/terminal/tui/modelPicker.js +1 -6
- package/dist/sdk/terminal/tui/page.d.ts +3 -0
- package/dist/sdk/terminal/tui/pageOverlay.js +17 -6
- package/dist/sdk/terminal/tui/pages/accountsPage.d.ts +26 -4
- package/dist/sdk/terminal/tui/pages/accountsPage.js +264 -36
- package/dist/sdk/terminal/tui/pages/agentsPage.d.ts +74 -0
- package/dist/sdk/terminal/tui/pages/agentsPage.js +190 -0
- package/dist/sdk/terminal/tui/pages/annotatePage.d.ts +94 -0
- package/dist/sdk/terminal/tui/pages/annotatePage.js +341 -0
- package/dist/sdk/terminal/tui/pages/browserPage.d.ts +87 -0
- package/dist/sdk/terminal/tui/pages/browserPage.js +738 -0
- package/dist/sdk/terminal/tui/pages/doctorPage.d.ts +2 -0
- package/dist/sdk/terminal/tui/pages/doctorPage.js +103 -15
- package/dist/sdk/terminal/tui/pages/notesPage.d.ts +75 -0
- package/dist/sdk/terminal/tui/pages/notesPage.js +201 -0
- package/dist/sdk/terminal/tui/pages/settingsPage.d.ts +8 -0
- package/dist/sdk/terminal/tui/pages/settingsPage.js +16 -1
- package/dist/sdk/terminal/tui/pages/subagentsPage.d.ts +23 -0
- package/dist/sdk/terminal/tui/pages/subagentsPage.js +70 -0
- package/dist/sdk/terminal/tui/pages/trajectoryPage.d.ts +12 -5
- package/dist/sdk/terminal/tui/pages/trajectoryPage.js +156 -83
- package/dist/sdk/terminal/tui/pages/worktreesPage.d.ts +72 -0
- package/dist/sdk/terminal/tui/pages/worktreesPage.js +203 -0
- package/dist/sdk/terminal/tui/planMode.d.ts +1 -1
- package/dist/sdk/terminal/tui/planMode.js +1 -1
- package/dist/sdk/terminal/tui/pluginCommands.d.ts +14 -0
- package/dist/sdk/terminal/tui/pluginCommands.js +27 -0
- package/dist/sdk/terminal/tui/questions.d.ts +2 -0
- package/dist/sdk/terminal/tui/runtime.js +4 -2
- package/dist/sdk/terminal/tui/settingsModel.d.ts +2 -0
- package/dist/sdk/terminal/tui/settingsModel.js +32 -2
- package/dist/sdk/terminal/tui/sideQuestion.d.ts +26 -26
- package/dist/sdk/terminal/tui/sideQuestion.js +131 -98
- package/dist/sdk/terminal/tui/ui.d.ts +2 -0
- package/dist/sdk/terminal/tui/ui.js +1 -0
- package/dist/sdk/tools/askUser.js +26 -8
- package/dist/sdk/tools/checklist.js +2 -2
- package/dist/sdk/tools/harnessTools.d.ts +2 -0
- package/dist/sdk/tools/harnessTools.js +1 -1
- package/dist/sdk/tools/spawnSubagent.js +13 -4
- package/dist/sdk/web/part.d.ts +10 -0
- package/dist/sdk/web/part.js +6 -0
- package/dist/sdk/web/serverMethods.d.ts +3 -0
- package/dist/sdk/web/serverMethods.js +7 -0
- package/dist/sdk/wiki/serverMethods.d.ts +3 -0
- package/dist/sdk/wiki/serverMethods.js +26 -0
- package/dist/sdk/worktrees/cli.d.ts +11 -0
- package/dist/sdk/worktrees/cli.js +146 -0
- package/dist/sdk/worktrees/copy.d.ts +9 -0
- package/dist/sdk/worktrees/copy.js +111 -0
- package/dist/sdk/worktrees/git.d.ts +16 -0
- package/dist/sdk/worktrees/git.js +81 -0
- package/dist/sdk/worktrees/serverMethods.d.ts +3 -0
- package/dist/sdk/worktrees/serverMethods.js +30 -0
- package/dist/sdk/worktrees/service.d.ts +73 -0
- package/dist/sdk/worktrees/service.js +483 -0
- package/dist/sdk/worktrees/setup.d.ts +23 -0
- package/dist/sdk/worktrees/setup.js +65 -0
- package/dist/sdk/worktrees/types.d.ts +91 -0
- package/dist/sdk/worktrees/types.js +1 -0
- package/dist/sdk/world/cli.d.ts +2 -2
- package/dist/sdk/world/cli.js +6 -6
- package/dist/sdk/world/world.d.ts +3 -1
- package/dist/sdk/world/world.js +8 -9
- package/dist/terminal-opentui.js +14540 -7374
- package/dist/terminal-pi.js +285 -666
- package/docs/README.md +22 -2
- package/docs/architecture/00-overview.md +28 -16
- package/docs/architecture/02-sessions-and-runs.md +72 -15
- package/docs/architecture/03-variants.md +10 -2
- package/docs/architecture/04-prompt.md +26 -6
- package/docs/architecture/06-plugins-and-capabilities.md +39 -3
- package/docs/architecture/07-tools-and-gate.md +21 -6
- package/docs/architecture/09-accounts-and-connectors.md +8 -0
- package/docs/architecture/10-server-protocol.md +111 -2
- package/docs/architecture/11-terminal.md +185 -5
- package/docs/architecture/12-storage.md +15 -1
- package/docs/architecture/13-world-tools.md +28 -6
- package/docs/architecture/14-surfaces-and-parity.md +89 -0
- package/docs/architecture/README.md +15 -4
- package/docs/architecture/proposals/PROP-0006-run-checklist.md +4 -2
- package/docs/architecture/proposals/PROP-0013-resource-browser.md +58 -0
- package/docs/architecture/proposals/PROP-0014-fullscreen-browser-tabs.md +58 -0
- package/docs/architecture/proposals/PROP-0015-one-capability-surface.md +88 -0
- package/docs/architecture/proposals/PROP-0016-managed-worktrees.md +156 -0
- package/docs/architecture/proposals/PROP-0017-page-annotations.md +127 -0
- package/docs/architecture/proposals/PROP-0018-annotation-queue-and-app-targets.md +179 -0
- package/docs/architecture/proposals/PROP-0019-contextual-side-questions.md +122 -0
- package/docs/architecture/proposals/PROP-0020-builtin-feature-plugins.md +105 -0
- package/docs/architecture/proposals/PROP-0021-command-namespaces-and-duplicates.md +99 -0
- package/docs/architecture/proposals/PROP-0022-plan-only-questions.md +102 -0
- package/docs/architecture/proposals/PROP-0023-xagent-cli-name.md +61 -0
- package/docs/architecture/proposals/PROP-0024-question-sketch-previews.md +130 -0
- package/docs/architecture/proposals/PROP-0025-contextual-question-wireframes.md +49 -0
- package/docs/architecture/proposals/PROP-0026-connected-sketch-display.md +94 -0
- package/docs/architecture/proposals/PROP-0027-xingular-agent-product-identity.md +60 -0
- package/docs/architecture/proposals/PROP-0028-normalize-question-wireframes.md +55 -0
- package/docs/architecture/proposals/PROP-0029-agent-profiles.md +124 -0
- package/docs/architecture/proposals/PROP-0030-subagent-title.md +86 -0
- package/docs/architecture/proposals/README.md +25 -0
- package/docs/decisions/ADR-0009-xharness-command.md +6 -5
- package/docs/decisions/ADR-0014-public-release-baseline.md +3 -2
- package/docs/decisions/ADR-0015-resource-browser.md +28 -0
- package/docs/decisions/ADR-0016-fullscreen-browser-tabs.md +31 -0
- package/docs/decisions/ADR-0017-one-capability-surface.md +34 -0
- package/docs/decisions/ADR-0018-builtin-feature-plugins.md +35 -0
- package/docs/decisions/ADR-0019-managed-git-worktrees.md +31 -0
- package/docs/decisions/ADR-0020-page-annotations.md +37 -0
- package/docs/decisions/ADR-0021-required-command-namespaces.md +40 -0
- package/docs/decisions/ADR-0022-plan-only-question-tools.md +34 -0
- package/docs/decisions/ADR-0023-contextual-side-question-runs.md +35 -0
- package/docs/decisions/ADR-0024-xagent-command.md +40 -0
- package/docs/decisions/ADR-0025-question-choice-sketches.md +49 -0
- package/docs/decisions/ADR-0026-contextual-question-wireframes.md +58 -0
- package/docs/decisions/ADR-0027-connected-sketch-display.md +60 -0
- package/docs/decisions/ADR-0028-xingular-agent-product-identity.md +56 -0
- package/docs/decisions/ADR-0029-normalized-question-wireframe-borders.md +33 -0
- package/docs/decisions/ADR-0030-agent-profiles.md +43 -0
- package/docs/doctor.md +10 -4
- package/docs/features/README.md +26 -0
- package/docs/features/interfaces/README.md +1 -1
- package/docs/future-work.md +48 -0
- package/docs/parity.md +78 -0
- package/docs/releases/1.0.0.md +2 -2
- package/docs/releases/4.0.0.md +46 -0
- package/docs/releases/README.md +20 -15
- package/docs/study/00-index.md +5 -0
- package/docs/study/autonomous-engineering-sources.md +277 -0
- package/docs/study/autonomy-sources.md +147 -0
- package/docs/study/proactive-autonomy.md +408 -0
- package/docs/study/tui-resource-browser.md +78 -0
- package/docs/terminal-tui.md +325 -93
- package/package.json +3 -3
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
// The page bridge (PROP-0018): page notes in a web view that has no DevTools port, such as a Tauri
|
|
2
|
+
// window on macOS. The developer loads one script into their app while developing it; the script
|
|
3
|
+
// carries the page helper and pulls operations from this loopback server, answering each with the
|
|
4
|
+
// helper's JSON. Only 127.0.0.1 listens, every request carries the bridge token, and everything a
|
|
5
|
+
// window answers is untrusted page data.
|
|
6
|
+
import { randomBytes } from 'node:crypto';
|
|
7
|
+
import { chmodSync, existsSync } from 'node:fs';
|
|
8
|
+
import { createServer } from 'node:http';
|
|
9
|
+
import { join } from 'node:path';
|
|
10
|
+
import { readJson, writeJson } from "../core/fs.js";
|
|
11
|
+
import { PAGE_SCRIPT } from "./pageScript.js";
|
|
12
|
+
const POLL_MS = 20_000;
|
|
13
|
+
const GONE_MS = 45_000;
|
|
14
|
+
const CALL_MS = 10_000;
|
|
15
|
+
const MAX_BODY = 1_000_000;
|
|
16
|
+
const MAX_WINDOWS = 32;
|
|
17
|
+
const bounded = (value, size) => (typeof value === 'string' ? Array.from(value).slice(0, size).join('') : '');
|
|
18
|
+
/** The script a window loads: the page helper plus a loop that pulls operations from the bridge. */
|
|
19
|
+
export function bridgeScript(base, token) {
|
|
20
|
+
const helper = PAGE_SCRIPT.replace('__ARGS__', () => '__op');
|
|
21
|
+
return `(() => {
|
|
22
|
+
if (window.__harnessPageNotesBridge) return
|
|
23
|
+
window.__harnessPageNotesBridge = true
|
|
24
|
+
const base = ${JSON.stringify(base)}
|
|
25
|
+
const token = ${JSON.stringify(token)}
|
|
26
|
+
const call = (__op) => ${helper}
|
|
27
|
+
let windowId = null
|
|
28
|
+
const post = (path, body) => fetch(base + path, { method: 'POST', headers: { 'content-type': 'text/plain' }, body: JSON.stringify({ token, ...body }) })
|
|
29
|
+
.then(response => response.ok ? response.json() : Promise.reject(new Error(String(response.status))))
|
|
30
|
+
const pause = ms => new Promise(resolve => setTimeout(resolve, ms))
|
|
31
|
+
const loop = async () => {
|
|
32
|
+
for (;;) {
|
|
33
|
+
try {
|
|
34
|
+
if (!windowId) windowId = (await post('/register', { url: location.href, title: document.title })).windowId
|
|
35
|
+
const query = '?token=' + encodeURIComponent(token) + '&window=' + encodeURIComponent(windowId) + '&url=' + encodeURIComponent(location.href.slice(0, 2000)) + '&title=' + encodeURIComponent(document.title.slice(0, 200))
|
|
36
|
+
const response = await fetch(base + '/poll' + query)
|
|
37
|
+
if (response.status === 410) { windowId = null; continue }
|
|
38
|
+
if (response.status !== 200) { if (response.status !== 204) await pause(2000); continue }
|
|
39
|
+
const { id, operation } = await response.json()
|
|
40
|
+
let reply
|
|
41
|
+
try { reply = call(operation) } catch (error) { reply = JSON.stringify({ ok: false, error: String(error && error.message || error) }) }
|
|
42
|
+
await post('/reply', { window: windowId, id, reply })
|
|
43
|
+
} catch {
|
|
44
|
+
await pause(2000)
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', loop, { once: true })
|
|
49
|
+
else loop()
|
|
50
|
+
})()
|
|
51
|
+
`;
|
|
52
|
+
}
|
|
53
|
+
export class NotesBridge {
|
|
54
|
+
options;
|
|
55
|
+
server = null;
|
|
56
|
+
starting = null;
|
|
57
|
+
info = null;
|
|
58
|
+
windows = new Map();
|
|
59
|
+
nextWindow = 1;
|
|
60
|
+
nextCall = 1;
|
|
61
|
+
constructor(options) {
|
|
62
|
+
this.options = options;
|
|
63
|
+
}
|
|
64
|
+
settingsFile() {
|
|
65
|
+
return join(this.options.stateDir, 'notes-bridge.json');
|
|
66
|
+
}
|
|
67
|
+
/** Starts the bridge (once) on the port and token kept from last time, so the app's snippet stays valid. */
|
|
68
|
+
start() {
|
|
69
|
+
if (this.info)
|
|
70
|
+
return Promise.resolve(this.info);
|
|
71
|
+
this.starting ??= this.listen().finally(() => { this.starting = null; });
|
|
72
|
+
return this.starting;
|
|
73
|
+
}
|
|
74
|
+
async listen() {
|
|
75
|
+
const settingsFile = this.settingsFile();
|
|
76
|
+
if (existsSync(settingsFile))
|
|
77
|
+
chmodSync(settingsFile, 0o600);
|
|
78
|
+
const saved = existsSync(settingsFile) ? readJson(settingsFile) : null;
|
|
79
|
+
const token = typeof saved?.token === 'string' && /^[a-f0-9]{48}$/.test(saved.token) ? saved.token : randomBytes(24).toString('hex');
|
|
80
|
+
const wanted = this.options.port ?? (Number.isSafeInteger(saved?.port) ? saved.port : 0);
|
|
81
|
+
const server = createServer((request, response) => void this.handle(request, response, token));
|
|
82
|
+
const port = await new Promise((resolve, reject) => {
|
|
83
|
+
const attempt = (port, retry) => {
|
|
84
|
+
server.once('error', (error) => {
|
|
85
|
+
if (retry && error.code === 'EADDRINUSE')
|
|
86
|
+
attempt(0, false);
|
|
87
|
+
else
|
|
88
|
+
reject(error);
|
|
89
|
+
});
|
|
90
|
+
server.listen(port, '127.0.0.1', () => resolve(server.address().port));
|
|
91
|
+
};
|
|
92
|
+
attempt(wanted, wanted !== 0);
|
|
93
|
+
});
|
|
94
|
+
this.server = server;
|
|
95
|
+
writeJson(settingsFile, { port, token });
|
|
96
|
+
chmodSync(settingsFile, 0o600);
|
|
97
|
+
const base = `http://127.0.0.1:${port}`;
|
|
98
|
+
const scriptUrl = `${base}/page-notes.js?token=${token}`;
|
|
99
|
+
this.info = {
|
|
100
|
+
port,
|
|
101
|
+
token,
|
|
102
|
+
scriptUrl,
|
|
103
|
+
snippet: `<script src="${scriptUrl}"></script>`
|
|
104
|
+
};
|
|
105
|
+
return this.info;
|
|
106
|
+
}
|
|
107
|
+
list() {
|
|
108
|
+
this.sweep();
|
|
109
|
+
return [...this.windows.values()].map(({ id, url, title, lastSeen }) => ({ id, url, title, lastSeen }));
|
|
110
|
+
}
|
|
111
|
+
/** Sends one helper operation to a window and waits for its JSON reply. */
|
|
112
|
+
call(windowId, operation) {
|
|
113
|
+
this.sweep();
|
|
114
|
+
const live = this.windows.get(windowId);
|
|
115
|
+
if (!live)
|
|
116
|
+
return Promise.reject(new Error('That app window is no longer connected.'));
|
|
117
|
+
return new Promise((resolve, reject) => {
|
|
118
|
+
const id = String(this.nextCall++);
|
|
119
|
+
const pending = {
|
|
120
|
+
id, operation, resolve, reject,
|
|
121
|
+
timer: setTimeout(() => {
|
|
122
|
+
live.sent.delete(id);
|
|
123
|
+
live.queue = live.queue.filter((entry) => entry !== pending);
|
|
124
|
+
reject(new Error('The app window did not answer.'));
|
|
125
|
+
}, CALL_MS)
|
|
126
|
+
};
|
|
127
|
+
live.queue.push(pending);
|
|
128
|
+
this.flush(live);
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
flush(live) {
|
|
132
|
+
if (!live.waiter || !live.queue.length)
|
|
133
|
+
return;
|
|
134
|
+
const pending = live.queue.shift();
|
|
135
|
+
live.sent.set(pending.id, pending);
|
|
136
|
+
const waiter = live.waiter;
|
|
137
|
+
live.waiter = null;
|
|
138
|
+
if (live.waiterTimer)
|
|
139
|
+
clearTimeout(live.waiterTimer);
|
|
140
|
+
live.waiterTimer = null;
|
|
141
|
+
this.json(waiter, 200, { id: pending.id, operation: pending.operation });
|
|
142
|
+
}
|
|
143
|
+
sweep() {
|
|
144
|
+
const now = Date.now();
|
|
145
|
+
for (const live of this.windows.values()) {
|
|
146
|
+
if (now - live.lastSeen <= GONE_MS || live.waiter)
|
|
147
|
+
continue;
|
|
148
|
+
this.windows.delete(live.id);
|
|
149
|
+
for (const pending of [...live.queue, ...live.sent.values()]) {
|
|
150
|
+
clearTimeout(pending.timer);
|
|
151
|
+
pending.reject(new Error('That app window is no longer connected.'));
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
json(response, status, body) {
|
|
156
|
+
response.writeHead(status, { 'content-type': 'application/json', 'access-control-allow-origin': '*', 'cache-control': 'no-store' });
|
|
157
|
+
response.end(body === undefined ? undefined : JSON.stringify(body));
|
|
158
|
+
}
|
|
159
|
+
async body(request) {
|
|
160
|
+
let size = 0;
|
|
161
|
+
const chunks = [];
|
|
162
|
+
for await (const chunk of request) {
|
|
163
|
+
size += chunk.length;
|
|
164
|
+
if (size > MAX_BODY)
|
|
165
|
+
return null;
|
|
166
|
+
chunks.push(chunk);
|
|
167
|
+
}
|
|
168
|
+
try {
|
|
169
|
+
const value = JSON.parse(Buffer.concat(chunks).toString('utf8'));
|
|
170
|
+
return value && typeof value === 'object' ? value : null;
|
|
171
|
+
}
|
|
172
|
+
catch {
|
|
173
|
+
return null;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
async handle(request, response, token) {
|
|
177
|
+
try {
|
|
178
|
+
const port = this.server?.address()?.port;
|
|
179
|
+
// Refuse other host names, so a web page cannot reach the bridge by DNS rebinding.
|
|
180
|
+
if (request.headers.host !== `127.0.0.1:${port}` && request.headers.host !== `localhost:${port}`)
|
|
181
|
+
return this.json(response, 421);
|
|
182
|
+
const url = new URL(request.url ?? '/', `http://127.0.0.1:${port}`);
|
|
183
|
+
if (request.method === 'OPTIONS')
|
|
184
|
+
return this.json(response, 204);
|
|
185
|
+
if (request.method === 'GET' && url.pathname === '/page-notes.js') {
|
|
186
|
+
if (url.searchParams.get('token') !== token)
|
|
187
|
+
return this.json(response, 403);
|
|
188
|
+
response.writeHead(200, { 'content-type': 'application/javascript; charset=utf-8', 'access-control-allow-origin': '*', 'cache-control': 'no-store' });
|
|
189
|
+
response.end(bridgeScript(`http://127.0.0.1:${port}`, token));
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
if (request.method === 'GET' && url.pathname === '/poll') {
|
|
193
|
+
if (url.searchParams.get('token') !== token)
|
|
194
|
+
return this.json(response, 403);
|
|
195
|
+
const live = this.windows.get(url.searchParams.get('window') ?? '');
|
|
196
|
+
if (!live)
|
|
197
|
+
return this.json(response, 410);
|
|
198
|
+
live.lastSeen = Date.now();
|
|
199
|
+
live.url = bounded(url.searchParams.get('url'), 2000) || live.url;
|
|
200
|
+
live.title = bounded(url.searchParams.get('title'), 200);
|
|
201
|
+
if (live.waiter)
|
|
202
|
+
this.json(live.waiter, 204);
|
|
203
|
+
live.waiter = response;
|
|
204
|
+
live.waiterTimer = setTimeout(() => {
|
|
205
|
+
if (live.waiter !== response)
|
|
206
|
+
return;
|
|
207
|
+
live.waiter = null;
|
|
208
|
+
this.json(response, 204);
|
|
209
|
+
}, POLL_MS);
|
|
210
|
+
response.on('close', () => { if (live.waiter === response)
|
|
211
|
+
live.waiter = null; });
|
|
212
|
+
this.flush(live);
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
if (request.method === 'POST' && (url.pathname === '/register' || url.pathname === '/reply')) {
|
|
216
|
+
const body = await this.body(request);
|
|
217
|
+
if (!body || body.token !== token)
|
|
218
|
+
return this.json(response, 403);
|
|
219
|
+
if (url.pathname === '/register') {
|
|
220
|
+
this.sweep();
|
|
221
|
+
if (this.windows.size >= MAX_WINDOWS)
|
|
222
|
+
return this.json(response, 429);
|
|
223
|
+
const id = `a${this.nextWindow++}`;
|
|
224
|
+
this.windows.set(id, { id, url: bounded(body.url, 2000), title: bounded(body.title, 200), lastSeen: Date.now(), queue: [], sent: new Map(), waiter: null, waiterTimer: null });
|
|
225
|
+
return this.json(response, 200, { windowId: id });
|
|
226
|
+
}
|
|
227
|
+
const live = this.windows.get(typeof body.window === 'string' ? body.window : '');
|
|
228
|
+
const pending = live?.sent.get(typeof body.id === 'string' ? body.id : '');
|
|
229
|
+
if (!live || !pending)
|
|
230
|
+
return this.json(response, 410);
|
|
231
|
+
live.sent.delete(pending.id);
|
|
232
|
+
clearTimeout(pending.timer);
|
|
233
|
+
pending.resolve(body.reply);
|
|
234
|
+
return this.json(response, 200, {});
|
|
235
|
+
}
|
|
236
|
+
this.json(response, 404);
|
|
237
|
+
}
|
|
238
|
+
catch {
|
|
239
|
+
if (!response.headersSent)
|
|
240
|
+
this.json(response, 500);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
async close() {
|
|
244
|
+
for (const live of this.windows.values()) {
|
|
245
|
+
if (live.waiter)
|
|
246
|
+
this.json(live.waiter, 204);
|
|
247
|
+
if (live.waiterTimer)
|
|
248
|
+
clearTimeout(live.waiterTimer);
|
|
249
|
+
for (const pending of [...live.queue, ...live.sent.values()]) {
|
|
250
|
+
clearTimeout(pending.timer);
|
|
251
|
+
pending.reject(new Error('The page bridge closed.'));
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
this.windows.clear();
|
|
255
|
+
const server = this.server;
|
|
256
|
+
this.server = null;
|
|
257
|
+
this.info = null;
|
|
258
|
+
if (server) {
|
|
259
|
+
const closed = new Promise((resolve) => server.close(() => resolve()));
|
|
260
|
+
server.closeAllConnections();
|
|
261
|
+
await closed;
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import type { Browser } from 'playwright-core';
|
|
2
|
+
import { NetworkPolicy } from "../browser/network.js";
|
|
3
|
+
import { type Launcher } from "../browser/session.js";
|
|
4
|
+
import { NotesBridge, type BridgeInfo } from "./bridge.js";
|
|
5
|
+
import { type NoteApp, type NoteTarget } from "./store.js";
|
|
6
|
+
export interface CaptureEnvelope {
|
|
7
|
+
url: string;
|
|
8
|
+
title: string;
|
|
9
|
+
documentId: string;
|
|
10
|
+
target: NoteTarget;
|
|
11
|
+
}
|
|
12
|
+
export type CaptureTargetSpec = {
|
|
13
|
+
kind: 'web';
|
|
14
|
+
} | {
|
|
15
|
+
kind: 'cdp';
|
|
16
|
+
endpoint: string;
|
|
17
|
+
name?: string | null;
|
|
18
|
+
} | {
|
|
19
|
+
kind: 'bridge';
|
|
20
|
+
name?: string | null;
|
|
21
|
+
};
|
|
22
|
+
export interface CaptureWindow {
|
|
23
|
+
id: string;
|
|
24
|
+
url: string;
|
|
25
|
+
title: string;
|
|
26
|
+
current: boolean;
|
|
27
|
+
}
|
|
28
|
+
/** One comment the person submitted in the inspector's popover. */
|
|
29
|
+
export interface InspectResult extends CaptureEnvelope {
|
|
30
|
+
body: string;
|
|
31
|
+
action: 'save' | 'queue' | 'send';
|
|
32
|
+
}
|
|
33
|
+
export interface InspectState {
|
|
34
|
+
/** False once the person or the page ended it. */
|
|
35
|
+
active: boolean;
|
|
36
|
+
url: string;
|
|
37
|
+
title: string;
|
|
38
|
+
documentId: string;
|
|
39
|
+
result: InspectResult | null;
|
|
40
|
+
/** Whether the result came from a script world the page cannot reach. */
|
|
41
|
+
trusted: boolean;
|
|
42
|
+
/** The app the window belongs to, for the note's source. */
|
|
43
|
+
app: NoteApp | null;
|
|
44
|
+
}
|
|
45
|
+
/** Opens a DevTools connection to a running app; tests replace it. */
|
|
46
|
+
export type AppConnector = (endpoint: string) => Promise<Browser>;
|
|
47
|
+
export interface AnnotationCaptureOptions {
|
|
48
|
+
stateDir: string;
|
|
49
|
+
launch?: Launcher;
|
|
50
|
+
network?: NetworkPolicy;
|
|
51
|
+
connect?: AppConnector;
|
|
52
|
+
bridge?: NotesBridge;
|
|
53
|
+
}
|
|
54
|
+
export declare class AnnotationCapture {
|
|
55
|
+
private readonly options;
|
|
56
|
+
private readonly targets;
|
|
57
|
+
private readonly inspecting;
|
|
58
|
+
private readonly network;
|
|
59
|
+
readonly bridge: NotesBridge;
|
|
60
|
+
constructor(options: AnnotationCaptureOptions);
|
|
61
|
+
private web;
|
|
62
|
+
private target;
|
|
63
|
+
private replace;
|
|
64
|
+
private call;
|
|
65
|
+
/** What `key` captures in now. */
|
|
66
|
+
describe(key: string): {
|
|
67
|
+
kind: CaptureTargetSpec['kind'];
|
|
68
|
+
app: NoteApp | null;
|
|
69
|
+
inspecting: boolean;
|
|
70
|
+
};
|
|
71
|
+
/** Captures in an app's windows instead of the browser: over its DevTools port, or the page bridge. */
|
|
72
|
+
attach(key: string, spec: CaptureTargetSpec): Promise<{
|
|
73
|
+
kind: CaptureTargetSpec['kind'];
|
|
74
|
+
app: NoteApp | null;
|
|
75
|
+
windows: CaptureWindow[];
|
|
76
|
+
bridge?: BridgeInfo;
|
|
77
|
+
}>;
|
|
78
|
+
windows(key: string): Promise<CaptureWindow[]>;
|
|
79
|
+
/** Switches to another window or tab of the target; an inspector there starts again. */
|
|
80
|
+
selectWindow(key: string, id: string): Promise<void>;
|
|
81
|
+
/** Shows the capture window, at `url` when given. Captures in the browser again if an app was attached. */
|
|
82
|
+
open(key: string, url?: string | null): Promise<{
|
|
83
|
+
url: string;
|
|
84
|
+
title: string;
|
|
85
|
+
}>;
|
|
86
|
+
identity(key: string): Promise<{
|
|
87
|
+
url: string;
|
|
88
|
+
title: string;
|
|
89
|
+
documentId: string;
|
|
90
|
+
}>;
|
|
91
|
+
/** Starts listening for the person's selection or click. Returns the id `capture` takes. */
|
|
92
|
+
begin(key: string, kind: 'text' | 'element'): Promise<{
|
|
93
|
+
captureId: string;
|
|
94
|
+
}>;
|
|
95
|
+
/** What the person selected, or null when nothing is selected yet. */
|
|
96
|
+
capture(key: string, captureId: string): Promise<CaptureEnvelope | null>;
|
|
97
|
+
cancel(key: string, captureId?: string): Promise<void>;
|
|
98
|
+
/** Turns on the inspector in the current window: hover outlines, a click opens the comment popover. */
|
|
99
|
+
inspect(key: string): Promise<{
|
|
100
|
+
captureId: string;
|
|
101
|
+
}>;
|
|
102
|
+
stopInspect(key: string): Promise<void>;
|
|
103
|
+
/**
|
|
104
|
+
* The next comment the person submitted, if any. A new document (a reload, a link) loses the
|
|
105
|
+
* inspector; it is turned on again there, so the person can keep going.
|
|
106
|
+
*/
|
|
107
|
+
next(key: string): Promise<InspectState>;
|
|
108
|
+
toast(key: string, text: string): Promise<void>;
|
|
109
|
+
/** Opens the note's page when another one is showing, then scrolls to and marks its target. */
|
|
110
|
+
reveal(key: string, url: string, target: NoteTarget): Promise<{
|
|
111
|
+
state: 'matched' | 'ambiguous' | 'unresolved';
|
|
112
|
+
reason?: string;
|
|
113
|
+
}>;
|
|
114
|
+
release(key: string): Promise<void>;
|
|
115
|
+
close(): Promise<void>;
|
|
116
|
+
}
|
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
// The terminal's capture surface for page notes (PROP-0017, PROP-0018). A front end captures in
|
|
2
|
+
// one target at a time:
|
|
3
|
+
// - `web`: a visible browser window the person browses in, separate from the agent's browser;
|
|
4
|
+
// - `cdp`: the windows of a desktop app started with a DevTools port (an Electron app, say);
|
|
5
|
+
// - `bridge`: app windows that loaded the page bridge script (a macOS web view has no DevTools port).
|
|
6
|
+
// The person selects text, clicks an element, or inspects with the popover; the front end pulls
|
|
7
|
+
// what they did through the page helper.
|
|
8
|
+
import { randomUUID } from 'node:crypto';
|
|
9
|
+
import { join } from 'node:path';
|
|
10
|
+
import { readBrowserConfig } from "../browser/config.js";
|
|
11
|
+
import { NetworkPolicy } from "../browser/network.js";
|
|
12
|
+
import { BrowserSession, fileName, launchBrowser } from "../browser/session.js";
|
|
13
|
+
import { NotesBridge } from "./bridge.js";
|
|
14
|
+
import { evaluateIsolated } from "./isolated.js";
|
|
15
|
+
import { pageCall, pageReply } from "./pageScript.js";
|
|
16
|
+
import { validateUrl } from "./store.js";
|
|
17
|
+
const IDLE_MS = 60 * 60_000;
|
|
18
|
+
const LOOPBACK = /^(?:https?|wss?):\/\/(?:127\.0\.0\.1|localhost|\[::1\])(?::\d+)?(?:\/|$)/;
|
|
19
|
+
const connectApp = async (endpoint) => {
|
|
20
|
+
const { chromium } = await import('playwright-core');
|
|
21
|
+
return chromium.connectOverCDP(endpoint);
|
|
22
|
+
};
|
|
23
|
+
/** An app's own windows over its DevTools port. Nothing is routed or opened, and the app is never closed. */
|
|
24
|
+
class CdpTarget {
|
|
25
|
+
browser;
|
|
26
|
+
app;
|
|
27
|
+
kind = 'cdp';
|
|
28
|
+
ids = new WeakMap();
|
|
29
|
+
nextId = 1;
|
|
30
|
+
current = null;
|
|
31
|
+
constructor(browser, app) {
|
|
32
|
+
this.browser = browser;
|
|
33
|
+
this.app = app;
|
|
34
|
+
}
|
|
35
|
+
pages() {
|
|
36
|
+
return this.browser.contexts().flatMap((context) => context.pages()).filter((page) => !/^(?:devtools|chrome-extension):/.test(page.url()));
|
|
37
|
+
}
|
|
38
|
+
id(page) {
|
|
39
|
+
let id = this.ids.get(page);
|
|
40
|
+
if (!id) {
|
|
41
|
+
id = `w${this.nextId++}`;
|
|
42
|
+
this.ids.set(page, id);
|
|
43
|
+
}
|
|
44
|
+
return id;
|
|
45
|
+
}
|
|
46
|
+
page() {
|
|
47
|
+
const pages = this.pages();
|
|
48
|
+
if (this.current && pages.includes(this.current))
|
|
49
|
+
return this.current;
|
|
50
|
+
this.current = pages[0] ?? null;
|
|
51
|
+
if (!this.current)
|
|
52
|
+
throw new Error('The app has no open window.');
|
|
53
|
+
return this.current;
|
|
54
|
+
}
|
|
55
|
+
async evaluate(operation) {
|
|
56
|
+
return evaluateIsolated(this.page(), pageCall(operation));
|
|
57
|
+
}
|
|
58
|
+
async windows() {
|
|
59
|
+
const current = this.pages().length ? this.page() : null;
|
|
60
|
+
return Promise.all(this.pages().map(async (page) => ({ id: this.id(page), url: page.url(), title: await page.title().catch(() => ''), current: page === current })));
|
|
61
|
+
}
|
|
62
|
+
select(id) {
|
|
63
|
+
const page = this.pages().find((candidate) => this.ids.get(candidate) === id);
|
|
64
|
+
if (!page)
|
|
65
|
+
throw new Error(`There is no app window ${id}.`);
|
|
66
|
+
this.current = page;
|
|
67
|
+
void page.bringToFront().catch(() => undefined);
|
|
68
|
+
}
|
|
69
|
+
async close() {
|
|
70
|
+
// Disconnects; an app we attached to keeps running.
|
|
71
|
+
await this.browser.close().catch(() => undefined);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
/** Windows that loaded the page bridge script. Every answer is page data the page could forge. */
|
|
75
|
+
class BridgeTarget {
|
|
76
|
+
bridge;
|
|
77
|
+
app;
|
|
78
|
+
kind = 'bridge';
|
|
79
|
+
current = null;
|
|
80
|
+
constructor(bridge, app) {
|
|
81
|
+
this.bridge = bridge;
|
|
82
|
+
this.app = app;
|
|
83
|
+
}
|
|
84
|
+
window() {
|
|
85
|
+
const windows = this.bridge.list();
|
|
86
|
+
if (this.current && windows.some((window) => window.id === this.current))
|
|
87
|
+
return this.current;
|
|
88
|
+
this.current = windows.sort((left, right) => right.lastSeen - left.lastSeen)[0]?.id ?? null;
|
|
89
|
+
if (!this.current)
|
|
90
|
+
throw new Error('No app window has connected to the page bridge yet. Load the bridge script in the app and reload it.');
|
|
91
|
+
return this.current;
|
|
92
|
+
}
|
|
93
|
+
async evaluate(operation) {
|
|
94
|
+
return { reply: await this.bridge.call(this.window(), operation), trusted: false };
|
|
95
|
+
}
|
|
96
|
+
async windows() {
|
|
97
|
+
const current = this.bridge.list().length ? this.window() : null;
|
|
98
|
+
return this.bridge.list().map((window) => ({ id: window.id, url: window.url, title: window.title, current: window.id === current }));
|
|
99
|
+
}
|
|
100
|
+
select(id) {
|
|
101
|
+
if (!this.bridge.list().some((window) => window.id === id))
|
|
102
|
+
throw new Error(`There is no app window ${id}.`);
|
|
103
|
+
this.current = id;
|
|
104
|
+
}
|
|
105
|
+
async close() { }
|
|
106
|
+
}
|
|
107
|
+
/** The visible browser window, with the helper in an isolated world. */
|
|
108
|
+
class WebTarget {
|
|
109
|
+
session;
|
|
110
|
+
kind = 'web';
|
|
111
|
+
app = null;
|
|
112
|
+
constructor(session) {
|
|
113
|
+
this.session = session;
|
|
114
|
+
}
|
|
115
|
+
evaluate(operation) {
|
|
116
|
+
return this.session.run(async () => evaluateIsolated((await this.session.tab()).page, pageCall(operation)));
|
|
117
|
+
}
|
|
118
|
+
async windows() {
|
|
119
|
+
if (!this.session.started)
|
|
120
|
+
return [];
|
|
121
|
+
return Promise.all(this.session.list().map(async (tab) => ({ id: tab.id, url: tab.url, title: await (await this.session.tab(tab.id)).page.title().catch(() => ''), current: tab.current })));
|
|
122
|
+
}
|
|
123
|
+
select(id) {
|
|
124
|
+
void this.session.select(id).page.bringToFront().catch(() => undefined);
|
|
125
|
+
}
|
|
126
|
+
close() {
|
|
127
|
+
return this.session.close();
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
export class AnnotationCapture {
|
|
131
|
+
options;
|
|
132
|
+
targets = new Map();
|
|
133
|
+
inspecting = new Map();
|
|
134
|
+
network;
|
|
135
|
+
bridge;
|
|
136
|
+
constructor(options) {
|
|
137
|
+
this.options = options;
|
|
138
|
+
this.network = options.network ?? new NetworkPolicy(() => readBrowserConfig(options.stateDir));
|
|
139
|
+
this.bridge = options.bridge ?? new NotesBridge({ stateDir: options.stateDir });
|
|
140
|
+
}
|
|
141
|
+
web(key) {
|
|
142
|
+
const existing = this.targets.get(key);
|
|
143
|
+
if (existing instanceof WebTarget)
|
|
144
|
+
return existing;
|
|
145
|
+
const session = new BrowserSession({
|
|
146
|
+
key: `notes-${key}`,
|
|
147
|
+
profileDir: join(this.options.stateDir, 'browser', 'profiles', 'person'),
|
|
148
|
+
downloadsDir: join(this.options.stateDir, 'browser', 'downloads', fileName(`notes-${key}`)),
|
|
149
|
+
// Always a window the person can see and select in.
|
|
150
|
+
config: () => ({ ...readBrowserConfig(this.options.stateDir), headed: true }),
|
|
151
|
+
network: this.network,
|
|
152
|
+
launch: this.options.launch ?? launchBrowser,
|
|
153
|
+
idleMs: IDLE_MS,
|
|
154
|
+
onIdle: () => void this.release(key)
|
|
155
|
+
});
|
|
156
|
+
const target = new WebTarget(session);
|
|
157
|
+
this.targets.set(key, target);
|
|
158
|
+
return target;
|
|
159
|
+
}
|
|
160
|
+
target(key) {
|
|
161
|
+
return this.targets.get(key) ?? this.web(key);
|
|
162
|
+
}
|
|
163
|
+
async replace(key, target) {
|
|
164
|
+
const old = this.targets.get(key);
|
|
165
|
+
this.targets.set(key, target);
|
|
166
|
+
this.inspecting.delete(key);
|
|
167
|
+
if (old && old !== target)
|
|
168
|
+
await old.close().catch(() => undefined);
|
|
169
|
+
}
|
|
170
|
+
async call(key, operation) {
|
|
171
|
+
return pageReply((await this.target(key).evaluate(operation)).reply);
|
|
172
|
+
}
|
|
173
|
+
/** What `key` captures in now. */
|
|
174
|
+
describe(key) {
|
|
175
|
+
const target = this.targets.get(key);
|
|
176
|
+
return { kind: target?.kind ?? 'web', app: target?.app ?? null, inspecting: this.inspecting.has(key) };
|
|
177
|
+
}
|
|
178
|
+
/** Captures in an app's windows instead of the browser: over its DevTools port, or the page bridge. */
|
|
179
|
+
async attach(key, spec) {
|
|
180
|
+
if (spec.kind === 'web') {
|
|
181
|
+
if (this.targets.get(key)?.kind !== 'web')
|
|
182
|
+
await this.replace(key, this.web(key));
|
|
183
|
+
return { kind: 'web', app: null, windows: await this.target(key).windows() };
|
|
184
|
+
}
|
|
185
|
+
if (spec.kind === 'cdp') {
|
|
186
|
+
if (typeof spec.endpoint !== 'string' || !LOOPBACK.test(spec.endpoint))
|
|
187
|
+
throw new Error('An app\'s DevTools address must be on this computer, such as http://127.0.0.1:9229.');
|
|
188
|
+
const browser = await (this.options.connect ?? connectApp)(spec.endpoint);
|
|
189
|
+
const name = spec.name?.trim() || `App at ${new URL(spec.endpoint).host}`;
|
|
190
|
+
const target = new CdpTarget(browser, { kind: 'cdp', name: name.slice(0, 200) });
|
|
191
|
+
await this.replace(key, target);
|
|
192
|
+
return { kind: 'cdp', app: target.app, windows: await target.windows() };
|
|
193
|
+
}
|
|
194
|
+
if (spec.kind === 'bridge') {
|
|
195
|
+
const bridge = await this.bridge.start();
|
|
196
|
+
const target = new BridgeTarget(this.bridge, { kind: 'bridge', name: (spec.name?.trim() || 'App window').slice(0, 200) });
|
|
197
|
+
await this.replace(key, target);
|
|
198
|
+
return { kind: 'bridge', app: target.app, windows: await target.windows(), bridge };
|
|
199
|
+
}
|
|
200
|
+
throw new Error('A capture target is web, cdp or bridge.');
|
|
201
|
+
}
|
|
202
|
+
windows(key) {
|
|
203
|
+
return this.target(key).windows();
|
|
204
|
+
}
|
|
205
|
+
/** Switches to another window or tab of the target; an inspector there starts again. */
|
|
206
|
+
async selectWindow(key, id) {
|
|
207
|
+
const inspecting = this.inspecting.has(key);
|
|
208
|
+
if (inspecting)
|
|
209
|
+
await this.stopInspect(key).catch(() => undefined);
|
|
210
|
+
this.target(key).select(id);
|
|
211
|
+
if (inspecting)
|
|
212
|
+
await this.inspect(key);
|
|
213
|
+
}
|
|
214
|
+
/** Shows the capture window, at `url` when given. Captures in the browser again if an app was attached. */
|
|
215
|
+
async open(key, url) {
|
|
216
|
+
if (this.targets.get(key) && this.targets.get(key).kind !== 'web')
|
|
217
|
+
await this.replace(key, this.web(key));
|
|
218
|
+
const { session } = this.web(key);
|
|
219
|
+
return session.run(async () => {
|
|
220
|
+
const tab = await session.tab();
|
|
221
|
+
if (url) {
|
|
222
|
+
const address = validateUrl(url);
|
|
223
|
+
const refusal = await this.network.refusal(address);
|
|
224
|
+
if (refusal)
|
|
225
|
+
throw new Error(refusal);
|
|
226
|
+
await tab.page.goto(address, { waitUntil: 'domcontentloaded' });
|
|
227
|
+
}
|
|
228
|
+
await tab.page.bringToFront().catch(() => undefined);
|
|
229
|
+
return { url: tab.page.url(), title: await tab.page.title().catch(() => '') };
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
identity(key) {
|
|
233
|
+
return this.call(key, { operation: 'identity' });
|
|
234
|
+
}
|
|
235
|
+
/** Starts listening for the person's selection or click. Returns the id `capture` takes. */
|
|
236
|
+
async begin(key, kind) {
|
|
237
|
+
this.inspecting.delete(key);
|
|
238
|
+
const captureId = randomUUID();
|
|
239
|
+
await this.call(key, { operation: 'begin', kind, captureId });
|
|
240
|
+
return { captureId };
|
|
241
|
+
}
|
|
242
|
+
/** What the person selected, or null when nothing is selected yet. */
|
|
243
|
+
capture(key, captureId) {
|
|
244
|
+
return this.call(key, { operation: 'capture', captureId });
|
|
245
|
+
}
|
|
246
|
+
async cancel(key, captureId) {
|
|
247
|
+
if (!this.targets.has(key))
|
|
248
|
+
return;
|
|
249
|
+
await this.call(key, { operation: 'cancel', ...(captureId ? { captureId } : {}) });
|
|
250
|
+
}
|
|
251
|
+
/** Turns on the inspector in the current window: hover outlines, a click opens the comment popover. */
|
|
252
|
+
async inspect(key) {
|
|
253
|
+
const captureId = randomUUID();
|
|
254
|
+
await this.call(key, { operation: 'begin', kind: 'inspect', captureId });
|
|
255
|
+
const identity = await this.identity(key).catch(() => null);
|
|
256
|
+
this.inspecting.set(key, { captureId, documentId: identity?.documentId ?? null });
|
|
257
|
+
return { captureId };
|
|
258
|
+
}
|
|
259
|
+
async stopInspect(key) {
|
|
260
|
+
const state = this.inspecting.get(key);
|
|
261
|
+
this.inspecting.delete(key);
|
|
262
|
+
if (state && this.targets.has(key))
|
|
263
|
+
await this.call(key, { operation: 'cancel', captureId: state.captureId }).catch(() => undefined);
|
|
264
|
+
}
|
|
265
|
+
/**
|
|
266
|
+
* The next comment the person submitted, if any. A new document (a reload, a link) loses the
|
|
267
|
+
* inspector; it is turned on again there, so the person can keep going.
|
|
268
|
+
*/
|
|
269
|
+
async next(key) {
|
|
270
|
+
const target = this.target(key);
|
|
271
|
+
const state = this.inspecting.get(key);
|
|
272
|
+
if (!state)
|
|
273
|
+
throw new Error('The inspector is off.');
|
|
274
|
+
const { reply, trusted } = await target.evaluate({ operation: 'next', captureId: state.captureId });
|
|
275
|
+
const value = pageReply(reply);
|
|
276
|
+
if (!value.active && this.inspecting.get(key) === state && value.documentId !== state.documentId) {
|
|
277
|
+
await target.evaluate({ operation: 'begin', kind: 'inspect', captureId: state.captureId });
|
|
278
|
+
state.documentId = value.documentId;
|
|
279
|
+
return { ...value, active: true, result: null, trusted, app: target.app };
|
|
280
|
+
}
|
|
281
|
+
if (!value.active)
|
|
282
|
+
this.inspecting.delete(key);
|
|
283
|
+
return { ...value, trusted, app: target.app };
|
|
284
|
+
}
|
|
285
|
+
async toast(key, text) {
|
|
286
|
+
await this.call(key, { operation: 'toast', text: text.slice(0, 200) });
|
|
287
|
+
}
|
|
288
|
+
/** Opens the note's page when another one is showing, then scrolls to and marks its target. */
|
|
289
|
+
async reveal(key, url, target) {
|
|
290
|
+
const current = this.targets.get(key);
|
|
291
|
+
if (!current || current.kind === 'web') {
|
|
292
|
+
const shown = current?.session.started ? await this.identity(key).catch(() => null) : null;
|
|
293
|
+
if (shown?.url !== url)
|
|
294
|
+
await this.open(key, url);
|
|
295
|
+
}
|
|
296
|
+
return this.call(key, { operation: 'reveal', target });
|
|
297
|
+
}
|
|
298
|
+
async release(key) {
|
|
299
|
+
const target = this.targets.get(key);
|
|
300
|
+
this.targets.delete(key);
|
|
301
|
+
this.inspecting.delete(key);
|
|
302
|
+
await target?.close();
|
|
303
|
+
}
|
|
304
|
+
async close() {
|
|
305
|
+
await Promise.all([...this.targets.keys()].map((key) => this.release(key)));
|
|
306
|
+
await this.bridge.close();
|
|
307
|
+
}
|
|
308
|
+
}
|