@everydaydevopsio/pilot 0.6.2 → 0.6.3

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.
Files changed (37) hide show
  1. package/AGENTS.md +6 -0
  2. package/CHANGELOG.md +29 -0
  3. package/README.md +86 -9
  4. package/dist/browser/browser-manager.d.ts +7 -1
  5. package/dist/browser/browser-manager.d.ts.map +1 -1
  6. package/dist/browser/browser-manager.js +196 -40
  7. package/dist/browser/browser-manager.js.map +1 -1
  8. package/dist/browser/chrome-launcher.d.ts +13 -2
  9. package/dist/browser/chrome-launcher.d.ts.map +1 -1
  10. package/dist/browser/chrome-launcher.js +33 -4
  11. package/dist/browser/chrome-launcher.js.map +1 -1
  12. package/dist/browser/sessions.d.ts +49 -0
  13. package/dist/browser/sessions.d.ts.map +1 -0
  14. package/dist/browser/sessions.js +249 -0
  15. package/dist/browser/sessions.js.map +1 -0
  16. package/dist/browser/types.d.ts +10 -0
  17. package/dist/browser/types.d.ts.map +1 -1
  18. package/dist/cli/skill-template.d.ts +1 -1
  19. package/dist/cli/skill-template.d.ts.map +1 -1
  20. package/dist/cli/skill-template.js +1 -1
  21. package/dist/cli/skill-template.js.map +1 -1
  22. package/dist/mcp/tools/browser.d.ts.map +1 -1
  23. package/dist/mcp/tools/browser.js +52 -3
  24. package/dist/mcp/tools/browser.js.map +1 -1
  25. package/dist/mcp/tools/names.d.ts +1 -1
  26. package/dist/mcp/tools/names.d.ts.map +1 -1
  27. package/dist/mcp/tools/names.js +2 -0
  28. package/dist/mcp/tools/names.js.map +1 -1
  29. package/package.json +1 -1
  30. package/skills/pilot/SKILL.md +9 -0
  31. package/src/browser/browser-manager.ts +248 -41
  32. package/src/browser/chrome-launcher.ts +40 -4
  33. package/src/browser/sessions.ts +299 -0
  34. package/src/browser/types.ts +11 -0
  35. package/src/cli/skill-template.ts +1 -1
  36. package/src/mcp/tools/browser.ts +72 -3
  37. package/src/mcp/tools/names.ts +2 -0
@@ -0,0 +1,299 @@
1
+ import { execFileSync } from 'child_process';
2
+ import { existsSync, lstatSync, readdirSync, statSync } from 'fs';
3
+ import { hostname } from 'os';
4
+ import { basename, dirname, join } from 'path';
5
+ import {
6
+ PROFILE_NAME_RE,
7
+ cleanupLockFiles,
8
+ isProcessAlive,
9
+ profileLockOwner,
10
+ resolveUserDataDir
11
+ } from './chrome-launcher.js';
12
+
13
+ export type ProfileLockState =
14
+ /** No lock files: the profile is free. */
15
+ | 'unlocked'
16
+ /** Locked by a live process on this host. */
17
+ | 'locked'
18
+ /** Locked by a process on this host that is no longer running. */
19
+ | 'stale'
20
+ /** Locked from another host; liveness cannot be checked here. */
21
+ | 'locked_other_host'
22
+ /** Lock files exist but name no owner Pilot can read. */
23
+ | 'locked_unknown';
24
+
25
+ export interface ProfileStatus {
26
+ profile: string;
27
+ userDataDir: string;
28
+ state: ProfileLockState;
29
+ pid?: number;
30
+ host?: string;
31
+ /** True when the lock owner's command line shows Chrome using this Pilot profile. */
32
+ pilotChrome?: boolean;
33
+ }
34
+
35
+ export function pilotDataDir(): string {
36
+ // resolveUserDataDir validates the name; any valid name gives the parent.
37
+ return dirname(resolveUserDataDir('profile1'));
38
+ }
39
+
40
+ /**
41
+ * Report the lock state of every Pilot profile. Read-only: unlike
42
+ * isProfileLocked, it never removes stale locks.
43
+ */
44
+ export function listProfiles(): ProfileStatus[] {
45
+ const dataDir = pilotDataDir();
46
+ let names: string[];
47
+ try {
48
+ names = readdirSync(dataDir);
49
+ } catch {
50
+ return [];
51
+ }
52
+
53
+ return names
54
+ .filter((name) => PROFILE_NAME_RE.test(name))
55
+ .filter((name) => {
56
+ try {
57
+ return statSync(join(dataDir, name)).isDirectory();
58
+ } catch {
59
+ return false;
60
+ }
61
+ })
62
+ .sort()
63
+ .map((profile) => profileStatus(profile, join(dataDir, profile)));
64
+ }
65
+
66
+ function profileStatus(profile: string, userDataDir: string): ProfileStatus {
67
+ const owner = profileLockOwner(userDataDir);
68
+ const base = { profile, userDataDir };
69
+
70
+ if (!owner) {
71
+ const hasLock =
72
+ lockPresent(join(userDataDir, 'SingletonLock')) ||
73
+ existsSync(join(userDataDir, 'lockfile'));
74
+ return { ...base, state: hasLock ? 'locked_unknown' : 'unlocked' };
75
+ }
76
+ if (owner.lockHost !== hostname()) {
77
+ return {
78
+ ...base,
79
+ state: 'locked_other_host',
80
+ pid: owner.pid,
81
+ host: owner.lockHost
82
+ };
83
+ }
84
+ if (!isProcessAlive(owner.pid)) {
85
+ return { ...base, state: 'stale', pid: owner.pid, host: owner.lockHost };
86
+ }
87
+ return {
88
+ ...base,
89
+ state: 'locked',
90
+ pid: owner.pid,
91
+ host: owner.lockHost,
92
+ pilotChrome: isPilotChrome(owner.pid, userDataDir)
93
+ };
94
+ }
95
+
96
+ function lockPresent(path: string): boolean {
97
+ try {
98
+ lstatSync(path);
99
+ return true;
100
+ } catch {
101
+ return false;
102
+ }
103
+ }
104
+
105
+ function ps(pid: number, field: 'args' | 'comm'): string | null {
106
+ if (process.platform === 'win32') return null;
107
+ try {
108
+ const out = execFileSync('ps', ['-o', `${field}=`, '-p', String(pid)], {
109
+ encoding: 'utf8',
110
+ stdio: ['ignore', 'pipe', 'ignore']
111
+ }).trim();
112
+ return out || null;
113
+ } catch {
114
+ return null;
115
+ }
116
+ }
117
+
118
+ /** The full command line of a process, or null when it cannot be read. */
119
+ export function readProcessCommand(pid: number): string | null {
120
+ return ps(pid, 'args');
121
+ }
122
+
123
+ // Chrome's main executable: "chrome" or "chromium-browser" on Linux (comm is
124
+ // truncated to 15 characters), "Google Chrome" or "Chromium" on macOS.
125
+ const CHROME_EXECUTABLE_RE = /chrom(e|ium)/i;
126
+
127
+ /**
128
+ * Whether pid is a Chrome or Chromium browser started with exactly this Pilot
129
+ * profile directory. A pid read from a lock can be reused by an unrelated
130
+ * process, so stop-all only signals processes that pass this check.
131
+ */
132
+ export function isPilotChrome(pid: number, userDataDir: string): boolean {
133
+ const executable = ps(pid, 'comm');
134
+ if (!executable || !CHROME_EXECUTABLE_RE.test(basename(executable))) {
135
+ return false;
136
+ }
137
+ const command = readProcessCommand(pid);
138
+ if (!command) return false;
139
+ const flag = ` --user-data-dir=${userDataDir}`;
140
+ let at = command.indexOf(flag);
141
+ while (at !== -1) {
142
+ const next = command.charAt(at + flag.length);
143
+ if (next === '' || next === ' ') return true;
144
+ at = command.indexOf(flag, at + 1);
145
+ }
146
+ return false;
147
+ }
148
+
149
+ export interface StopResult {
150
+ profile: string;
151
+ pid?: number;
152
+ outcome: 'stopped' | 'cleaned' | 'failed' | 'skipped';
153
+ detail: string;
154
+ }
155
+
156
+ async function waitForProcessExit(pid: number, ms: number): Promise<boolean> {
157
+ const deadline = Date.now() + ms;
158
+ while (Date.now() < deadline) {
159
+ if (!isProcessAlive(pid)) return true;
160
+ await new Promise((r) => setTimeout(r, 100));
161
+ }
162
+ return !isProcessAlive(pid);
163
+ }
164
+
165
+ type TerminateResult =
166
+ | { outcome: 'stopped' }
167
+ | { outcome: 'not_pilot' }
168
+ | { outcome: 'failed'; detail: string };
169
+
170
+ /**
171
+ * SIGTERM, then SIGKILL. The pid's identity is re-checked right before each
172
+ * signal: a process seen earlier can exit and its pid be reused while
173
+ * other browsers are being stopped.
174
+ */
175
+ async function terminate(
176
+ pid: number,
177
+ userDataDir: string
178
+ ): Promise<TerminateResult> {
179
+ const signal = (sig: NodeJS.Signals): 'sent' | 'gone' | { error: string } => {
180
+ if (!isPilotChrome(pid, userDataDir)) return 'gone';
181
+ try {
182
+ process.kill(pid, sig);
183
+ return 'sent';
184
+ } catch (err) {
185
+ const code = (err as NodeJS.ErrnoException).code;
186
+ if (code === 'ESRCH') return 'gone';
187
+ return { error: `could not send ${sig} to pid ${pid} (${code})` };
188
+ }
189
+ };
190
+
191
+ const term = signal('SIGTERM');
192
+ if (term === 'gone') {
193
+ return isProcessAlive(pid)
194
+ ? { outcome: 'not_pilot' }
195
+ : { outcome: 'stopped' };
196
+ }
197
+ if (typeof term === 'object')
198
+ return { outcome: 'failed', detail: term.error };
199
+ if (await waitForProcessExit(pid, 5000)) return { outcome: 'stopped' };
200
+
201
+ const kill = signal('SIGKILL');
202
+ if (kill === 'gone') {
203
+ // Only a confirmed exit counts as stopped. A live pid that no longer
204
+ // verifies (ps failed, or the process changed) is left alone.
205
+ return isProcessAlive(pid)
206
+ ? {
207
+ outcome: 'failed',
208
+ detail: `pid ${pid} did not exit after SIGTERM and could no longer be verified as Chrome using ${userDataDir}; not killed, lock kept`
209
+ }
210
+ : { outcome: 'stopped' };
211
+ }
212
+ if (typeof kill === 'object')
213
+ return { outcome: 'failed', detail: kill.error };
214
+ return (await waitForProcessExit(pid, 2000))
215
+ ? { outcome: 'stopped' }
216
+ : { outcome: 'failed', detail: `pid ${pid} did not exit after SIGKILL` };
217
+ }
218
+
219
+ /**
220
+ * Stop every Chrome on this host that holds a Pilot profile lock. Processes
221
+ * are only signalled after their command line confirms they use that
222
+ * profile's directory; nothing else is touched.
223
+ */
224
+ export async function stopPilotBrowsers(): Promise<StopResult[]> {
225
+ const results: StopResult[] = [];
226
+ for (const status of listProfiles()) {
227
+ const { profile, pid } = status;
228
+ switch (status.state) {
229
+ case 'unlocked':
230
+ continue;
231
+ case 'stale': {
232
+ const removed = cleanupLockFiles(status.userDataDir, pid!);
233
+ results.push(
234
+ removed
235
+ ? {
236
+ profile,
237
+ pid,
238
+ outcome: 'cleaned',
239
+ detail: `removed stale lock left by pid ${pid}, which is no longer running`
240
+ }
241
+ : {
242
+ profile,
243
+ pid,
244
+ outcome: 'failed',
245
+ detail: `stale lock left by pid ${pid} could not be removed (the lock changed owner or is not writable)`
246
+ }
247
+ );
248
+ continue;
249
+ }
250
+ case 'locked_other_host':
251
+ results.push({
252
+ profile,
253
+ pid,
254
+ outcome: 'skipped',
255
+ detail: `locked by pid ${pid} on host "${status.host}"; Pilot can only stop browsers on this host`
256
+ });
257
+ continue;
258
+ case 'locked_unknown':
259
+ results.push({
260
+ profile,
261
+ outcome: 'skipped',
262
+ detail: 'lock files name no owner; left in place'
263
+ });
264
+ continue;
265
+ case 'locked':
266
+ results.push(await stopLockedProfile(status));
267
+ }
268
+ }
269
+ return results;
270
+ }
271
+
272
+ /** Stop the Chrome holding a profile lock, if it is verifiably Pilot's. */
273
+ export async function stopLockedProfile(
274
+ status: ProfileStatus
275
+ ): Promise<StopResult> {
276
+ const { profile, pid, userDataDir } = status;
277
+ const notPilot: StopResult = {
278
+ profile,
279
+ pid,
280
+ outcome: 'skipped',
281
+ detail: `pid ${pid} holds the lock but its command line does not show Chrome using ${userDataDir}; not stopped`
282
+ };
283
+ if (pid === undefined || !status.pilotChrome) return notPilot;
284
+
285
+ const result = await terminate(pid, userDataDir);
286
+ if (result.outcome === 'not_pilot') return notPilot;
287
+ if (result.outcome === 'failed') {
288
+ return { profile, pid, outcome: 'failed', detail: result.detail };
289
+ }
290
+ const removed = cleanupLockFiles(userDataDir, pid);
291
+ return {
292
+ profile,
293
+ pid,
294
+ outcome: 'stopped',
295
+ detail: removed
296
+ ? `stopped Chrome pid ${pid}`
297
+ : `stopped Chrome pid ${pid}, but its lock could not be removed`
298
+ };
299
+ }
@@ -28,5 +28,16 @@ export interface LaunchOptions {
28
28
  startupTimeoutMs?: number;
29
29
  }
30
30
 
31
+ export interface SessionInfo {
32
+ /** launched: Chrome started by this session; external: browser_connect. */
33
+ mode: 'launched' | 'external' | 'none';
34
+ connected: boolean;
35
+ profile?: string;
36
+ userDataDir?: string;
37
+ pid?: number;
38
+ host?: string;
39
+ cdpPort?: number;
40
+ }
41
+
31
42
  export type SandboxDecision =
32
43
  { disable: false } | { disable: true; reason: 'env_override' | 'root_user' };
@@ -1,4 +1,4 @@
1
1
  // Generated by scripts/build-skill.mjs from skills/pilot/SKILL.md.
2
2
  // Do not edit this file directly.
3
3
  export const SKILL_CONTENT =
4
- '---\nname: pilot\ndescription: Control and debug web pages with the Pilot MCP browser tools. Use when the user asks to open, inspect, test, or troubleshoot a site in Chrome with Pilot.\n---\n\n# Pilot browser workflow\n\nUse the `pilot` MCP tools to operate Chrome through the Chrome DevTools Protocol.\n\n## Start and inspect\n\n1. Call `browser_start` before any other Pilot browser tool. Use visible mode unless the user requests headless operation or the environment has no graphical display.\n2. Navigate with `browser_navigate`, then confirm the result with `browser_page_info` or `browser_snapshot`.\n3. Use refs returned by `browser_snapshot` or `browser_find` for interaction. Take a fresh snapshot after navigation or substantial DOM changes because refs can become stale.\n4. For debugging, clear errors before reproducing the problem, then inspect `browser_errors`, `browser_console`, and `browser_network`. Use performance tracing only when the user asks about load or runtime performance.\n5. Call `browser_stop` when the browser is no longer needed.\n\nDo not enter credentials, submit consequential forms, upload files, or make other externally visible changes unless the user authorized that action.\n\n## Troubleshooting\n\n### Visible Chrome on Linux\n\nRun `pilot check` when `browser_start` fails. Visible Chrome requires `DISPLAY` or `WAYLAND_DISPLAY` in the Pilot MCP server process. Headless operation does not.\n\nIf desktop applications open but Pilot reports that the display is missing, inspect the graphical session with `printenv DISPLAY`. Forward the returned value through the MCP server configuration and restart the agent so Pilot is relaunched. For Codex:\n\n```toml\n[mcp_servers.pilot]\ncommand = "npx"\nargs = ["-y", "@everydaydevopsio/pilot"]\nenv = { DISPLAY = ":1" }\n```\n\nReplace `:1` with the actual value. If the X server requires a cookie, forward `XAUTHORITY` in the same map. For SSH, use trusted X11 forwarding (`ssh -Y`) when policy permits, or use a remote desktop session and its display value. Use `PILOT_HEADLESS=true` on systems without a desktop. In CI, use headless mode or run Pilot under Xvfb.\n\n### Other Linux preflight failures\n\n- If the profile directory is not writable, set `XDG_DATA_HOME` to a writable directory.\n- If loopback binding fails with `EPERM`, the host or container must allow binding on `127.0.0.1`; disabling Chrome\'s renderer sandbox does not fix it.\n- If Chrome is not detected, set `PILOT_CHROME_PATH` to its executable.\n- Avoid `PILOT_CHROME_NO_SANDBOX=true` unless the runtime genuinely cannot support Chrome\'s sandbox and the user accepts the security tradeoff.\n\n### Connection and page failures\n\n- Confirm the active page with `browser_page_info` and list tabs if Pilot controls the wrong tab.\n- Inspect failed document, script, XHR, and fetch requests with `browser_network`.\n- If element refs stop resolving after a page update, take another snapshot and retry with the new refs.\n';
4
+ '---\nname: pilot\ndescription: Control and debug web pages with the Pilot MCP browser tools. Use when the user asks to open, inspect, test, or troubleshoot a site in Chrome with Pilot.\n---\n\n# Pilot browser workflow\n\nUse the `pilot` MCP tools to operate Chrome through the Chrome DevTools Protocol.\n\n## Start and inspect\n\n1. Call `browser_start` before any other Pilot browser tool. Use visible mode unless the user requests headless operation or the environment has no graphical display.\n2. Navigate with `browser_navigate`, then confirm the result with `browser_page_info` or `browser_snapshot`.\n3. Use refs returned by `browser_snapshot` or `browser_find` for interaction. Take a fresh snapshot after navigation or substantial DOM changes because refs can become stale.\n4. For debugging, clear errors before reproducing the problem, then inspect `browser_errors`, `browser_console`, and `browser_network`. Use performance tracing only when the user asks about load or runtime performance.\n5. Call `browser_stop` when the browser is no longer needed.\n\nDo not enter credentials, submit consequential forms, upload files, or make other externally visible changes unless the user authorized that action.\n\n## Troubleshooting\n\n### Visible Chrome on Linux\n\nRun `pilot check` when `browser_start` fails. Visible Chrome requires `DISPLAY` or `WAYLAND_DISPLAY` in the Pilot MCP server process. Headless operation does not.\n\nIf desktop applications open but Pilot reports that the display is missing, inspect the graphical session with `printenv DISPLAY`. Forward the returned value through the MCP server configuration and restart the agent so Pilot is relaunched. For Codex:\n\n```toml\n[mcp_servers.pilot]\ncommand = "npx"\nargs = ["-y", "@everydaydevopsio/pilot"]\nenv = { DISPLAY = ":1" }\n```\n\nReplace `:1` with the actual value. If the X server requires a cookie, forward `XAUTHORITY` in the same map. For SSH, use trusted X11 forwarding (`ssh -Y`) when policy permits, or use a remote desktop session and its display value. Use `PILOT_HEADLESS=true` on systems without a desktop. In CI, use headless mode or run Pilot under Xvfb.\n\n### Startup failures\n\nRead the `Launch:`, `Cleanup:`, and `Next:` lines in the `browser_start` error before retrying.\n\n- A failed `browser_start` stops the Chrome it launched and frees the profile unless `Cleanup:` names a pid that did not stop. Do not switch profiles just because a start failed.\n- On a timeout, retry once with a larger `startupTimeoutMs` (for example `180000`). First runs and software-rendered Linux desktops can be slow.\n- If Chrome exited in visible mode, check the display (above) or retry with `headless: true`.\n- If the profile is already in use, the error names the owning Chrome pid and host. `browser_stop` cannot stop a browser that this session did not launch. Call `browser_status` to see which profiles are free and who holds the others. Use a different `profileName`, or, only after the user agrees, call `browser_stop_all`. It also stops browsers that other agents may be using.\n\n### Other Linux preflight failures\n\n- If the profile directory is not writable, set `XDG_DATA_HOME` to a writable directory.\n- If loopback binding fails with `EPERM`, the host or container must allow binding on `127.0.0.1`; disabling Chrome\'s renderer sandbox does not fix it.\n- If Chrome is not detected, set `PILOT_CHROME_PATH` to its executable.\n- Avoid `PILOT_CHROME_NO_SANDBOX=true` unless the runtime genuinely cannot support Chrome\'s sandbox and the user accepts the security tradeoff.\n\n### Connection and page failures\n\n- Confirm the active page with `browser_page_info` and list tabs if Pilot controls the wrong tab.\n- Inspect failed document, script, XHR, and fetch requests with `browser_network`.\n- If element refs stop resolving after a page update, take another snapshot and retry with the new refs.\n';
@@ -3,6 +3,12 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
3
3
  import type { Client } from 'chrome-remote-interface';
4
4
  import type { BrowserManager } from '../../browser.js';
5
5
  import type { BrowserContext } from '../server.js';
6
+ import { DEFAULT_STARTUP_TIMEOUT_MS } from '../../browser/chrome-launcher.js';
7
+ import {
8
+ listProfiles,
9
+ pilotDataDir,
10
+ stopPilotBrowsers
11
+ } from '../../browser/sessions.js';
6
12
  import { executeScreenshot } from '../../commands/screenshot.js';
7
13
  import { executeNavigate } from '../../commands/navigate.js';
8
14
  import { executeClick } from '../../commands/click.js';
@@ -185,9 +191,9 @@ const startShape = {
185
191
  .number()
186
192
  .int()
187
193
  .positive()
188
- .default(30000)
194
+ .default(DEFAULT_STARTUP_TIMEOUT_MS)
189
195
  .describe(
190
- 'Timeout waiting for Chrome DevTools to become ready, in milliseconds.'
196
+ `Timeout waiting for Chrome DevTools to become ready, in milliseconds. Defaults to ${DEFAULT_STARTUP_TIMEOUT_MS}. Startup fails immediately if Chrome exits, so raising this only helps when Chrome is slow to start (for example a first run on a Linux server with software rendering).`
191
197
  )
192
198
  };
193
199
 
@@ -323,7 +329,12 @@ export function registerBrowserTools(
323
329
  async () => {
324
330
  if (!context.manager) {
325
331
  return {
326
- content: [{ type: 'text' as const, text: 'Browser is not running.' }]
332
+ content: [
333
+ {
334
+ type: 'text' as const,
335
+ text: 'Browser is not running. This Pilot session is not tracking a launched or connected browser; a failed browser_start stops the Chrome it launched and reports the outcome in its error.'
336
+ }
337
+ ]
327
338
  };
328
339
  }
329
340
 
@@ -336,6 +347,64 @@ export function registerBrowserTools(
336
347
  }
337
348
  );
338
349
 
350
+ server.tool(
351
+ 'browser_status',
352
+ 'Show the browser tracked by this Pilot session and the lock state of every Pilot profile on this machine (including browsers started by other Pilot sessions). Read-only. Use it to diagnose "profile already in use" errors.',
353
+ {},
354
+ async () => {
355
+ const status = {
356
+ session: context.manager?.getSessionInfo() ?? {
357
+ mode: 'none',
358
+ connected: false
359
+ },
360
+ dataDir: pilotDataDir(),
361
+ profiles: listProfiles()
362
+ };
363
+ return {
364
+ content: [
365
+ { type: 'text' as const, text: JSON.stringify(status, null, 2) }
366
+ ]
367
+ };
368
+ }
369
+ );
370
+
371
+ server.tool(
372
+ 'browser_stop_all',
373
+ 'Stop every Pilot-managed Chrome on this machine: the browser in this session plus any Chrome holding a Pilot profile lock, including ones started by other Pilot sessions. Only processes whose command line shows Chrome using a Pilot profile directory are stopped; browsers on other hosts and unverifiable locks are skipped. Also removes stale locks. Ask the user before calling this, since it can close browsers other agents are using.',
374
+ {},
375
+ async () => {
376
+ const lines: string[] = [];
377
+ if (context.manager) {
378
+ const info = context.manager.getSessionInfo();
379
+ await context.manager.destroy();
380
+ context.manager = null;
381
+ if (info.mode === 'launched') {
382
+ lines.push(
383
+ `this session: stopped Chrome pid ${info.pid} (profile "${info.profile}")`
384
+ );
385
+ } else if (info.mode === 'external') {
386
+ lines.push(
387
+ `this session: disconnected from ${info.host}:${info.cdpPort} (not stopped; Pilot did not launch it)`
388
+ );
389
+ }
390
+ }
391
+ for (const result of await stopPilotBrowsers()) {
392
+ lines.push(`${result.profile}: ${result.outcome} - ${result.detail}`);
393
+ }
394
+ return {
395
+ content: [
396
+ {
397
+ type: 'text' as const,
398
+ text:
399
+ lines.length > 0
400
+ ? lines.join('\n')
401
+ : 'No Pilot-managed browsers were running.'
402
+ }
403
+ ]
404
+ };
405
+ }
406
+ );
407
+
339
408
  server.tool(
340
409
  'browser_screenshot',
341
410
  'Capture a screenshot of the current browser viewport or full page',
@@ -5,6 +5,8 @@
5
5
  export const EXPECTED_TOOLS = [
6
6
  'browser_start',
7
7
  'browser_stop',
8
+ 'browser_status',
9
+ 'browser_stop_all',
8
10
  'browser_screenshot',
9
11
  'browser_navigate',
10
12
  'browser_click',