@phi-code-admin/phi-code 0.75.7 → 0.76.1

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 CHANGED
@@ -1,5 +1,41 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.76.0] - 2026-05-16
4
+
5
+ ### Added
6
+
7
+ - **Bundled browser engine.** Phi-code now ships a fully self-hosted
8
+ Camoufox stack as new optional dependency `@phi-code-admin/browser` and
9
+ a new `browser.ts` extension that registers ten high-level tools:
10
+ `browser_navigate`, `browser_extract`, `browser_screenshot`,
11
+ `browser_search`, `browser_click`, `browser_type`, `browser_scroll`,
12
+ `browser_snapshot`, `browser_close_tab`, `browser_list_tabs`. Backed by
13
+ vendored snapshots of [apify/camoufox-js](https://github.com/apify/camoufox-js)
14
+ (MPL-2.0) and [jo-inc/camofox-browser](https://github.com/jo-inc/camofox-browser)
15
+ (MIT). Camoufox v135.0.1-beta.24 binaries are re-hosted on
16
+ [uglyswap/phi-code releases](https://github.com/uglyswap/phi-code/releases/tag/binaries-v1.0.0)
17
+ and downloaded once by the camoufox-js postinstall — no runtime call to
18
+ daijro/camoufox.
19
+ - New env vars: `PHI_BROWSER_DISABLED=1`, `CAMOUFOX_BIN_DIR=/path`,
20
+ `CAMOUFOX_SKIP_DOWNLOAD=1`, `CAMOUFOX_ALLOW_GITHUB_FETCH=1`,
21
+ `CAMOFOX_CRASH_REPORT_URL=https://your-relay` (crash telemetry is OFF
22
+ by default — see THIRD_PARTY_LICENSES.md).
23
+
24
+ ## [0.75.7] - 2026-05-15
25
+
26
+ ### Fixed
27
+
28
+ - Settings flush before process.exit so `/model` persists across sessions.
29
+
30
+ ## [0.75.6] - 2026-05-15
31
+
32
+ ### Fixed
33
+
34
+ - `/model` is now the single source of truth for the chat default model.
35
+ The smart router no longer auto-switches per prompt (autoSwitch=off by
36
+ default; re-enable with `/routing autoswitch on`). `/phi-init` and
37
+ `/setup` no longer offer "Default model" — orchestration roles only.
38
+
3
39
  ## [Unreleased]
4
40
 
5
41
  ### Added
@@ -1,6 +1,6 @@
1
1
  # Phi Code Extensions
2
2
 
3
- 10 TypeScript extensions automatically loaded at startup.
3
+ 11 TypeScript extensions automatically loaded at startup.
4
4
 
5
5
  ## Extensions
6
6
 
@@ -17,6 +17,31 @@
17
17
  | **Setup** | `setup.ts` | — | `/setup` | — |
18
18
  | **Keys** | `keys.ts` | — | `/keys` | `session_start` (hot-reload watcher) |
19
19
  | **Models** | `models.ts` | — | `/models` | `session_start` (background catalog refresh) |
20
+ | **Browser** | `browser.ts` | `browser_navigate`, `browser_extract`, `browser_screenshot`, `browser_search`, `browser_click`, `browser_type`, `browser_scroll`, `browser_snapshot`, `browser_close_tab`, `browser_list_tabs` | — | `session_shutdown` (kill Firefox) |
21
+
22
+ ## Bundled browser engine (Camoufox)
23
+
24
+ The `browser.ts` extension exposes ten high-level tools backed by a
25
+ vendored snapshot of [Camoufox](https://github.com/daijro/camoufox) v135.0.1-beta.24
26
+ (anti-detect Firefox fork, MPL-2.0). It bypasses Cloudflare and most
27
+ bot-detection that plain `fetch` + cheerio can't.
28
+
29
+ - Phi-code ships **its own copy** of the JS launcher and the OpenClaw
30
+ automation server (`@phi-code-admin/camoufox-js`,
31
+ `@phi-code-admin/camofox-browser`, `@phi-code-admin/browser`) so no
32
+ third-party-maintained npm package sits on the critical path.
33
+ - The Firefox binary itself is re-hosted on
34
+ [uglyswap/phi-code releases](https://github.com/uglyswap/phi-code/releases/tag/binaries-v1.0.0)
35
+ and downloaded once by the camoufox-js postinstall, cached under
36
+ `~/.cache/phi-code/camoufox/v1.0.0/<platform>-<arch>/` (XDG / Library /
37
+ LOCALAPPDATA respected). No runtime call to daijro/camoufox.
38
+ - `PHI_BROWSER_DISABLED=1` turns the extension off without uninstalling.
39
+ - `CAMOUFOX_BIN_DIR=/absolute/path` overrides the cache (air-gapped CI).
40
+ - `CAMOUFOX_SKIP_DOWNLOAD=1` skips the postinstall download; useful when
41
+ you want to ship pre-baked Docker images.
42
+
43
+ The web-search cascade (`web_search`, `/search`) is unchanged — Camoufox
44
+ is an additional capability, not a replacement.
20
45
 
21
46
  ## Live Model Catalogs
22
47
 
@@ -0,0 +1,319 @@
1
+ /**
2
+ * Browser Extension for Phi Code
3
+ *
4
+ * Registers 10 browser tools backed by the bundled Camoufox stack
5
+ * (`@phi-code-admin/browser`):
6
+ *
7
+ * browser_navigate — open/follow a URL
8
+ * browser_extract — readability extraction (works on SPAs)
9
+ * browser_screenshot — PNG capture, base64 in the tool result
10
+ * browser_search — DDG/Google search macro
11
+ * browser_click — click by accessibility ref or CSS selector
12
+ * browser_type — type text into focused/targeted element
13
+ * browser_scroll — page/element scroll
14
+ * browser_snapshot — accessibility tree with refs for follow-up tools
15
+ * browser_close_tab — release a single tab
16
+ * browser_list_tabs — list open tabs for the current session
17
+ *
18
+ * Lifecycle:
19
+ * - Lazy boot: the Camoufox server starts on the first tool call.
20
+ * - `session_shutdown`: best-effort `closeAll()` to avoid zombie Firefox.
21
+ * - PHI_BROWSER_DISABLED=1 disables the whole extension at startup (the
22
+ * user keeps the legacy `web_search` / `fetch_url` only).
23
+ */
24
+
25
+ import { createRequire } from "node:module";
26
+ import { existsSync } from "node:fs";
27
+ import { dirname, join } from "node:path";
28
+ import { pathToFileURL } from "node:url";
29
+ import { Type } from "@sinclair/typebox";
30
+ import type { ExtensionAPI } from "phi-code";
31
+
32
+ // PHI-VENDOR: dynamic import so phi-code keeps starting even when the
33
+ // vendored browser stack isn't installed (e.g. binaries unavailable for
34
+ // the host's `process.platform`-`process.arch` combo). We surface a
35
+ // concrete error on first tool call instead of refusing to boot.
36
+ type BrowserApi = typeof import("@phi-code-admin/browser");
37
+
38
+ let cachedApi: BrowserApi | undefined;
39
+
40
+ /**
41
+ * Resolve `@phi-code-admin/browser` from the host phi-code installation
42
+ * (the binary that loaded us, via `process.argv[1]`), not from this file's
43
+ * location. The extension is typically copied by phi-code's postinstall
44
+ * into `~/.phi/agent/extensions/browser.ts`, which has no `node_modules`
45
+ * of its own — a plain `import("@phi-code-admin/browser")` would resolve
46
+ * relative to that copy and fail. Walking the resolution from
47
+ * `process.argv[1]` (the `phi` CLI entry, which DOES sit next to its
48
+ * bundled `node_modules`) finds the package every time.
49
+ */
50
+ function browserPackageFromPhi(): string | undefined {
51
+ const cliPath = process.argv[1];
52
+ if (!cliPath) return undefined;
53
+ try {
54
+ const req = createRequire(pathToFileURL(cliPath));
55
+ return req.resolve("@phi-code-admin/browser");
56
+ } catch {
57
+ // Fall through — we'll try walking up from cliPath manually.
58
+ }
59
+ let dir = dirname(cliPath);
60
+ for (let depth = 0; depth < 8; depth++) {
61
+ const candidate = join(dir, "node_modules", "@phi-code-admin", "browser", "dist", "index.js");
62
+ if (existsSync(candidate)) return candidate;
63
+ const parent = dirname(dir);
64
+ if (parent === dir) break;
65
+ dir = parent;
66
+ }
67
+ return undefined;
68
+ }
69
+
70
+ async function getBrowserApi(): Promise<BrowserApi> {
71
+ if (cachedApi) return cachedApi;
72
+
73
+ // 1. Try the standard dynamic import first. This works when the extension
74
+ // lives next to a `node_modules/@phi-code-admin/browser` (dev / monorepo
75
+ // layouts and any setup where the user has run a fresh `npm install` in
76
+ // the extension's directory).
77
+ try {
78
+ cachedApi = (await import("@phi-code-admin/browser")) as BrowserApi;
79
+ return cachedApi;
80
+ } catch (firstErr) {
81
+ // 2. Fall back to resolving through the phi CLI binary, which always
82
+ // sits next to its bundled deps even when the extension was copied
83
+ // elsewhere by the postinstall script.
84
+ const resolved = browserPackageFromPhi();
85
+ if (resolved) {
86
+ try {
87
+ cachedApi = (await import(pathToFileURL(resolved).href)) as BrowserApi;
88
+ return cachedApi;
89
+ } catch (secondErr) {
90
+ // Re-throw the second error: it's the more informative one.
91
+ throw secondErr instanceof Error ? secondErr : new Error(String(secondErr));
92
+ }
93
+ }
94
+ // 3. No path worked. Throw the original error WITHOUT caching it, so
95
+ // the user can fix their install and the next tool call retries.
96
+ throw firstErr instanceof Error ? firstErr : new Error(String(firstErr));
97
+ }
98
+ }
99
+
100
+ function isDisabled(): boolean {
101
+ const v = process.env.PHI_BROWSER_DISABLED;
102
+ return v === "1" || v === "true" || v === "yes";
103
+ }
104
+
105
+ function jsonResult(value: unknown): string {
106
+ return typeof value === "string" ? value : JSON.stringify(value, null, 2);
107
+ }
108
+
109
+ export default function browserExtension(pi: ExtensionAPI) {
110
+ if (isDisabled()) {
111
+ // Keep startup quiet — the user opted out.
112
+ return;
113
+ }
114
+
115
+ // ─── browser_navigate ─────────────────────────────────────────────
116
+ pi.registerTool({
117
+ name: "browser_navigate",
118
+ description:
119
+ "Open a URL in a Camoufox tab. If `tabId` is omitted, a new tab is created. Returns the tab id, the final URL and the HTTP status when available.",
120
+ parameters: Type.Object({
121
+ url: Type.String({ description: "Full URL (https://...)" }),
122
+ tabId: Type.Optional(Type.String()),
123
+ waitUntil: Type.Optional(
124
+ Type.Union([
125
+ Type.Literal("load"),
126
+ Type.Literal("domcontentloaded"),
127
+ Type.Literal("networkidle"),
128
+ ]),
129
+ ),
130
+ timeoutMs: Type.Optional(Type.Number()),
131
+ }),
132
+ execute: async (params) => {
133
+ const api = await getBrowserApi();
134
+ const res = await api.navigate(params);
135
+ return { content: [{ type: "text", text: jsonResult(res) }] };
136
+ },
137
+ });
138
+
139
+ // ─── browser_extract ──────────────────────────────────────────────
140
+ pi.registerTool({
141
+ name: "browser_extract",
142
+ description:
143
+ "Return the readable content of a page (Mozilla Readability under the hood). Works on SPA / JS-heavy sites. Pass either `tabId` (existing tab) or `url` (opens a fresh tab).",
144
+ parameters: Type.Object({
145
+ tabId: Type.Optional(Type.String()),
146
+ url: Type.Optional(Type.String()),
147
+ mode: Type.Optional(
148
+ Type.Union([
149
+ Type.Literal("readability"),
150
+ Type.Literal("html"),
151
+ Type.Literal("text"),
152
+ ]),
153
+ ),
154
+ }),
155
+ execute: async (params) => {
156
+ const api = await getBrowserApi();
157
+ const res = await api.extract(params);
158
+ return { content: [{ type: "text", text: jsonResult(res) }] };
159
+ },
160
+ });
161
+
162
+ // ─── browser_screenshot ───────────────────────────────────────────
163
+ pi.registerTool({
164
+ name: "browser_screenshot",
165
+ description:
166
+ "Capture a screenshot of the current tab as a PNG. The image bytes are returned base64-encoded under `bytesBase64`.",
167
+ parameters: Type.Object({
168
+ tabId: Type.String(),
169
+ fullPage: Type.Optional(Type.Boolean()),
170
+ }),
171
+ execute: async (params) => {
172
+ const api = await getBrowserApi();
173
+ const res = await api.screenshot(params);
174
+ return { content: [{ type: "text", text: jsonResult(res) }] };
175
+ },
176
+ });
177
+
178
+ // ─── browser_search ───────────────────────────────────────────────
179
+ pi.registerTool({
180
+ name: "browser_search",
181
+ description:
182
+ "Run a web search through the Camoufox browser (anti-detect Firefox) and return the readability extraction of the results page. Useful when scraping Google directly is rate-limited.",
183
+ parameters: Type.Object({
184
+ query: Type.String(),
185
+ engine: Type.Optional(
186
+ Type.Union([
187
+ Type.Literal("google"),
188
+ Type.Literal("duckduckgo"),
189
+ Type.Literal("bing"),
190
+ ]),
191
+ ),
192
+ }),
193
+ execute: async (params) => {
194
+ const api = await getBrowserApi();
195
+ const res = await api.search(params);
196
+ return { content: [{ type: "text", text: jsonResult(res) }] };
197
+ },
198
+ });
199
+
200
+ // ─── browser_click ────────────────────────────────────────────────
201
+ pi.registerTool({
202
+ name: "browser_click",
203
+ description:
204
+ "Click an element. Pass either `ref` (returned by browser_snapshot) or `selector` (CSS).",
205
+ parameters: Type.Object({
206
+ tabId: Type.String(),
207
+ ref: Type.Optional(Type.String()),
208
+ selector: Type.Optional(Type.String()),
209
+ button: Type.Optional(
210
+ Type.Union([
211
+ Type.Literal("left"),
212
+ Type.Literal("right"),
213
+ Type.Literal("middle"),
214
+ ]),
215
+ ),
216
+ }),
217
+ execute: async (params) => {
218
+ const api = await getBrowserApi();
219
+ const res = await api.click(params);
220
+ return { content: [{ type: "text", text: jsonResult(res) }] };
221
+ },
222
+ });
223
+
224
+ // ─── browser_type ─────────────────────────────────────────────────
225
+ pi.registerTool({
226
+ name: "browser_type",
227
+ description:
228
+ "Type text into an element. Pass `ref` or `selector` to target a specific input; otherwise types into the currently focused element. Set `pressEnter: true` to submit a form.",
229
+ parameters: Type.Object({
230
+ tabId: Type.String(),
231
+ text: Type.String(),
232
+ ref: Type.Optional(Type.String()),
233
+ selector: Type.Optional(Type.String()),
234
+ pressEnter: Type.Optional(Type.Boolean()),
235
+ delayMs: Type.Optional(Type.Number()),
236
+ }),
237
+ execute: async (params) => {
238
+ const api = await getBrowserApi();
239
+ const res = await api.type(params);
240
+ return { content: [{ type: "text", text: jsonResult(res) }] };
241
+ },
242
+ });
243
+
244
+ // ─── browser_scroll ───────────────────────────────────────────────
245
+ pi.registerTool({
246
+ name: "browser_scroll",
247
+ description:
248
+ "Scroll the page (or a specific element by `ref`) by `pixels` in the given direction.",
249
+ parameters: Type.Object({
250
+ tabId: Type.String(),
251
+ direction: Type.Union([
252
+ Type.Literal("up"),
253
+ Type.Literal("down"),
254
+ Type.Literal("left"),
255
+ Type.Literal("right"),
256
+ ]),
257
+ ref: Type.Optional(Type.String()),
258
+ pixels: Type.Optional(Type.Number()),
259
+ }),
260
+ execute: async (params) => {
261
+ const api = await getBrowserApi();
262
+ const res = await api.scroll(params);
263
+ return { content: [{ type: "text", text: jsonResult(res) }] };
264
+ },
265
+ });
266
+
267
+ // ─── browser_snapshot ─────────────────────────────────────────────
268
+ pi.registerTool({
269
+ name: "browser_snapshot",
270
+ description:
271
+ "Return the accessibility tree for the current tab. Each node carries a `ref` that can be passed back to browser_click / browser_type / browser_scroll. Cheaper than parsing HTML.",
272
+ parameters: Type.Object({
273
+ tabId: Type.String(),
274
+ }),
275
+ execute: async (params) => {
276
+ const api = await getBrowserApi();
277
+ const res = await api.snapshot(params);
278
+ return { content: [{ type: "text", text: jsonResult(res) }] };
279
+ },
280
+ });
281
+
282
+ // ─── browser_close_tab ────────────────────────────────────────────
283
+ pi.registerTool({
284
+ name: "browser_close_tab",
285
+ description: "Close a single tab. The browser process stays warm.",
286
+ parameters: Type.Object({
287
+ tabId: Type.String(),
288
+ }),
289
+ execute: async (params) => {
290
+ const api = await getBrowserApi();
291
+ const res = await api.closeTab(params);
292
+ return { content: [{ type: "text", text: jsonResult(res) }] };
293
+ },
294
+ });
295
+
296
+ // ─── browser_list_tabs ────────────────────────────────────────────
297
+ pi.registerTool({
298
+ name: "browser_list_tabs",
299
+ description: "List open tabs for the current user.",
300
+ parameters: Type.Object({
301
+ userId: Type.Optional(Type.String()),
302
+ }),
303
+ execute: async (params) => {
304
+ const api = await getBrowserApi();
305
+ const res = await api.listTabs(params);
306
+ return { content: [{ type: "text", text: jsonResult(res) }] };
307
+ },
308
+ });
309
+
310
+ // ─── Lifecycle: shut the Firefox process down on session shutdown ──
311
+ pi.on("session_shutdown", async () => {
312
+ if (!cachedApi) return;
313
+ try {
314
+ await cachedApi.closeAll();
315
+ } catch {
316
+ // best-effort
317
+ }
318
+ });
319
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@phi-code-admin/phi-code",
3
- "version": "0.75.7",
3
+ "version": "0.76.1",
4
4
  "description": "Coding agent CLI with persistent memory, sub-agents, intelligent routing, and orchestration",
5
5
  "type": "module",
6
6
  "piConfig": {
@@ -47,6 +47,7 @@
47
47
  },
48
48
  "dependencies": {
49
49
  "@mariozechner/jiti": "^2.6.5",
50
+ "@phi-code-admin/browser": "^1.0.1",
50
51
  "@silvia-odwyer/photon-node": "^0.3.4",
51
52
  "chalk": "^5.5.0",
52
53
  "cli-highlight": "^2.1.11",