@narumitw/pi-chrome-devtools 0.31.0 → 0.36.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 +10 -7
- package/package.json +2 -1
- package/src/chrome-devtools.ts +83 -35
- package/src/runtime.ts +4 -0
- package/src/settings.ts +89 -67
- package/src/tool-selector.ts +135 -173
package/README.md
CHANGED
|
@@ -25,7 +25,7 @@ This package is inspired by [`chrome-devtools-mcp`](https://github.com/ChromeDev
|
|
|
25
25
|
- Retries briefly while Chrome is starting and reports actionable endpoint errors.
|
|
26
26
|
- Shows statusline activity only while Chrome DevTools tools are running.
|
|
27
27
|
- Provides a `/chrome-devtools` menu with quick-start help and tool controls.
|
|
28
|
-
-
|
|
28
|
+
- Uses `@narumitw/pi-tui-kit` for width-safe menus and individual tool selection.
|
|
29
29
|
- Persists the selected Chrome DevTools tools across Pi restarts.
|
|
30
30
|
|
|
31
31
|
## 📦 Install
|
|
@@ -155,7 +155,7 @@ Direct subcommands are also available:
|
|
|
155
155
|
candidates, last launch attempt, and launch hints.
|
|
156
156
|
- `status` shows runtime tool state, persisted selection, settings file path, endpoint source,
|
|
157
157
|
launch mode, last launch attempt, and active non-Chrome tool count.
|
|
158
|
-
- `tools` opens a
|
|
158
|
+
- `tools` opens a width-safe selector for choosing individual `chrome_devtools_*` tools.
|
|
159
159
|
- `toggle` is an alias for `tools`.
|
|
160
160
|
- `enable` enables all `chrome_devtools_*` tools for future turns.
|
|
161
161
|
- `disable` disables all `chrome_devtools_*` tools for future turns. The slash command remains
|
|
@@ -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.36.0",
|
|
4
4
|
"description": "Pi extension that exposes Chrome DevTools Protocol tools.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -29,6 +29,7 @@
|
|
|
29
29
|
"typecheck": "tsc --noEmit"
|
|
30
30
|
},
|
|
31
31
|
"dependencies": {
|
|
32
|
+
"@narumitw/pi-tui-kit": "<1",
|
|
32
33
|
"typebox": "^1.3.8"
|
|
33
34
|
},
|
|
34
35
|
"devDependencies": {
|
package/src/chrome-devtools.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { defineMenu, runMenu } from "@narumitw/pi-tui-kit";
|
|
2
3
|
import { shutdownManagedBrowser } from "./browser-manager.js";
|
|
3
4
|
import { state } from "./runtime.js";
|
|
4
5
|
import { loadSettings } from "./settings.js";
|
|
@@ -10,6 +11,7 @@ import {
|
|
|
10
11
|
buildToolStatusMessage,
|
|
11
12
|
showToolSelector,
|
|
12
13
|
updateChromeDevtoolsTools,
|
|
14
|
+
waitForChromeDevtoolsSettings,
|
|
13
15
|
} from "./tool-selector.js";
|
|
14
16
|
import {
|
|
15
17
|
evaluateTool,
|
|
@@ -51,15 +53,19 @@ export default function chromeDevtools(pi: ExtensionAPI) {
|
|
|
51
53
|
description: "Open Chrome DevTools help and tool controls",
|
|
52
54
|
getArgumentCompletions: (prefix) => commandCompletions(prefix),
|
|
53
55
|
handler: async (args, ctx) => {
|
|
54
|
-
|
|
56
|
+
const generation = state.sessionGeneration;
|
|
57
|
+
await handleChromeDevtoolsCommand(pi, args, ctx, generation);
|
|
55
58
|
},
|
|
56
59
|
});
|
|
57
60
|
|
|
58
61
|
pi.on("session_start", async (_event, ctx) => {
|
|
62
|
+
const generation = ++state.sessionGeneration;
|
|
63
|
+
replaceSessionController("Chrome DevTools session replaced");
|
|
59
64
|
state.shuttingDown = false;
|
|
60
65
|
state.settingsNotice = undefined;
|
|
61
66
|
ctx.ui.setStatus(STATUS_KEY, undefined);
|
|
62
67
|
const settings = await loadSettings();
|
|
68
|
+
if (generation !== state.sessionGeneration) return;
|
|
63
69
|
state.settingsNotice = settings.notice;
|
|
64
70
|
if (settings.notice) ctx.ui.notify(settings.notice, "warning");
|
|
65
71
|
if (settings.kind === "loaded") {
|
|
@@ -72,16 +78,25 @@ export default function chromeDevtools(pi: ExtensionAPI) {
|
|
|
72
78
|
});
|
|
73
79
|
|
|
74
80
|
pi.on("session_shutdown", async (_event, ctx) => {
|
|
81
|
+
state.sessionGeneration += 1;
|
|
82
|
+
replaceSessionController("Chrome DevTools session shut down");
|
|
75
83
|
ctx.ui.setStatus(STATUS_KEY, undefined);
|
|
76
|
-
|
|
84
|
+
const browserShutdown = shutdownManagedBrowser(undefined, { cancelLaunch: true });
|
|
85
|
+
await waitForChromeDevtoolsSettings();
|
|
86
|
+
await browserShutdown;
|
|
77
87
|
});
|
|
78
88
|
}
|
|
79
89
|
|
|
80
|
-
async function handleChromeDevtoolsCommand(
|
|
90
|
+
async function handleChromeDevtoolsCommand(
|
|
91
|
+
pi: ExtensionAPI,
|
|
92
|
+
args: string,
|
|
93
|
+
ctx: CommandContext,
|
|
94
|
+
generation: number,
|
|
95
|
+
) {
|
|
81
96
|
const command = parseCommand(args);
|
|
82
97
|
switch (command) {
|
|
83
98
|
case "menu":
|
|
84
|
-
await showMenu(pi, ctx);
|
|
99
|
+
await showMenu(pi, ctx, generation);
|
|
85
100
|
return;
|
|
86
101
|
case "help":
|
|
87
102
|
ctx.ui.notify(buildCommandGuide(), "info");
|
|
@@ -89,9 +104,12 @@ async function handleChromeDevtoolsCommand(pi: ExtensionAPI, args: string, ctx:
|
|
|
89
104
|
case "quickstart":
|
|
90
105
|
ctx.ui.notify(buildQuickstartMessage(), "info");
|
|
91
106
|
return;
|
|
92
|
-
case "status":
|
|
93
|
-
|
|
107
|
+
case "status": {
|
|
108
|
+
const status = await buildToolStatusMessage(pi);
|
|
109
|
+
if (generation !== state.sessionGeneration) return;
|
|
110
|
+
ctx.ui.notify(status, "info");
|
|
94
111
|
return;
|
|
112
|
+
}
|
|
95
113
|
case "tools":
|
|
96
114
|
await showToolSelector(pi, ctx);
|
|
97
115
|
return;
|
|
@@ -111,38 +129,68 @@ ${buildCommandGuide()}`,
|
|
|
111
129
|
);
|
|
112
130
|
}
|
|
113
131
|
|
|
114
|
-
async function showMenu(pi: ExtensionAPI, ctx: CommandContext) {
|
|
132
|
+
async function showMenu(pi: ExtensionAPI, ctx: CommandContext, generation: number) {
|
|
115
133
|
if (!ctx.hasUI) {
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
${await buildToolStatusMessage(pi)}`,
|
|
120
|
-
"info",
|
|
121
|
-
);
|
|
134
|
+
const status = await buildToolStatusMessage(pi);
|
|
135
|
+
if (generation !== state.sessionGeneration) return;
|
|
136
|
+
ctx.ui.notify(`${buildCommandGuide()}\n\n${status}`, "info");
|
|
122
137
|
return;
|
|
123
138
|
}
|
|
124
139
|
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
140
|
+
type Screen = "main";
|
|
141
|
+
type Action = keyof typeof MENU_OPTIONS;
|
|
142
|
+
const menu = defineMenu<undefined, Screen, Action>({
|
|
143
|
+
start: "main",
|
|
144
|
+
screens: {
|
|
145
|
+
main: () => ({
|
|
146
|
+
kind: "actions",
|
|
147
|
+
title: "Chrome DevTools",
|
|
148
|
+
items: Object.entries(MENU_OPTIONS).map(([id, label]) => ({
|
|
149
|
+
id,
|
|
150
|
+
label,
|
|
151
|
+
action: id as Action,
|
|
152
|
+
})),
|
|
153
|
+
hint: "close",
|
|
154
|
+
}),
|
|
155
|
+
},
|
|
156
|
+
actions: {
|
|
157
|
+
quickstart: async () => {
|
|
158
|
+
ctx.ui.notify(buildQuickstartMessage(), "info");
|
|
159
|
+
return { kind: "close" };
|
|
160
|
+
},
|
|
161
|
+
help: async () => {
|
|
162
|
+
ctx.ui.notify(buildCommandGuide(), "info");
|
|
163
|
+
return { kind: "close" };
|
|
164
|
+
},
|
|
165
|
+
status: async () => {
|
|
166
|
+
const status = await buildToolStatusMessage(pi);
|
|
167
|
+
if (generation === state.sessionGeneration) ctx.ui.notify(status, "info");
|
|
168
|
+
return { kind: "close" };
|
|
169
|
+
},
|
|
170
|
+
tools: async () => {
|
|
171
|
+
await showToolSelector(pi, ctx);
|
|
172
|
+
return { kind: "close" };
|
|
173
|
+
},
|
|
174
|
+
enable: async () => {
|
|
175
|
+
await updateChromeDevtoolsTools(pi, ctx, allChromeDevtoolsTools(), "enabled all");
|
|
176
|
+
return { kind: "close" };
|
|
177
|
+
},
|
|
178
|
+
disable: async () => {
|
|
179
|
+
await updateChromeDevtoolsTools(pi, ctx, [], "disabled all");
|
|
180
|
+
return { kind: "close" };
|
|
181
|
+
},
|
|
182
|
+
},
|
|
183
|
+
});
|
|
184
|
+
await runMenu(ctx, menu, {
|
|
185
|
+
getState: () => undefined,
|
|
186
|
+
signal: state.sessionController.signal,
|
|
187
|
+
isCurrent: () => generation === state.sessionGeneration,
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function replaceSessionController(reason: string) {
|
|
192
|
+
state.sessionController.abort(new DOMException(reason, "AbortError"));
|
|
193
|
+
state.sessionController = new AbortController();
|
|
146
194
|
}
|
|
147
195
|
|
|
148
196
|
export function parseCommand(args: string): CommandAction | "unknown" {
|
|
@@ -179,5 +227,5 @@ export {
|
|
|
179
227
|
resolveScreenshotPath,
|
|
180
228
|
selectAllowedRoot,
|
|
181
229
|
} from "./screenshot.js";
|
|
182
|
-
export {
|
|
230
|
+
export { normalizeChromeDevtoolsSettings } from "./settings.js";
|
|
183
231
|
export { orderedChromeDevtoolsTools } from "./tool-selector.js";
|
package/src/runtime.ts
CHANGED
|
@@ -31,6 +31,8 @@ export interface ChromeDevToolsState {
|
|
|
31
31
|
launchPromise?: Promise<void>;
|
|
32
32
|
lastLaunchAttempt?: BrowserLaunchAttempt;
|
|
33
33
|
shuttingDown: boolean;
|
|
34
|
+
sessionGeneration: number;
|
|
35
|
+
sessionController: AbortController;
|
|
34
36
|
settingsNotice?: string;
|
|
35
37
|
}
|
|
36
38
|
|
|
@@ -82,4 +84,6 @@ export const state: ChromeDevToolsState = {
|
|
|
82
84
|
autoLaunchEnabled: process.env.PI_CHROME_DEVTOOLS_AUTO_LAUNCH !== "0",
|
|
83
85
|
browserExecutable: process.env.PI_CHROME_DEVTOOLS_BROWSER,
|
|
84
86
|
shuttingDown: false,
|
|
87
|
+
sessionGeneration: 0,
|
|
88
|
+
sessionController: new AbortController(),
|
|
85
89
|
};
|
package/src/settings.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
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";
|
|
5
|
+
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
6
6
|
import { CHROME_DEVTOOLS_TOOL_NAMES, type ChromeDevToolsToolName } from "./tool-names.js";
|
|
7
7
|
|
|
8
8
|
const NEW_SETTINGS_FILE_NAME = "pi-chrome-devtools.json";
|
|
@@ -12,17 +12,23 @@ export interface ChromeDevToolsSettings {
|
|
|
12
12
|
updatedAt: number;
|
|
13
13
|
}
|
|
14
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
|
+
|
|
15
25
|
export type SettingsLoadResult =
|
|
16
26
|
| { kind: "missing"; notice?: string }
|
|
17
27
|
| { kind: "invalid"; reason: string; notice?: string }
|
|
18
28
|
| { kind: "loaded"; settings: ChromeDevToolsSettings; notice?: string };
|
|
19
29
|
|
|
20
|
-
type SettingsMigrationResult = {
|
|
21
|
-
kind: "migrated" | "failed";
|
|
22
|
-
notice: string;
|
|
23
|
-
};
|
|
24
|
-
|
|
25
30
|
export async function loadSettings(): Promise<SettingsLoadResult> {
|
|
31
|
+
await settingsSaveQueue;
|
|
26
32
|
const newPath = settingsFilePath();
|
|
27
33
|
const newSettings = await readSettingsFile(newPath);
|
|
28
34
|
if (newSettings.kind !== "missing") {
|
|
@@ -38,39 +44,50 @@ export async function loadSettings(): Promise<SettingsLoadResult> {
|
|
|
38
44
|
if (legacySettings.kind === "missing") return { kind: "missing" };
|
|
39
45
|
if (legacySettings.kind === "invalid") return legacySettings;
|
|
40
46
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
}
|
|
47
|
-
}
|
|
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
|
+
}
|
|
48
52
|
|
|
49
|
-
|
|
53
|
+
interface SettingsDocumentResult {
|
|
54
|
+
result: SettingsLoadResult;
|
|
55
|
+
document?: Record<string, unknown>;
|
|
50
56
|
}
|
|
51
57
|
|
|
52
|
-
async function
|
|
58
|
+
async function readSettingsDocument(filePath: string): Promise<SettingsDocumentResult> {
|
|
53
59
|
let text: string;
|
|
54
60
|
try {
|
|
55
61
|
text = await readFile(filePath, "utf8");
|
|
56
62
|
} catch (error) {
|
|
57
|
-
if (isNodeError(error) && error.code === "ENOENT") return { kind: "missing" };
|
|
58
|
-
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)}` } };
|
|
59
65
|
}
|
|
60
66
|
|
|
61
67
|
try {
|
|
62
68
|
const parsed = JSON.parse(text) as unknown;
|
|
63
69
|
const settings = normalizeChromeDevtoolsSettings(parsed);
|
|
64
|
-
if (settings)
|
|
70
|
+
if (settings) {
|
|
71
|
+
return {
|
|
72
|
+
result: { kind: "loaded", settings },
|
|
73
|
+
document: { ...(parsed as Record<string, unknown>) },
|
|
74
|
+
};
|
|
75
|
+
}
|
|
65
76
|
return {
|
|
66
|
-
|
|
67
|
-
|
|
77
|
+
result: {
|
|
78
|
+
kind: "invalid",
|
|
79
|
+
reason: `${filePath}: expected tools to be an array of Chrome DevTools tool names`,
|
|
80
|
+
},
|
|
68
81
|
};
|
|
69
82
|
} catch (error) {
|
|
70
|
-
return { kind: "invalid", reason: `${filePath}: ${formatError(error)}` };
|
|
83
|
+
return { result: { kind: "invalid", reason: `${filePath}: ${formatError(error)}` } };
|
|
71
84
|
}
|
|
72
85
|
}
|
|
73
86
|
|
|
87
|
+
async function readSettingsFile(filePath: string): Promise<SettingsLoadResult> {
|
|
88
|
+
return (await readSettingsDocument(filePath)).result;
|
|
89
|
+
}
|
|
90
|
+
|
|
74
91
|
async function withLegacyIgnoredNotice(settings: SettingsLoadResult): Promise<SettingsLoadResult> {
|
|
75
92
|
if (!(await fileExists(legacySettingsFilePath()))) return settings;
|
|
76
93
|
return {
|
|
@@ -79,46 +96,6 @@ async function withLegacyIgnoredNotice(settings: SettingsLoadResult): Promise<Se
|
|
|
79
96
|
};
|
|
80
97
|
}
|
|
81
98
|
|
|
82
|
-
export async function installSettingsFileExclusively(filePath: string, contents: string) {
|
|
83
|
-
await mkdir(dirname(filePath), { recursive: true });
|
|
84
|
-
const tempFile = `${filePath}.${process.pid}.${randomUUID()}.tmp`;
|
|
85
|
-
try {
|
|
86
|
-
await writeFile(tempFile, contents, { encoding: "utf8", flag: "wx" });
|
|
87
|
-
await link(tempFile, filePath);
|
|
88
|
-
} finally {
|
|
89
|
-
await rm(tempFile, { force: true }).catch(() => undefined);
|
|
90
|
-
}
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
async function migrateLegacySettings(
|
|
94
|
-
legacyPath: string,
|
|
95
|
-
settings: ChromeDevToolsSettings,
|
|
96
|
-
): Promise<SettingsMigrationResult> {
|
|
97
|
-
const newPath = settingsFilePath();
|
|
98
|
-
try {
|
|
99
|
-
await installSettingsFileExclusively(newPath, `${JSON.stringify(settings, null, 2)}\n`);
|
|
100
|
-
} catch (error) {
|
|
101
|
-
return {
|
|
102
|
-
kind: "failed",
|
|
103
|
-
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}.`,
|
|
104
|
-
};
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
try {
|
|
108
|
-
await rm(legacyPath, { force: true });
|
|
109
|
-
} catch (error) {
|
|
110
|
-
return {
|
|
111
|
-
kind: "migrated",
|
|
112
|
-
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.`,
|
|
113
|
-
};
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
return {
|
|
117
|
-
kind: "migrated",
|
|
118
|
-
notice: `Chrome DevTools settings migrated from ${legacyPath} to ${newPath}. ${LEGACY_SETTINGS_FILE_NAME} is deprecated and will be removed in a future major release.`,
|
|
119
|
-
};
|
|
120
|
-
}
|
|
121
|
-
|
|
122
99
|
async function fileExists(filePath: string) {
|
|
123
100
|
try {
|
|
124
101
|
await access(filePath, constants.F_OK);
|
|
@@ -128,6 +105,16 @@ async function fileExists(filePath: string) {
|
|
|
128
105
|
}
|
|
129
106
|
}
|
|
130
107
|
|
|
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
|
+
|
|
131
118
|
export function normalizeChromeDevtoolsSettings(
|
|
132
119
|
value: unknown,
|
|
133
120
|
): ChromeDevToolsSettings | undefined {
|
|
@@ -154,13 +141,48 @@ function orderedUniqueChromeDevtoolsTools(tools: readonly ChromeDevToolsToolName
|
|
|
154
141
|
return CHROME_DEVTOOLS_TOOL_NAMES.filter((toolName) => selectedTools.has(toolName));
|
|
155
142
|
}
|
|
156
143
|
|
|
157
|
-
|
|
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> {
|
|
158
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
|
+
};
|
|
159
173
|
await mkdir(dirname(filePath), { recursive: true });
|
|
160
|
-
const tempFile = `${filePath}.${process.pid}.${
|
|
174
|
+
const tempFile = `${filePath}.${process.pid}.${randomUUID()}.tmp`;
|
|
161
175
|
try {
|
|
162
|
-
await
|
|
163
|
-
|
|
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);
|
|
164
186
|
} catch (error) {
|
|
165
187
|
await rm(tempFile, { force: true }).catch(() => undefined);
|
|
166
188
|
throw error;
|
|
@@ -176,7 +198,7 @@ function legacySettingsFilePath() {
|
|
|
176
198
|
}
|
|
177
199
|
|
|
178
200
|
function agentDir() {
|
|
179
|
-
return
|
|
201
|
+
return getAgentDir();
|
|
180
202
|
}
|
|
181
203
|
|
|
182
204
|
function isNodeError(error: unknown): error is NodeJS.ErrnoException {
|
package/src/tool-selector.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { defineMenu, runMenu } from "@narumitw/pi-tui-kit";
|
|
2
3
|
import {
|
|
3
4
|
browserCandidateHint,
|
|
4
5
|
devToolsEndpoint,
|
|
@@ -13,13 +14,9 @@ import { loadSettings, saveSettings, settingsFilePath } from "./settings.js";
|
|
|
13
14
|
import { CHROME_DEVTOOLS_TOOL_NAMES, type ChromeDevToolsToolName } from "./tool-names.js";
|
|
14
15
|
|
|
15
16
|
type CommandContext = ExtensionCommandContext;
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
type ToolSelectorAction = "enableAll" | "disableAll" | "done";
|
|
20
|
-
type ToolSelectorRow =
|
|
21
|
-
| { kind: "tool"; toolName: ChromeDevToolsToolName }
|
|
22
|
-
| { kind: "action"; action: ToolSelectorAction; label: string };
|
|
17
|
+
type ToolSelectorScreen = "tools";
|
|
18
|
+
type ToolSelectorAction = "toggle" | "enableAll" | "disableAll";
|
|
19
|
+
|
|
23
20
|
function unique<T>(values: T[]) {
|
|
24
21
|
return Array.from(new Set(values));
|
|
25
22
|
}
|
|
@@ -39,165 +36,150 @@ interface ToolStatusSummary {
|
|
|
39
36
|
}
|
|
40
37
|
|
|
41
38
|
export async function showToolSelector(pi: ExtensionAPI, ctx: CommandContext) {
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
return;
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
if (row.action === "enableAll") {
|
|
79
|
-
selectedTools = new Set(allChromeDevtoolsTools());
|
|
80
|
-
commitSelectedTools();
|
|
81
|
-
return;
|
|
82
|
-
}
|
|
83
|
-
if (row.action === "disableAll") {
|
|
84
|
-
selectedTools = new Set();
|
|
85
|
-
commitSelectedTools();
|
|
86
|
-
return;
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
done("closed");
|
|
90
|
-
};
|
|
91
|
-
|
|
92
|
-
return {
|
|
93
|
-
invalidate() {},
|
|
94
|
-
render() {
|
|
95
|
-
return [
|
|
96
|
-
theme.fg("accent", theme.bold(toolSelectorTitle(selectedTools))),
|
|
97
|
-
"",
|
|
98
|
-
...rows.map((row, index) => {
|
|
99
|
-
const label = formatToolSelectorRow(row, selectedTools);
|
|
100
|
-
if (index === selectedIndex) {
|
|
101
|
-
return `${theme.fg("accent", "›")} ${theme.fg("accent", label)}`;
|
|
102
|
-
}
|
|
103
|
-
return ` ${label}`;
|
|
104
|
-
}),
|
|
105
|
-
"",
|
|
106
|
-
theme.fg("dim", "↑↓ navigate • Enter/Space toggle • Esc close"),
|
|
107
|
-
];
|
|
108
|
-
},
|
|
109
|
-
handleInput(data: string) {
|
|
110
|
-
if (keybindings.matches(data, "tui.select.up")) {
|
|
111
|
-
moveSelection(-1);
|
|
112
|
-
tui.requestRender();
|
|
113
|
-
return;
|
|
114
|
-
}
|
|
115
|
-
if (keybindings.matches(data, "tui.select.down")) {
|
|
116
|
-
moveSelection(1);
|
|
117
|
-
tui.requestRender();
|
|
118
|
-
return;
|
|
119
|
-
}
|
|
120
|
-
if (keybindings.matches(data, "tui.select.pageUp")) {
|
|
121
|
-
selectedIndex = 0;
|
|
122
|
-
tui.requestRender();
|
|
123
|
-
return;
|
|
124
|
-
}
|
|
125
|
-
if (keybindings.matches(data, "tui.select.pageDown")) {
|
|
126
|
-
selectedIndex = rows.length - 1;
|
|
127
|
-
tui.requestRender();
|
|
128
|
-
return;
|
|
129
|
-
}
|
|
130
|
-
if (keybindings.matches(data, "tui.select.confirm") || data === " ") {
|
|
131
|
-
activateSelectedRow();
|
|
132
|
-
tui.requestRender();
|
|
133
|
-
return;
|
|
134
|
-
}
|
|
135
|
-
if (keybindings.matches(data, "tui.select.cancel")) {
|
|
136
|
-
done("closed");
|
|
137
|
-
}
|
|
138
|
-
},
|
|
139
|
-
};
|
|
39
|
+
const generation = state.sessionGeneration;
|
|
40
|
+
if (!ctx.hasUI) return;
|
|
41
|
+
const menu = defineMenu<undefined, ToolSelectorScreen, ToolSelectorAction>({
|
|
42
|
+
start: "tools",
|
|
43
|
+
screens: {
|
|
44
|
+
tools: () => {
|
|
45
|
+
const selectedTools = new Set(getActiveChromeDevtoolsTools(pi));
|
|
46
|
+
return {
|
|
47
|
+
kind: "multiSelect",
|
|
48
|
+
title: toolSelectorTitle(selectedTools),
|
|
49
|
+
items: CHROME_DEVTOOLS_TOOL_NAMES.map((toolName) => ({
|
|
50
|
+
id: toolName,
|
|
51
|
+
label: toolName,
|
|
52
|
+
selected: selectedTools.has(toolName),
|
|
53
|
+
})),
|
|
54
|
+
action: "toggle",
|
|
55
|
+
actions: [
|
|
56
|
+
{
|
|
57
|
+
id: "enable-all",
|
|
58
|
+
label: "Enable all Chrome DevTools tools",
|
|
59
|
+
action: "enableAll",
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
id: "disable-all",
|
|
63
|
+
label: "Disable all Chrome DevTools tools",
|
|
64
|
+
action: "disableAll",
|
|
65
|
+
},
|
|
66
|
+
{ id: "done", label: "Done", close: true },
|
|
67
|
+
],
|
|
68
|
+
hint: "close",
|
|
69
|
+
doneLabel: "Done",
|
|
70
|
+
};
|
|
71
|
+
},
|
|
140
72
|
},
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
73
|
+
actions: {
|
|
74
|
+
toggle: async ({ itemId, selected }) => {
|
|
75
|
+
if (!isChromeDevtoolsToolName(itemId)) return { kind: "rejected" };
|
|
76
|
+
const selectedTools = new Set(getActiveChromeDevtoolsTools(pi));
|
|
77
|
+
if (selected) selectedTools.add(itemId);
|
|
78
|
+
else selectedTools.delete(itemId);
|
|
79
|
+
const saved = await transactSelectedTools(
|
|
80
|
+
pi,
|
|
81
|
+
ctx,
|
|
82
|
+
orderedChromeDevtoolsTools(selectedTools),
|
|
83
|
+
generation,
|
|
84
|
+
);
|
|
85
|
+
return saved ? { kind: "stay" } : { kind: "rejected" };
|
|
86
|
+
},
|
|
87
|
+
enableAll: async () => {
|
|
88
|
+
const saved = await transactSelectedTools(pi, ctx, allChromeDevtoolsTools(), generation);
|
|
89
|
+
return saved ? { kind: "stay" } : { kind: "rejected" };
|
|
90
|
+
},
|
|
91
|
+
disableAll: async () => {
|
|
92
|
+
const saved = await transactSelectedTools(pi, ctx, [], generation);
|
|
93
|
+
return saved ? { kind: "stay" } : { kind: "rejected" };
|
|
94
|
+
},
|
|
95
|
+
},
|
|
96
|
+
});
|
|
97
|
+
const result = await runMenu(ctx, menu, {
|
|
98
|
+
getState: () => undefined,
|
|
99
|
+
signal: state.sessionController.signal,
|
|
100
|
+
isCurrent: () => generation === state.sessionGeneration,
|
|
101
|
+
});
|
|
102
|
+
if (result.kind !== "closed" || generation !== state.sessionGeneration) return;
|
|
103
|
+
const status = await buildToolStatusMessage(pi);
|
|
104
|
+
if (generation !== state.sessionGeneration) return;
|
|
105
|
+
ctx.ui.notify(status, "info");
|
|
150
106
|
}
|
|
151
107
|
|
|
152
|
-
async function
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
108
|
+
export async function updateChromeDevtoolsTools(
|
|
109
|
+
pi: ExtensionAPI,
|
|
110
|
+
ctx: CommandContext,
|
|
111
|
+
selectedTools: readonly ChromeDevToolsToolName[],
|
|
112
|
+
action: string,
|
|
113
|
+
) {
|
|
114
|
+
const generation = state.sessionGeneration;
|
|
115
|
+
const saved = await transactSelectedTools(pi, ctx, selectedTools, generation);
|
|
116
|
+
if (!saved || generation !== state.sessionGeneration) return;
|
|
117
|
+
const status = await buildToolStatusMessage(pi);
|
|
118
|
+
if (generation !== state.sessionGeneration) return;
|
|
119
|
+
ctx.ui.notify(`Chrome DevTools tools ${action}.\n\n${status}`, "info");
|
|
120
|
+
}
|
|
163
121
|
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
}
|
|
122
|
+
export async function setSelectedChromeDevtoolsTools(
|
|
123
|
+
pi: ExtensionAPI,
|
|
124
|
+
ctx: CommandContext,
|
|
125
|
+
selectedTools: readonly ChromeDevToolsToolName[],
|
|
126
|
+
): Promise<boolean> {
|
|
127
|
+
return transactSelectedTools(pi, ctx, selectedTools, state.sessionGeneration);
|
|
128
|
+
}
|
|
172
129
|
|
|
173
|
-
|
|
174
|
-
}
|
|
130
|
+
let toolTransactionQueue = Promise.resolve();
|
|
175
131
|
|
|
176
|
-
|
|
132
|
+
export async function waitForChromeDevtoolsSettings(): Promise<void> {
|
|
133
|
+
await toolTransactionQueue;
|
|
177
134
|
}
|
|
178
135
|
|
|
179
|
-
|
|
136
|
+
function transactSelectedTools(
|
|
180
137
|
pi: ExtensionAPI,
|
|
181
138
|
ctx: CommandContext,
|
|
182
139
|
selectedTools: readonly ChromeDevToolsToolName[],
|
|
183
|
-
|
|
184
|
-
) {
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
140
|
+
expectedGeneration: number,
|
|
141
|
+
): Promise<boolean> {
|
|
142
|
+
const operation = toolTransactionQueue.then(() =>
|
|
143
|
+
transactSelectedToolsNow(pi, ctx, selectedTools, expectedGeneration),
|
|
144
|
+
);
|
|
145
|
+
toolTransactionQueue = operation.then(
|
|
146
|
+
() => undefined,
|
|
147
|
+
() => undefined,
|
|
191
148
|
);
|
|
149
|
+
return operation;
|
|
192
150
|
}
|
|
193
151
|
|
|
194
|
-
|
|
152
|
+
async function transactSelectedToolsNow(
|
|
195
153
|
pi: ExtensionAPI,
|
|
196
154
|
ctx: CommandContext,
|
|
197
155
|
selectedTools: readonly ChromeDevToolsToolName[],
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
156
|
+
expectedGeneration: number,
|
|
157
|
+
): Promise<boolean> {
|
|
158
|
+
if (expectedGeneration !== state.sessionGeneration) return false;
|
|
159
|
+
const previousActiveTools = pi.getActiveTools();
|
|
160
|
+
try {
|
|
161
|
+
applyChromeDevtoolsTools(pi, selectedTools);
|
|
162
|
+
await persistSettings(selectedTools);
|
|
163
|
+
return expectedGeneration === state.sessionGeneration;
|
|
164
|
+
} catch (error) {
|
|
165
|
+
let rollbackError: unknown;
|
|
166
|
+
try {
|
|
167
|
+
const previousChromeTools = previousActiveTools.filter((name) =>
|
|
168
|
+
CHROME_DEVTOOLS_TOOL_NAMES.includes(name as ChromeDevToolsToolName),
|
|
169
|
+
) as ChromeDevToolsToolName[];
|
|
170
|
+
applyChromeDevtoolsTools(pi, previousChromeTools);
|
|
171
|
+
} catch (caught) {
|
|
172
|
+
rollbackError = caught;
|
|
173
|
+
}
|
|
174
|
+
if (expectedGeneration !== state.sessionGeneration) return false;
|
|
175
|
+
ctx.ui.notify(
|
|
176
|
+
rollbackError
|
|
177
|
+
? `Chrome DevTools settings save failed: ${formatError(error)}; active-tool rollback failed: ${formatError(rollbackError)}`
|
|
178
|
+
: `Chrome DevTools settings save failed; active tools restored: ${formatError(error)}`,
|
|
179
|
+
"warning",
|
|
180
|
+
);
|
|
181
|
+
return false;
|
|
182
|
+
}
|
|
201
183
|
}
|
|
202
184
|
|
|
203
185
|
export function applyChromeDevtoolsTools(
|
|
@@ -275,21 +257,8 @@ function toolSelectorTitle(selectedTools: ReadonlySet<ChromeDevToolsToolName>) {
|
|
|
275
257
|
return `Chrome DevTools tools (${selectedTools.size}/${CHROME_DEVTOOLS_TOOL_NAMES.length}). Non-built-in tools run at user risk.`;
|
|
276
258
|
}
|
|
277
259
|
|
|
278
|
-
function
|
|
279
|
-
return
|
|
280
|
-
...CHROME_DEVTOOLS_TOOL_NAMES.map((toolName) => ({ kind: "tool" as const, toolName })),
|
|
281
|
-
{ kind: "action", action: "enableAll", label: TOOL_SELECTOR_ENABLE_ALL },
|
|
282
|
-
{ kind: "action", action: "disableAll", label: TOOL_SELECTOR_DISABLE_ALL },
|
|
283
|
-
{ kind: "action", action: "done", label: TOOL_SELECTOR_DONE },
|
|
284
|
-
];
|
|
285
|
-
}
|
|
286
|
-
|
|
287
|
-
function formatToolSelectorRow(
|
|
288
|
-
row: ToolSelectorRow,
|
|
289
|
-
selectedTools: ReadonlySet<ChromeDevToolsToolName>,
|
|
290
|
-
) {
|
|
291
|
-
if (row.kind === "action") return row.label;
|
|
292
|
-
return `${selectedTools.has(row.toolName) ? "[x]" : "[ ]"} ${row.toolName}`;
|
|
260
|
+
function isChromeDevtoolsToolName(value: string): value is ChromeDevToolsToolName {
|
|
261
|
+
return CHROME_DEVTOOLS_TOOL_NAMES.includes(value as ChromeDevToolsToolName);
|
|
293
262
|
}
|
|
294
263
|
|
|
295
264
|
function getActiveChromeDevtoolsTools(pi: ExtensionAPI) {
|
|
@@ -327,13 +296,6 @@ function formatPersistedSelection(tools: readonly ChromeDevToolsToolName[]) {
|
|
|
327
296
|
return `${tools.length}/${CHROME_DEVTOOLS_TOOL_NAMES.length} selected: ${tools.join(", ")}`;
|
|
328
297
|
}
|
|
329
298
|
|
|
330
|
-
async function persistSettings(
|
|
331
|
-
|
|
332
|
-
selectedTools: readonly ChromeDevToolsToolName[],
|
|
333
|
-
) {
|
|
334
|
-
try {
|
|
335
|
-
await saveSettings({ tools: [...selectedTools], updatedAt: Date.now() });
|
|
336
|
-
} catch (error) {
|
|
337
|
-
ctx.ui.notify(`Chrome DevTools settings save failed: ${formatError(error)}`, "warning");
|
|
338
|
-
}
|
|
299
|
+
async function persistSettings(selectedTools: readonly ChromeDevToolsToolName[]) {
|
|
300
|
+
await saveSettings({ tools: [...selectedTools], updatedAt: Date.now() });
|
|
339
301
|
}
|