@oh-my-pi/pi-coding-agent 14.5.10 → 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.
- package/CHANGELOG.md +42 -0
- package/package.json +7 -7
- package/src/export/html/template.generated.ts +1 -1
- package/src/export/html/template.js +29 -9
- package/src/internal-urls/docs-index.generated.ts +54 -54
- package/src/ipy/gateway-coordinator.ts +2 -1
- package/src/modes/controllers/todo-command-controller.ts +22 -74
- package/src/modes/interactive-mode.ts +9 -6
- package/src/modes/types.ts +0 -2
- package/src/prompts/system/eager-todo.md +1 -1
- package/src/prompts/tools/atom.md +3 -2
- package/src/prompts/tools/browser.md +61 -16
- package/src/prompts/tools/todo-write.md +19 -19
- package/src/session/agent-session.ts +23 -29
- package/src/tools/browser/attach.ts +175 -0
- package/src/tools/browser/launch.ts +554 -0
- package/src/tools/browser/readable.ts +90 -0
- package/src/tools/browser/registry.ts +417 -0
- package/src/tools/browser/render.ts +212 -0
- package/src/tools/browser/vm.ts +792 -0
- package/src/tools/browser.ts +249 -1568
- package/src/tools/plan-mode-guard.ts +27 -1
- package/src/tools/renderers.ts +2 -0
- package/src/tools/todo-write.ts +157 -195
- package/examples/custom-tools/todo/index.ts +0 -211
- package/examples/extensions/todo.ts +0 -295
|
@@ -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
|
+
}
|