@xingular/harness 1.3.1-unstable.0 → 1.3.1-unstable.2
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 +29 -1
- package/README.md +16 -12
- package/builtin/skills/plugin-authoring/SKILL.md +34 -0
- package/dist/cli.js +2334 -1715
- package/dist/sdk/agents/profiles.d.ts +13 -4
- package/dist/sdk/agents/profiles.js +59 -7
- package/dist/sdk/annotations/bridge.d.ts +2 -0
- package/dist/sdk/annotations/bridge.js +4 -0
- package/dist/sdk/annotations/capture.d.ts +44 -2
- package/dist/sdk/annotations/capture.js +104 -3
- package/dist/sdk/annotations/delivery.d.ts +24 -17
- package/dist/sdk/annotations/delivery.js +82 -49
- package/dist/sdk/annotations/inspect.js +7 -6
- package/dist/sdk/annotations/pageScript.js +4 -4
- package/dist/sdk/annotations/part.d.ts +0 -2
- package/dist/sdk/annotations/part.js +4 -33
- package/dist/sdk/annotations/queue.d.ts +2 -2
- package/dist/sdk/annotations/queue.js +12 -7
- package/dist/sdk/annotations/serverMethods.js +11 -7
- package/dist/sdk/annotations/store.d.ts +13 -21
- package/dist/sdk/annotations/store.js +47 -37
- package/dist/sdk/commands/list.js +2 -0
- package/dist/sdk/core/links.d.ts +51 -0
- package/dist/sdk/core/links.js +155 -0
- package/dist/sdk/harness.d.ts +5 -0
- package/dist/sdk/harness.js +11 -6
- package/dist/sdk/index.d.ts +1 -0
- package/dist/sdk/index.js +1 -0
- package/dist/sdk/plugins/builtin.js +11 -6
- package/dist/sdk/plugins/builtinRegistry.d.ts +3 -1
- package/dist/sdk/plugins/builtinRegistry.js +9 -3
- package/dist/sdk/plugins/discover.d.ts +7 -1
- package/dist/sdk/plugins/discover.js +41 -2
- package/dist/sdk/plugins/identity.js +1 -1
- package/dist/sdk/plugins/inventory.js +1 -1
- package/dist/sdk/plugins/manifest.d.ts +2 -0
- package/dist/sdk/plugins/manifest.js +21 -4
- package/dist/sdk/plugins/plugins.d.ts +2 -0
- package/dist/sdk/plugins/plugins.js +1 -0
- package/dist/sdk/plugins/proposals.d.ts +17 -0
- package/dist/sdk/plugins/proposals.js +147 -0
- package/dist/sdk/plugins/types.d.ts +1 -1
- package/dist/sdk/plugins/types.js +1 -1
- package/dist/sdk/prompt/compile.d.ts +11 -2
- package/dist/sdk/prompt/compile.js +32 -4
- package/dist/sdk/prompt/standard.d.ts +1 -1
- package/dist/sdk/prompt/standard.js +1 -1
- package/dist/sdk/server/methods.js +10 -0
- package/dist/sdk/sessions/sessionState.d.ts +13 -0
- package/dist/sdk/sessions/sessionState.js +31 -0
- package/dist/sdk/sessions/sessions.d.ts +7 -0
- package/dist/sdk/sessions/sessions.js +45 -5
- package/dist/sdk/sessions/subagentService.js +3 -3
- package/dist/sdk/terminal/opentui/theme.d.ts +2 -0
- package/dist/sdk/terminal/opentui/theme.js +1 -0
- package/dist/sdk/terminal/piExtension.js +71 -2
- package/dist/sdk/terminal/terminals.d.ts +9 -0
- package/dist/sdk/terminal/terminals.js +46 -14
- package/dist/sdk/terminal/tui/annotateCommand.d.ts +5 -1
- package/dist/sdk/terminal/tui/annotateCommand.js +21 -7
- package/dist/sdk/terminal/tui/annotateSend.d.ts +30 -0
- package/dist/sdk/terminal/tui/annotateSend.js +40 -0
- package/dist/sdk/terminal/tui/commandPages.d.ts +1 -1
- package/dist/sdk/terminal/tui/commandPages.js +2 -7
- package/dist/sdk/terminal/tui/commands.js +4 -2
- package/dist/sdk/terminal/tui/dialogs.js +1 -0
- package/dist/sdk/terminal/tui/keybindings.d.ts +1 -1
- package/dist/sdk/terminal/tui/keybindings.js +1 -1
- package/dist/sdk/terminal/tui/pageOverlay.d.ts +3 -0
- package/dist/sdk/terminal/tui/pageOverlay.js +12 -1
- package/dist/sdk/terminal/tui/pages/agentsPage.js +5 -2
- package/dist/sdk/terminal/tui/pages/annotatePage.d.ts +70 -16
- package/dist/sdk/terminal/tui/pages/annotatePage.js +349 -115
- package/dist/sdk/terminal/tui/pages/pluginsPage.d.ts +6 -0
- package/dist/sdk/terminal/tui/pages/pluginsPage.js +99 -1
- package/dist/sdk/terminal/tui/pages/usagePage.d.ts +4 -2
- package/dist/sdk/terminal/tui/pages/usagePage.js +39 -12
- package/dist/sdk/terminal/tui/pluginCommands.d.ts +1 -1
- package/dist/sdk/terminal/tui/pluginCommands.js +13 -3
- package/dist/sdk/tools/defaultSkills.d.ts +4 -0
- package/dist/sdk/tools/defaultSkills.js +20 -0
- package/dist/sdk/tools/definitions.d.ts +3 -1
- package/dist/sdk/tools/definitions.js +17 -1
- package/dist/sdk/tools/dispatch.js +12 -0
- package/dist/sdk/tools/harnessTools.js +1 -1
- package/dist/sdk/tools/sessionName.d.ts +7 -0
- package/dist/sdk/tools/sessionName.js +53 -8
- package/dist/sdk/world/world.d.ts +0 -2
- package/dist/terminal-opentui.js +2019 -1269
- package/dist/terminal-pi.js +106 -7
- package/docs/README.md +3 -0
- package/docs/architecture/02-sessions-and-runs.md +11 -5
- package/docs/architecture/03-variants.md +1 -3
- package/docs/architecture/04-prompt.md +16 -7
- package/docs/architecture/06-plugins-and-capabilities.md +36 -4
- package/docs/architecture/07-tools-and-gate.md +20 -5
- package/docs/architecture/10-server-protocol.md +52 -7
- package/docs/architecture/11-terminal.md +50 -19
- package/docs/architecture/12-storage.md +1 -0
- package/docs/architecture/13-world-tools.md +8 -6
- package/docs/architecture/README.md +3 -1
- package/docs/architecture/proposals/PROP-0018-annotation-queue-and-app-targets.md +6 -2
- package/docs/architecture/proposals/PROP-0048-simple-annotate.md +85 -0
- package/docs/architecture/proposals/README.md +1 -0
- package/docs/decisions/ADR-0044-annotations-pending-or-sent.md +24 -0
- package/docs/decisions/ADR-0045-default-plugin-authoring-skill.md +9 -0
- package/docs/decisions/ADR-0046-hash-bound-plugin-proposals.md +20 -0
- package/docs/decisions/ADR-0047-links-open-with-the-os-handler.md +47 -0
- package/docs/parity.md +7 -4
- package/docs/releases/1.3.1-unstable.1.md +8 -0
- package/docs/releases/1.3.1-unstable.2.md +11 -0
- package/docs/releases/README.md +7 -4
- package/docs/terminal-tui.md +62 -23
- package/package.json +1 -1
- package/dist/sdk/terminal/tui/pages/notesPage.d.ts +0 -75
- package/dist/sdk/terminal/tui/pages/notesPage.js +0 -201
|
@@ -5,7 +5,7 @@ export declare const PROFILE_NAME: RegExp;
|
|
|
5
5
|
export declare const RESERVED_PROFILE_NAMES: readonly string[];
|
|
6
6
|
export declare const PROFILE_COLORS: readonly ["blue", "green", "yellow", "red", "magenta", "cyan", "orange", "purple"];
|
|
7
7
|
export type ProfileColor = (typeof PROFILE_COLORS)[number];
|
|
8
|
-
export type ProfileSource = 'builtin' | 'global' | 'project' | 'variant';
|
|
8
|
+
export type ProfileSource = 'builtin' | 'global' | 'project' | 'variant' | 'plugin';
|
|
9
9
|
/** Where a person's own profile file lives. */
|
|
10
10
|
export type ProfileScope = 'global' | 'project';
|
|
11
11
|
/** Which variant a profile's subagent runs: a scope's head or version, none, or the parent's (null). */
|
|
@@ -54,12 +54,21 @@ export declare function formatVariant(variant: ProfileVariant): string | null;
|
|
|
54
54
|
export declare function parseProfile(text: string, name: string, source: ProfileSource, path: string | null): AgentProfile;
|
|
55
55
|
/** The file text for a profile, as `createProfile` writes it. */
|
|
56
56
|
export declare function profileText(profile: ProfileDefinition): string;
|
|
57
|
+
/**
|
|
58
|
+
* A profile a package brings. It is named by its frontmatter, since such packages are often written
|
|
59
|
+
* for other agent harnesses whose files are not named after their agents. What only those
|
|
60
|
+
* harnesses understand is dropped: their tool names are translated, a model without a provider
|
|
61
|
+
* (`inherit`, `sonnet`) is left to the parent, and a package never pins a variant or plugins. A
|
|
62
|
+
* tool list of which nothing translates keeps the subagent read-only rather than widening it.
|
|
63
|
+
*/
|
|
64
|
+
export declare function parsePackageProfile(text: string, path: string): AgentProfile;
|
|
57
65
|
/**
|
|
58
66
|
* Every profile a session in `cwd` can use. An unreadable file is reported in `problems`; it hides
|
|
59
67
|
* nothing, so a broken project file leaves the global or built-in profile of that name in force.
|
|
68
|
+
* `packageProfiles` are the profile files of the session's switched-on packages.
|
|
60
69
|
*/
|
|
61
|
-
export declare function listProfiles(root: string, cwd?: string | null, variantDir?: string | null): ProfileListing;
|
|
62
|
-
export declare function resolveProfile(root: string, cwd: string | null | undefined, name: string, variantDir?: string | null): AgentProfile | null;
|
|
70
|
+
export declare function listProfiles(root: string, cwd?: string | null, variantDir?: string | null, packageProfiles?: readonly string[]): ProfileListing;
|
|
71
|
+
export declare function resolveProfile(root: string, cwd: string | null | undefined, name: string, variantDir?: string | null, packageProfiles?: readonly string[]): AgentProfile | null;
|
|
63
72
|
/** Writes a new profile file from the template, or a copy of `from`. Refuses to overwrite one. */
|
|
64
73
|
export declare function createProfile(root: string, cwd: string | null | undefined, input: {
|
|
65
74
|
name: string;
|
|
@@ -74,7 +83,7 @@ export declare function removeProfile(root: string, cwd: string | null | undefin
|
|
|
74
83
|
/** Where the profile list sits among the prompt sections: the slot a variant's subagents used. */
|
|
75
84
|
export declare const PROFILE_SECTION_ORDER = 64;
|
|
76
85
|
/** The prompt section that lists the profiles a run can start subagents from. */
|
|
77
|
-
export declare function profileSection(root: string, cwd: string | null | undefined, variantDir?: string | null, planResearchOnly?: boolean, researchContext?: PlanResearchContext): {
|
|
86
|
+
export declare function profileSection(root: string, cwd: string | null | undefined, variantDir?: string | null, planResearchOnly?: boolean, researchContext?: PlanResearchContext, packageProfiles?: readonly string[]): {
|
|
78
87
|
id: string;
|
|
79
88
|
title: string;
|
|
80
89
|
body: string;
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
// Agent profiles (PROP-0029): named bundles a subagent starts from, with its instructions, colour,
|
|
2
2
|
// tools, plugins, variant, model and effort. They come from four places, later ones winning:
|
|
3
3
|
// built-ins in code, `<data root>/agents/*.md`, `<project>/.xingular/agents/*.md`; a variant's own
|
|
4
|
-
// c5 subagents fill in names nobody else took.
|
|
4
|
+
// c5 subagents, then the profiles of switched-on packages, fill in names nobody else took.
|
|
5
|
+
// Profiles only narrow what the parent has.
|
|
5
6
|
import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync } from 'node:fs';
|
|
6
|
-
import { dirname, join } from 'node:path';
|
|
7
|
+
import { basename, dirname, join } from 'node:path';
|
|
7
8
|
import { EFFORTS } from "../core/effort.js";
|
|
8
9
|
import { invalid, notFound } from "../core/errors.js";
|
|
9
10
|
import { parse, serialize } from "../core/frontmatter.js";
|
|
@@ -139,11 +140,49 @@ function variantDefinitions(variantDir) {
|
|
|
139
140
|
}];
|
|
140
141
|
});
|
|
141
142
|
}
|
|
143
|
+
// Tool names that agent plugins written for other harnesses use, as this harness names them.
|
|
144
|
+
const FOREIGN_TOOLS = {
|
|
145
|
+
Read: 'read', Grep: 'grep', Glob: 'find', LS: 'ls', Edit: 'edit', MultiEdit: 'edit', Write: 'write', Bash: 'bash',
|
|
146
|
+
WebSearch: 'web_search', WebFetch: 'web_fetch', TodoWrite: 'todo_write'
|
|
147
|
+
};
|
|
148
|
+
const READ_ONLY_TOOLS = ['read', 'grep', 'find', 'ls'];
|
|
149
|
+
/**
|
|
150
|
+
* A profile a package brings. It is named by its frontmatter, since such packages are often written
|
|
151
|
+
* for other agent harnesses whose files are not named after their agents. What only those
|
|
152
|
+
* harnesses understand is dropped: their tool names are translated, a model without a provider
|
|
153
|
+
* (`inherit`, `sonnet`) is left to the parent, and a package never pins a variant or plugins. A
|
|
154
|
+
* tool list of which nothing translates keeps the subagent read-only rather than widening it.
|
|
155
|
+
*/
|
|
156
|
+
export function parsePackageProfile(text, path) {
|
|
157
|
+
const parsed = parse(text);
|
|
158
|
+
if (!parsed)
|
|
159
|
+
throw invalid('The profile needs a frontmatter block between --- lines.');
|
|
160
|
+
const front = parsed.front;
|
|
161
|
+
const name = optionalText(front.name, 'name');
|
|
162
|
+
if (!name || !PROFILE_NAME.test(name))
|
|
163
|
+
throw invalid('A profile name uses lowercase letters, digits, - and _, and starts with a letter or digit.');
|
|
164
|
+
if (RESERVED_PROFILE_NAMES.includes(name))
|
|
165
|
+
throw invalid(reservedRefusal(name));
|
|
166
|
+
const description = optionalText(front.description, 'description');
|
|
167
|
+
if (!description)
|
|
168
|
+
throw invalid('The profile needs a description.');
|
|
169
|
+
const declared = stringList(front.tools, 'tools');
|
|
170
|
+
const tools = declared ? [...new Set(declared.flatMap((tool) => FOREIGN_TOOLS[tool] ?? (/^[a-z][a-z0-9_]*$/.test(tool) ? [tool] : [])))] : null;
|
|
171
|
+
const color = typeof front.color === 'string' && PROFILE_COLORS.includes(front.color) ? front.color : 'blue';
|
|
172
|
+
const model = typeof front.model === 'string' && front.model.includes('/') ? front.model : null;
|
|
173
|
+
const effort = typeof front.effort === 'string' && EFFORTS.includes(front.effort) ? front.effort : null;
|
|
174
|
+
return {
|
|
175
|
+
name, description, color, instructions: parsed.body.trim(),
|
|
176
|
+
tools: tools && !tools.length ? READ_ONLY_TOOLS : tools, plugins: null, variant: null, model, effort,
|
|
177
|
+
source: 'plugin', path
|
|
178
|
+
};
|
|
179
|
+
}
|
|
142
180
|
/**
|
|
143
181
|
* Every profile a session in `cwd` can use. An unreadable file is reported in `problems`; it hides
|
|
144
182
|
* nothing, so a broken project file leaves the global or built-in profile of that name in force.
|
|
183
|
+
* `packageProfiles` are the profile files of the session's switched-on packages.
|
|
145
184
|
*/
|
|
146
|
-
export function listProfiles(root, cwd, variantDir) {
|
|
185
|
+
export function listProfiles(root, cwd, variantDir, packageProfiles = []) {
|
|
147
186
|
const profiles = new Map();
|
|
148
187
|
const problems = [];
|
|
149
188
|
for (const definition of BUILTIN_PROFILES)
|
|
@@ -154,12 +193,25 @@ export function listProfiles(root, cwd, variantDir) {
|
|
|
154
193
|
for (const definition of variantDefinitions(variantDir))
|
|
155
194
|
if (!profiles.has(definition.name))
|
|
156
195
|
profiles.set(definition.name, definition);
|
|
196
|
+
// A package cannot replace a profile a person, the harness or the variant already has.
|
|
197
|
+
for (const path of packageProfiles) {
|
|
198
|
+
try {
|
|
199
|
+
const profile = parsePackageProfile(readFileSync(path, 'utf8'), path);
|
|
200
|
+
if (!profiles.has(profile.name))
|
|
201
|
+
profiles.set(profile.name, profile);
|
|
202
|
+
else
|
|
203
|
+
problems.push({ name: profile.name, source: 'plugin', path, error: `A ${profiles.get(profile.name).source} profile already has this name, so the package's is not used.` });
|
|
204
|
+
}
|
|
205
|
+
catch (error) {
|
|
206
|
+
problems.push({ name: basename(path, '.md'), source: 'plugin', path, error: error instanceof Error ? error.message : String(error) });
|
|
207
|
+
}
|
|
208
|
+
}
|
|
157
209
|
return { profiles: [...profiles.values()].sort((left, right) => left.name.localeCompare(right.name)), problems };
|
|
158
210
|
}
|
|
159
|
-
export function resolveProfile(root, cwd, name, variantDir) {
|
|
211
|
+
export function resolveProfile(root, cwd, name, variantDir, packageProfiles = []) {
|
|
160
212
|
if (!PROFILE_NAME.test(name))
|
|
161
213
|
return null;
|
|
162
|
-
return listProfiles(root, cwd, variantDir).profiles.find(profile => profile.name === name) ?? null;
|
|
214
|
+
return listProfiles(root, cwd, variantDir, packageProfiles).profiles.find(profile => profile.name === name) ?? null;
|
|
163
215
|
}
|
|
164
216
|
function scopeDir(root, cwd, scope) {
|
|
165
217
|
const dirs = profileDirs(root, cwd);
|
|
@@ -214,8 +266,8 @@ export function removeProfile(root, cwd, input) {
|
|
|
214
266
|
/** Where the profile list sits among the prompt sections: the slot a variant's subagents used. */
|
|
215
267
|
export const PROFILE_SECTION_ORDER = 64;
|
|
216
268
|
/** The prompt section that lists the profiles a run can start subagents from. */
|
|
217
|
-
export function profileSection(root, cwd, variantDir, planResearchOnly = false, researchContext) {
|
|
218
|
-
const { profiles } = listProfiles(root, cwd, variantDir);
|
|
269
|
+
export function profileSection(root, cwd, variantDir, planResearchOnly = false, researchContext, packageProfiles = []) {
|
|
270
|
+
const { profiles } = listProfiles(root, cwd, variantDir, packageProfiles);
|
|
219
271
|
const listed = planResearchOnly
|
|
220
272
|
? profiles.filter(profile => researchContext
|
|
221
273
|
? planResearchEligibility(profile, researchContext).eligible
|
|
@@ -29,6 +29,8 @@ export declare class NotesBridge {
|
|
|
29
29
|
private settingsFile;
|
|
30
30
|
/** Starts the bridge (once) on the port and token kept from last time, so the app's snippet stays valid. */
|
|
31
31
|
start(): Promise<BridgeInfo>;
|
|
32
|
+
/** What a window loads, once the bridge is listening. */
|
|
33
|
+
current(): BridgeInfo | null;
|
|
32
34
|
private listen;
|
|
33
35
|
list(): BridgeWindow[];
|
|
34
36
|
/** Sends one helper operation to a window and waits for its JSON reply. */
|
|
@@ -71,6 +71,10 @@ export class NotesBridge {
|
|
|
71
71
|
this.starting ??= this.listen().finally(() => { this.starting = null; });
|
|
72
72
|
return this.starting;
|
|
73
73
|
}
|
|
74
|
+
/** What a window loads, once the bridge is listening. */
|
|
75
|
+
current() {
|
|
76
|
+
return this.info;
|
|
77
|
+
}
|
|
74
78
|
async listen() {
|
|
75
79
|
const settingsFile = this.settingsFile();
|
|
76
80
|
if (existsSync(settingsFile))
|
|
@@ -28,7 +28,7 @@ export interface CaptureWindow {
|
|
|
28
28
|
/** One comment the person submitted in the inspector's popover. */
|
|
29
29
|
export interface InspectResult extends CaptureEnvelope {
|
|
30
30
|
body: string;
|
|
31
|
-
action: 'save' | '
|
|
31
|
+
action: 'save' | 'send';
|
|
32
32
|
}
|
|
33
33
|
export interface InspectState {
|
|
34
34
|
/** False once the person or the page ended it. */
|
|
@@ -44,16 +44,43 @@ export interface InspectState {
|
|
|
44
44
|
}
|
|
45
45
|
/** Opens a DevTools connection to a running app; tests replace it. */
|
|
46
46
|
export type AppConnector = (endpoint: string) => Promise<Browser>;
|
|
47
|
+
/** A desktop app on this computer that answers on a DevTools port. */
|
|
48
|
+
export interface DevToolsApp {
|
|
49
|
+
endpoint: string;
|
|
50
|
+
port: number;
|
|
51
|
+
/** The title of its first window, or what the app calls itself. */
|
|
52
|
+
name: string;
|
|
53
|
+
/** What the DevTools port reports, such as `Chrome/126.0` or `Electron/31.0`. */
|
|
54
|
+
browser: string;
|
|
55
|
+
electron: boolean;
|
|
56
|
+
windows: number;
|
|
57
|
+
}
|
|
58
|
+
/** Reads a JSON answer from a loopback DevTools port, or null when nothing answers in time; tests replace it. */
|
|
59
|
+
export type DevToolsFetch = (url: string, timeoutMs: number) => Promise<unknown>;
|
|
60
|
+
/** Starts an app the person named; tests replace it. */
|
|
61
|
+
export type AppSpawner = (command: string, args: string[]) => void;
|
|
47
62
|
export interface AnnotationCaptureOptions {
|
|
48
63
|
stateDir: string;
|
|
49
64
|
launch?: Launcher;
|
|
50
65
|
network?: NetworkPolicy;
|
|
51
66
|
connect?: AppConnector;
|
|
52
67
|
bridge?: NotesBridge;
|
|
68
|
+
fetchJson?: DevToolsFetch;
|
|
69
|
+
spawnApp?: AppSpawner;
|
|
70
|
+
/** A free loopback port for an app started here; tests replace it. */
|
|
71
|
+
freePort?: () => Promise<number>;
|
|
72
|
+
/** How long a started app has to open its DevTools port, in milliseconds. */
|
|
73
|
+
launchTimeoutMs?: number;
|
|
53
74
|
}
|
|
75
|
+
/** Where apps started with `--remote-debugging-port` or `--inspect` usually listen. */
|
|
76
|
+
export declare const DEVTOOLS_PORTS: readonly [9222, 9223, 9224, 9225, 9226, 9227, 9228, 9229, 9230];
|
|
77
|
+
/** The program inside a macOS `.app` bundle, or the path itself. */
|
|
78
|
+
export declare function appExecutable(path: string): string;
|
|
54
79
|
export declare class AnnotationCapture {
|
|
55
80
|
private readonly options;
|
|
56
81
|
private readonly targets;
|
|
82
|
+
/** Ports the person attached to or started an app on, checked again by `discover`. */
|
|
83
|
+
private readonly knownPorts;
|
|
57
84
|
private readonly inspecting;
|
|
58
85
|
private readonly network;
|
|
59
86
|
readonly bridge: NotesBridge;
|
|
@@ -62,11 +89,14 @@ export declare class AnnotationCapture {
|
|
|
62
89
|
private target;
|
|
63
90
|
private replace;
|
|
64
91
|
private call;
|
|
65
|
-
/** What `key` captures in now. */
|
|
92
|
+
/** What `key` captures in now, with the script to load while it captures a bridge. */
|
|
66
93
|
describe(key: string): {
|
|
67
94
|
kind: CaptureTargetSpec['kind'];
|
|
68
95
|
app: NoteApp | null;
|
|
69
96
|
inspecting: boolean;
|
|
97
|
+
bridge?: {
|
|
98
|
+
snippet: string;
|
|
99
|
+
};
|
|
70
100
|
};
|
|
71
101
|
/** Captures in an app's windows instead of the browser: over its DevTools port, or the page bridge. */
|
|
72
102
|
attach(key: string, spec: CaptureTargetSpec): Promise<{
|
|
@@ -76,6 +106,18 @@ export declare class AnnotationCapture {
|
|
|
76
106
|
bridge?: BridgeInfo;
|
|
77
107
|
}>;
|
|
78
108
|
windows(key: string): Promise<CaptureWindow[]>;
|
|
109
|
+
/** Apps on this computer that answer on a DevTools port: the usual ports and any used before. */
|
|
110
|
+
discover(ports?: readonly number[]): Promise<DevToolsApp[]>;
|
|
111
|
+
/**
|
|
112
|
+
* Starts the app the person named with a DevTools port and attaches to its windows. A macOS `.app`
|
|
113
|
+
* bundle starts its own program. The app keeps running when the capture ends.
|
|
114
|
+
*/
|
|
115
|
+
launchApp(key: string, path: string, args?: readonly string[]): Promise<{
|
|
116
|
+
kind: 'cdp';
|
|
117
|
+
app: NoteApp | null;
|
|
118
|
+
windows: CaptureWindow[];
|
|
119
|
+
endpoint: string;
|
|
120
|
+
}>;
|
|
79
121
|
/** Switches to another window or tab of the target; an inspector there starts again. */
|
|
80
122
|
selectWindow(key: string, id: string): Promise<void>;
|
|
81
123
|
/** Shows the capture window, at `url` when given. Captures in the browser again if an app was attached. */
|
|
@@ -5,8 +5,11 @@
|
|
|
5
5
|
// - `bridge`: app windows that loaded the page bridge script (a macOS web view has no DevTools port).
|
|
6
6
|
// The person selects text, clicks an element, or inspects with the popover; the front end pulls
|
|
7
7
|
// what they did through the page helper.
|
|
8
|
+
import { spawn } from 'node:child_process';
|
|
8
9
|
import { randomUUID } from 'node:crypto';
|
|
9
|
-
import {
|
|
10
|
+
import { existsSync, readdirSync, statSync } from 'node:fs';
|
|
11
|
+
import { createServer } from 'node:net';
|
|
12
|
+
import { basename, join } from 'node:path';
|
|
10
13
|
import { readBrowserConfig } from "../browser/config.js";
|
|
11
14
|
import { NetworkPolicy } from "../browser/network.js";
|
|
12
15
|
import { BrowserSession, fileName, launchBrowser } from "../browser/session.js";
|
|
@@ -14,6 +17,56 @@ import { NotesBridge } from "./bridge.js";
|
|
|
14
17
|
import { evaluateIsolated } from "./isolated.js";
|
|
15
18
|
import { pageCall, pageReply } from "./pageScript.js";
|
|
16
19
|
import { validateUrl } from "./store.js";
|
|
20
|
+
/** Where apps started with `--remote-debugging-port` or `--inspect` usually listen. */
|
|
21
|
+
export const DEVTOOLS_PORTS = [9222, 9223, 9224, 9225, 9226, 9227, 9228, 9229, 9230];
|
|
22
|
+
const PROBE_MS = 400;
|
|
23
|
+
const fetchJson = async (url, timeoutMs) => {
|
|
24
|
+
try {
|
|
25
|
+
const response = await fetch(url, { signal: AbortSignal.timeout(timeoutMs) });
|
|
26
|
+
return response.ok ? await response.json() : null;
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
};
|
|
32
|
+
const spawnApp = (command, args) => {
|
|
33
|
+
// The app is the person's: it keeps running after the terminal ends.
|
|
34
|
+
const child = spawn(command, args, { detached: true, stdio: 'ignore' });
|
|
35
|
+
child.on('error', () => undefined);
|
|
36
|
+
child.unref();
|
|
37
|
+
};
|
|
38
|
+
const freePort = () => new Promise((resolve, reject) => {
|
|
39
|
+
const server = createServer();
|
|
40
|
+
server.unref();
|
|
41
|
+
server.on('error', reject);
|
|
42
|
+
server.listen(0, '127.0.0.1', () => {
|
|
43
|
+
const address = server.address();
|
|
44
|
+
server.close(() => typeof address === 'object' && address ? resolve(address.port) : reject(new Error('No free port.')));
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
/** The program inside a macOS `.app` bundle, or the path itself. */
|
|
48
|
+
export function appExecutable(path) {
|
|
49
|
+
if (!/\.app\/?$/.test(path))
|
|
50
|
+
return path;
|
|
51
|
+
const folder = join(path, 'Contents', 'MacOS');
|
|
52
|
+
if (!existsSync(folder))
|
|
53
|
+
throw new Error(`${path} has no Contents/MacOS folder.`);
|
|
54
|
+
const programs = readdirSync(folder).filter((name) => !name.startsWith('.') && statSync(join(folder, name)).isFile());
|
|
55
|
+
const named = basename(path.replace(/\/$/, ''), '.app');
|
|
56
|
+
const program = programs.find((name) => name === named) ?? programs[0];
|
|
57
|
+
if (!program)
|
|
58
|
+
throw new Error(`${path} has no program in Contents/MacOS.`);
|
|
59
|
+
return join(folder, program);
|
|
60
|
+
}
|
|
61
|
+
function describeApp(port, version, list) {
|
|
62
|
+
const agent = typeof version['User-Agent'] === 'string' ? version['User-Agent'] : '';
|
|
63
|
+
const electron = /Electron\/([\d.]+)/.exec(agent);
|
|
64
|
+
const browser = electron ? `Electron/${electron[1]}` : typeof version.Browser === 'string' ? version.Browser : 'DevTools';
|
|
65
|
+
const pages = Array.isArray(list) ? list.filter((entry) => !!entry && typeof entry === 'object' && entry.type === 'page' && !/^(?:devtools|chrome-extension):/.test(String(entry.url ?? ''))) : [];
|
|
66
|
+
const title = pages.map((page) => typeof page.title === 'string' ? page.title.trim() : '').find(Boolean);
|
|
67
|
+
const product = /\)\s+([A-Za-z][\w .-]*?)\/[\d.]+\s+(?:Chrome|Electron)\//.exec(agent)?.[1];
|
|
68
|
+
return { endpoint: `http://127.0.0.1:${port}`, port, name: (title || product || browser).slice(0, 120), browser, electron: !!electron, windows: pages.length };
|
|
69
|
+
}
|
|
17
70
|
const IDLE_MS = 60 * 60_000;
|
|
18
71
|
const LOOPBACK = /^(?:https?|wss?):\/\/(?:127\.0\.0\.1|localhost|\[::1\])(?::\d+)?(?:\/|$)/;
|
|
19
72
|
const connectApp = async (endpoint) => {
|
|
@@ -130,6 +183,8 @@ class WebTarget {
|
|
|
130
183
|
export class AnnotationCapture {
|
|
131
184
|
options;
|
|
132
185
|
targets = new Map();
|
|
186
|
+
/** Ports the person attached to or started an app on, checked again by `discover`. */
|
|
187
|
+
knownPorts = new Set();
|
|
133
188
|
inspecting = new Map();
|
|
134
189
|
network;
|
|
135
190
|
bridge;
|
|
@@ -170,10 +225,11 @@ export class AnnotationCapture {
|
|
|
170
225
|
async call(key, operation) {
|
|
171
226
|
return pageReply((await this.target(key).evaluate(operation)).reply);
|
|
172
227
|
}
|
|
173
|
-
/** What `key` captures in now. */
|
|
228
|
+
/** What `key` captures in now, with the script to load while it captures a bridge. */
|
|
174
229
|
describe(key) {
|
|
175
230
|
const target = this.targets.get(key);
|
|
176
|
-
|
|
231
|
+
const bridge = target?.kind === 'bridge' ? this.bridge.current() : null;
|
|
232
|
+
return { kind: target?.kind ?? 'web', app: target?.app ?? null, inspecting: this.inspecting.has(key), ...(bridge ? { bridge: { snippet: bridge.snippet } } : {}) };
|
|
177
233
|
}
|
|
178
234
|
/** Captures in an app's windows instead of the browser: over its DevTools port, or the page bridge. */
|
|
179
235
|
async attach(key, spec) {
|
|
@@ -189,6 +245,9 @@ export class AnnotationCapture {
|
|
|
189
245
|
const name = spec.name?.trim() || `App at ${new URL(spec.endpoint).host}`;
|
|
190
246
|
const target = new CdpTarget(browser, { kind: 'cdp', name: name.slice(0, 200) });
|
|
191
247
|
await this.replace(key, target);
|
|
248
|
+
const port = Number(new URL(spec.endpoint).port);
|
|
249
|
+
if (port)
|
|
250
|
+
this.knownPorts.add(port);
|
|
192
251
|
return { kind: 'cdp', app: target.app, windows: await target.windows() };
|
|
193
252
|
}
|
|
194
253
|
if (spec.kind === 'bridge') {
|
|
@@ -202,6 +261,48 @@ export class AnnotationCapture {
|
|
|
202
261
|
windows(key) {
|
|
203
262
|
return this.target(key).windows();
|
|
204
263
|
}
|
|
264
|
+
/** Apps on this computer that answer on a DevTools port: the usual ports and any used before. */
|
|
265
|
+
async discover(ports = []) {
|
|
266
|
+
const probe = this.options.fetchJson ?? fetchJson;
|
|
267
|
+
const wanted = [...new Set([...DEVTOOLS_PORTS, ...this.knownPorts, ...ports])].filter((port) => Number.isInteger(port) && port > 0 && port < 65536);
|
|
268
|
+
const found = await Promise.all(wanted.map(async (port) => {
|
|
269
|
+
const version = await probe(`http://127.0.0.1:${port}/json/version`, PROBE_MS);
|
|
270
|
+
if (!version || typeof version !== 'object')
|
|
271
|
+
return null;
|
|
272
|
+
// A Node `--inspect` port answers too, but has no windows to annotate.
|
|
273
|
+
if (typeof version.webSocketDebuggerUrl !== 'string' && typeof version.Browser !== 'string')
|
|
274
|
+
return null;
|
|
275
|
+
if (/^node(?:\.js)?\//i.test(String(version.Browser ?? '')))
|
|
276
|
+
return null;
|
|
277
|
+
return describeApp(port, version, await probe(`http://127.0.0.1:${port}/json/list`, PROBE_MS));
|
|
278
|
+
}));
|
|
279
|
+
return found.filter((app) => app !== null).sort((left, right) => left.port - right.port);
|
|
280
|
+
}
|
|
281
|
+
/**
|
|
282
|
+
* Starts the app the person named with a DevTools port and attaches to its windows. A macOS `.app`
|
|
283
|
+
* bundle starts its own program. The app keeps running when the capture ends.
|
|
284
|
+
*/
|
|
285
|
+
async launchApp(key, path, args = []) {
|
|
286
|
+
const program = appExecutable(path.trim());
|
|
287
|
+
if (!program)
|
|
288
|
+
throw new Error('Give the path of the app to start.');
|
|
289
|
+
const port = await (this.options.freePort ?? freePort)();
|
|
290
|
+
(this.options.spawnApp ?? spawnApp)(program, [...args, `--remote-debugging-port=${port}`]);
|
|
291
|
+
this.knownPorts.add(port);
|
|
292
|
+
const endpoint = `http://127.0.0.1:${port}`;
|
|
293
|
+
const probe = this.options.fetchJson ?? fetchJson;
|
|
294
|
+
const deadline = Date.now() + (this.options.launchTimeoutMs ?? 20_000);
|
|
295
|
+
for (;;) {
|
|
296
|
+
if (await probe(`${endpoint}/json/version`, PROBE_MS))
|
|
297
|
+
break;
|
|
298
|
+
if (Date.now() > deadline)
|
|
299
|
+
throw new Error(`${basename(path)} did not open a DevTools port. Only Chromium and Electron apps can be inspected this way.`);
|
|
300
|
+
await new Promise((resolve) => setTimeout(resolve, 250));
|
|
301
|
+
}
|
|
302
|
+
const name = basename(path.trim().replace(/\/$/, '')).replace(/\.app$/, '');
|
|
303
|
+
const attached = await this.attach(key, { kind: 'cdp', endpoint, name });
|
|
304
|
+
return { kind: 'cdp', app: attached.app, windows: attached.windows, endpoint };
|
|
305
|
+
}
|
|
205
306
|
/** Switches to another window or tab of the target; an inspector there starts again. */
|
|
206
307
|
async selectWindow(key, id) {
|
|
207
308
|
const inspecting = this.inspecting.has(key);
|
|
@@ -23,27 +23,29 @@ export type SendResult = {
|
|
|
23
23
|
} | {
|
|
24
24
|
state: 'empty';
|
|
25
25
|
ids: [];
|
|
26
|
+
unconfirmed: number;
|
|
26
27
|
};
|
|
27
|
-
export type NoteAction = 'save' | '
|
|
28
|
+
export type NoteAction = 'save' | 'send';
|
|
28
29
|
export interface NoteDeliveryOptions {
|
|
29
30
|
root: () => string;
|
|
30
31
|
bus: Bus;
|
|
31
32
|
terminals: () => TerminalSender | null;
|
|
33
|
+
/** Waits between tries to send notes held back while a run ends; tests shorten it. */
|
|
34
|
+
retryDelays?: readonly number[];
|
|
32
35
|
}
|
|
33
36
|
export declare class NoteDeliveries {
|
|
34
37
|
private readonly options;
|
|
35
38
|
private readonly sending;
|
|
39
|
+
private readonly retrying;
|
|
40
|
+
private readonly again;
|
|
36
41
|
constructor(options: NoteDeliveryOptions);
|
|
37
42
|
private changed;
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
confirm?: boolean;
|
|
41
|
-
}): store.Note;
|
|
42
|
-
dequeue(id: string, expectedVersion: number | undefined): store.Note;
|
|
43
|
+
/** Shares a saved note with the session; it waits there, unsent. */
|
|
44
|
+
share(id: string, expectedVersion: number | undefined, sessionId: string): store.Note;
|
|
43
45
|
/**
|
|
44
|
-
* Creates a note and acts on it in one step: what the popover's
|
|
45
|
-
* false when the comment came from a script the page
|
|
46
|
-
* for the person to confirm it.
|
|
46
|
+
* Creates a note and acts on it in one step: what the popover's two buttons do. With a session the
|
|
47
|
+
* note is always shared with it. `trusted` is false when the comment came from a script the page
|
|
48
|
+
* could reach; it then waits for the person to confirm it before it is ever sent.
|
|
47
49
|
*/
|
|
48
50
|
createAndAct(input: store.NoteInput, action: NoteAction, sessionId: string | null, trusted: boolean): Promise<{
|
|
49
51
|
note: store.Note;
|
|
@@ -51,22 +53,27 @@ export declare class NoteDeliveries {
|
|
|
51
53
|
}>;
|
|
52
54
|
/** The person confirms a note the page could have forged; it is then sent with the next send. */
|
|
53
55
|
confirm(id: string, expectedVersion: number | undefined): store.Note;
|
|
54
|
-
/**
|
|
55
|
-
|
|
56
|
+
/** Confirms every page-origin note of the session at once; the person checked them in one prompt. */
|
|
57
|
+
confirmAll(sessionId: string): store.Note[];
|
|
58
|
+
/** `/annotate:clear`: the session's unsent notes are resolved and stop being shared with it. */
|
|
59
|
+
clear(sessionId: string): store.Note[];
|
|
60
|
+
private sendable;
|
|
61
|
+
/** The message for the session's unsent notes (or the given ones), for a host that runs it itself. */
|
|
62
|
+
compose(sessionId: string, ids?: string[] | null, prompt?: string | null): {
|
|
56
63
|
text: string;
|
|
57
64
|
ids: string[];
|
|
58
65
|
};
|
|
59
66
|
/** A host started `runId` with composed notes. */
|
|
60
67
|
delivered(ids: string[], sessionId: string, runId: string): store.Note[];
|
|
61
|
-
/** Sends the session's
|
|
68
|
+
/** Sends the session's unsent notes (or the given ones) as one run, with the person's own request when given. */
|
|
62
69
|
send(sessionId: string, filter?: {
|
|
63
70
|
ids?: string[] | null;
|
|
64
71
|
auto?: boolean;
|
|
72
|
+
prompt?: string | null;
|
|
65
73
|
}): Promise<SendResult>;
|
|
66
|
-
/**
|
|
74
|
+
/**
|
|
75
|
+
* A run of the session ended: notes waiting for it to be idle go out now. The terminal can still
|
|
76
|
+
* report the turn as active for a moment, so a busy answer is tried again a few times.
|
|
77
|
+
*/
|
|
67
78
|
runEnded(sessionId: string | null | undefined): Promise<void>;
|
|
68
|
-
markFixed(id: string, caller: {
|
|
69
|
-
sessionId?: string | null;
|
|
70
|
-
agentId?: string | null;
|
|
71
|
-
}, summary: string): store.Note;
|
|
72
79
|
}
|