pi-lean-dimension 0.5.0 → 0.6.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.
Files changed (104) hide show
  1. package/README.md +5 -3
  2. package/node_modules/pi-lean-host/AGENTS.md +13 -6
  3. package/node_modules/pi-lean-host/__tests__/api-learn-fetch-recipe.test.ts +282 -10
  4. package/node_modules/pi-lean-host/__tests__/api-learn-multi-file.test.ts +8 -16
  5. package/node_modules/pi-lean-host/__tests__/api-probe.test.ts +276 -279
  6. package/node_modules/pi-lean-host/__tests__/api-scaffold.test.ts +12 -33
  7. package/node_modules/pi-lean-host/__tests__/api-toggle.test.ts +14 -12
  8. package/node_modules/pi-lean-host/__tests__/bootstrap-command.test.ts +1 -7
  9. package/node_modules/pi-lean-host/__tests__/delete-command.test.ts +1 -12
  10. package/node_modules/pi-lean-host/__tests__/guide-catalog.test.ts +484 -0
  11. package/node_modules/pi-lean-host/__tests__/helpers.test.ts +0 -132
  12. package/node_modules/pi-lean-host/__tests__/oauth-command.test.ts +95 -0
  13. package/node_modules/pi-lean-host/__tests__/oauth-flow.test.ts +163 -0
  14. package/node_modules/pi-lean-host/__tests__/oauth-mint.test.ts +79 -6
  15. package/node_modules/pi-lean-host/__tests__/parse-api-guide.test.ts +4 -409
  16. package/node_modules/pi-lean-host/__tests__/response-spill.test.ts +0 -1
  17. package/node_modules/pi-lean-host/__tests__/secrets-command.test.ts +19 -35
  18. package/node_modules/pi-lean-host/__tests__/smoke.test.ts +1 -82
  19. package/node_modules/pi-lean-host/__tests__/ssrf-guard.test.ts +98 -0
  20. package/node_modules/pi-lean-host/__tests__/test-utils.ts +73 -0
  21. package/node_modules/pi-lean-host/__tests__/tools.test.ts +2 -518
  22. package/node_modules/pi-lean-host/__tests__/transport.test.ts +178 -31
  23. package/node_modules/pi-lean-host/__tests__/verify-command.test.ts +1 -8
  24. package/node_modules/pi-lean-host/__tests__/verify-stamp.test.ts +1 -4
  25. package/node_modules/pi-lean-host/api-guides/boe/local-helper.test.ts +9 -20
  26. package/node_modules/pi-lean-host/api-guides/dnb/error-envelope.test.ts +6 -18
  27. package/node_modules/pi-lean-host/api-guides/dnb/resumption-token.test.ts +6 -18
  28. package/node_modules/pi-lean-host/api-guides/frost-sensorthings/dotted-key.test.ts +6 -18
  29. package/node_modules/pi-lean-host/api-guides/github/static-key.test.ts +7 -25
  30. package/node_modules/pi-lean-host/api-guides/inaturalist/derived-id.test.ts +6 -18
  31. package/node_modules/pi-lean-host/api-guides/internet-archive/multi-recipe.test.ts +6 -26
  32. package/node_modules/pi-lean-host/api-guides/stripe/has-more.test.ts +6 -18
  33. package/node_modules/pi-lean-host/api-guides/telegram-bot/path-auth.test.ts +7 -25
  34. package/node_modules/pi-lean-host/api-guides/twitch/oauth2.test.ts +12 -47
  35. package/node_modules/pi-lean-host/api-guides/twitch-user/oauth-user.test.ts +11 -52
  36. package/node_modules/pi-lean-host/api-guides/usgs/transform.test.ts +10 -27
  37. package/node_modules/pi-lean-host/api-guides/wikidata-search/numeric-cursor.test.ts +6 -18
  38. package/node_modules/pi-lean-host/api-guides/wikimedia-action/token-bag.test.ts +9 -20
  39. package/node_modules/pi-lean-host/core/auth.ts +2 -1
  40. package/node_modules/pi-lean-host/core/helpers.ts +29 -35
  41. package/node_modules/pi-lean-host/core/oauth-command.ts +10 -8
  42. package/node_modules/pi-lean-host/core/oauth-flow.ts +20 -4
  43. package/node_modules/pi-lean-host/core/parse-api-guide.ts +3 -5
  44. package/node_modules/pi-lean-host/core/transport.ts +1 -1
  45. package/node_modules/pi-lean-host/package.json +1 -1
  46. package/node_modules/pi-lean-host/tools/api-guide.ts +18 -57
  47. package/node_modules/pi-lean-host/tools/api-learn.ts +20 -49
  48. package/node_modules/pi-lean-host/tools/api-probe.ts +17 -26
  49. package/node_modules/pi-lean-host/tools/api-scaffold.ts +20 -42
  50. package/node_modules/pi-lean-host/tools/utils.ts +69 -0
  51. package/node_modules/pi-lean-portal/AGENTS.md +4 -3
  52. package/node_modules/pi-lean-portal/README.md +9 -9
  53. package/node_modules/pi-lean-portal/__tests__/accessibility-tree.test.ts +0 -15
  54. package/node_modules/pi-lean-portal/__tests__/browser-install.test.ts +737 -0
  55. package/node_modules/pi-lean-portal/__tests__/browser-navigate.test.ts +33 -2
  56. package/node_modules/pi-lean-portal/__tests__/browser-status.test.ts +48 -25
  57. package/node_modules/pi-lean-portal/__tests__/browser-toggle-profile.test.ts +3 -9
  58. package/node_modules/pi-lean-portal/__tests__/browser-toggle.test.ts +3 -29
  59. package/node_modules/pi-lean-portal/__tests__/fetch-backend.test.ts +51 -0
  60. package/node_modules/pi-lean-portal/__tests__/helpers/__pycache__/mock-python-bridge.cpython-313-pytest-9.1.1.pyc +0 -0
  61. package/node_modules/pi-lean-portal/__tests__/helpers/__pycache__/mock-python-bridge.cpython-313.pyc +0 -0
  62. package/node_modules/pi-lean-portal/__tests__/helpers/mock-pi.ts +34 -0
  63. package/node_modules/pi-lean-portal/__tests__/helpers/mock-python-bridge.py +14 -24
  64. package/node_modules/pi-lean-portal/__tests__/plugin-registry.test.ts +0 -90
  65. package/node_modules/pi-lean-portal/__tests__/python-adapter.test.ts +32 -125
  66. package/node_modules/pi-lean-portal/__tests__/router-dispatch.test.ts +12 -170
  67. package/node_modules/pi-lean-portal/__tests__/router-session.test.ts +39 -151
  68. package/node_modules/pi-lean-portal/__tests__/web-guides.test.ts +1 -44
  69. package/node_modules/pi-lean-portal/backends/chromium/index.ts +1 -1
  70. package/node_modules/pi-lean-portal/backends/firefox/index.ts +1 -1
  71. package/node_modules/pi-lean-portal/backends/playwright-base/playwright-plugin.ts +1 -1
  72. package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/__pycache__/playwright_base.cpython-312.pyc +0 -0
  73. package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/__pycache__/playwright_base.cpython-313.pyc +0 -0
  74. package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/__pycache__/transport.cpython-312.pyc +0 -0
  75. package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/__pycache__/transport.cpython-313.pyc +0 -0
  76. package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/playwright_base.py +43 -57
  77. package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/transport.py +6 -21
  78. package/node_modules/pi-lean-portal/backends/python-base/tests/__pycache__/test_accessibility.cpython-313-pytest-9.1.1.pyc +0 -0
  79. package/node_modules/pi-lean-portal/backends/python-base/tests/__pycache__/test_playwright_base_quirks.cpython-313-pytest-9.1.1.pyc +0 -0
  80. package/node_modules/pi-lean-portal/backends/python-base/tests/__pycache__/test_playwright_base_quirks.cpython-313.pyc +0 -0
  81. package/node_modules/pi-lean-portal/backends/python-base/tests/__pycache__/test_transport.cpython-313-pytest-9.1.1.pyc +0 -0
  82. package/node_modules/pi-lean-portal/backends/python-base/tests/test_playwright_base_quirks.py +157 -73
  83. package/node_modules/pi-lean-portal/backends/python-base/tests/test_transport.py +15 -41
  84. package/node_modules/pi-lean-portal/browser-install.ts +454 -0
  85. package/node_modules/pi-lean-portal/browser-status.ts +21 -0
  86. package/node_modules/pi-lean-portal/browser-toggle.ts +13 -11
  87. package/node_modules/pi-lean-portal/core/fetch-backend.ts +2 -2
  88. package/node_modules/pi-lean-portal/core/router.ts +7 -32
  89. package/node_modules/pi-lean-portal/core/shared/session-manager.ts +1 -3
  90. package/node_modules/pi-lean-portal/index.ts +1 -18
  91. package/node_modules/pi-lean-portal/package.json +2 -1
  92. package/node_modules/pi-lean-portal/tools/browser-navigate.ts +6 -2
  93. package/node_modules/pi-lean-portal/tools/utils.ts +5 -6
  94. package/node_modules/pi-lean-search/AGENTS.md +5 -3
  95. package/node_modules/pi-lean-search/__tests__/web-search.test.ts +180 -127
  96. package/node_modules/pi-lean-search/index.ts +0 -3
  97. package/node_modules/pi-lean-search/package.json +1 -1
  98. package/node_modules/pi-lean-search/web-search-tool.ts +22 -13
  99. package/node_modules/yaml/browser/dist/compose/resolve-flow-scalar.js +19 -18
  100. package/node_modules/yaml/browser/dist/nodes/Alias.js +25 -23
  101. package/node_modules/yaml/dist/compose/resolve-flow-scalar.js +19 -18
  102. package/node_modules/yaml/dist/nodes/Alias.js +25 -23
  103. package/node_modules/yaml/package.json +1 -1
  104. package/package.json +4 -4
@@ -15,7 +15,7 @@ import type { PythonBridgeConfig } from "./backends/python-adapter.js";
15
15
  import { sessionManager } from "./core/shared/session-manager.js";
16
16
  import { removeAllSnapshotFiles } from "./core/shared/snapshot-cache.js";
17
17
  import initBrowserToggle from "./browser-toggle.js";
18
- import { updateFooterStatus, getLastCtx, setLastCtx } from "./tools/utils.js";
18
+ import { updateFooterStatus, setLastCtx } from "./tools/utils.js";
19
19
  import { deleteSessionKey, resetTaskIds } from "./core/shared/task-id.js";
20
20
  import { resetToggleModuleState } from "./browser-toggle.js";
21
21
  import { registerGuideProvider } from "./core/guides.js";
@@ -214,23 +214,6 @@ export default function (pi: ExtensionAPI) {
214
214
  // --- Register commands ------------------------------------------
215
215
  initBrowserToggle(pi);
216
216
 
217
- // --- Profile change callback for TUI status updates ------------
218
- router.setOnProfileChange((taskId, profileName, profileMode) => {
219
- // Update TUI status bar on any profile lifecycle event
220
- const lastCtx = getLastCtx();
221
- if (lastCtx) {
222
- updateFooterStatus(lastCtx);
223
- }
224
-
225
- // Debug logging when BROWSER_DEBUG is set
226
- if (process.env.BROWSER_DEBUG) {
227
- const parts = [`[browser] profile_changed: task=${taskId}`];
228
- if (profileName) parts.push(`profile=${profileName}`);
229
- if (profileMode) parts.push(`mode=${profileMode}`);
230
- console.error(parts.join(" "));
231
- }
232
- });
233
-
234
217
  // --- Startup ----------------------------------------------------
235
218
  pi.on("session_start", async (_event, ctx) => {
236
219
  const pluginNames = pluginRegistry.available().join(", ");
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-lean-portal",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "description": "Interactive Playwright web browsing for Pi. Chromium and Firefox built in; author stealth backends on either engine. Snapshots and screenshots cache to disk, saving context. Configurable profiles/cookies. Agents write site guides, auto-matched by domain.",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -32,6 +32,7 @@
32
32
  "browser-profile.ts",
33
33
  "browser-cookies.ts",
34
34
  "browser-status.ts",
35
+ "browser-install.ts",
35
36
  "LICENSE",
36
37
  "backends/",
37
38
  "!backends/python-base/.venv/",
@@ -137,9 +137,13 @@ export const browserNavigateTool = defineTool({
137
137
  // If the browser executable is not installed, notify the user with install instructions.
138
138
  const isInstallError =
139
139
  result.error && result.error.toLowerCase().includes("not installed");
140
- if (isInstallError) {
140
+ // Python backends (chromium-py, firefox-py, stealth -py backends) report a
141
+ // missing venv Playwright wheel — their error already carries the accurate
142
+ // pip install hint, and /web install cannot fix that, so stay quiet.
143
+ const isPythonBackend = result.backendUsed?.endsWith("-py") ?? false;
144
+ if (isInstallError && !isPythonBackend) {
141
145
  ctx?.ui?.notify(
142
- "Browser not installed. Run: npx playwright install chromium firefox",
146
+ "Browser not installed. Run /web install to install it.",
143
147
  "warning",
144
148
  );
145
149
  }
@@ -29,10 +29,9 @@ export function contentText(
29
29
  // ─── Status bar ─────────────────────────────────────────────────
30
30
 
31
31
  /**
32
- * Retained reference to the most recent extension context for use by
33
- * the browser.profile event listener (which fires asynchronously from
34
- * tool execution contexts). Updated on session_start and cleared on
35
- * session_shutdown.
32
+ * Retained reference to the most recent extension context, so event
33
+ * handlers that fire outside a tool/ctx scope (e.g. browser-toggle's
34
+ * toolset changed/restored listeners) can still repaint the status bar.
36
35
  */
37
36
  let _lastCtx: {
38
37
  ui: {
@@ -62,12 +61,12 @@ export function updateFooterStatus(ctx: {
62
61
  ctx.ui.setStatus("browser", `${dot} ${body}`);
63
62
  }
64
63
 
65
- /** @internal — used by index.ts's profile event listener */
64
+ /** @internal — read by browser-toggle's event-driven status repaint */
66
65
  export function getLastCtx(): typeof _lastCtx {
67
66
  return _lastCtx;
68
67
  }
69
68
 
70
- /** @internal — set by index.ts on startup/shutdown */
69
+ /** @internal — updated on session_start/session_tree, cleared on shutdown */
71
70
  export function setLastCtx(ctx: typeof _lastCtx): void {
72
71
  _lastCtx = ctx;
73
72
  }
@@ -47,7 +47,7 @@ Search owns the `search` status bar slot, shown only when `pi-lean-search` is in
47
47
  - `○ searxng` — search tools off
48
48
  - *(no slot)* — unconfigured: no `searxng.url` in settings; a one-time warning notify on Pi process boot (`session_start` `reason: "startup"` only, not `/new`/`/resume`/`/fork`) points at the setting
49
49
 
50
- Search probes SearXNG reachability on `session_start` and `/searxng-status` and sets the glyph color. Portal writes the `○ searxng` off state when `/web off` is called — search overrides with the health-colored glyph on the next probe. (The `browser` slot is owned by `pi-lean-portal`; see that package's `AGENTS.md`.)
50
+ Search probes SearXNG reachability on `session_start` and `/searxng-status` and sets the glyph color. Search itself writes the `○ searxng` off state: its `/web off` co-activation mirror (see "Peer relationship") disables the `pi-lean-dimension.search` toolset, and the resulting `changed` event re-renders the glyph; the health-colored glyph returns on the next probe. (The `browser` slot is owned by `pi-lean-portal`; see that package's `AGENTS.md`.)
51
51
 
52
52
  ## Graceful degradation
53
53
 
@@ -64,5 +64,7 @@ notify at Pi startup (boot only, not `/new`/`/resume`/`/fork`) points at the
64
64
  `pi-lean-search` declares `pi-lean-portal` as a **soft peer**
65
65
  (`peerDependencies` + `peerDependenciesMeta.optional: true`). Search-only
66
66
  installs are valid — the tool works standalone, it just doesn't get a `/web`
67
- toggle. Portal lists `"web-search"` in its `SIBLING_TOOL_NAMES` set so
68
- `/web on|off` picks it up automatically when both are installed.
67
+ toggle. Co-activation is search-owned: search listens on `pi-tool-masking`'s
68
+ `TOOLSET_EVENTS.changed` and mirrors `pi-lean-dimension.web`, so `/web on|off`
69
+ enables/disables search's own toolset when both are installed (portal has no
70
+ reference to `web-search`).
@@ -23,7 +23,6 @@ function mockSearchPi(initialActive?: string[]) {
23
23
  let active = initialActive ?? ["web-search"];
24
24
  const eventEmitter = new EventEmitter();
25
25
  const handlers = new Map<string, Array<(...args: any[]) => void>>();
26
- const entryCalls: Array<{ customType: string; data: unknown }> = [];
27
26
 
28
27
  // A minimal mock of the ExtensionAPI. We intentionally don't mock
29
28
  // _everything_ — only the methods the extension code actually calls.
@@ -33,9 +32,7 @@ function mockSearchPi(initialActive?: string[]) {
33
32
  setActiveTools: vi.fn((names: string[]) => {
34
33
  active = [...names];
35
34
  }),
36
- appendEntry: vi.fn((customType: string, data?: unknown) => {
37
- entryCalls.push({ customType, data });
38
- }),
35
+ appendEntry: vi.fn(),
39
36
  registerCommand: vi.fn(),
40
37
  registerTool: vi.fn(),
41
38
  on: vi.fn(<T>(event: string, handler: (event: T, ctx: any) => void) => {
@@ -54,7 +51,7 @@ function mockSearchPi(initialActive?: string[]) {
54
51
  },
55
52
  } as unknown as ExtensionAPI;
56
53
 
57
- return { pi, events: eventEmitter, handlers, entryCalls };
54
+ return { pi, events: eventEmitter, handlers };
58
55
  }
59
56
 
60
57
  // ─── Config reader (mocked fs) ──────────────────────────────────
@@ -109,18 +106,6 @@ describe("readSearxngUrl", () => {
109
106
  expect(readSearxngUrl()).toBeUndefined();
110
107
  });
111
108
 
112
- it("returns the URL when searxng.url is set (global)", () => {
113
- vi.mocked(readFileSync).mockImplementation((path: unknown) => {
114
- if (typeof path === "string" && path.includes(".pi/agent/settings")) {
115
- return JSON.stringify({
116
- searxng: { url: "http://localhost:8888" },
117
- });
118
- }
119
- return JSON.stringify({});
120
- });
121
- expect(readSearxngUrl()).toBe("http://localhost:8888");
122
- });
123
-
124
109
  it("project settings override global settings", () => {
125
110
  vi.mocked(readFileSync).mockImplementation((path: unknown) => {
126
111
  if (typeof path === "string" && path.includes(".pi/agent/settings")) {
@@ -158,8 +143,8 @@ describe("readSearxngUrl", () => {
158
143
  // ─── Tool definition structural checks ──────────────────────────
159
144
 
160
145
  describe("webSearchTool", () => {
161
- // The exact name is a cross-package contract: portal's SIBLING_TOOL_NAMES and
162
- // SEARCH_WEB_SPEC.names use Set.has() on it for the /web on|off toggle.
146
+ // The exact name is load-bearing: SEARCH_WEB_SPEC.names uses Set.has()
147
+ // membership to mask/restore it with the /web co-activation toggle.
163
148
  it("has the correct name", () => {
164
149
  expect(webSearchTool.name).toBe("web-search");
165
150
  });
@@ -172,46 +157,33 @@ describe("webSearchTool", () => {
172
157
  });
173
158
 
174
159
  describe("renderResult answer badge", () => {
175
- it("shows 💡 badge when answerCount > 0 and no results", () => {
176
- const mockTheme = {
177
- fg: (_style: string, text: string) => text,
178
- } as any;
179
- const result = (webSearchTool.renderResult as any)(
180
- {
181
- content: [{ text: "" }],
182
- details: { answerCount: 2, query: "weather" },
183
- } as any,
184
- { expanded: false, isPartial: false },
185
- mockTheme,
160
+ const theme = { fg: (_style: string, text: string) => text } as any;
161
+ const render = (details: unknown, expanded = false) =>
162
+ (webSearchTool.renderResult as any)(
163
+ { content: [{ text: "" }], details },
164
+ { expanded, isPartial: false },
165
+ theme,
186
166
  {} as any,
187
- ) as any;
167
+ ) as { text: string };
168
+
169
+ it("shows 💡 badge when answerCount > 0 and no results", () => {
170
+ const result = render({ answerCount: 2, query: "weather" });
188
171
  expect(result.text).toContain("💡");
189
172
  expect(result.text).toContain("2 answer(s)");
190
173
  expect(result.text).toContain('"weather"');
191
174
  });
192
175
 
193
176
  it("shows 💡 badge alongside 🔍 when both answers and results present", () => {
194
- const mockTheme = {
195
- fg: (_style: string, text: string) => text,
196
- } as any;
197
- const result = (webSearchTool.renderResult as any)(
198
- {
199
- content: [{ text: "" }],
200
- details: {
201
- answerCount: 1,
202
- resultCount: 3,
203
- query: "test",
204
- results: [
205
- { title: "A", url: "http://a.com", engine: "google" },
206
- { title: "B", url: "http://b.com", engine: "google" },
207
- { title: "C", url: "http://c.com", engine: "google" },
208
- ],
209
- },
210
- } as any,
211
- { expanded: false, isPartial: false },
212
- mockTheme,
213
- {} as any,
214
- ) as any;
177
+ const result = render({
178
+ answerCount: 1,
179
+ resultCount: 3,
180
+ query: "test",
181
+ results: [
182
+ { title: "A", url: "http://a.com", engine: "google" },
183
+ { title: "B", url: "http://b.com", engine: "google" },
184
+ { title: "C", url: "http://c.com", engine: "google" },
185
+ ],
186
+ });
215
187
  expect(result.text).toContain("💡");
216
188
  expect(result.text).toContain("1 answer(s)");
217
189
  expect(result.text).toContain("🔍");
@@ -219,29 +191,21 @@ describe("webSearchTool", () => {
219
191
  });
220
192
 
221
193
  it("shows answer text above results when expanded", () => {
222
- const mockTheme = {
223
- fg: (_style: string, text: string) => text,
224
- } as any;
225
- const result = (webSearchTool.renderResult as any)(
194
+ const result = render(
226
195
  {
227
- content: [{ text: "" }],
228
- details: {
229
- answerCount: 1,
230
- resultCount: 1,
231
- query: "weather berlin",
232
- answers: [
233
- {
234
- template: "answer/weather.html",
235
- text: "Berlin: 14°C, light rain",
236
- },
237
- ],
238
- results: [{ title: "A", url: "http://a.com" }],
239
- },
240
- } as any,
241
- { expanded: true, isPartial: false },
242
- mockTheme,
243
- {} as any,
244
- ) as any;
196
+ answerCount: 1,
197
+ resultCount: 1,
198
+ query: "weather berlin",
199
+ answers: [
200
+ {
201
+ template: "answer/weather.html",
202
+ text: "Berlin: 14°C, light rain",
203
+ },
204
+ ],
205
+ results: [{ title: "A", url: "http://a.com" }],
206
+ },
207
+ true,
208
+ );
245
209
  const answerIdx = result.text.indexOf("Berlin: 14°C, light rain");
246
210
  const resultIdx = result.text.indexOf("A");
247
211
  expect(answerIdx).toBeGreaterThan(-1);
@@ -250,22 +214,14 @@ describe("webSearchTool", () => {
250
214
  });
251
215
 
252
216
  it("shows answer text when expanded with no results", () => {
253
- const mockTheme = {
254
- fg: (_style: string, text: string) => text,
255
- } as any;
256
- const result = (webSearchTool.renderResult as any)(
217
+ const result = render(
257
218
  {
258
- content: [{ text: "" }],
259
- details: {
260
- answerCount: 1,
261
- query: "avg 1 2 3",
262
- answers: [{ template: "answer/legacy.html", text: "2" }],
263
- },
264
- } as any,
265
- { expanded: true, isPartial: false },
266
- mockTheme,
267
- {} as any,
268
- ) as any;
219
+ answerCount: 1,
220
+ query: "avg 1 2 3",
221
+ answers: [{ template: "answer/legacy.html", text: "2" }],
222
+ },
223
+ true,
224
+ );
269
225
  expect(result.text).toContain("💡");
270
226
  expect(result.text).toContain("2");
271
227
  });
@@ -309,11 +265,6 @@ describe("buildSearchUrl", () => {
309
265
  expect(second).toContain("pageno=2");
310
266
  });
311
267
 
312
- it("omits empty-string optional params", () => {
313
- const url = buildSearchUrl("http://localhost:8888", "test", baseOptions);
314
- expect(url).toBe("http://localhost:8888/search?format=json&q=test");
315
- });
316
-
317
268
  it("passes through optional params when set", () => {
318
269
  const url = buildSearchUrl("http://localhost:8888", "test", {
319
270
  ...baseOptions,
@@ -357,6 +308,60 @@ describe("execute answer rendering", () => {
357
308
  }),
358
309
  );
359
310
 
311
+ // Headers arrive at once; the body read only settles on abort.
312
+ const mockStallingFetch = () =>
313
+ vi.stubGlobal(
314
+ "fetch",
315
+ vi.fn().mockImplementation((_url: string, init: { signal?: AbortSignal }) =>
316
+ Promise.resolve({
317
+ ok: true,
318
+ status: 200,
319
+ text: () =>
320
+ new Promise((_resolve, reject) => {
321
+ init.signal?.addEventListener(
322
+ "abort",
323
+ () =>
324
+ reject(new DOMException("The operation was aborted", "AbortError")),
325
+ { once: true },
326
+ );
327
+ }),
328
+ }),
329
+ ),
330
+ );
331
+
332
+ const runSearch = (params: Record<string, unknown>) =>
333
+ webSearchTool.execute("call", params as any, undefined, undefined, {} as any);
334
+
335
+ it("degrades gracefully when searxng.url is unconfigured", async () => {
336
+ vi.mocked(readFileSync).mockReturnValue(JSON.stringify({ theme: "dark" }));
337
+ const fetchMock = vi.fn();
338
+ vi.stubGlobal("fetch", fetchMock);
339
+
340
+ const r = (await runSearch({ query: "anything" })) as any;
341
+ expect(r.details.unconfigured).toBe(true);
342
+ expect(r.content[0].text).toContain("Web search is not configured");
343
+ expect(fetchMock).not.toHaveBeenCalled();
344
+ });
345
+
346
+ it("times out when the body stalls after the headers arrive", async () => {
347
+ vi.useFakeTimers();
348
+ try {
349
+ mockStallingFetch();
350
+
351
+ const pending = runSearch({ query: "stalled", timeout: 1 });
352
+
353
+ // Fire the abort timer deterministically instead of waiting on it.
354
+ await vi.advanceTimersByTimeAsync(1000);
355
+ const result = await pending;
356
+
357
+ const r = result as any;
358
+ expect(r.details.timedOut).toBe(true);
359
+ expect(r.content[0].text).toContain("timed out after 1s");
360
+ } finally {
361
+ vi.useRealTimers();
362
+ }
363
+ });
364
+
360
365
  it("legacy answer renders + bypasses empty-results early return", async () => {
361
366
  mockFetch({
362
367
  results: [],
@@ -364,13 +369,7 @@ describe("execute answer rendering", () => {
364
369
  suggestions: [],
365
370
  });
366
371
 
367
- const result = await webSearchTool.execute(
368
- "call-1",
369
- { query: "614.24" },
370
- undefined,
371
- undefined,
372
- {} as any,
373
- );
372
+ const result = await runSearch({ query: "614.24" });
374
373
  const r = result as any;
375
374
  expect(r.content[0].text).toContain("614.24");
376
375
  expect(r.content[0].text).not.toContain("No web search results found");
@@ -399,13 +398,7 @@ describe("execute answer rendering", () => {
399
398
  suggestions: [],
400
399
  });
401
400
 
402
- const result = await webSearchTool.execute(
403
- "call-2",
404
- { query: "weather berlin" },
405
- undefined,
406
- undefined,
407
- {} as any,
408
- );
401
+ const result = await runSearch({ query: "weather berlin" });
409
402
  // Uses server-provided summary
410
403
  const r = result as any;
411
404
  expect(r.content[0].text).toContain("Berlin: 14°C, light rain");
@@ -413,8 +406,6 @@ describe("execute answer rendering", () => {
413
406
  expect(r.content[0].text).toContain("feels like 12°C");
414
407
  expect(r.content[0].text).toContain("humidity 80%");
415
408
  expect(r.content[0].text).toContain("wind 18km/h");
416
- // forecasts not rendered
417
- expect(r.content[0].text).not.toContain("forecast");
418
409
  // source
419
410
  expect(r.content[0].text).toContain("open-meteo");
420
411
  });
@@ -426,13 +417,7 @@ describe("execute answer rendering", () => {
426
417
  suggestions: [],
427
418
  });
428
419
 
429
- const result = await webSearchTool.execute(
430
- "call-3",
431
- { query: "future" },
432
- undefined,
433
- undefined,
434
- {} as any,
435
- );
420
+ const result = await runSearch({ query: "future" });
436
421
  const r = result as any;
437
422
  expect(r.content[0].text).toContain("x");
438
423
  });
@@ -452,22 +437,13 @@ describe("execute answer rendering", () => {
452
437
  suggestions: [],
453
438
  });
454
439
 
455
- const result = await webSearchTool.execute(
456
- "call-5",
457
- { query: "answer of life" },
458
- undefined,
459
- undefined,
460
- {} as any,
461
- );
440
+ const result = await runSearch({ query: "answer of life" });
462
441
  // Answer above results
463
442
  const r = result as any;
464
443
  const answerIdx = r.content[0].text.indexOf("42");
465
444
  const resultIdx = r.content[0].text.indexOf("Result 1");
466
445
  expect(answerIdx).toBeGreaterThanOrEqual(0);
467
446
  expect(resultIdx).toBeGreaterThan(answerIdx);
468
- // Both present
469
- expect(r.content[0].text).toContain("42");
470
- expect(r.content[0].text).toContain("Result 1");
471
447
  });
472
448
  });
473
449
 
@@ -530,3 +506,80 @@ describe("pi-lean-dimension.web co-activation mirror", () => {
530
506
  expect(pi.setActiveTools).not.toHaveBeenCalled();
531
507
  });
532
508
  });
509
+
510
+ // ─── session_start: health probe + status glyph ──────────────
511
+
512
+ describe("session_start glyph", () => {
513
+ function mockCtx() {
514
+ const setStatus = vi.fn();
515
+ const notify = vi.fn();
516
+ const ctx = {
517
+ ui: {
518
+ setStatus,
519
+ notify,
520
+ // Style-tagged passthrough so glyph color states are assertable.
521
+ theme: { fg: (c: string, t: string) => `${c}:${t}` },
522
+ },
523
+ };
524
+ return { ctx: ctx as any, setStatus, notify };
525
+ }
526
+
527
+ async function fireSessionStart(reason: string) {
528
+ const { pi, handlers } = mockSearchPi();
529
+ searchExtension(pi);
530
+ const { ctx, setStatus, notify } = mockCtx();
531
+ // index.ts registers its handler after defineToolset's, so it's last.
532
+ await handlers.get("session_start")!.at(-1)!({ reason }, ctx);
533
+ return { setStatus, notify };
534
+ }
535
+
536
+ afterEach(() => {
537
+ vi.unstubAllGlobals();
538
+ });
539
+
540
+ it("unconfigured at startup: clears slot and warns once", async () => {
541
+ vi.mocked(readFileSync).mockReturnValue(JSON.stringify({ theme: "dark" }));
542
+
543
+ const { setStatus, notify } = await fireSessionStart("startup");
544
+
545
+ expect(setStatus).toHaveBeenCalledWith("search", "");
546
+ expect(notify).toHaveBeenCalledTimes(1);
547
+ expect(notify).toHaveBeenCalledWith(
548
+ expect.stringContaining("not configured"),
549
+ "warning",
550
+ );
551
+ });
552
+
553
+ const configuredSettings = () =>
554
+ vi
555
+ .mocked(readFileSync)
556
+ .mockReturnValue(
557
+ JSON.stringify({ searxng: { url: "http://localhost:8888" } }),
558
+ );
559
+
560
+ it("reachable at startup: healthy accent glyph + info notify", async () => {
561
+ configuredSettings();
562
+ vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ ok: true, status: 200 }));
563
+
564
+ const { setStatus, notify } = await fireSessionStart("startup");
565
+
566
+ expect(setStatus).toHaveBeenCalledWith("search", "accent:● searxng");
567
+ expect(notify).toHaveBeenCalledWith(
568
+ expect.stringContaining("available"),
569
+ "info",
570
+ );
571
+ });
572
+
573
+ it("unreachable at startup: error glyph + warning notify", async () => {
574
+ configuredSettings();
575
+ vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new Error("ECONNREFUSED")));
576
+
577
+ const { setStatus, notify } = await fireSessionStart("startup");
578
+
579
+ expect(setStatus).toHaveBeenCalledWith("search", "error:● searxng");
580
+ expect(notify).toHaveBeenCalledWith(
581
+ expect.stringContaining("unreachable"),
582
+ "warning",
583
+ );
584
+ });
585
+ });
@@ -177,9 +177,6 @@ export { _resetStateForTest };
177
177
  // ─── Extension entry point ───────────────────────────────────────
178
178
 
179
179
  export default function (pi: ExtensionAPI) {
180
- // Read config at startup
181
- _searxngUrl = readSearxngUrl();
182
-
183
180
  // ── Register the web-search tool ─────────────────────────
184
181
  pi.registerTool(webSearchTool);
185
182
 
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-lean-search",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "description": "SearXNG web search for Pi. Self-hosted or public instance, no API keys or quotas; returns a setup message instead of erroring when unreachable. Pairs with pi-lean-portal's /web toggle or works standalone.",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -393,6 +393,19 @@ export const webSearchTool = defineTool({
393
393
  controller.abort();
394
394
  }, timeoutSeconds * 1000);
395
395
 
396
+ // Abort can surface in either fetch or the body read; label it correctly.
397
+ const abortFail = () => {
398
+ if (timedOut) {
399
+ return fail(
400
+ `Web search timed out after ${timeoutSeconds}s. ` +
401
+ `The SearXNG instance at \`${searxngUrl}\` may be slow ` +
402
+ "or unresponsive.",
403
+ { timedOut: true, timeout: timeoutSeconds },
404
+ );
405
+ }
406
+ return fail("Web search was cancelled.", { cancelled: true });
407
+ };
408
+
396
409
  try {
397
410
  // ── Layer 1: Connection-level error handling ──
398
411
  let response: Response;
@@ -402,20 +415,11 @@ export const webSearchTool = defineTool({
402
415
  headers: { Accept: "application/json" },
403
416
  });
404
417
  } catch (connectionErr) {
405
- clearTimeout(timeoutId);
406
418
  if (
407
419
  connectionErr instanceof DOMException &&
408
420
  connectionErr.name === "AbortError"
409
421
  ) {
410
- if (timedOut) {
411
- return fail(
412
- `Web search timed out after ${timeoutSeconds}s. ` +
413
- `The SearXNG instance at \`${searxngUrl}\` may be slow ` +
414
- "or unresponsive.",
415
- { timedOut: true, timeout: timeoutSeconds },
416
- );
417
- }
418
- return fail("Web search was cancelled.", { cancelled: true });
422
+ return abortFail();
419
423
  }
420
424
  return fail(
421
425
  "Web search connection failed: " +
@@ -426,8 +430,6 @@ export const webSearchTool = defineTool({
426
430
  );
427
431
  }
428
432
 
429
- clearTimeout(timeoutId);
430
-
431
433
  // ── Layer 2: HTTP error handling ──
432
434
  if (!response.ok) {
433
435
  return fail(
@@ -444,6 +446,10 @@ export const webSearchTool = defineTool({
444
446
  ? (JSON.parse(text) as SearXNGResponse)
445
447
  : { results: [], answers: [], suggestions: [] };
446
448
  } catch (parseErr) {
449
+ // Abort can surface here if the body read stalls past the timeout.
450
+ if (parseErr instanceof DOMException && parseErr.name === "AbortError") {
451
+ return abortFail();
452
+ }
447
453
  return fail(
448
454
  "Web search returned unexpected response format. " +
449
455
  "SearXNG may be misconfigured. Error: " +
@@ -545,7 +551,6 @@ export const webSearchTool = defineTool({
545
551
  },
546
552
  };
547
553
  } catch (unexpectedErr) {
548
- clearTimeout(timeoutId);
549
554
  return fail(
550
555
  "An unexpected error occurred during web search: " +
551
556
  (unexpectedErr instanceof Error
@@ -553,6 +558,10 @@ export const webSearchTool = defineTool({
553
558
  : String(unexpectedErr)),
554
559
  { unexpectedError: true },
555
560
  );
561
+ } finally {
562
+ // Timer stays armed through the body read, so `timeout` bounds the
563
+ // whole request, not just the response headers.
564
+ clearTimeout(timeoutId);
556
565
  }
557
566
  },
558
567