@oh-my-pi/pi-coding-agent 14.4.3 → 14.4.4

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.
@@ -348,6 +348,7 @@ const OPTION_PROVIDERS: Partial<Record<SettingPath, OptionProvider>> = {
348
348
  { value: "kagi", label: "Kagi", description: "Requires KAGI_API_KEY and Kagi Search API beta access" },
349
349
  { value: "synthetic", label: "Synthetic", description: "Requires SYNTHETIC_API_KEY" },
350
350
  { value: "parallel", label: "Parallel", description: "Requires PARALLEL_API_KEY" },
351
+ { value: "searxng", label: "SearXNG", description: "Self-hosted metasearch; set searxng.endpoint" },
351
352
  ],
352
353
  "providers.image": [
353
354
  {
@@ -28,6 +28,7 @@ import { buildHotkeysMarkdown } from "../../modes/utils/hotkeys-markdown";
28
28
  import { buildToolsMarkdown } from "../../modes/utils/tools-markdown";
29
29
  import type { AsyncJobSnapshotItem } from "../../session/agent-session";
30
30
  import type { AuthStorage } from "../../session/auth-storage";
31
+ import type { NewSessionOptions } from "../../session/session-manager";
31
32
  import { outputMeta } from "../../tools/output-meta";
32
33
  import { resolveToCwd, stripOuterDoubleQuotes } from "../../tools/path-utils";
33
34
  import { replaceTabs } from "../../tools/render-utils";
@@ -573,7 +574,7 @@ export class CommandController {
573
574
  this.ctx.showError("Usage: /memory <view|clear|reset|enqueue|rebuild>");
574
575
  }
575
576
 
576
- async handleClearCommand(): Promise<void> {
577
+ async #runNewSessionFlow(options?: NewSessionOptions, label: string = "New session started"): Promise<void> {
577
578
  if (this.ctx.loadingAnimation) {
578
579
  this.ctx.loadingAnimation.stop();
579
580
  this.ctx.loadingAnimation = undefined;
@@ -586,7 +587,7 @@ export class CommandController {
586
587
  await Bun.sleep(10);
587
588
  }
588
589
  }
589
- await this.ctx.session.newSession();
590
+ if (!(await this.ctx.session.newSession(options))) return;
590
591
  this.ctx.resetObserverRegistry();
591
592
  setSessionTerminalTitle(
592
593
  this.ctx.sessionManager.getSessionName(),
@@ -608,13 +609,23 @@ export class CommandController {
608
609
  this.ctx.pendingTools.clear();
609
610
 
610
611
  this.ctx.chatContainer.addChild(new Spacer(1));
611
- this.ctx.chatContainer.addChild(
612
- new Text(`${theme.fg("accent", `${theme.status.success} New session started`)}`, 1, 1),
613
- );
612
+ this.ctx.chatContainer.addChild(new Text(`${theme.fg("accent", `${theme.status.success} ${label}`)}`, 1, 1));
614
613
  await this.ctx.reloadTodos();
615
614
  this.ctx.ui.requestRender();
616
615
  }
617
616
 
617
+ async handleClearCommand(): Promise<void> {
618
+ await this.#runNewSessionFlow();
619
+ }
620
+
621
+ async handleDropCommand(): Promise<void> {
622
+ if (!this.ctx.sessionManager.getSessionFile()) {
623
+ this.ctx.showError("Nothing to drop (in-memory session)");
624
+ return;
625
+ }
626
+ await this.#runNewSessionFlow({ drop: true }, "Session dropped");
627
+ }
628
+
618
629
  async handleForkCommand(): Promise<void> {
619
630
  if (this.ctx.session.isStreaming) {
620
631
  this.ctx.showWarning("Wait for the current response to finish or abort it before forking.");
@@ -1,14 +1,17 @@
1
+ import * as os from "node:os";
2
+ import * as path from "node:path";
1
3
  import { ThinkingLevel } from "@oh-my-pi/pi-agent-core";
2
4
  import { getOAuthProviders, type OAuthProvider } from "@oh-my-pi/pi-ai";
3
- import type { Component } from "@oh-my-pi/pi-tui";
5
+ import type { Component, OverlayHandle } from "@oh-my-pi/pi-tui";
4
6
  import { Input, Loader, Spacer, Text } from "@oh-my-pi/pi-tui";
5
- import { getAgentDbPath, getProjectDir } from "@oh-my-pi/pi-utils";
7
+ import { getAgentDbPath, getConfigDirName, getProjectDir } from "@oh-my-pi/pi-utils";
8
+ import { invalidate as invalidateFsCache } from "../../capability/fs";
6
9
  import { getRoleInfo } from "../../config/model-registry";
7
10
  import { formatModelSelectorValue } from "../../config/model-resolver";
8
11
  import { settings } from "../../config/settings";
9
12
  import { DebugSelectorComponent } from "../../debug";
10
13
  import { disableProvider, enableProvider } from "../../discovery";
11
- import { clearPluginRootsAndCaches, resolveActiveProjectRegistryPath } from "../../discovery/helpers";
14
+ import { clearClaudePluginRootsCache, resolveActiveProjectRegistryPath } from "../../discovery/helpers";
12
15
  import {
13
16
  getInstalledPluginsRegistryPath,
14
17
  getMarketplacesCacheDir,
@@ -440,7 +443,13 @@ export class SelectorController {
440
443
  projectInstalledRegistryPath: (await resolveActiveProjectRegistryPath(getProjectDir())) ?? undefined,
441
444
  marketplacesCacheDir: getMarketplacesCacheDir(),
442
445
  pluginsCacheDir: getPluginsCacheDir(),
443
- clearPluginRootsCache: clearPluginRootsAndCaches,
446
+ clearPluginRootsCache: (extraPaths?: readonly string[]) => {
447
+ const home = os.homedir();
448
+ invalidateFsCache(path.join(home, ".claude", "plugins", "installed_plugins.json"));
449
+ invalidateFsCache(path.join(home, getConfigDirName(), "plugins", "installed_plugins.json"));
450
+ for (const p of extraPaths ?? []) invalidateFsCache(p);
451
+ clearClaudePluginRootsCache();
452
+ },
444
453
  });
445
454
 
446
455
  const [marketplaces, installed] = await Promise.all([mgr.listMarketplaces(), mgr.listInstalledPlugins()]);
@@ -969,25 +978,29 @@ export class SelectorController {
969
978
 
970
979
  showSessionObserver(registry: SessionObserverRegistry): void {
971
980
  const observeKeys = this.ctx.keybindings.getKeys("app.session.observe");
981
+ let cleanup: (() => void) | undefined;
982
+ let overlayHandle: OverlayHandle | undefined;
972
983
 
973
- this.showSelector(done => {
974
- let cleanup: (() => void) | undefined;
984
+ const done = () => {
985
+ cleanup?.();
986
+ overlayHandle?.hide();
987
+ this.ctx.ui.requestRender();
988
+ };
975
989
 
976
- const selector = new SessionObserverOverlayComponent(
977
- registry,
978
- () => {
979
- cleanup?.();
980
- done();
981
- },
982
- observeKeys,
983
- );
990
+ const selector = new SessionObserverOverlayComponent(registry, done, observeKeys);
984
991
 
985
- cleanup = registry.onChange(() => {
986
- selector.refreshFromRegistry();
987
- this.ctx.ui.requestRender();
988
- });
992
+ cleanup = registry.onChange(() => {
993
+ selector.refreshFromRegistry();
994
+ this.ctx.ui.requestRender();
995
+ });
989
996
 
990
- return { component: selector, focus: selector };
997
+ overlayHandle = this.ctx.ui.showOverlay(selector, {
998
+ anchor: "bottom-center",
999
+ width: "100%",
1000
+ maxHeight: "100%",
1001
+ margin: 0,
991
1002
  });
1003
+ this.ctx.ui.setFocus(selector);
1004
+ this.ctx.ui.requestRender();
992
1005
  }
993
1006
  }
@@ -1324,13 +1324,22 @@ export class InteractiveMode implements InteractiveModeContext {
1324
1324
  this.#commandController.handleToolsCommand();
1325
1325
  }
1326
1326
 
1327
- handleClearCommand(): Promise<void> {
1327
+ #prepareSessionSwitch(): void {
1328
1328
  this.#btwController.dispose();
1329
1329
  this.#extensionUiController.clearExtensionTerminalInputListeners();
1330
1330
  this.#planReviewContainer = undefined;
1331
+ }
1332
+
1333
+ handleClearCommand(): Promise<void> {
1334
+ this.#prepareSessionSwitch();
1331
1335
  return this.#commandController.handleClearCommand();
1332
1336
  }
1333
1337
 
1338
+ handleDropCommand(): Promise<void> {
1339
+ this.#prepareSessionSwitch();
1340
+ return this.#commandController.handleDropCommand();
1341
+ }
1342
+
1334
1343
  handleForkCommand(): Promise<void> {
1335
1344
  this.#btwController.dispose();
1336
1345
  return this.#commandController.handleForkCommand();
@@ -181,6 +181,7 @@ export interface InteractiveModeContext {
181
181
  handleDumpCommand(): void;
182
182
  handleDebugTranscriptCommand(): Promise<void>;
183
183
  handleClearCommand(): Promise<void>;
184
+ handleDropCommand(): Promise<void>;
184
185
  handleForkCommand(): Promise<void>;
185
186
  handleBashCommand(command: string, excludeFromContext?: boolean): Promise<void>;
186
187
  handlePythonCommand(code: string, excludeFromContext?: boolean): Promise<void>;
@@ -3373,7 +3373,15 @@ export class AgentSession {
3373
3373
  this.#asyncJobManager?.cancelAll();
3374
3374
  this.#closeAllProviderSessions("new session");
3375
3375
  this.agent.reset();
3376
- await this.sessionManager.flush();
3376
+ if (options?.drop && previousSessionFile) {
3377
+ try {
3378
+ await this.sessionManager.dropSession(previousSessionFile);
3379
+ } catch (err) {
3380
+ logger.error("Failed to delete session during /drop", { err });
3381
+ }
3382
+ } else {
3383
+ await this.sessionManager.flush();
3384
+ }
3377
3385
  await this.sessionManager.newSession(options);
3378
3386
  this.setTodoPhases([]);
3379
3387
  this.agent.sessionId = this.sessionManager.getSessionId();
@@ -66,6 +66,8 @@ export interface SessionHeader {
66
66
 
67
67
  export interface NewSessionOptions {
68
68
  parentSession?: string;
69
+ /** Skip flushing the current session and delete it instead of saving. */
70
+ drop?: boolean;
69
71
  }
70
72
 
71
73
  export interface SessionEntryBase {
@@ -1707,6 +1709,17 @@ export class SessionManager {
1707
1709
  return this.#newSessionSync(options);
1708
1710
  }
1709
1711
 
1712
+ /** Delete a session file and its artifacts. Drains the persist writer first to avoid EPERM on Windows. ENOENT is treated as success. */
1713
+ async dropSession(sessionPath: string): Promise<void> {
1714
+ await this.#closePersistWriter();
1715
+ try {
1716
+ await this.storage.deleteSessionWithArtifacts(sessionPath);
1717
+ } catch (err) {
1718
+ if (isEnoent(err)) return;
1719
+ throw err;
1720
+ }
1721
+ }
1722
+
1710
1723
  /**
1711
1724
  * Fork the current session, creating a new session file with the same entries.
1712
1725
  * Returns both the old and new session file paths for artifact copying.
@@ -32,6 +32,7 @@ export interface SessionStorage {
32
32
  writeText(path: string, content: string): Promise<void>;
33
33
  rename(path: string, nextPath: string): Promise<void>;
34
34
  unlink(path: string): Promise<void>;
35
+ deleteSessionWithArtifacts(sessionPath: string): Promise<void>;
35
36
  openWriter(path: string, options?: { flags?: "a" | "w"; onError?: (err: Error) => void }): SessionStorageWriter;
36
37
  }
37
38
 
@@ -360,6 +361,9 @@ export class MemorySessionStorage implements SessionStorage {
360
361
  this.#files.delete(path);
361
362
  return Promise.resolve();
362
363
  }
364
+ deleteSessionWithArtifacts(_sessionPath: string): Promise<void> {
365
+ return Promise.resolve();
366
+ }
363
367
 
364
368
  openWriter(path: string, options?: { flags?: "a" | "w"; onError?: (err: Error) => void }): SessionStorageWriter {
365
369
  return new MemorySessionStorageWriter(this, path, options);
@@ -512,6 +512,14 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<BuiltinSlashCommandSpec> = [
512
512
  await runtime.ctx.handleClearCommand();
513
513
  },
514
514
  },
515
+ {
516
+ name: "drop",
517
+ description: "Delete the current session and start a new one",
518
+ handle: async (_command, runtime) => {
519
+ runtime.ctx.editor.setText("");
520
+ await runtime.ctx.handleDropCommand();
521
+ },
522
+ },
515
523
  {
516
524
  name: "compact",
517
525
  description: "Manually compact the session context",
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Unified Web Search Tool
3
3
  *
4
- * Single tool supporting Anthropic, Perplexity, Exa, Brave, Jina, Kimi, Gemini, Codex, Tavily, Kagi, Z.AI, and Synthetic
4
+ * Single tool supporting Anthropic, Perplexity, Exa, Brave, Jina, Kimi, Gemini, Codex, Tavily, Kagi, Z.AI, SearXNG, and Synthetic
5
5
  * providers with provider-specific parameters exposed conditionally.
6
6
  *
7
7
  */
@@ -202,7 +202,7 @@ export async function runSearchQuery(
202
202
  /**
203
203
  * Web search tool implementation.
204
204
  *
205
- * Supports Anthropic, Perplexity, Exa, Brave, Jina, Kimi, Gemini, Codex, Z.AI, and Synthetic providers with automatic fallback.
205
+ * Supports Anthropic, Perplexity, Exa, Brave, Jina, Kimi, Gemini, Codex, Z.AI, SearXNG, and Synthetic providers with automatic fallback.
206
206
  * Session is accepted for interface consistency but not used.
207
207
  */
208
208
  export class SearchTool implements AgentTool<typeof webSearchSchema, SearchRenderDetails> {
@@ -9,6 +9,7 @@ import { KagiProvider } from "./providers/kagi";
9
9
  import { KimiProvider } from "./providers/kimi";
10
10
  import { ParallelProvider } from "./providers/parallel";
11
11
  import { PerplexityProvider } from "./providers/perplexity";
12
+ import { SearXNGProvider } from "./providers/searxng";
12
13
  import { SyntheticProvider } from "./providers/synthetic";
13
14
  import { TavilyProvider } from "./providers/tavily";
14
15
  import { ZaiProvider } from "./providers/zai";
@@ -31,6 +32,7 @@ const SEARCH_PROVIDERS: Record<SearchProviderId, SearchProvider> = {
31
32
  parallel: new ParallelProvider(),
32
33
  kagi: new KagiProvider(),
33
34
  synthetic: new SyntheticProvider(),
35
+ searxng: new SearXNGProvider(),
34
36
  } as const;
35
37
 
36
38
  export const SEARCH_PROVIDER_ORDER: SearchProviderId[] = [
@@ -47,6 +49,7 @@ export const SEARCH_PROVIDER_ORDER: SearchProviderId[] = [
47
49
  "parallel",
48
50
  "kagi",
49
51
  "synthetic",
52
+ "searxng",
50
53
  ];
51
54
 
52
55
  export function getSearchProvider(provider: SearchProviderId): SearchProvider {
@@ -0,0 +1,238 @@
1
+ /**
2
+ * SearXNG Web Search Provider
3
+ *
4
+ * Calls a SearXNG instance's JSON search API and maps results into the unified
5
+ * SearchResponse shape used by the web search tool.
6
+ *
7
+ * SearXNG is a free, open-source metasearch engine that aggregates results from
8
+ * multiple sources without tracking users. It supports self-hosted instances
9
+ * and various authentication methods (bearer token, basic auth, or none).
10
+ *
11
+ * Configuration via settings:
12
+ * searxng.endpoint - Base URL of the SearXNG instance (e.g. https://searx.example.org)
13
+ * searxng.token - Optional bearer token for authentication
14
+ * searxng.categories - Optional comma-separated categories filter
15
+ * searxng.language - Optional language code (e.g. en, zh-CN)
16
+ *
17
+ * Environment variable fallbacks:
18
+ * SEARXNG_ENDPOINT - Base URL of the SearXNG instance
19
+ * SEARXNG_TOKEN - Optional bearer token
20
+ *
21
+ * Reference: https://docs.searxng.org/dev/search_api.html
22
+ */
23
+
24
+ import { settings } from "../../../config/settings";
25
+ import type { SearchResponse, SearchSource } from "../../../web/search/types";
26
+ import { SearchProviderError } from "../../../web/search/types";
27
+ import { clampNumResults, dateToAgeSeconds } from "../utils";
28
+ import type { SearchParams } from "./base";
29
+ import { SearchProvider } from "./base";
30
+
31
+ const DEFAULT_NUM_RESULTS = 10;
32
+ const MAX_NUM_RESULTS = 20;
33
+
34
+ /** Map our recency filter to SearXNG time_range parameter.
35
+ * SearXNG only supports day/month/year, so week maps to month. */
36
+ const RECENCY_MAP: Record<"day" | "week" | "month" | "year", string> = {
37
+ day: "day",
38
+ week: "month",
39
+ month: "month",
40
+ year: "year",
41
+ };
42
+
43
+ /** SearXNG JSON API response types */
44
+ interface SearXNGResult {
45
+ title?: string;
46
+ url?: string;
47
+ content?: string;
48
+ engine?: string;
49
+ publishedDate?: string;
50
+ /** SearXNG sometimes uses publishedDate, sometimes just date */
51
+ published_date?: string;
52
+ score?: number;
53
+ }
54
+
55
+ interface SearXNGResponse {
56
+ query?: string;
57
+ number_of_results?: number;
58
+ results?: SearXNGResult[];
59
+ suggestions?: string[];
60
+ corrections?: string[];
61
+ unresponsive_engines?: Array<[string, string]>;
62
+ }
63
+
64
+ /** Find SearXNG endpoint from settings or environment. */
65
+ function findEndpoint(): string | null {
66
+ try {
67
+ const endpoint = settings.get("searxng.endpoint");
68
+ if (endpoint) return endpoint;
69
+ } catch {
70
+ // Settings not initialized yet
71
+ }
72
+ return process.env.SEARXNG_ENDPOINT ?? null;
73
+ }
74
+
75
+ /** Find SearXNG bearer token from settings or environment. */
76
+ function findToken(): string | null {
77
+ try {
78
+ const token = settings.get("searxng.token");
79
+ if (token) return token;
80
+ } catch {
81
+ // Settings not initialized yet
82
+ }
83
+ return process.env.SEARXNG_TOKEN ?? null;
84
+ }
85
+
86
+ /** Build the search URL and headers for a SearXNG request */
87
+ function buildRequest(
88
+ endpoint: string,
89
+ params: {
90
+ query: string;
91
+ num_results?: number;
92
+ recency?: "day" | "week" | "month" | "year";
93
+ categories?: string;
94
+ language?: string;
95
+ signal?: AbortSignal;
96
+ },
97
+ token: string | null,
98
+ ): { url: URL; headers: Record<string, string> } {
99
+ const base = endpoint.replace(/\/+$/, "");
100
+ const url = new URL(`${base}/search`);
101
+
102
+ url.searchParams.set("q", params.query);
103
+ url.searchParams.set("format", "json");
104
+
105
+ if (params.num_results) {
106
+ url.searchParams.set("pageno", "1");
107
+ }
108
+
109
+ if (params.recency) {
110
+ url.searchParams.set("time_range", RECENCY_MAP[params.recency]);
111
+ }
112
+
113
+ if (params.categories) {
114
+ url.searchParams.set("categories", params.categories);
115
+ }
116
+
117
+ if (params.language) {
118
+ url.searchParams.set("language", params.language);
119
+ }
120
+
121
+ const headers: Record<string, string> = {
122
+ Accept: "application/json",
123
+ };
124
+
125
+ if (token) {
126
+ headers.Authorization = `Bearer ${token}`;
127
+ }
128
+
129
+ return { url, headers };
130
+ }
131
+
132
+ async function callSearXNGSearch(
133
+ endpoint: string,
134
+ params: {
135
+ query: string;
136
+ num_results?: number;
137
+ recency?: "day" | "week" | "month" | "year";
138
+ categories?: string;
139
+ language?: string;
140
+ signal?: AbortSignal;
141
+ },
142
+ token: string | null,
143
+ ): Promise<SearXNGResponse> {
144
+ const { url, headers } = buildRequest(endpoint, params, token);
145
+
146
+ const response = await fetch(url, {
147
+ headers,
148
+ signal: params.signal,
149
+ });
150
+
151
+ if (!response.ok) {
152
+ const errorText = await response.text();
153
+ throw new SearchProviderError("searxng", `SearXNG API error (${response.status}): ${errorText}`, response.status);
154
+ }
155
+
156
+ return (await response.json()) as SearXNGResponse;
157
+ }
158
+
159
+ /** Execute SearXNG web search. */
160
+ export async function searchSearXNG(params: {
161
+ query: string;
162
+ num_results?: number;
163
+ recency?: "day" | "week" | "month" | "year";
164
+ signal?: AbortSignal;
165
+ }): Promise<SearchResponse> {
166
+ const numResults = clampNumResults(params.num_results, DEFAULT_NUM_RESULTS, MAX_NUM_RESULTS);
167
+
168
+ const endpoint = findEndpoint();
169
+ if (!endpoint) {
170
+ throw new Error(
171
+ "SearXNG endpoint not configured. Set searxng.endpoint in settings or SEARXNG_ENDPOINT in environment.",
172
+ );
173
+ }
174
+
175
+ const token = findToken();
176
+
177
+ let categories: string | undefined;
178
+ let language: string | undefined;
179
+ try {
180
+ categories = settings.get("searxng.categories") ?? undefined;
181
+ language = settings.get("searxng.language") ?? undefined;
182
+ } catch {
183
+ // Settings not initialized yet
184
+ }
185
+
186
+ const response = await callSearXNGSearch(
187
+ endpoint,
188
+ {
189
+ ...params,
190
+ categories,
191
+ language,
192
+ },
193
+ token,
194
+ );
195
+
196
+ const sources: SearchSource[] = [];
197
+
198
+ for (const result of response.results ?? []) {
199
+ if (!result.url) continue;
200
+ const publishedDate = result.publishedDate ?? result.published_date;
201
+ sources.push({
202
+ title: result.title ?? result.url,
203
+ url: result.url,
204
+ snippet: result.content?.trim() || undefined,
205
+ publishedDate: publishedDate ?? undefined,
206
+ ageSeconds: dateToAgeSeconds(publishedDate),
207
+ });
208
+ }
209
+
210
+ return {
211
+ provider: "searxng",
212
+ sources: sources.slice(0, numResults),
213
+ relatedQuestions: response.suggestions?.length ? response.suggestions : undefined,
214
+ };
215
+ }
216
+
217
+ /** Search provider for SearXNG web search. */
218
+ export class SearXNGProvider extends SearchProvider {
219
+ readonly id = "searxng";
220
+ readonly label = "SearXNG";
221
+
222
+ isAvailable() {
223
+ try {
224
+ return !!findEndpoint();
225
+ } catch {
226
+ return false;
227
+ }
228
+ }
229
+
230
+ search(params: SearchParams): Promise<SearchResponse> {
231
+ return searchSearXNG({
232
+ query: params.query,
233
+ num_results: params.numSearchResults ?? params.limit,
234
+ recency: params.recency,
235
+ signal: params.signal,
236
+ });
237
+ }
238
+ }
@@ -18,7 +18,8 @@ export type SearchProviderId =
18
18
  | "tavily"
19
19
  | "parallel"
20
20
  | "kagi"
21
- | "synthetic";
21
+ | "synthetic"
22
+ | "searxng";
22
23
 
23
24
  export function isSearchProviderId(value: string): value is SearchProviderId {
24
25
  return [
@@ -35,6 +36,7 @@ export function isSearchProviderId(value: string): value is SearchProviderId {
35
36
  "parallel",
36
37
  "kagi",
37
38
  "synthetic",
39
+ "searxng",
38
40
  ].includes(value);
39
41
  }
40
42