@oh-my-pi/pi-coding-agent 16.1.2 → 16.1.3

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 (48) hide show
  1. package/CHANGELOG.md +30 -1
  2. package/dist/cli.js +3046 -3047
  3. package/dist/types/config/model-resolver.d.ts +3 -3
  4. package/dist/types/mnemopi/embed-client.d.ts +70 -0
  5. package/dist/types/mnemopi/embed-protocol.d.ts +52 -0
  6. package/dist/types/mnemopi/embed-worker.d.ts +12 -0
  7. package/dist/types/mnemopi/state.d.ts +9 -1
  8. package/dist/types/session/agent-storage.d.ts +2 -0
  9. package/dist/types/session/auth-broker-config.d.ts +3 -2
  10. package/dist/types/session/history-storage.d.ts +1 -1
  11. package/dist/types/tools/image-gen.d.ts +2 -2
  12. package/dist/types/utils/image-loading.d.ts +1 -1
  13. package/dist/types/utils/ipc.d.ts +22 -0
  14. package/dist/types/web/search/providers/perplexity-auth.d.ts +37 -0
  15. package/package.json +12 -12
  16. package/src/cli.ts +8 -0
  17. package/src/commands/token.ts +52 -33
  18. package/src/config/append-only-context-mode.ts +45 -0
  19. package/src/config/model-discovery.ts +3 -0
  20. package/src/config/model-registry.ts +21 -3
  21. package/src/config/model-resolver.ts +31 -8
  22. package/src/discovery/builtin-rules/ts-no-return-type.md +0 -1
  23. package/src/lsp/client.ts +24 -0
  24. package/src/mnemopi/backend.ts +49 -3
  25. package/src/mnemopi/embed-client.ts +401 -0
  26. package/src/mnemopi/embed-protocol.ts +35 -0
  27. package/src/mnemopi/embed-worker.ts +113 -0
  28. package/src/mnemopi/state.ts +29 -1
  29. package/src/modes/components/custom-editor.ts +1 -1
  30. package/src/modes/components/model-selector.ts +2 -2
  31. package/src/modes/components/welcome.ts +1 -1
  32. package/src/modes/controllers/event-controller.ts +8 -0
  33. package/src/modes/controllers/selector-controller.ts +2 -2
  34. package/src/modes/theme/theme.ts +69 -0
  35. package/src/sdk.ts +4 -0
  36. package/src/session/agent-session.ts +8 -0
  37. package/src/session/agent-storage.ts +14 -0
  38. package/src/session/auth-broker-config.ts +2 -1
  39. package/src/session/history-storage.ts +13 -1
  40. package/src/stt/asr-client.ts +2 -7
  41. package/src/tiny/title-client.ts +2 -7
  42. package/src/tools/image-gen.ts +4 -8
  43. package/src/tools/render-utils.ts +4 -1
  44. package/src/tts/tts-client.ts +2 -7
  45. package/src/utils/image-loading.ts +12 -2
  46. package/src/utils/ipc.ts +38 -0
  47. package/src/web/search/providers/perplexity-auth.ts +133 -0
  48. package/src/web/search/providers/perplexity.ts +2 -125
@@ -0,0 +1,113 @@
1
+ /**
2
+ * Mnemopi local-embeddings worker. Loaded inside the dedicated subprocess
3
+ * spawned by `embed-client.ts` (re-entered through the agent CLI's hidden
4
+ * `__omp_worker_mnemopi_embed` selector). The whole point of this module is
5
+ * that `loadFastembed()` — and therefore `onnxruntime-node`'s NAPI
6
+ * constructor + finalizer — only ever runs in this child address space. The
7
+ * parent `SIGKILL`s us on shutdown so the destructor that crashes Bun on
8
+ * Windows shutdown (issue #3031, mnemopi sibling of #1606/#1607) never runs
9
+ * in either process.
10
+ */
11
+
12
+ import type { StandardEmbeddingModel } from "@oh-my-pi/pi-mnemopi/core";
13
+ import { loadFastembed } from "@oh-my-pi/pi-mnemopi/core/fastembed-runtime";
14
+ import type { MnemopiEmbedModelId, MnemopiEmbedTransport, MnemopiEmbedWorkerInbound } from "./embed-protocol";
15
+
16
+ interface LoadedModel {
17
+ model: MnemopiEmbedModelId;
18
+ cacheDir: string | undefined;
19
+ instance: {
20
+ embed(texts: string[], batchSize?: number): AsyncIterable<number[][]> | Iterable<number[][]>;
21
+ };
22
+ }
23
+
24
+ let loaded: Promise<LoadedModel> | null = null;
25
+ let loadedKey = "";
26
+
27
+ async function loadModel(model: MnemopiEmbedModelId, cacheDir: string | undefined): Promise<LoadedModel> {
28
+ const { FlagEmbedding } = await loadFastembed();
29
+ // Cast: `model` arrives as a string from the parent (resolved by
30
+ // mnemopi's `fastembedModelName`). Cast to the non-CUSTOM overload's
31
+ // argument so TypeScript picks the standard-model branch — the parent
32
+ // only ever passes pre-vetted fast-* identifiers.
33
+ const instance = await FlagEmbedding.init({
34
+ model: model as StandardEmbeddingModel,
35
+ cacheDir,
36
+ showDownloadProgress: false,
37
+ });
38
+ return { model, cacheDir, instance };
39
+ }
40
+
41
+ function ensureLoaded(model: MnemopiEmbedModelId, cacheDir: string | undefined): Promise<LoadedModel> {
42
+ const key = `${model}\u0000${cacheDir ?? ""}`;
43
+ if (loaded !== null && loadedKey === key) return loaded;
44
+ const loading = loadModel(model, cacheDir).catch(error => {
45
+ // Failed loads must not poison the cache — a retry with the same key
46
+ // should re-attempt the load.
47
+ if (loaded === loading) {
48
+ loaded = null;
49
+ loadedKey = "";
50
+ }
51
+ throw error;
52
+ });
53
+ loaded = loading;
54
+ loadedKey = key;
55
+ return loading;
56
+ }
57
+ async function handleEmbed(
58
+ transport: MnemopiEmbedTransport,
59
+ message: Extract<MnemopiEmbedWorkerInbound, { type: "embed" }>,
60
+ ): Promise<void> {
61
+ try {
62
+ // Each `embed` carries the model + cacheDir the wrapper was bound to.
63
+ // `ensureLoaded` is idempotent for the same key, so this is a no-op
64
+ // once the model is in memory — and it transparently re-loads after
65
+ // the parent SIGKILLed the previous subprocess but mnemopi still
66
+ // holds the cached `LocalEmbeddingModel` wrapper from before.
67
+ const { instance } = await ensureLoaded(message.model, message.cacheDir);
68
+ const vectors: number[][] = [];
69
+ const batches = instance.embed([...message.texts], message.batchSize);
70
+ for await (const batch of batches) {
71
+ for (const row of batch) vectors.push(row);
72
+ }
73
+ transport.send({ type: "vectors", id: message.id, vectors });
74
+ } catch (error) {
75
+ transport.send({
76
+ type: "error",
77
+ id: message.id,
78
+ error: error instanceof Error ? error.message : String(error),
79
+ });
80
+ }
81
+ }
82
+
83
+ async function handleInit(
84
+ transport: MnemopiEmbedTransport,
85
+ message: Extract<MnemopiEmbedWorkerInbound, { type: "init" }>,
86
+ ): Promise<void> {
87
+ try {
88
+ await ensureLoaded(message.model, message.cacheDir);
89
+ transport.send({ type: "ready", id: message.id });
90
+ } catch (error) {
91
+ transport.send({
92
+ type: "error",
93
+ id: message.id,
94
+ error: error instanceof Error ? error.message : String(error),
95
+ });
96
+ }
97
+ }
98
+
99
+ export function startMnemopiEmbedWorker(transport: MnemopiEmbedTransport): void {
100
+ transport.onMessage(message => {
101
+ switch (message.type) {
102
+ case "ping":
103
+ transport.send({ type: "pong", id: message.id });
104
+ return;
105
+ case "init":
106
+ void handleInit(transport, message);
107
+ return;
108
+ case "embed":
109
+ void handleEmbed(transport, message);
110
+ return;
111
+ }
112
+ });
113
+ }
@@ -3,6 +3,7 @@ import type { AgentMessage } from "@oh-my-pi/pi-agent-core";
3
3
  import type * as MnemopiNs from "@oh-my-pi/pi-mnemopi";
4
4
  import type { Mnemopi, RecallResult } from "@oh-my-pi/pi-mnemopi";
5
5
  import type * as MnemopiCoreNs from "@oh-my-pi/pi-mnemopi/core";
6
+ import type { LocalModelInitializer } from "@oh-my-pi/pi-mnemopi/core";
6
7
  import { logger } from "@oh-my-pi/pi-utils";
7
8
  import {
8
9
  composeRecallQuery,
@@ -13,16 +14,42 @@ import {
13
14
  import { extractMessages } from "../hindsight/transcript";
14
15
  import type { AgentSession, AgentSessionEvent } from "../session/agent-session";
15
16
  import type { MnemopiBackendConfig, MnemopiScoping } from "./config";
17
+ import { mnemopiEmbedClient } from "./embed-client";
16
18
 
17
19
  // The mnemopi package pulls the embeddings stack; keep it off the CLI startup
18
20
  // module graph by loading it lazily at the async boundaries that need it.
19
21
  let mnemopiMod: typeof MnemopiNs | undefined;
20
22
  let mnemopiCoreMod: typeof MnemopiCoreNs | undefined;
21
23
 
22
- /** Lazily load `@oh-my-pi/pi-mnemopi` (memoized). */
24
+ // `setLocalModelInitializer` writes a single module-level slot shared by
25
+ // both the root and `/core` re-exports, so install at most once across both
26
+ // loaders. Either entry point is enough to wire up the override.
27
+ let localModelInitializerInstalled = false;
28
+
29
+ function installLocalModelInitializer(setInitializer: (initializer: LocalModelInitializer) => void): void {
30
+ if (localModelInitializerInstalled) return;
31
+ localModelInitializerInstalled = true;
32
+ setInitializer(({ model, cacheDir }) =>
33
+ mnemopiEmbedClient.initialize(model, cacheDir).then(handle => {
34
+ if (handle) return handle;
35
+ throw new Error("mnemopi embed subprocess unavailable");
36
+ }),
37
+ );
38
+ }
39
+
40
+ /**
41
+ * Lazily load `@oh-my-pi/pi-mnemopi` (memoized) and route fastembed loads
42
+ * through the dedicated embeddings subprocess. The override is installed once
43
+ * — before any consumer gets the chance to call `embed()` — so
44
+ * `onnxruntime-node`'s NAPI constructor + finalizer never run inside the
45
+ * agent's address space (issue #3031). Test seams that swap the initializer
46
+ * with `setLocalModelInitializerForTests` still win because both go through
47
+ * the same module-level slot.
48
+ */
23
49
  export async function loadMnemopi(): Promise<typeof MnemopiNs> {
24
50
  if (!mnemopiMod) {
25
51
  mnemopiMod = await import("@oh-my-pi/pi-mnemopi");
52
+ installLocalModelInitializer(mnemopiMod.setLocalModelInitializer);
26
53
  }
27
54
  return mnemopiMod;
28
55
  }
@@ -31,6 +58,7 @@ export async function loadMnemopi(): Promise<typeof MnemopiNs> {
31
58
  export async function loadMnemopiCore(): Promise<typeof MnemopiCoreNs> {
32
59
  if (!mnemopiCoreMod) {
33
60
  mnemopiCoreMod = await import("@oh-my-pi/pi-mnemopi/core");
61
+ installLocalModelInitializer(mnemopiCoreMod.setLocalModelInitializer);
34
62
  }
35
63
  return mnemopiCoreMod;
36
64
  }
@@ -177,7 +177,7 @@ export class CustomEditor extends Editor {
177
177
  /** Per-render scratch flag: did any layout line in this render contain a magic
178
178
  * keyword that should shimmer? Reset by {@link #scheduleShimmerIfNeeded} each
179
179
  * time a frame is queued. */
180
- #shimmerTimer: ReturnType<typeof setTimeout> | undefined;
180
+ #shimmerTimer: Timer | undefined;
181
181
  /** Repaint hook the host wires once at construction. Called from the shimmer
182
182
  * timer to request the next animation frame. Undefined when nobody is
183
183
  * listening (tests, headless callers); the timer chain still self-cleans. */
@@ -179,9 +179,9 @@ export class ModelSelectorComponent extends Container {
179
179
  #providers: ProviderTabState[] = STATIC_PROVIDER_TABS;
180
180
  #activeTabIndex: number = 0;
181
181
  #refreshingProviders: Set<string> = new Set();
182
- #scheduledProviderRefreshes: Map<string, ReturnType<typeof setTimeout>> = new Map();
182
+ #scheduledProviderRefreshes: Map<string, Timer> = new Map();
183
183
  #refreshSpinnerFrame: number = 0;
184
- #refreshSpinnerInterval?: NodeJS.Timeout;
184
+ #refreshSpinnerInterval?: Timer;
185
185
 
186
186
  // Context menu state
187
187
  #isMenuOpen: boolean = false;
@@ -142,7 +142,7 @@ export interface LspServerInfo {
142
142
  */
143
143
  export class WelcomeComponent implements Component {
144
144
  #animStart: number | null = null;
145
- #animTimer: ReturnType<typeof setInterval> | null = null;
145
+ #animTimer: Timer | null = null;
146
146
  #selectedTip: string | undefined;
147
147
  // Render cache: the welcome box is the first transcript-area component, so
148
148
  // returning a stable array reference keeps the whole frame prefix stable.
@@ -1,4 +1,5 @@
1
1
  import type { ImageContent } from "@oh-my-pi/pi-ai";
2
+ import { THINKING_LOOP_ERROR_MARKER } from "@oh-my-pi/pi-ai/utils/thinking-loop";
2
3
  import { type Component, Loader, TERMINAL } from "@oh-my-pi/pi-tui";
3
4
  import { INTENT_FIELD } from "@oh-my-pi/pi-wire";
4
5
  import { extractTextContent } from "../../commit/utils";
@@ -1014,6 +1015,13 @@ export class EventController {
1014
1015
  async #handleAutoRetryStart(event: Extract<AgentSessionEvent, { type: "auto_retry_start" }>): Promise<void> {
1015
1016
  this.#stopWorkingLoader();
1016
1017
  this.ctx.statusContainer.clear();
1018
+ if (event.errorMessage?.includes(THINKING_LOOP_ERROR_MARKER)) {
1019
+ // The retry path drops the failed assistant from runtime context. Do not
1020
+ // restore its inline Error row; just unpin the fixed-region banner so the
1021
+ // retry UI is the visible state.
1022
+ this.#pinnedErrorComponent = undefined;
1023
+ this.ctx.clearPinnedError();
1024
+ }
1017
1025
  const delaySeconds = Math.round(event.delayMs / 1000);
1018
1026
  this.ctx.retryLoader = new Loader(
1019
1027
  this.ctx.ui,
@@ -27,7 +27,7 @@ import {
27
27
  theme,
28
28
  } from "../../modes/theme/theme";
29
29
  import type { InteractiveModeContext } from "../../modes/types";
30
- import type { ResetCreditRedeemOutcome } from "../../session/auth-storage";
30
+ import type { ResetCreditAccountStatus, ResetCreditRedeemOutcome } from "../../session/auth-storage";
31
31
  import type { SessionInfo } from "../../session/session-listing";
32
32
  import { SessionManager } from "../../session/session-manager";
33
33
  import { FileSessionStorage } from "../../session/session-storage";
@@ -1161,7 +1161,7 @@ export class SelectorController {
1161
1161
  async showResetUsageSelector(): Promise<void> {
1162
1162
  const session = this.ctx.session;
1163
1163
  this.ctx.showStatus("Checking saved rate-limit resets…", { dim: true });
1164
- let statuses: Awaited<ReturnType<typeof session.listResetCredits>>;
1164
+ let statuses: ResetCreditAccountStatus[];
1165
1165
  try {
1166
1166
  statuses = await session.listResetCredits();
1167
1167
  } catch (error) {
@@ -2743,6 +2743,35 @@ export function highlightCode(code: string, lang?: string, highlightTheme: Theme
2743
2743
  }
2744
2744
 
2745
2745
  export function getSymbolTheme(): SymbolTheme {
2746
+ // Guard against `theme` being undefined (pre-init or cross-module-instance
2747
+ // plugin calls). Fall back to the ASCII preset so the returned symbols are
2748
+ // usable instead of crashing. See #2998.
2749
+ if (typeof theme === "undefined") {
2750
+ const box = {
2751
+ topLeft: "+",
2752
+ topRight: "+",
2753
+ bottomLeft: "+",
2754
+ bottomRight: "+",
2755
+ horizontal: "-",
2756
+ vertical: "|",
2757
+ cross: "+",
2758
+ teeDown: "+",
2759
+ teeUp: "+",
2760
+ teeLeft: "+",
2761
+ teeRight: "+",
2762
+ };
2763
+ return {
2764
+ cursor: ">",
2765
+ inputCursor: "|",
2766
+ boxRound: box,
2767
+ boxSharp: box,
2768
+ table: box,
2769
+ quoteBorder: "|",
2770
+ hrChar: "-",
2771
+ colorSwatch: "[]",
2772
+ spinnerFrames: ["-", "\\", "|", "/"],
2773
+ };
2774
+ }
2746
2775
  const preset = theme.getSymbolPreset();
2747
2776
 
2748
2777
  return {
@@ -2808,6 +2837,19 @@ export function getMarkdownTheme(): MarkdownTheme {
2808
2837
  }
2809
2838
 
2810
2839
  export function getSelectListTheme(): SelectListTheme {
2840
+ // Guard against `theme` being undefined (pre-init or cross-module-instance
2841
+ // plugin calls). See #2998.
2842
+ if (typeof theme === "undefined") {
2843
+ return {
2844
+ selectedPrefix: (text: string) => text,
2845
+ selectedText: (text: string) => text,
2846
+ description: (text: string) => text,
2847
+ scrollInfo: (text: string) => text,
2848
+ noMatch: (text: string) => text,
2849
+ symbols: getSymbolTheme(),
2850
+ hovered: (text: string) => text,
2851
+ };
2852
+ }
2811
2853
  return {
2812
2854
  selectedPrefix: (text: string) => theme.fg("accent", text),
2813
2855
  selectedText: (text: string) => theme.fg("accent", text),
@@ -2820,6 +2862,16 @@ export function getSelectListTheme(): SelectListTheme {
2820
2862
  }
2821
2863
 
2822
2864
  export function getEditorTheme(): EditorTheme {
2865
+ // Guard against `theme` being undefined (pre-init or cross-module-instance
2866
+ // plugin calls). See #2998.
2867
+ if (typeof theme === "undefined") {
2868
+ return {
2869
+ borderColor: (text: string) => text,
2870
+ selectList: getSelectListTheme(),
2871
+ symbols: getSymbolTheme(),
2872
+ hintStyle: (text: string) => text,
2873
+ };
2874
+ }
2823
2875
  return {
2824
2876
  borderColor: (text: string) => theme.fg("borderMuted", text),
2825
2877
  selectList: getSelectListTheme(),
@@ -2829,6 +2881,23 @@ export function getEditorTheme(): EditorTheme {
2829
2881
  }
2830
2882
 
2831
2883
  export function getSettingsListTheme(): SettingsListTheme {
2884
+ // Plugins (e.g. pi-rtk-optimizer) may call this before `initTheme()` assigns
2885
+ // the global `theme`, or from a separate module instance under npm-global
2886
+ // installs where the live binding was never initialized. Fall back to plain
2887
+ // text so the call returns a usable (unstyled) theme instead of crashing with
2888
+ // "undefined is not an object (evaluating 'theme.fg')". See #2998.
2889
+ if (typeof theme === "undefined") {
2890
+ return {
2891
+ label: (text: string) => text,
2892
+ value: (text: string) => text,
2893
+ description: (text: string) => text,
2894
+ cursor: "> ",
2895
+ hint: (text: string) => text,
2896
+ heading: (text: string) => text,
2897
+ section: (text: string) => text,
2898
+ hovered: (text: string) => text,
2899
+ };
2900
+ }
2832
2901
  return {
2833
2902
  label: (text: string, selected: boolean, changed: boolean) =>
2834
2903
  changed ? theme.fg("statusLineGitDirty", text) : selected ? theme.fg("accent", text) : text,
package/src/sdk.ts CHANGED
@@ -1051,6 +1051,9 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
1051
1051
  const modelRegistry =
1052
1052
  options.modelRegistry ??
1053
1053
  new ModelRegistry(options.authStorage ?? (await logger.time("discoverModels", discoverAuthStorage, agentDir)));
1054
+ // Track whether we internally created the authStorage so we can close it
1055
+ // if construction fails before the session takes ownership.
1056
+ const ownsAuthStorage = !options.authStorage && !options.modelRegistry;
1054
1057
  const authStorage = modelRegistry.authStorage;
1055
1058
  if (options.authStorage && options.authStorage !== authStorage) {
1056
1059
  throw new Error(
@@ -2854,6 +2857,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
2854
2857
  await asyncJobManager.dispose({ timeoutMs: 3_000 });
2855
2858
  }
2856
2859
  await disposeKernelSessionsByOwner(evalKernelOwnerId);
2860
+ if (ownsAuthStorage) authStorage.close();
2857
2861
  }
2858
2862
  } catch (cleanupError) {
2859
2863
  logger.warn("Failed to clean up createAgentSession resources after startup error", {
@@ -104,6 +104,7 @@ import {
104
104
  streamSimple,
105
105
  } from "@oh-my-pi/pi-ai";
106
106
  import { stripToolDescriptions } from "@oh-my-pi/pi-ai/utils/schema";
107
+ import { THINKING_LOOP_ERROR_MARKER } from "@oh-my-pi/pi-ai/utils/thinking-loop";
107
108
  import { getSupportedEfforts } from "@oh-my-pi/pi-catalog/model-thinking";
108
109
  import { modelsAreEqual } from "@oh-my-pi/pi-catalog/models";
109
110
  import { MacOSPowerAssertion } from "@oh-my-pi/pi-natives";
@@ -205,6 +206,7 @@ import type { HindsightSessionState } from "../hindsight/state";
205
206
  import { type LocalProtocolOptions, resolveLocalUrlToPath } from "../internal-urls";
206
207
  import { IrcBus, type IrcMessage } from "../irc/bus";
207
208
  import { resolveMemoryBackend } from "../memory-backend";
209
+ import { shutdownMnemopiEmbedClient } from "../mnemopi/embed-client";
208
210
  import { getMnemopiSessionState, type MnemopiSessionState, setMnemopiSessionState } from "../mnemopi/state";
209
211
  import { containsOrchestrate, ORCHESTRATE_NOTICE } from "../modes/orchestrate";
210
212
  import { getCurrentThemeName, theme } from "../modes/theme/theme";
@@ -4208,6 +4210,11 @@ export class AgentSession {
4208
4210
  hindsightState?.dispose();
4209
4211
  const mnemopiState = setMnemopiSessionState(this, undefined);
4210
4212
  await mnemopiState?.dispose();
4213
+ // Tear down the embeddings subprocess AFTER mnemopi state.dispose:
4214
+ // consolidate-on-dispose may still call `embed()` to store the final
4215
+ // memories, and that round-trips through the worker we are about to
4216
+ // hard-kill (issue #3031).
4217
+ await shutdownMnemopiEmbedClient();
4211
4218
  this.#disconnectFromAgent();
4212
4219
  if (this.#unsubscribeAppendOnly) {
4213
4220
  this.#unsubscribeAppendOnly();
@@ -9977,6 +9984,7 @@ export class AgentSession {
9977
9984
  if (this.#isProviderErrorFinishReasonBeforeToolUse(message)) return true;
9978
9985
  if (this.#isMalformedFunctionCallError(message)) return true;
9979
9986
  if (this.#hasReplayUnsafeToolOutput(message)) return false;
9987
+ if (message.errorMessage.includes(THINKING_LOOP_ERROR_MARKER)) return true;
9980
9988
  if (this.#isStaleOpenAIResponsesReplayError(message)) return true;
9981
9989
 
9982
9990
  const err = message.errorMessage;
@@ -247,6 +247,20 @@ FROM model_usage_legacy
247
247
  { cause: lastError },
248
248
  );
249
249
  }
250
+ /** @internal Reset all singletons and close their databases — test-only. */
251
+ static resetInstance(): void {
252
+ for (const storage of instances.values()) storage.#close();
253
+ instances.clear();
254
+ }
255
+
256
+ #close(): void {
257
+ this.#listSettingsStmt.finalize();
258
+ this.#upsertModelUsageStmt.finalize();
259
+ this.#listModelUsageStmt.finalize();
260
+ // SqliteAuthCredentialStore.close() finalizes its own statements and
261
+ // closes the shared #db handle — must run after our statements finalize.
262
+ this.#authStore.close();
263
+ }
250
264
 
251
265
  /**
252
266
  * Reads legacy settings persisted in the agent.db `settings` table.
@@ -30,6 +30,7 @@ import {
30
30
  } from "@oh-my-pi/pi-ai/auth-broker/discover";
31
31
  import { getAgentDir } from "@oh-my-pi/pi-utils";
32
32
  import { resolveConfigValue } from "../config/resolve-config-value";
33
+ import type { AuthStorage } from "./auth-storage";
33
34
 
34
35
  export { type AuthBrokerClientConfig, getAuthBrokerTokenFilePath };
35
36
 
@@ -82,7 +83,7 @@ export function resolveAuthBrokerConfig(): Promise<AuthBrokerClientConfig | null
82
83
  export function discoverAuthStorage(
83
84
  agentDir: string = getAgentDir(),
84
85
  options?: Omit<DiscoverAuthStorageOptions, "agentDir" | "configValueResolver">,
85
- ): ReturnType<typeof discoverAuthStorageShared> {
86
+ ): Promise<AuthStorage> {
86
87
  return discoverAuthStorageShared({
87
88
  ...options,
88
89
  agentDir,
@@ -145,9 +145,21 @@ CREATE TRIGGER IF NOT EXISTS history_ai AFTER INSERT ON history BEGIN
145
145
  return HistoryStorage.#instance;
146
146
  }
147
147
 
148
- /** @internal Reset the singleton — test-only. */
148
+ /** @internal Reset the singleton and close its database — test-only. */
149
149
  static resetInstance(): void {
150
+ const instance = HistoryStorage.#instance;
150
151
  HistoryStorage.#instance = undefined;
152
+ if (instance) instance.#close();
153
+ }
154
+
155
+ #close(): void {
156
+ for (const stmt of this.#substringStmts.values()) stmt.finalize();
157
+ this.#substringStmts.clear();
158
+ this.#insertRowStmt.finalize();
159
+ this.#recentStmt.finalize();
160
+ this.#searchStmt.finalize();
161
+ this.#lastPromptStmt.finalize();
162
+ this.#db.close();
151
163
  }
152
164
 
153
165
  #insertBatch(rows: Array<Pick<HistoryEntry, "prompt" | "cwd" | "sessionId">>): void {
@@ -3,6 +3,7 @@ import { $env, isBunTestRuntime, isCompiledBinary, logger, workerHostEntry } fro
3
3
  import type { Subprocess } from "bun";
4
4
  import { settings } from "../config/settings";
5
5
  import { tinyWorkerEnvOverlay } from "../tiny/title-client";
6
+ import { safeSend } from "../utils/ipc";
6
7
  import type { SttProgressEvent, SttWorkerInbound, SttWorkerOutbound } from "./asr-protocol";
7
8
  import type { SttModelKey } from "./models";
8
9
 
@@ -181,13 +182,7 @@ export function createSttSubprocess(): SpawnedSubprocess {
181
182
  function wrapSubprocess({ proc, inbound, errors, intentionalExit }: SpawnedSubprocess): WorkerHandle {
182
183
  return {
183
184
  send(message) {
184
- try {
185
- proc.send(message);
186
- } catch (error) {
187
- logger.debug("stt: send to subprocess failed", {
188
- error: error instanceof Error ? error.message : String(error),
189
- });
190
- }
185
+ safeSend(proc, message, "stt");
191
186
  },
192
187
  onMessage(handler) {
193
188
  inbound.add(handler);
@@ -2,6 +2,7 @@ import * as path from "node:path";
2
2
  import { $env, isBunTestRuntime, isCompiledBinary, logger, workerHostEntry } from "@oh-my-pi/pi-utils";
3
3
  import type { Subprocess } from "bun";
4
4
  import { settings } from "../config/settings";
5
+ import { safeSend } from "../utils/ipc";
5
6
  import { tinyModelDeviceSettingToEnv } from "./device";
6
7
  import { tinyModelDtypeSettingToEnv } from "./dtype";
7
8
  import {
@@ -216,13 +217,7 @@ export function createTinyTitleSubprocess(): SpawnedSubprocess {
216
217
  function wrapSubprocess({ proc, inbound, errors, intentionalExit }: SpawnedSubprocess): WorkerHandle {
217
218
  return {
218
219
  send(message) {
219
- try {
220
- proc.send(message);
221
- } catch (error) {
222
- logger.debug("tiny-title: send to subprocess failed", {
223
- error: error instanceof Error ? error.message : String(error),
224
- });
225
- }
220
+ safeSend(proc, message, "tiny-title");
226
221
  },
227
222
  onMessage(handler) {
228
223
  inbound.add(handler);
@@ -1572,19 +1572,15 @@ export const imageGenTool: CustomTool<typeof imageGenSchema, ImageGenToolDetails
1572
1572
  };
1573
1573
 
1574
1574
  export async function getImageGenTools(
1575
- modelRegistry?: ModelRegistry,
1576
- activeModel?: Model,
1575
+ _modelRegistry?: ModelRegistry,
1576
+ _activeModel?: Model,
1577
1577
  ): Promise<Array<CustomTool<typeof imageGenSchema, ImageGenToolDetails>>> {
1578
- const apiKey = await findImageApiKey(modelRegistry, activeModel);
1579
- if (!apiKey) return [];
1580
1578
  return [imageGenTool];
1581
1579
  }
1582
1580
 
1583
1581
  export async function getImageGenToolsWithRegistry(
1584
- modelRegistry: ModelRegistry,
1585
- activeModel?: Model,
1582
+ _modelRegistry: ModelRegistry,
1583
+ _activeModel?: Model,
1586
1584
  ): Promise<Array<CustomTool<typeof imageGenSchema, ImageGenToolDetails>>> {
1587
- const apiKey = await findImageApiKey(modelRegistry, activeModel);
1588
- if (!apiKey) return [];
1589
1585
  return [imageGenTool];
1590
1586
  }
@@ -657,7 +657,10 @@ export function truncateDiffByHunk(
657
657
  export function shortenPath(filePath: string, homeDir?: string): string {
658
658
  const home = homeDir ?? os.homedir();
659
659
  if (home && filePath.startsWith(home)) {
660
- return `~${filePath.slice(home.length)}`;
660
+ const suffix = filePath.slice(home.length);
661
+ if (suffix === "" || suffix.startsWith(path.posix.sep) || suffix.startsWith(path.win32.sep)) {
662
+ return `~${suffix.replaceAll(path.win32.sep, path.posix.sep)}`;
663
+ }
661
664
  }
662
665
  return filePath;
663
666
  }
@@ -3,6 +3,7 @@ import { $env, isBunTestRuntime, isCompiledBinary, logger, workerHostEntry } fro
3
3
  import type { Subprocess } from "bun";
4
4
  import { settings } from "../config/settings";
5
5
  import { tinyWorkerEnvOverlay } from "../tiny/title-client";
6
+ import { safeSend } from "../utils/ipc";
6
7
  import { isTtsLocalModelKey, type TtsLocalModelKey } from "./models";
7
8
  import type { TtsProgressEvent, TtsWorkerInbound, TtsWorkerOutbound } from "./tts-protocol";
8
9
 
@@ -245,13 +246,7 @@ export function createTtsSubprocess(): SpawnedSubprocess {
245
246
  function wrapSubprocess({ proc, inbound, errors, intentionalExit }: SpawnedSubprocess): WorkerHandle {
246
247
  return {
247
248
  send(message) {
248
- try {
249
- proc.send(message);
250
- } catch (error) {
251
- logger.debug("tts: send to subprocess failed", {
252
- error: error instanceof Error ? error.message : String(error),
253
- });
254
- }
249
+ safeSend(proc, message, "tts");
255
250
  },
256
251
  onMessage(handler) {
257
252
  inbound.add(handler);
@@ -13,9 +13,19 @@ export const SUPPORTED_INPUT_IMAGE_MIME_TYPES = SUPPORTED_IMAGE_MIME_TYPES;
13
13
  * with an opaque HTTP 400. Detect those models so the resize pipeline encodes
14
14
  * to PNG/JPEG instead — the automatic equivalent of `OMP_NO_WEBP=1`.
15
15
  */
16
- export function modelLacksWebpSupport(model: Pick<Model, "provider" | "api"> | undefined): boolean {
16
+ export function modelLacksWebpSupport(
17
+ model: Pick<Model, "provider" | "api" | "imageInputDecoder"> | undefined,
18
+ ): boolean {
17
19
  if (!model) return false;
18
- return model.provider === "ollama" || model.provider === "ollama-cloud" || model.api === "ollama-chat";
20
+ return (
21
+ model.imageInputDecoder === "stb" ||
22
+ model.provider === "ollama" ||
23
+ model.provider === "ollama-cloud" ||
24
+ model.provider === "llama.cpp" ||
25
+ model.provider === "lm-studio" ||
26
+ model.provider === "local-server" ||
27
+ model.api === "ollama-chat"
28
+ );
19
29
  }
20
30
 
21
31
  /**
@@ -0,0 +1,38 @@
1
+ import { logger } from "@oh-my-pi/pi-utils";
2
+
3
+ /**
4
+ * Narrow a value to a thenable so a rejection handler can be attached.
5
+ *
6
+ * Mirrors the local helper in `mcp/transports/stdio.ts` (kept separate because
7
+ * that copy serves the FileSink stdin-write path and is battle-tested there).
8
+ * This shared copy is the home for the IPC `send()` sites.
9
+ */
10
+ export function isThenable(value: unknown): value is PromiseLike<unknown> {
11
+ return (
12
+ value != null &&
13
+ (typeof value === "object" || typeof value === "function") &&
14
+ typeof (value as { then?: unknown }).then === "function"
15
+ );
16
+ }
17
+
18
+ /**
19
+ * Send a message to a Bun subprocess over IPC, neutralizing both the
20
+ * synchronous throw ("cannot be used after the process has exited") and any
21
+ * asynchronous rejection (EPIPE from a pipe that broke between exit being
22
+ * observed and the next `send()`). The dead worker is detected separately via
23
+ * `onExit`/`onError` and respawned or disabled by the owning client; an
24
+ * un-awaited EPIPE rejection must not escape as a fatal unhandled rejection
25
+ * that takes down the whole session. See issue #2997.
26
+ *
27
+ * `label` prefixes the debug log on synchronous failure (e.g. "tts").
28
+ */
29
+ export function safeSend(proc: { send(message: unknown): unknown }, message: unknown, label: string): void {
30
+ try {
31
+ const result = proc.send(message);
32
+ if (isThenable(result)) result.then(undefined, () => {});
33
+ } catch (error) {
34
+ logger.debug(`${label}: send to subprocess failed`, {
35
+ error: error instanceof Error ? error.message : String(error),
36
+ });
37
+ }
38
+ }