@narumitw/pi-chrome-devtools 0.28.0 → 0.35.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/README.md +8 -5
- package/package.json +4 -4
- package/src/browser-manager.ts +26 -13
- package/src/cdp-client.ts +2 -2
- package/src/chrome-devtools.ts +38 -17
- package/src/render.ts +7 -8
- package/src/runtime.ts +2 -0
- package/src/screenshot.ts +8 -3
- package/src/settings.ts +93 -79
- package/src/tool-selector.ts +102 -37
package/README.md
CHANGED
|
@@ -169,11 +169,14 @@ ${PI_CODING_AGENT_DIR:-~/.pi/agent}/pi-chrome-devtools.json
|
|
|
169
169
|
|
|
170
170
|
When the file is missing or invalid, the extension preserves Pi's current active-tool policy
|
|
171
171
|
instead of enabling tools by itself. A valid saved selection is restored on Pi startup and
|
|
172
|
-
`/reload`.
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
172
|
+
`/reload`. A missing file is created by the first successful selection change. Within one Pi process, selection saves run in invocation order, reread the latest valid document, and preserve unknown fields. Malformed JSON
|
|
173
|
+
or invalid recognized fields block the save without replacement; a failed save restores the prior
|
|
174
|
+
Chrome DevTools tool selection while preserving other extensions' current tools.
|
|
175
|
+
|
|
176
|
+
Compatibility: older versions used `pi-chrome-devtools-settings.json`. A legacy-only file remains
|
|
177
|
+
readable with a warning and is never modified automatically; rename it to
|
|
178
|
+
`pi-chrome-devtools.json`. The first subsequent settings save writes the canonical file. If both
|
|
179
|
+
files exist, `pi-chrome-devtools.json` wins and the legacy file is ignored. The legacy
|
|
177
180
|
filename is deprecated and will be removed in a future major release.
|
|
178
181
|
|
|
179
182
|
## 🧠 Use cases
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@narumitw/pi-chrome-devtools",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.35.0",
|
|
4
4
|
"description": "Pi extension that exposes Chrome DevTools Protocol tools.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -29,11 +29,11 @@
|
|
|
29
29
|
"typecheck": "tsc --noEmit"
|
|
30
30
|
},
|
|
31
31
|
"dependencies": {
|
|
32
|
-
"typebox": "^1.3.
|
|
32
|
+
"typebox": "^1.3.8"
|
|
33
33
|
},
|
|
34
34
|
"devDependencies": {
|
|
35
|
-
"@biomejs/biome": "2.5.
|
|
36
|
-
"@earendil-works/pi-coding-agent": "0.
|
|
35
|
+
"@biomejs/biome": "2.5.5",
|
|
36
|
+
"@earendil-works/pi-coding-agent": "0.82.1",
|
|
37
37
|
"typescript": "7.0.2"
|
|
38
38
|
},
|
|
39
39
|
"repository": {
|
package/src/browser-manager.ts
CHANGED
|
@@ -1,21 +1,20 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { type ChildProcess, spawn } from "node:child_process";
|
|
2
2
|
import { constants } from "node:fs";
|
|
3
3
|
import { access, mkdtemp, readFile, rm } from "node:fs/promises";
|
|
4
4
|
import { tmpdir } from "node:os";
|
|
5
|
-
import {
|
|
5
|
+
import { delimiter, isAbsolute, join, resolve } from "node:path";
|
|
6
6
|
import {
|
|
7
7
|
BROWSER_SHUTDOWN_WAIT_MS,
|
|
8
|
+
type BrowserCandidate,
|
|
9
|
+
type BrowserCandidateDefinition,
|
|
8
10
|
DEFAULT_ENDPOINT_RETRY_MS,
|
|
9
11
|
DEFAULT_ENDPOINT_WAIT_MS,
|
|
10
|
-
DEFAULT_HOST,
|
|
11
12
|
DEFAULT_HTTP_TIMEOUT_MS,
|
|
12
13
|
DEVTOOLS_ACTIVE_PORT_FILE,
|
|
13
|
-
MANAGED_BROWSER_PROFILE_PREFIX,
|
|
14
|
-
state,
|
|
15
|
-
type BrowserCandidate,
|
|
16
|
-
type BrowserCandidateDefinition,
|
|
17
14
|
type DevToolsPage,
|
|
15
|
+
MANAGED_BROWSER_PROFILE_PREFIX,
|
|
18
16
|
type ManagedBrowser,
|
|
17
|
+
state,
|
|
19
18
|
} from "./runtime.js";
|
|
20
19
|
|
|
21
20
|
function formatError(error: unknown) {
|
|
@@ -128,7 +127,12 @@ async function launchBrowserCandidate(candidate: BrowserCandidate, waitMs: numbe
|
|
|
128
127
|
];
|
|
129
128
|
throwIfBrowserLaunchCancelled();
|
|
130
129
|
const child = spawn(candidate.resolvedExecutable, args, { shell: false, stdio: "ignore" });
|
|
131
|
-
const launchedBrowser: ManagedBrowser = {
|
|
130
|
+
const launchedBrowser: ManagedBrowser = {
|
|
131
|
+
process: child,
|
|
132
|
+
userDataDir,
|
|
133
|
+
exited: false,
|
|
134
|
+
ready: false,
|
|
135
|
+
};
|
|
132
136
|
managedBrowser = launchedBrowser;
|
|
133
137
|
state.managedBrowser = launchedBrowser;
|
|
134
138
|
|
|
@@ -259,7 +263,8 @@ export async function shutdownManagedBrowser(
|
|
|
259
263
|
});
|
|
260
264
|
}
|
|
261
265
|
await rm(managedBrowser.userDataDir, { recursive: true, force: true }).catch(() => undefined);
|
|
262
|
-
if (!state.portConfigured && managedBrowser.port === state.port)
|
|
266
|
+
if (!state.portConfigured && managedBrowser.port === state.port)
|
|
267
|
+
state.port = state.configuredPort;
|
|
263
268
|
}
|
|
264
269
|
|
|
265
270
|
function killManagedBrowserProcess(managedBrowser: ManagedBrowser, signal?: NodeJS.Signals) {
|
|
@@ -400,9 +405,13 @@ export function launchModeLabel() {
|
|
|
400
405
|
if (!isLocalDevToolsHost(state.host)) return "manual remote endpoint";
|
|
401
406
|
if (!state.autoLaunchEnabled) return "manual; auto-launch disabled";
|
|
402
407
|
if (state.managedBrowser && !state.managedBrowser.exited) {
|
|
403
|
-
return state.portConfigured
|
|
408
|
+
return state.portConfigured
|
|
409
|
+
? "auto-launched on explicit port"
|
|
410
|
+
: "auto-launched on dynamic port";
|
|
404
411
|
}
|
|
405
|
-
return state.portConfigured
|
|
412
|
+
return state.portConfigured
|
|
413
|
+
? "attach first; auto-launch explicit port"
|
|
414
|
+
: "attach first; auto-launch dynamic port";
|
|
406
415
|
}
|
|
407
416
|
|
|
408
417
|
export function launchAttemptLines() {
|
|
@@ -435,7 +444,9 @@ export function launchHint() {
|
|
|
435
444
|
}
|
|
436
445
|
|
|
437
446
|
export function browserCandidateHint() {
|
|
438
|
-
return `Browser candidates: ${browserCandidateDefinitions()
|
|
447
|
+
return `Browser candidates: ${browserCandidateDefinitions()
|
|
448
|
+
.map((candidate) => candidate.label)
|
|
449
|
+
.join(", ")}`;
|
|
439
450
|
}
|
|
440
451
|
|
|
441
452
|
export function chromeLaunchCommand() {
|
|
@@ -475,7 +486,9 @@ function browserCandidateDefinitions(): BrowserCandidateDefinition[] {
|
|
|
475
486
|
|
|
476
487
|
function explicitBrowserCandidateDefinition(): BrowserCandidateDefinition[] {
|
|
477
488
|
if (!state.browserExecutable) return [];
|
|
478
|
-
return [
|
|
489
|
+
return [
|
|
490
|
+
{ label: "PI_CHROME_DEVTOOLS_BROWSER", executable: state.browserExecutable, source: "env" },
|
|
491
|
+
];
|
|
479
492
|
}
|
|
480
493
|
|
|
481
494
|
function platformBrowserCandidateDefinitions(): BrowserCandidateDefinition[] {
|
package/src/cdp-client.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import {
|
|
2
|
-
ensureDevToolsEndpoint,
|
|
3
2
|
devToolsEndpoint,
|
|
3
|
+
ensureDevToolsEndpoint,
|
|
4
4
|
fetchDevToolsJson,
|
|
5
5
|
formatPageListItem,
|
|
6
6
|
launchHint,
|
|
@@ -9,8 +9,8 @@ import {
|
|
|
9
9
|
import {
|
|
10
10
|
DEFAULT_ENDPOINT_WAIT_MS,
|
|
11
11
|
DEFAULT_TIMEOUT_MS,
|
|
12
|
-
state,
|
|
13
12
|
type DevToolsPage,
|
|
13
|
+
state,
|
|
14
14
|
} from "./runtime.js";
|
|
15
15
|
|
|
16
16
|
interface CdpResponse<T = unknown> {
|
package/src/chrome-devtools.ts
CHANGED
|
@@ -1,16 +1,16 @@
|
|
|
1
1
|
import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import { shutdownManagedBrowser } from "./browser-manager.js";
|
|
3
|
-
import { loadSettings } from "./settings.js";
|
|
4
3
|
import { state } from "./runtime.js";
|
|
4
|
+
import { loadSettings } from "./settings.js";
|
|
5
5
|
import {
|
|
6
6
|
allChromeDevtoolsTools,
|
|
7
7
|
applyChromeDevtoolsTools,
|
|
8
8
|
buildCommandGuide,
|
|
9
9
|
buildQuickstartMessage,
|
|
10
10
|
buildToolStatusMessage,
|
|
11
|
-
setSelectedChromeDevtoolsTools,
|
|
12
11
|
showToolSelector,
|
|
13
12
|
updateChromeDevtoolsTools,
|
|
13
|
+
waitForChromeDevtoolsSettings,
|
|
14
14
|
} from "./tool-selector.js";
|
|
15
15
|
import {
|
|
16
16
|
evaluateTool,
|
|
@@ -52,15 +52,18 @@ export default function chromeDevtools(pi: ExtensionAPI) {
|
|
|
52
52
|
description: "Open Chrome DevTools help and tool controls",
|
|
53
53
|
getArgumentCompletions: (prefix) => commandCompletions(prefix),
|
|
54
54
|
handler: async (args, ctx) => {
|
|
55
|
-
|
|
55
|
+
const generation = state.sessionGeneration;
|
|
56
|
+
await handleChromeDevtoolsCommand(pi, args, ctx, generation);
|
|
56
57
|
},
|
|
57
58
|
});
|
|
58
59
|
|
|
59
60
|
pi.on("session_start", async (_event, ctx) => {
|
|
61
|
+
const generation = ++state.sessionGeneration;
|
|
60
62
|
state.shuttingDown = false;
|
|
61
63
|
state.settingsNotice = undefined;
|
|
62
64
|
ctx.ui.setStatus(STATUS_KEY, undefined);
|
|
63
65
|
const settings = await loadSettings();
|
|
66
|
+
if (generation !== state.sessionGeneration) return;
|
|
64
67
|
state.settingsNotice = settings.notice;
|
|
65
68
|
if (settings.notice) ctx.ui.notify(settings.notice, "warning");
|
|
66
69
|
if (settings.kind === "loaded") {
|
|
@@ -73,16 +76,24 @@ export default function chromeDevtools(pi: ExtensionAPI) {
|
|
|
73
76
|
});
|
|
74
77
|
|
|
75
78
|
pi.on("session_shutdown", async (_event, ctx) => {
|
|
79
|
+
state.sessionGeneration += 1;
|
|
76
80
|
ctx.ui.setStatus(STATUS_KEY, undefined);
|
|
77
|
-
|
|
81
|
+
const browserShutdown = shutdownManagedBrowser(undefined, { cancelLaunch: true });
|
|
82
|
+
await waitForChromeDevtoolsSettings();
|
|
83
|
+
await browserShutdown;
|
|
78
84
|
});
|
|
79
85
|
}
|
|
80
86
|
|
|
81
|
-
async function handleChromeDevtoolsCommand(
|
|
87
|
+
async function handleChromeDevtoolsCommand(
|
|
88
|
+
pi: ExtensionAPI,
|
|
89
|
+
args: string,
|
|
90
|
+
ctx: CommandContext,
|
|
91
|
+
generation: number,
|
|
92
|
+
) {
|
|
82
93
|
const command = parseCommand(args);
|
|
83
94
|
switch (command) {
|
|
84
95
|
case "menu":
|
|
85
|
-
await showMenu(pi, ctx);
|
|
96
|
+
await showMenu(pi, ctx, generation);
|
|
86
97
|
return;
|
|
87
98
|
case "help":
|
|
88
99
|
ctx.ui.notify(buildCommandGuide(), "info");
|
|
@@ -90,9 +101,12 @@ async function handleChromeDevtoolsCommand(pi: ExtensionAPI, args: string, ctx:
|
|
|
90
101
|
case "quickstart":
|
|
91
102
|
ctx.ui.notify(buildQuickstartMessage(), "info");
|
|
92
103
|
return;
|
|
93
|
-
case "status":
|
|
94
|
-
|
|
104
|
+
case "status": {
|
|
105
|
+
const status = await buildToolStatusMessage(pi);
|
|
106
|
+
if (generation !== state.sessionGeneration) return;
|
|
107
|
+
ctx.ui.notify(status, "info");
|
|
95
108
|
return;
|
|
109
|
+
}
|
|
96
110
|
case "tools":
|
|
97
111
|
await showToolSelector(pi, ctx);
|
|
98
112
|
return;
|
|
@@ -104,20 +118,24 @@ async function handleChromeDevtoolsCommand(pi: ExtensionAPI, args: string, ctx:
|
|
|
104
118
|
return;
|
|
105
119
|
}
|
|
106
120
|
|
|
107
|
-
ctx.ui.notify(
|
|
121
|
+
ctx.ui.notify(
|
|
122
|
+
`Unknown /chrome-devtools command: ${args.trim()}
|
|
108
123
|
|
|
109
|
-
${buildCommandGuide()}`,
|
|
124
|
+
${buildCommandGuide()}`,
|
|
125
|
+
"warning",
|
|
126
|
+
);
|
|
110
127
|
}
|
|
111
128
|
|
|
112
|
-
async function showMenu(pi: ExtensionAPI, ctx: CommandContext) {
|
|
129
|
+
async function showMenu(pi: ExtensionAPI, ctx: CommandContext, generation: number) {
|
|
113
130
|
if (!ctx.hasUI) {
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
131
|
+
const status = await buildToolStatusMessage(pi);
|
|
132
|
+
if (generation !== state.sessionGeneration) return;
|
|
133
|
+
ctx.ui.notify(`${buildCommandGuide()}\n\n${status}`, "info");
|
|
117
134
|
return;
|
|
118
135
|
}
|
|
119
136
|
|
|
120
137
|
const choice = await ctx.ui.select("Chrome DevTools", Object.values(MENU_OPTIONS));
|
|
138
|
+
if (generation !== state.sessionGeneration) return;
|
|
121
139
|
switch (choice) {
|
|
122
140
|
case MENU_OPTIONS.quickstart:
|
|
123
141
|
ctx.ui.notify(buildQuickstartMessage(), "info");
|
|
@@ -125,9 +143,12 @@ ${await buildToolStatusMessage(pi)}`, "info");
|
|
|
125
143
|
case MENU_OPTIONS.help:
|
|
126
144
|
ctx.ui.notify(buildCommandGuide(), "info");
|
|
127
145
|
return;
|
|
128
|
-
case MENU_OPTIONS.status:
|
|
129
|
-
|
|
146
|
+
case MENU_OPTIONS.status: {
|
|
147
|
+
const status = await buildToolStatusMessage(pi);
|
|
148
|
+
if (generation !== state.sessionGeneration) return;
|
|
149
|
+
ctx.ui.notify(status, "info");
|
|
130
150
|
return;
|
|
151
|
+
}
|
|
131
152
|
case MENU_OPTIONS.tools:
|
|
132
153
|
await showToolSelector(pi, ctx);
|
|
133
154
|
return;
|
|
@@ -174,5 +195,5 @@ export {
|
|
|
174
195
|
resolveScreenshotPath,
|
|
175
196
|
selectAllowedRoot,
|
|
176
197
|
} from "./screenshot.js";
|
|
177
|
-
export {
|
|
198
|
+
export { normalizeChromeDevtoolsSettings } from "./settings.js";
|
|
178
199
|
export { orderedChromeDevtoolsTools } from "./tool-selector.js";
|
package/src/render.ts
CHANGED
|
@@ -1,7 +1,4 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
AgentToolResult,
|
|
3
|
-
ToolRenderResultOptions,
|
|
4
|
-
} from "@earendil-works/pi-coding-agent";
|
|
1
|
+
import type { AgentToolResult, ToolRenderResultOptions } from "@earendil-works/pi-coding-agent";
|
|
5
2
|
|
|
6
3
|
const STATUS_KEY = "chrome-devtools";
|
|
7
4
|
interface StatusContext {
|
|
@@ -90,9 +87,7 @@ class PiTextComponent implements RenderComponent {
|
|
|
90
87
|
.split(/\r?\n/)
|
|
91
88
|
.map((line) => {
|
|
92
89
|
const truncatedLine = truncateLine(line, Math.max(1, width));
|
|
93
|
-
return this.theme && this.color
|
|
94
|
-
? this.theme.fg(this.color, truncatedLine)
|
|
95
|
-
: truncatedLine;
|
|
90
|
+
return this.theme && this.color ? this.theme.fg(this.color, truncatedLine) : truncatedLine;
|
|
96
91
|
});
|
|
97
92
|
}
|
|
98
93
|
}
|
|
@@ -101,7 +96,11 @@ function truncateLine(line: string, maxWidth: number) {
|
|
|
101
96
|
return Array.from(line).slice(0, maxWidth).join("");
|
|
102
97
|
}
|
|
103
98
|
|
|
104
|
-
export async function withStatus<T>(
|
|
99
|
+
export async function withStatus<T>(
|
|
100
|
+
ctx: StatusContext,
|
|
101
|
+
status: string,
|
|
102
|
+
callback: () => Promise<T>,
|
|
103
|
+
) {
|
|
105
104
|
ctx.ui.setStatus(STATUS_KEY, status);
|
|
106
105
|
try {
|
|
107
106
|
return await callback();
|
package/src/runtime.ts
CHANGED
|
@@ -31,6 +31,7 @@ export interface ChromeDevToolsState {
|
|
|
31
31
|
launchPromise?: Promise<void>;
|
|
32
32
|
lastLaunchAttempt?: BrowserLaunchAttempt;
|
|
33
33
|
shuttingDown: boolean;
|
|
34
|
+
sessionGeneration: number;
|
|
34
35
|
settingsNotice?: string;
|
|
35
36
|
}
|
|
36
37
|
|
|
@@ -82,4 +83,5 @@ export const state: ChromeDevToolsState = {
|
|
|
82
83
|
autoLaunchEnabled: process.env.PI_CHROME_DEVTOOLS_AUTO_LAUNCH !== "0",
|
|
83
84
|
browserExecutable: process.env.PI_CHROME_DEVTOOLS_BROWSER,
|
|
84
85
|
shuttingDown: false,
|
|
86
|
+
sessionGeneration: 0,
|
|
85
87
|
};
|
package/src/screenshot.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
|
-
import {
|
|
2
|
+
import { lstat, mkdir, realpath, rename, rm, writeFile } from "node:fs/promises";
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
4
|
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
5
5
|
import { withFileMutationQueue } from "@earendil-works/pi-coding-agent";
|
|
@@ -47,7 +47,10 @@ export async function saveScreenshot(
|
|
|
47
47
|
};
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
-
export function resolveScreenshotPath(
|
|
50
|
+
export function resolveScreenshotPath(
|
|
51
|
+
savePath: string | undefined,
|
|
52
|
+
cwd: string,
|
|
53
|
+
): ResolvedScreenshotPath {
|
|
51
54
|
const cwdRoot = resolve(cwd);
|
|
52
55
|
const tempRoot = resolve(tmpdir());
|
|
53
56
|
|
|
@@ -228,7 +231,9 @@ export function isPathInsideRoot(path: string, root: string) {
|
|
|
228
231
|
const normalizedPath = normalizePathForComparison(resolve(path));
|
|
229
232
|
const normalizedRoot = normalizePathForComparison(resolve(root));
|
|
230
233
|
if (normalizedPath === normalizedRoot) return true;
|
|
231
|
-
const rootWithSeparator = normalizedRoot.endsWith(sep)
|
|
234
|
+
const rootWithSeparator = normalizedRoot.endsWith(sep)
|
|
235
|
+
? normalizedRoot
|
|
236
|
+
: `${normalizedRoot}${sep}`;
|
|
232
237
|
return normalizedPath.startsWith(rootWithSeparator);
|
|
233
238
|
}
|
|
234
239
|
|
package/src/settings.ts
CHANGED
|
@@ -1,12 +1,9 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
2
|
import { constants } from "node:fs";
|
|
3
|
-
import { access,
|
|
4
|
-
import { homedir } from "node:os";
|
|
3
|
+
import { access, lstat, mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
|
|
5
4
|
import { dirname, join } from "node:path";
|
|
6
|
-
import {
|
|
7
|
-
|
|
8
|
-
type ChromeDevToolsToolName,
|
|
9
|
-
} from "./tool-names.js";
|
|
5
|
+
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
6
|
+
import { CHROME_DEVTOOLS_TOOL_NAMES, type ChromeDevToolsToolName } from "./tool-names.js";
|
|
10
7
|
|
|
11
8
|
const NEW_SETTINGS_FILE_NAME = "pi-chrome-devtools.json";
|
|
12
9
|
const LEGACY_SETTINGS_FILE_NAME = "pi-chrome-devtools-settings.json";
|
|
@@ -15,17 +12,23 @@ export interface ChromeDevToolsSettings {
|
|
|
15
12
|
updatedAt: number;
|
|
16
13
|
}
|
|
17
14
|
|
|
15
|
+
export interface SettingsFileOperations {
|
|
16
|
+
write(path: string, data: string): Promise<void>;
|
|
17
|
+
rename(source: string, destination: string): Promise<void>;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const DEFAULT_FILE_OPERATIONS: SettingsFileOperations = {
|
|
21
|
+
write: (path, data) => writeFile(path, data, "utf8").then(() => undefined),
|
|
22
|
+
rename,
|
|
23
|
+
};
|
|
24
|
+
|
|
18
25
|
export type SettingsLoadResult =
|
|
19
26
|
| { kind: "missing"; notice?: string }
|
|
20
27
|
| { kind: "invalid"; reason: string; notice?: string }
|
|
21
28
|
| { kind: "loaded"; settings: ChromeDevToolsSettings; notice?: string };
|
|
22
29
|
|
|
23
|
-
type SettingsMigrationResult = {
|
|
24
|
-
kind: "migrated" | "failed";
|
|
25
|
-
notice: string;
|
|
26
|
-
};
|
|
27
|
-
|
|
28
30
|
export async function loadSettings(): Promise<SettingsLoadResult> {
|
|
31
|
+
await settingsSaveQueue;
|
|
29
32
|
const newPath = settingsFilePath();
|
|
30
33
|
const newSettings = await readSettingsFile(newPath);
|
|
31
34
|
if (newSettings.kind !== "missing") {
|
|
@@ -41,39 +44,50 @@ export async function loadSettings(): Promise<SettingsLoadResult> {
|
|
|
41
44
|
if (legacySettings.kind === "missing") return { kind: "missing" };
|
|
42
45
|
if (legacySettings.kind === "invalid") return legacySettings;
|
|
43
46
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
}
|
|
50
|
-
}
|
|
47
|
+
return {
|
|
48
|
+
...legacySettings,
|
|
49
|
+
notice: `Using legacy ${LEGACY_SETTINGS_FILE_NAME}; rename it to ${NEW_SETTINGS_FILE_NAME}. Future saves write ${NEW_SETTINGS_FILE_NAME} without modifying the legacy file.`,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
51
52
|
|
|
52
|
-
|
|
53
|
+
interface SettingsDocumentResult {
|
|
54
|
+
result: SettingsLoadResult;
|
|
55
|
+
document?: Record<string, unknown>;
|
|
53
56
|
}
|
|
54
57
|
|
|
55
|
-
async function
|
|
58
|
+
async function readSettingsDocument(filePath: string): Promise<SettingsDocumentResult> {
|
|
56
59
|
let text: string;
|
|
57
60
|
try {
|
|
58
61
|
text = await readFile(filePath, "utf8");
|
|
59
62
|
} catch (error) {
|
|
60
|
-
if (isNodeError(error) && error.code === "ENOENT") return { kind: "missing" };
|
|
61
|
-
return { kind: "invalid", reason: `${filePath}: ${formatError(error)}` };
|
|
63
|
+
if (isNodeError(error) && error.code === "ENOENT") return { result: { kind: "missing" } };
|
|
64
|
+
return { result: { kind: "invalid", reason: `${filePath}: ${formatError(error)}` } };
|
|
62
65
|
}
|
|
63
66
|
|
|
64
67
|
try {
|
|
65
68
|
const parsed = JSON.parse(text) as unknown;
|
|
66
69
|
const settings = normalizeChromeDevtoolsSettings(parsed);
|
|
67
|
-
if (settings)
|
|
70
|
+
if (settings) {
|
|
71
|
+
return {
|
|
72
|
+
result: { kind: "loaded", settings },
|
|
73
|
+
document: { ...(parsed as Record<string, unknown>) },
|
|
74
|
+
};
|
|
75
|
+
}
|
|
68
76
|
return {
|
|
69
|
-
|
|
70
|
-
|
|
77
|
+
result: {
|
|
78
|
+
kind: "invalid",
|
|
79
|
+
reason: `${filePath}: expected tools to be an array of Chrome DevTools tool names`,
|
|
80
|
+
},
|
|
71
81
|
};
|
|
72
82
|
} catch (error) {
|
|
73
|
-
return { kind: "invalid", reason: `${filePath}: ${formatError(error)}` };
|
|
83
|
+
return { result: { kind: "invalid", reason: `${filePath}: ${formatError(error)}` } };
|
|
74
84
|
}
|
|
75
85
|
}
|
|
76
86
|
|
|
87
|
+
async function readSettingsFile(filePath: string): Promise<SettingsLoadResult> {
|
|
88
|
+
return (await readSettingsDocument(filePath)).result;
|
|
89
|
+
}
|
|
90
|
+
|
|
77
91
|
async function withLegacyIgnoredNotice(settings: SettingsLoadResult): Promise<SettingsLoadResult> {
|
|
78
92
|
if (!(await fileExists(legacySettingsFilePath()))) return settings;
|
|
79
93
|
return {
|
|
@@ -82,49 +96,6 @@ async function withLegacyIgnoredNotice(settings: SettingsLoadResult): Promise<Se
|
|
|
82
96
|
};
|
|
83
97
|
}
|
|
84
98
|
|
|
85
|
-
export async function installSettingsFileExclusively(filePath: string, contents: string) {
|
|
86
|
-
await mkdir(dirname(filePath), { recursive: true });
|
|
87
|
-
const tempFile = `${filePath}.${process.pid}.${randomUUID()}.tmp`;
|
|
88
|
-
try {
|
|
89
|
-
await writeFile(tempFile, contents, { encoding: "utf8", flag: "wx" });
|
|
90
|
-
await link(tempFile, filePath);
|
|
91
|
-
} finally {
|
|
92
|
-
await rm(tempFile, { force: true }).catch(() => undefined);
|
|
93
|
-
}
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
async function migrateLegacySettings(
|
|
97
|
-
legacyPath: string,
|
|
98
|
-
settings: ChromeDevToolsSettings,
|
|
99
|
-
): Promise<SettingsMigrationResult> {
|
|
100
|
-
const newPath = settingsFilePath();
|
|
101
|
-
try {
|
|
102
|
-
await installSettingsFileExclusively(
|
|
103
|
-
newPath,
|
|
104
|
-
`${JSON.stringify(settings, null, 2)}\n`,
|
|
105
|
-
);
|
|
106
|
-
} catch (error) {
|
|
107
|
-
return {
|
|
108
|
-
kind: "failed",
|
|
109
|
-
notice: `Chrome DevTools legacy settings migration failed: could not migrate ${legacyPath} to ${newPath}: ${formatError(error)}. The legacy file was used for this session; future saves will write ${NEW_SETTINGS_FILE_NAME}.`,
|
|
110
|
-
};
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
try {
|
|
114
|
-
await rm(legacyPath, { force: true });
|
|
115
|
-
} catch (error) {
|
|
116
|
-
return {
|
|
117
|
-
kind: "migrated",
|
|
118
|
-
notice: `Chrome DevTools settings migrated from ${legacyPath} to ${newPath}, but the legacy file could not be removed: ${formatError(error)}. Delete ${LEGACY_SETTINGS_FILE_NAME} after confirming your settings.`,
|
|
119
|
-
};
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
return {
|
|
123
|
-
kind: "migrated",
|
|
124
|
-
notice: `Chrome DevTools settings migrated from ${legacyPath} to ${newPath}. ${LEGACY_SETTINGS_FILE_NAME} is deprecated and will be removed in a future major release.`,
|
|
125
|
-
};
|
|
126
|
-
}
|
|
127
|
-
|
|
128
99
|
async function fileExists(filePath: string) {
|
|
129
100
|
try {
|
|
130
101
|
await access(filePath, constants.F_OK);
|
|
@@ -134,7 +105,19 @@ async function fileExists(filePath: string) {
|
|
|
134
105
|
}
|
|
135
106
|
}
|
|
136
107
|
|
|
137
|
-
|
|
108
|
+
async function pathEntryExists(filePath: string) {
|
|
109
|
+
try {
|
|
110
|
+
await lstat(filePath);
|
|
111
|
+
return true;
|
|
112
|
+
} catch (error) {
|
|
113
|
+
if (isNodeError(error) && error.code === "ENOENT") return false;
|
|
114
|
+
throw error;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function normalizeChromeDevtoolsSettings(
|
|
119
|
+
value: unknown,
|
|
120
|
+
): ChromeDevToolsSettings | undefined {
|
|
138
121
|
if (!value || typeof value !== "object") return undefined;
|
|
139
122
|
const settings = value as { tools?: unknown; updatedAt?: unknown };
|
|
140
123
|
if (typeof settings.updatedAt !== "number") return undefined;
|
|
@@ -158,13 +141,48 @@ function orderedUniqueChromeDevtoolsTools(tools: readonly ChromeDevToolsToolName
|
|
|
158
141
|
return CHROME_DEVTOOLS_TOOL_NAMES.filter((toolName) => selectedTools.has(toolName));
|
|
159
142
|
}
|
|
160
143
|
|
|
161
|
-
|
|
144
|
+
let settingsSaveQueue = Promise.resolve();
|
|
145
|
+
|
|
146
|
+
export function saveSettings(
|
|
147
|
+
settings: ChromeDevToolsSettings,
|
|
148
|
+
operations: Partial<SettingsFileOperations> = {},
|
|
149
|
+
): Promise<void> {
|
|
150
|
+
const operation = settingsSaveQueue.then(() => saveSettingsNow(settings, operations));
|
|
151
|
+
settingsSaveQueue = operation.catch(() => undefined);
|
|
152
|
+
return operation;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async function saveSettingsNow(
|
|
156
|
+
settings: ChromeDevToolsSettings,
|
|
157
|
+
operations: Partial<SettingsFileOperations>,
|
|
158
|
+
): Promise<void> {
|
|
162
159
|
const filePath = settingsFilePath();
|
|
160
|
+
let current = await readSettingsDocument(filePath);
|
|
161
|
+
const replaceCanonical = current.result.kind !== "missing";
|
|
162
|
+
if (!replaceCanonical) current = await readSettingsDocument(legacySettingsFilePath());
|
|
163
|
+
if (current.result.kind === "invalid") {
|
|
164
|
+
throw new Error(
|
|
165
|
+
`Cannot save Chrome DevTools settings until you repair ${current.result.reason}`,
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
const nextDocument = {
|
|
169
|
+
...(current.document ?? {}),
|
|
170
|
+
tools: [...settings.tools],
|
|
171
|
+
updatedAt: settings.updatedAt,
|
|
172
|
+
};
|
|
163
173
|
await mkdir(dirname(filePath), { recursive: true });
|
|
164
|
-
const tempFile = `${filePath}.${process.pid}.${
|
|
174
|
+
const tempFile = `${filePath}.${process.pid}.${randomUUID()}.tmp`;
|
|
165
175
|
try {
|
|
166
|
-
await
|
|
167
|
-
|
|
176
|
+
await (operations.write ?? DEFAULT_FILE_OPERATIONS.write)(
|
|
177
|
+
tempFile,
|
|
178
|
+
`${JSON.stringify(nextDocument, null, 2)}\n`,
|
|
179
|
+
);
|
|
180
|
+
if (!replaceCanonical && (await pathEntryExists(filePath))) {
|
|
181
|
+
throw new Error(
|
|
182
|
+
`${NEW_SETTINGS_FILE_NAME} was created concurrently; reopen settings and retry.`,
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
await (operations.rename ?? DEFAULT_FILE_OPERATIONS.rename)(tempFile, filePath);
|
|
168
186
|
} catch (error) {
|
|
169
187
|
await rm(tempFile, { force: true }).catch(() => undefined);
|
|
170
188
|
throw error;
|
|
@@ -180,7 +198,7 @@ function legacySettingsFilePath() {
|
|
|
180
198
|
}
|
|
181
199
|
|
|
182
200
|
function agentDir() {
|
|
183
|
-
return
|
|
201
|
+
return getAgentDir();
|
|
184
202
|
}
|
|
185
203
|
|
|
186
204
|
function isNodeError(error: unknown): error is NodeJS.ErrnoException {
|
|
@@ -190,7 +208,3 @@ function isNodeError(error: unknown): error is NodeJS.ErrnoException {
|
|
|
190
208
|
function formatError(error: unknown) {
|
|
191
209
|
return error instanceof Error ? error.message : String(error);
|
|
192
210
|
}
|
|
193
|
-
|
|
194
|
-
function unique<T>(values: T[]) {
|
|
195
|
-
return Array.from(new Set(values));
|
|
196
|
-
}
|
package/src/tool-selector.ts
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import {
|
|
3
3
|
browserCandidateHint,
|
|
4
|
-
chromeLaunchCommand,
|
|
5
4
|
devToolsEndpoint,
|
|
6
5
|
endpointConfigHint,
|
|
7
6
|
endpointSourceLabel,
|
|
@@ -9,12 +8,9 @@ import {
|
|
|
9
8
|
launchHint,
|
|
10
9
|
launchModeLabel,
|
|
11
10
|
} from "./browser-manager.js";
|
|
12
|
-
import { loadSettings, saveSettings, settingsFilePath } from "./settings.js";
|
|
13
11
|
import { state } from "./runtime.js";
|
|
14
|
-
import {
|
|
15
|
-
|
|
16
|
-
type ChromeDevToolsToolName,
|
|
17
|
-
} from "./tool-names.js";
|
|
12
|
+
import { loadSettings, saveSettings, settingsFilePath } from "./settings.js";
|
|
13
|
+
import { CHROME_DEVTOOLS_TOOL_NAMES, type ChromeDevToolsToolName } from "./tool-names.js";
|
|
18
14
|
|
|
19
15
|
type CommandContext = ExtensionCommandContext;
|
|
20
16
|
const TOOL_SELECTOR_DONE = "Done";
|
|
@@ -43,26 +39,32 @@ interface ToolStatusSummary {
|
|
|
43
39
|
}
|
|
44
40
|
|
|
45
41
|
export async function showToolSelector(pi: ExtensionAPI, ctx: CommandContext) {
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
${await buildToolStatusMessage(pi)}`,
|
|
51
|
-
"info",
|
|
52
|
-
);
|
|
42
|
+
const generation = state.sessionGeneration;
|
|
43
|
+
if (!ctx.hasUI) return;
|
|
44
|
+
if (ctx.mode !== "tui") {
|
|
45
|
+
await showDialogToolSelector(pi, ctx, generation);
|
|
53
46
|
return;
|
|
54
47
|
}
|
|
55
48
|
|
|
56
49
|
let selectedTools = new Set<ChromeDevToolsToolName>(getActiveChromeDevtoolsTools(pi));
|
|
57
50
|
let persistQueue = Promise.resolve();
|
|
51
|
+
let requestedRevision = 0;
|
|
52
|
+
let requestRender: () => void = () => undefined;
|
|
58
53
|
const commitSelectedTools = () => {
|
|
59
54
|
const nextSelectedTools = orderedChromeDevtoolsTools(selectedTools);
|
|
60
|
-
|
|
61
|
-
persistQueue = persistQueue.then(() =>
|
|
55
|
+
const revision = ++requestedRevision;
|
|
56
|
+
persistQueue = persistQueue.then(async () => {
|
|
57
|
+
const saved = await transactSelectedTools(pi, ctx, nextSelectedTools, generation);
|
|
58
|
+
if (!saved && generation === state.sessionGeneration && revision === requestedRevision) {
|
|
59
|
+
selectedTools = new Set(getActiveChromeDevtoolsTools(pi));
|
|
60
|
+
requestRender();
|
|
61
|
+
}
|
|
62
|
+
});
|
|
62
63
|
};
|
|
63
64
|
|
|
64
65
|
const customResult = await ctx.ui.custom<"closed" | undefined>(
|
|
65
66
|
(tui, theme, keybindings, done) => {
|
|
67
|
+
requestRender = () => tui.requestRender();
|
|
66
68
|
const rows = chromeDevtoolsToolSelectorRows();
|
|
67
69
|
let selectedIndex = 0;
|
|
68
70
|
const moveSelection = (delta: number) => {
|
|
@@ -144,22 +146,26 @@ ${await buildToolStatusMessage(pi)}`,
|
|
|
144
146
|
},
|
|
145
147
|
);
|
|
146
148
|
|
|
149
|
+
if (generation !== state.sessionGeneration) return;
|
|
147
150
|
if (customResult !== "closed") {
|
|
148
|
-
await showDialogToolSelector(pi, ctx);
|
|
151
|
+
await showDialogToolSelector(pi, ctx, generation);
|
|
149
152
|
return;
|
|
150
153
|
}
|
|
151
154
|
|
|
152
155
|
await persistQueue;
|
|
153
|
-
|
|
156
|
+
if (generation !== state.sessionGeneration) return;
|
|
157
|
+
const status = await buildToolStatusMessage(pi);
|
|
158
|
+
if (generation !== state.sessionGeneration) return;
|
|
159
|
+
ctx.ui.notify(status, "info");
|
|
154
160
|
}
|
|
155
161
|
|
|
156
|
-
async function showDialogToolSelector(pi: ExtensionAPI, ctx: CommandContext) {
|
|
162
|
+
async function showDialogToolSelector(pi: ExtensionAPI, ctx: CommandContext, generation: number) {
|
|
157
163
|
let selectedTools = new Set<ChromeDevToolsToolName>(getActiveChromeDevtoolsTools(pi));
|
|
158
164
|
while (true) {
|
|
159
165
|
const rows = chromeDevtoolsToolSelectorRows();
|
|
160
166
|
const choices = rows.map((row) => formatToolSelectorRow(row, selectedTools));
|
|
161
167
|
const choice = await ctx.ui.select(toolSelectorTitle(selectedTools), choices);
|
|
162
|
-
if (!choice) break;
|
|
168
|
+
if (generation !== state.sessionGeneration || !choice) break;
|
|
163
169
|
|
|
164
170
|
const row = rows[choices.indexOf(choice)];
|
|
165
171
|
if (!row) continue;
|
|
@@ -174,10 +180,20 @@ async function showDialogToolSelector(pi: ExtensionAPI, ctx: CommandContext) {
|
|
|
174
180
|
selectedTools = new Set();
|
|
175
181
|
}
|
|
176
182
|
|
|
177
|
-
|
|
183
|
+
const saved = await transactSelectedTools(
|
|
184
|
+
pi,
|
|
185
|
+
ctx,
|
|
186
|
+
orderedChromeDevtoolsTools(selectedTools),
|
|
187
|
+
generation,
|
|
188
|
+
);
|
|
189
|
+
if (generation !== state.sessionGeneration) return;
|
|
190
|
+
if (!saved) selectedTools = new Set(getActiveChromeDevtoolsTools(pi));
|
|
178
191
|
}
|
|
179
192
|
|
|
180
|
-
|
|
193
|
+
if (generation !== state.sessionGeneration) return;
|
|
194
|
+
const status = await buildToolStatusMessage(pi);
|
|
195
|
+
if (generation !== state.sessionGeneration) return;
|
|
196
|
+
ctx.ui.notify(status, "info");
|
|
181
197
|
}
|
|
182
198
|
|
|
183
199
|
export async function updateChromeDevtoolsTools(
|
|
@@ -186,19 +202,75 @@ export async function updateChromeDevtoolsTools(
|
|
|
186
202
|
selectedTools: readonly ChromeDevToolsToolName[],
|
|
187
203
|
action: string,
|
|
188
204
|
) {
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
205
|
+
const generation = state.sessionGeneration;
|
|
206
|
+
const saved = await transactSelectedTools(pi, ctx, selectedTools, generation);
|
|
207
|
+
if (!saved || generation !== state.sessionGeneration) return;
|
|
208
|
+
const status = await buildToolStatusMessage(pi);
|
|
209
|
+
if (generation !== state.sessionGeneration) return;
|
|
210
|
+
ctx.ui.notify(`Chrome DevTools tools ${action}.\n\n${status}`, "info");
|
|
193
211
|
}
|
|
194
212
|
|
|
195
213
|
export async function setSelectedChromeDevtoolsTools(
|
|
196
214
|
pi: ExtensionAPI,
|
|
197
215
|
ctx: CommandContext,
|
|
198
216
|
selectedTools: readonly ChromeDevToolsToolName[],
|
|
199
|
-
) {
|
|
200
|
-
|
|
201
|
-
|
|
217
|
+
): Promise<boolean> {
|
|
218
|
+
return transactSelectedTools(pi, ctx, selectedTools, state.sessionGeneration);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
let toolTransactionQueue = Promise.resolve();
|
|
222
|
+
|
|
223
|
+
export async function waitForChromeDevtoolsSettings(): Promise<void> {
|
|
224
|
+
await toolTransactionQueue;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function transactSelectedTools(
|
|
228
|
+
pi: ExtensionAPI,
|
|
229
|
+
ctx: CommandContext,
|
|
230
|
+
selectedTools: readonly ChromeDevToolsToolName[],
|
|
231
|
+
expectedGeneration: number,
|
|
232
|
+
): Promise<boolean> {
|
|
233
|
+
const operation = toolTransactionQueue.then(() =>
|
|
234
|
+
transactSelectedToolsNow(pi, ctx, selectedTools, expectedGeneration),
|
|
235
|
+
);
|
|
236
|
+
toolTransactionQueue = operation.then(
|
|
237
|
+
() => undefined,
|
|
238
|
+
() => undefined,
|
|
239
|
+
);
|
|
240
|
+
return operation;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
async function transactSelectedToolsNow(
|
|
244
|
+
pi: ExtensionAPI,
|
|
245
|
+
ctx: CommandContext,
|
|
246
|
+
selectedTools: readonly ChromeDevToolsToolName[],
|
|
247
|
+
expectedGeneration: number,
|
|
248
|
+
): Promise<boolean> {
|
|
249
|
+
if (expectedGeneration !== state.sessionGeneration) return false;
|
|
250
|
+
const previousActiveTools = pi.getActiveTools();
|
|
251
|
+
try {
|
|
252
|
+
applyChromeDevtoolsTools(pi, selectedTools);
|
|
253
|
+
await persistSettings(selectedTools);
|
|
254
|
+
return expectedGeneration === state.sessionGeneration;
|
|
255
|
+
} catch (error) {
|
|
256
|
+
let rollbackError: unknown;
|
|
257
|
+
try {
|
|
258
|
+
const previousChromeTools = previousActiveTools.filter((name) =>
|
|
259
|
+
CHROME_DEVTOOLS_TOOL_NAMES.includes(name as ChromeDevToolsToolName),
|
|
260
|
+
) as ChromeDevToolsToolName[];
|
|
261
|
+
applyChromeDevtoolsTools(pi, previousChromeTools);
|
|
262
|
+
} catch (caught) {
|
|
263
|
+
rollbackError = caught;
|
|
264
|
+
}
|
|
265
|
+
if (expectedGeneration !== state.sessionGeneration) return false;
|
|
266
|
+
ctx.ui.notify(
|
|
267
|
+
rollbackError
|
|
268
|
+
? `Chrome DevTools settings save failed: ${formatError(error)}; active-tool rollback failed: ${formatError(rollbackError)}`
|
|
269
|
+
: `Chrome DevTools settings save failed; active tools restored: ${formatError(error)}`,
|
|
270
|
+
"warning",
|
|
271
|
+
);
|
|
272
|
+
return false;
|
|
273
|
+
}
|
|
202
274
|
}
|
|
203
275
|
|
|
204
276
|
export function applyChromeDevtoolsTools(
|
|
@@ -328,13 +400,6 @@ function formatPersistedSelection(tools: readonly ChromeDevToolsToolName[]) {
|
|
|
328
400
|
return `${tools.length}/${CHROME_DEVTOOLS_TOOL_NAMES.length} selected: ${tools.join(", ")}`;
|
|
329
401
|
}
|
|
330
402
|
|
|
331
|
-
async function persistSettings(
|
|
332
|
-
|
|
333
|
-
selectedTools: readonly ChromeDevToolsToolName[],
|
|
334
|
-
) {
|
|
335
|
-
try {
|
|
336
|
-
await saveSettings({ tools: [...selectedTools], updatedAt: Date.now() });
|
|
337
|
-
} catch (error) {
|
|
338
|
-
ctx.ui.notify(`Chrome DevTools settings save failed: ${formatError(error)}`, "warning");
|
|
339
|
-
}
|
|
403
|
+
async function persistSettings(selectedTools: readonly ChromeDevToolsToolName[]) {
|
|
404
|
+
await saveSettings({ tools: [...selectedTools], updatedAt: Date.now() });
|
|
340
405
|
}
|