@oh-my-pi/pi-coding-agent 14.5.11 → 14.5.12

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.
@@ -1078,17 +1078,24 @@
1078
1078
  return html;
1079
1079
  }
1080
1080
 
1081
- function renderPuppeteer(name, args, result, ctx) {
1081
+ function renderBrowser(name, args, result, ctx) {
1082
1082
  const action = str(args.action) || '?';
1083
+ const tabName = str(args.name);
1083
1084
  const badges = [];
1085
+ if (tabName) badges.push('name=' + tabName);
1084
1086
  if (args.url) badges.push(String(args.url));
1085
- if (args.selector) badges.push('selector=' + args.selector);
1086
- if (args.element_id != null) badges.push('id=' + args.element_id);
1087
- let head = '<span class="tool-name">puppeteer</span> <span class="tool-badge">' + escapeHtml(action) + '</span>';
1087
+ if (args.app && typeof args.app === 'object') {
1088
+ if (args.app.path) badges.push('app=' + shortenPath(String(args.app.path)));
1089
+ else if (args.app.cdp_url) badges.push('cdp=' + String(args.app.cdp_url));
1090
+ }
1091
+ if (args.all) badges.push('all');
1092
+ if (args.kill) badges.push('kill');
1093
+ let head = '<span class="tool-name">browser</span> <span class="tool-badge">' + escapeHtml(action) + '</span>';
1088
1094
  for (const b of badges) head += ' <span class="tool-badge">' + escapeHtml(String(b)) + '</span>';
1089
1095
  let html = '<div class="tool-header">' + head + '</div>';
1090
- if (args.script) html += codeBlock(String(args.script), 'javascript');
1091
- if (args.text) html += '<div class="tool-output"><div>' + escapeHtml(String(args.text)) + '</div></div>';
1096
+ if (action === 'run' && args.code) {
1097
+ html += codeBlock(String(args.code), 'javascript');
1098
+ }
1092
1099
  if (result) {
1093
1100
  html += ctx.renderResultImages();
1094
1101
  const output = ctx.getResultText();
@@ -1277,7 +1284,8 @@
1277
1284
  web_search: renderWebSearch,
1278
1285
  fetch: renderFetch,
1279
1286
  debug: renderDebug,
1280
- puppeteer: renderPuppeteer,
1287
+ puppeteer: renderBrowser,
1288
+ browser: renderBrowser,
1281
1289
  inspect_image: renderInspectImage,
1282
1290
  generate_image: renderGenerateImage,
1283
1291
  ask: renderAsk,
@@ -1,6 +1,7 @@
1
1
  import * as fs from "node:fs";
2
2
  import { createServer } from "node:net";
3
3
  import * as path from "node:path";
4
+ import { Process } from "@oh-my-pi/pi-natives";
4
5
  import { getAgentDir, isEnoent, logger, procmgr } from "@oh-my-pi/pi-utils";
5
6
  import type { Subprocess } from "bun";
6
7
  import { Settings } from "../config/settings";
@@ -300,7 +301,7 @@ async function startGatewayProcess(
300
301
 
301
302
  async function killGateway(pid: number, context: string): Promise<void> {
302
303
  try {
303
- await procmgr.terminate({ target: pid });
304
+ await Process.fromPid(pid)?.terminate();
304
305
  } catch (err) {
305
306
  logger.warn("Failed to kill shared gateway process", {
306
307
  error: err instanceof Error ? err.message : String(err),
@@ -36,7 +36,7 @@ Lid= blank the anchored line's content but KEEP the line (results in an em
36
36
  - To insert ABOVE a line, you **MUST** use `^Lid` then `+TEXT`. To insert above line 1, you **MUST** use `^` (BOF) then `+TEXT`. To insert below a line, you **MUST** use `@Lid` then `+TEXT`.
37
37
  - Multiple `---PATH` sections **MAY** appear in one input; each section is applied in order.
38
38
  - `!rm` / `!mv DEST` **MUST NOT** be combined with line edits in the same section.
39
- - Lids contain a content hash. If a line has changed since you read it, the tool rejects the edit and shows the current content; you **MUST** re-read and retry with fresh Lids. Small drift (≤5 lines) where the original hash still matches a nearby line auto-rebases with a warning. Larger shifts may show a hash-only candidate, but two-letter hashes collide; verify surrounding content or re-read before using it.
39
+ - Lids contain a content hash. If a line has changed since you read it, the tool rejects the edit and shows the current content; you **MUST** re-read and retry with fresh Lids.
40
40
  - After `+TEXT` (or `+`) the cursor advances past the inserted line, so consecutive `+TEXT` ops stack in order. After `Lid=TEXT` the cursor sits on the modified anchor; after `-Lid` it sits on the slot the deleted line vacated. You **MUST** use a fresh `@Lid` / `^Lid` / `^` / `$` to reposition.
41
41
  - The tool is syntax-blind: it will not check brackets, indentation, table column counts, or fence integrity. You **MUST** verify indentation-sensitive or structured files after editing (Python, Markdown tables/fences).
42
42
  - A section whose PATH does not yet exist creates the file from your `+TEXT` lines (use `^` or `$` then `+TEXT…`). No separate "create file" op is needed.
@@ -83,7 +83,7 @@ Lid= blank the anchored line's content but KEEP the line (results in an em
83
83
  \ return (name || DEF).trim().toUpperCase();
84
84
  \}
85
85
 
86
- # Replace a block with a longer multi-line block, including blank lines (canonical form for refactors)
86
+ # Replace one contiguous block when the existing lines themselves change; the replacement may have more/fewer lines than the selected range
87
87
  ---a.ts
88
88
  {{hrefr 3}}..{{hrefr 6}}=/** Format a display label, falling back to DEF when empty. */
89
89
  \export function label(name: string): string {
@@ -139,6 +139,7 @@ $
139
139
  - Current/added preview lines include fresh `LINE+hash|content` anchors. Removed preview lines show deleted content and **MUST NOT** be reused as anchors.
140
140
  - You **MUST** emit only lines that change. You **MUST NOT** echo unchanged context; the anchor implies position.
141
141
  - You **MUST NOT** write `Lid=<sameTextThatIsAlreadyOnThatLine>`; the tool reports a no-op (no change applied). Emit `Lid=TEXT` only when TEXT differs.
142
+ - You **MUST NOT** use `Lid=<originalLineContent>` + `\continuations` as an "insert after" idiom. That form is a *replacement*: its first line lands at the anchor, and its continuations push the original next line down. When the anchor is a closing brace and your continuations also end in `}`, the original line below — often itself `}` (a sibling block, mod, or impl closer) — sits adjacent to yours and you ship a duplicate `}`. For pure insertion, use `@Lid` + `+TEXT…` (after) or `^Lid` + `+TEXT…` (before). Never re-state the anchor's content as the first line of a replacement.
142
143
  - A line of the form `Lid|content` (a Lid, then `|`, then text, with NO leading `+`/`-`/`^`/`@`/`\`/`=`/`..`) is **FORBIDDEN**. That shape only appears in `read`/`grep` output as an anchor for *you*; it is never an edit op. If you copy a `Lid|content` line verbatim from a read into a patch, you have made an error — every edit op must start with `+`, `-`, `^`, `@`, `\`, `$`, `!`, or a Lid immediately followed by `=` or `..`.
143
144
  - To replace a contiguous block with new content, the canonical form is `LidA..LidB=FIRST_LINE` + `\NEXT_LINE…`. You **MUST NOT** write the old block and then the new block — that is unified-diff thinking and the tool does not understand it. If you find yourself emitting pre-image lines (with or without operators) before your new content, STOP and rewrite the section as a single range-replace.
144
145
  - TEXT after `=`, `+`, or `\` includes leading whitespace verbatim. You **MUST NOT** trim or re-indent it.
@@ -1,25 +1,70 @@
1
- Navigates, clicks, types, scrolls, drags, queries DOM content, and captures screenshots.
1
+ Drives a real Chromium tab with full puppeteer access via JS execution.
2
2
 
3
3
  <instruction>
4
- - For fetching static web content (articles, docs, issues/PRs, JSON, PDFs, feeds), prefer the `read` tool with a URL — it returns clean reader-mode text without spinning up a browser. Use this tool only when you need JS execution, authentication, or interactive actions.
5
- - `"open"` starts a headless session (or implicitly on first action); `"goto"` navigates to `url`; `"close"` releases the browser
6
- - `"observe"` captures a numbered accessibility snapshot prefer `click_id`/`type_id`/`fill_id` using returned `element_id` values; flags: `include_all`, `viewport_only`
7
- - `"click"`, `"type"`, `"fill"`, `"press"`, `"scroll"`, `"drag"` for selector-based interactions prefer ARIA/text selectors (`p-aria/[name="Sign in"]`, `p-text/Continue`) over brittle CSS
8
- - `"click_id"`, `"type_id"`, `"fill_id"` to interact with observed elements without selectors
9
- - `"wait_for_selector"` before interacting when the page is dynamic
10
- - `"evaluate"` runs a JS expression in page context
11
- - `"get_text"`, `"get_html"`, `"get_attribute"` for DOM queries batch via `args: [{ selector, attribute? }]`
12
- - `"extract_readable"` returns reader-mode content; `format`: `"markdown"` (default) or `"text"`
13
- - `"screenshot"` captures images (optionally with `selector`); can save to disk via `path`
4
+ - For fetching static web content (articles, docs, issues/PRs, JSON, PDFs, feeds), prefer the `read` tool with a URL — reader-mode text without spinning up a browser. Use this tool when you need JS execution, authentication, or interactive actions.
5
+ - Three actions only:
6
+ - `open` — acquire (or reuse) a named tab. `name` defaults to `"main"`. Optional `url` navigates after the tab is ready. Optional `viewport` sets dimensions. Optional `dialogs: "accept" | "dismiss"` auto-handles `alert`/`confirm`/`beforeunload` so navigation/clicks don't hang (default: leave dialogs unhandled — page hangs until caller wires `page.on('dialog', …)`).
7
+ - `close` release a tab by `name`, or every tab with `all: true`. For spawned-app browsers, set `kill: true` to terminate the process tree (default leaves it running).
8
+ - `run` — execute JS against an existing tab. The `code` is the body of an async function with `page`, `browser`, `tab`, `display`, `assert`, `wait` in scope. The function's return value is JSON-stringified into the tool result; multiple `display(value)` calls accumulate text/images.
9
+ - Tabs survive across `run` calls and across in-process subagents. Open once, reuse many times.
10
+ - Browser kinds, selected by the `app` field on `open`:
11
+ - default (no `app`) headless Chromium with stealth patches.
12
+ - `app.path` spawn an absolute binary (Electron/CDP). If a running instance already exposes a CDP port, it is reused; otherwise stale instances are killed and a fresh one is spawned. No stealth patches — never tamper with a real desktop app.
13
+ - `app.cdp_url` connect to an existing CDP endpoint (e.g. `http://127.0.0.1:9222`).
14
+ - `app.target` (with `path`/`cdp_url`) — substring matched against url+title to pick a BrowserWindow when the app exposes several.
15
+ - Inside `run`, `tab` exposes high-level helpers; reach for `page` (raw puppeteer Page) when you need anything they don't cover. Available helpers:
16
+ - `tab.goto(url, { waitUntil? })` — clears the element cache and navigates.
17
+ - `tab.observe({ includeAll?, viewportOnly? })` — accessibility snapshot. Returns `{ url, title, viewport, scroll, elements: [{ id, role, name, value, states, … }] }`. Element ids are stable until the next observe/goto.
18
+ - `tab.id(n)` — resolves an element id from the most recent observe to a real `ElementHandle` you can `.click()`, `.type()`, etc.
19
+ - `tab.click(selector)` / `tab.type(selector, text)` / `tab.fill(selector, value)` / `tab.press(key, { selector? })` / `tab.scroll(dx, dy)` — selector-based actions.
20
+ - `tab.waitFor(selector)` — waits until the selector is attached, returns the resolved `ElementHandle` for chaining (e.g. `const btn = await tab.waitFor('text/Submit'); await btn.click();`).
21
+ - `tab.drag(from, to)` — drag from one point to another. Each endpoint is either a selector string (drag center-to-center) or a `{ x, y }` viewport-coordinate point (e.g. for canvases, sliders).
22
+ - `tab.scrollIntoView(selector)` — scroll the matching element to the center of the viewport (use before clicking off-screen elements).
23
+ - `tab.select(selector, …values)` — set the selected option(s) on a `<select>`. Returns the values that ended up selected. `tab.fill` does **NOT** work for selects.
24
+ - `tab.uploadFile(selector, …filePaths)` — attach files to an `<input type="file">`. Paths resolve relative to cwd.
25
+ - `tab.waitForUrl(pattern, { timeout? })` — pattern is a substring or `RegExp`. Polls `location.href` so it works for SPA pushState navigations, not just real navigations. Returns the matched URL.
26
+ - `tab.waitForResponse(pattern, { timeout? })` — pattern is a substring, `RegExp`, or `(response) => boolean`. Returns the raw puppeteer `HTTPResponse` (call `.text()` / `.json()` / `.status()` / `.headers()` on it).
27
+ - `tab.evaluate(fn, …args)` — sugar for `page.evaluate` with the abort signal already wired. Use this instead of dropping to `page.evaluate` for ad-hoc DOM reads.
28
+ - `tab.screenshot({ selector?, fullPage?, save?, silent? })` — auto-attaches the image to the tool output unless `silent: true`. Saves full-res to `save` (or `browser.screenshotDir` setting) and a downscaled copy to the model.
29
+ - `tab.extract(format = "markdown")` — Readability-extracted page content.
30
+ - Selectors accept CSS as well as puppeteer query handlers: `aria/Sign in`, `text/Continue`, `xpath/…`, `pierce/…`. Playwright-style `p-aria/[name="…"]`, `p-text/…`, etc. are normalized.
31
+ - Default to `tab.observe()` over `tab.screenshot()` for understanding page state. Screenshot only when visual appearance matters.
14
32
  </instruction>
15
33
 
16
34
  <critical>
17
- **You **MUST** default to `observe`, not `screenshot`.**
18
- - `observe` is cheaper, faster, and returns structured datause it to understand page state, find elements, and plan interactions.
19
- - You **SHOULD** only use `screenshot` when visual appearance matters (verifying layout, debugging CSS, capturing a visual artifact for the user).
20
- - You **MUST NOT** screenshot just to "see what's on the page" `observe` gives you that with element IDs you can act on immediately.
35
+ - You **MUST** call `open` before `run`. `run` does not implicitly create a tab.
36
+ - You **MUST NOT** screenshot just to "see what's on the page" `tab.observe()` returns structured data with element ids you can act on immediately.
37
+ - After a `tab.goto()` or any navigation, prior element ids from `tab.observe()` are invalidated. Re-observe before referencing them.
38
+ - `code` runs with full Node access. Treat it as your code, not sandboxed code.
21
39
  </critical>
22
40
 
41
+ <examples>
42
+ # Open a tab and read structured page data
43
+ `{"action":"open","name":"docs","url":"https://example.com"}`
44
+ `{"action":"run","name":"docs","code":"const obs = await tab.observe(); display(obs); return obs.elements.length;"}`
45
+
46
+ # Click an observed element by id
47
+ `{"action":"run","name":"docs","code":"const obs = await tab.observe(); const link = obs.elements.find(e => e.role === 'link' && e.name === 'Sign in'); assert(link, 'Sign in link missing'); await (await tab.id(link.id)).click();"}`
48
+
49
+ # Save a full-page screenshot to disk
50
+ `{"action":"run","name":"docs","code":"await tab.screenshot({ fullPage: true, save: 'screenshot.png' });"}`
51
+
52
+ # Fill and submit a form via selectors
53
+ `{"action":"run","name":"docs","code":"await tab.fill('input[name=email]', 'me@example.com'); await tab.click('text/Continue');"}`
54
+
55
+ # Attach to an existing Electron app
56
+ `{"action":"open","name":"cursor","app":{"path":"/Applications/Cursor.app/Contents/MacOS/Cursor"}}`
57
+
58
+ # Close one tab (browser stays alive if other tabs reference it)
59
+ `{"action":"close","name":"docs"}`
60
+
61
+ # Close every tab; leave spawned apps running
62
+ `{"action":"close","all":true}`
63
+
64
+ # Close every tab and kill spawned-app processes too
65
+ `{"action":"close","all":true,"kill":true}`
66
+ </examples>
67
+
23
68
  <output>
24
- Text for navigation/DOM queries, images for screenshots.
69
+ Per call: any `display(value)` outputs (text/images) followed by the JSON-stringified return value of the `code` function. `run` always produces at least a status line.
25
70
  </output>
@@ -52,16 +52,8 @@ import {
52
52
  parseRateLimitReason,
53
53
  streamSimple,
54
54
  } from "@oh-my-pi/pi-ai";
55
- import { killTree, MacOSPowerAssertion } from "@oh-my-pi/pi-natives";
56
- import {
57
- abortableSleep,
58
- getAgentDbPath,
59
- isEnoent,
60
- logger,
61
- prompt,
62
- Snowflake,
63
- setNativeKillTree,
64
- } from "@oh-my-pi/pi-utils";
55
+ import { MacOSPowerAssertion } from "@oh-my-pi/pi-natives";
56
+ import { abortableSleep, getAgentDbPath, isEnoent, logger, prompt, Snowflake } from "@oh-my-pi/pi-utils";
65
57
  import type { AsyncJob, AsyncJobManager } from "../async";
66
58
  import type { Rule } from "../capability/rule";
67
59
  import { MODEL_ROLE_IDS, type ModelRegistry } from "../config/model-registry";
@@ -580,8 +572,6 @@ export class AgentSession {
580
572
  }
581
573
 
582
574
  constructor(config: AgentSessionConfig) {
583
- setNativeKillTree(killTree);
584
-
585
575
  this.agent = config.agent;
586
576
  this.sessionManager = config.sessionManager;
587
577
  this.settings = config.settings;
@@ -0,0 +1,175 @@
1
+ import * as net from "node:net";
2
+ import { Process, ProcessStatus } from "@oh-my-pi/pi-natives";
3
+ import type { Browser, Page } from "puppeteer-core";
4
+ import { ToolError, throwIfAborted } from "../tool-errors";
5
+
6
+ export const ATTACH_TARGET_SKIP_PATTERN =
7
+ /request[\s_-]?handler|devtools|background[\s_-]?(?:page|host)|service[\s_-]?worker/i;
8
+
9
+ /**
10
+ * Allocate an unused TCP port on 127.0.0.1 by binding to port 0 and reading
11
+ * back the kernel-assigned port. There's a small race between close and the
12
+ * subsequent bind in the launched app, but Chromium's listener will retry.
13
+ */
14
+ export async function findFreeCdpPort(): Promise<number> {
15
+ const { promise, resolve, reject } = Promise.withResolvers<number>();
16
+ const server = net.createServer();
17
+ server.unref();
18
+ server.once("error", reject);
19
+ server.listen(0, "127.0.0.1", () => {
20
+ const addr = server.address();
21
+ if (addr && typeof addr === "object" && typeof addr.port === "number") {
22
+ const port = addr.port;
23
+ server.close(closeErr => (closeErr ? reject(closeErr) : resolve(port)));
24
+ } else {
25
+ server.close();
26
+ reject(new Error("Failed to allocate ephemeral CDP port"));
27
+ }
28
+ });
29
+ return promise;
30
+ }
31
+
32
+ /** Poll `${cdpUrl}/json/version` until it responds with 200, with abort + timeout support. */
33
+ export async function waitForCdp(cdpUrl: string, timeoutMs: number, signal?: AbortSignal): Promise<void> {
34
+ const deadline = Date.now() + timeoutMs;
35
+ let lastErr: unknown;
36
+ const probeUrl = `${cdpUrl.replace(/\/+$/, "")}/json/version`;
37
+ while (Date.now() < deadline) {
38
+ throwIfAborted(signal);
39
+ const probeTimeout = AbortSignal.timeout(2000);
40
+ const probeSignal = signal ? AbortSignal.any([signal, probeTimeout]) : probeTimeout;
41
+ try {
42
+ const res = await fetch(probeUrl, { signal: probeSignal });
43
+ if (res.ok) {
44
+ await res.body?.cancel();
45
+ return;
46
+ }
47
+ lastErr = new Error(`HTTP ${res.status}`);
48
+ await res.body?.cancel();
49
+ } catch (err) {
50
+ if (signal?.aborted) throwIfAborted(signal);
51
+ lastErr = err;
52
+ }
53
+ await Bun.sleep(150);
54
+ }
55
+ throw new ToolError(
56
+ `Timed out waiting for CDP endpoint ${cdpUrl}${lastErr instanceof Error ? `: ${lastErr.message}` : ""}`,
57
+ );
58
+ }
59
+
60
+ /**
61
+ * Pull a `--remote-debugging-port=<n>` value out of an argv array (Chromium
62
+ * accepts both `--flag=value` and `--flag value`). Returns null if absent or
63
+ * malformed.
64
+ */
65
+ export function findCdpPortInArgs(args: string[]): number | null {
66
+ for (const arg of args) {
67
+ const m = /^--remote-debugging-port=(\d+)$/.exec(arg);
68
+ if (m) {
69
+ const port = Number.parseInt(m[1]!, 10);
70
+ if (Number.isFinite(port) && port > 0) return port;
71
+ }
72
+ }
73
+ for (let i = 0; i < args.length - 1; i++) {
74
+ if (args[i] === "--remote-debugging-port") {
75
+ const port = Number.parseInt(args[i + 1]!, 10);
76
+ if (Number.isFinite(port) && port > 0) return port;
77
+ }
78
+ }
79
+ return null;
80
+ }
81
+
82
+ /** One-shot probe: returns true when `/json/version` answers 200 within the timeout. */
83
+ export async function probeCdpAt(port: number, signal?: AbortSignal): Promise<boolean> {
84
+ const probeTimeout = AbortSignal.timeout(1500);
85
+ const probeSignal = signal ? AbortSignal.any([signal, probeTimeout]) : probeTimeout;
86
+ try {
87
+ const res = await fetch(`http://127.0.0.1:${port}/json/version`, { signal: probeSignal });
88
+ await res.body?.cancel();
89
+ return res.ok;
90
+ } catch {
91
+ return false;
92
+ }
93
+ }
94
+
95
+ /**
96
+ * If any running instance of `exe` was launched with `--remote-debugging-port`
97
+ * and that endpoint actually answers, return it so attach can reuse it instead
98
+ * of killing and respawning. Idempotent re-attaches are the common case.
99
+ */
100
+ export async function findReusableCdp(
101
+ exe: string,
102
+ signal?: AbortSignal,
103
+ ): Promise<{ cdpUrl: string; pid: number } | null> {
104
+ const candidates = Process.fromPath(exe).filter(p => p.status() === ProcessStatus.Running);
105
+ for (const proc of candidates) {
106
+ let args: string[];
107
+ try {
108
+ args = proc.args();
109
+ } catch {
110
+ continue;
111
+ }
112
+ const port = findCdpPortInArgs(args);
113
+ if (port === null) continue;
114
+ if (await probeCdpAt(port, signal)) {
115
+ return { cdpUrl: `http://127.0.0.1:${port}`, pid: proc.pid };
116
+ }
117
+ }
118
+ return null;
119
+ }
120
+
121
+ /**
122
+ * Pick the best page target on an attached browser. Without a matcher, prefer
123
+ * a page that doesn't look like a helper window (devtools, request handler,
124
+ * background pages); with a matcher, return the first url+title substring hit.
125
+ */
126
+ export async function pickElectronTarget(browser: Browser, matcher?: string): Promise<Page> {
127
+ const pages = await browser.pages();
128
+ if (!pages.length) {
129
+ throw new ToolError("No page targets available on the attached browser");
130
+ }
131
+ const enriched = await Promise.all(
132
+ pages.map(async page => ({
133
+ page,
134
+ url: page.url(),
135
+ title: ((await page.title().catch(() => "")) ?? "").trim(),
136
+ })),
137
+ );
138
+ if (matcher) {
139
+ const needle = matcher.toLowerCase();
140
+ const hit = enriched.find(p => p.url.toLowerCase().includes(needle) || p.title.toLowerCase().includes(needle));
141
+ if (hit) return hit.page;
142
+ const summary = enriched.map(p => `- ${p.title || "(untitled)"} ${p.url}`).join("\n");
143
+ throw new ToolError(`No page target matched ${JSON.stringify(matcher)}. Available pages:\n${summary}`);
144
+ }
145
+ return (
146
+ enriched.find(p => !ATTACH_TARGET_SKIP_PATTERN.test(p.url) && !ATTACH_TARGET_SKIP_PATTERN.test(p.title))?.page ??
147
+ enriched[0]!.page
148
+ );
149
+ }
150
+
151
+ /**
152
+ * SIGTERM the process tree, wait briefly, then SIGKILL anything still alive.
153
+ * Single-process variant for our own spawned children.
154
+ */
155
+ export async function gracefulKillTreeOnce(pid: number, gracePeriodMs = 2000): Promise<void> {
156
+ const process = Process.fromPid(pid);
157
+ if (!process) return;
158
+ await process.terminate({ gracefulMs: gracePeriodMs, timeoutMs: 500 });
159
+ }
160
+
161
+ /**
162
+ * Multi-process variant for attach: find every PID running `executablePath`
163
+ * (single-instance apps may keep an orphan around) and tear them all down.
164
+ */
165
+ export async function killExistingByPath(executablePath: string, signal?: AbortSignal): Promise<number> {
166
+ const processes = Process.fromPath(executablePath);
167
+ if (!processes.length) return 0;
168
+ const results = await Promise.all(
169
+ processes.map(async process => {
170
+ throwIfAborted(signal);
171
+ return await process.terminate({ gracefulMs: 3000, timeoutMs: 1000 });
172
+ }),
173
+ );
174
+ return results.length;
175
+ }