@oh-my-pi/pi-coding-agent 16.1.2 → 16.1.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.
Files changed (65) hide show
  1. package/CHANGELOG.md +44 -1
  2. package/dist/cli.js +2990 -2991
  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/modes/components/cache-invalidation-marker.d.ts +23 -10
  9. package/dist/types/modes/components/status-line/component.d.ts +2 -3
  10. package/dist/types/sdk.d.ts +12 -0
  11. package/dist/types/session/agent-session.d.ts +2 -0
  12. package/dist/types/session/agent-storage.d.ts +2 -0
  13. package/dist/types/session/auth-broker-config.d.ts +3 -2
  14. package/dist/types/session/history-storage.d.ts +1 -1
  15. package/dist/types/session/tool-choice-queue.d.ts +2 -0
  16. package/dist/types/tools/image-gen.d.ts +2 -2
  17. package/dist/types/tools/index.d.ts +2 -0
  18. package/dist/types/tui/hyperlink.d.ts +3 -2
  19. package/dist/types/utils/image-loading.d.ts +1 -1
  20. package/dist/types/utils/ipc.d.ts +22 -0
  21. package/dist/types/web/search/providers/perplexity-auth.d.ts +37 -0
  22. package/package.json +12 -12
  23. package/src/cli/bench-cli.ts +33 -2
  24. package/src/cli/dry-balance-cli.ts +4 -2
  25. package/src/cli.ts +8 -0
  26. package/src/commands/token.ts +52 -33
  27. package/src/config/append-only-context-mode.ts +45 -0
  28. package/src/config/model-discovery.ts +3 -0
  29. package/src/config/model-registry.ts +21 -3
  30. package/src/config/model-resolver.ts +31 -8
  31. package/src/discovery/builtin-rules/ts-no-return-type.md +0 -1
  32. package/src/extensibility/plugins/manager.ts +82 -22
  33. package/src/lsp/client.ts +24 -0
  34. package/src/mnemopi/backend.ts +49 -3
  35. package/src/mnemopi/embed-client.ts +401 -0
  36. package/src/mnemopi/embed-protocol.ts +35 -0
  37. package/src/mnemopi/embed-worker.ts +113 -0
  38. package/src/mnemopi/state.ts +29 -1
  39. package/src/modes/components/cache-invalidation-marker.ts +31 -15
  40. package/src/modes/components/custom-editor.test.ts +4 -3
  41. package/src/modes/components/custom-editor.ts +1 -1
  42. package/src/modes/components/model-selector.ts +2 -2
  43. package/src/modes/components/status-line/component.ts +64 -18
  44. package/src/modes/components/welcome.ts +1 -1
  45. package/src/modes/controllers/event-controller.ts +8 -0
  46. package/src/modes/controllers/selector-controller.ts +2 -2
  47. package/src/modes/theme/theme.ts +69 -0
  48. package/src/sdk.ts +37 -0
  49. package/src/session/agent-session.ts +13 -0
  50. package/src/session/agent-storage.ts +14 -0
  51. package/src/session/auth-broker-config.ts +2 -1
  52. package/src/session/history-storage.ts +13 -1
  53. package/src/session/tool-choice-queue.ts +6 -0
  54. package/src/stt/asr-client.ts +2 -7
  55. package/src/tiny/title-client.ts +2 -7
  56. package/src/tools/image-gen.ts +4 -8
  57. package/src/tools/index.ts +2 -0
  58. package/src/tools/render-utils.ts +4 -1
  59. package/src/tools/resolve.ts +1 -0
  60. package/src/tts/tts-client.ts +2 -7
  61. package/src/tui/hyperlink.ts +6 -3
  62. package/src/utils/image-loading.ts +12 -2
  63. package/src/utils/ipc.ts +38 -0
  64. package/src/web/search/providers/perplexity-auth.ts +133 -0
  65. package/src/web/search/providers/perplexity.ts +2 -125
@@ -39,11 +39,12 @@ function feedGaps(editor: CustomEditor, gaps: number[]): void {
39
39
  }
40
40
  }
41
41
 
42
- async function decorateInFreshProcess(text: string): Promise<string> {
42
+ async function decorateInFreshProcess(text: string, imageLinks?: readonly string[]): Promise<string> {
43
43
  const customEditorUrl = new URL("./custom-editor.ts", import.meta.url).href;
44
44
  const script = `
45
45
  import { CustomEditor } from ${JSON.stringify(customEditorUrl)};
46
46
  const editor = new CustomEditor({});
47
+ editor.imageLinks = ${JSON.stringify(imageLinks)};
47
48
  process.stdout.write(editor.decorateText(${JSON.stringify(text)}));
48
49
  `;
49
50
  const child = await $`bun -e ${script}`.quiet().nothrow();
@@ -59,8 +60,8 @@ describe("CustomEditor placeholder decoration", () => {
59
60
  expect(output).toBe("[Paste #1, +30 lines]");
60
61
  });
61
62
 
62
- it("renders image placeholders before theme initialization", async () => {
63
- const output = await decorateInFreshProcess("[Image #1]");
63
+ it("renders linked image placeholders before theme and settings initialization", async () => {
64
+ const output = await decorateInFreshProcess("[Image #1]", ["/tmp/example.png"]);
64
65
  expect(output).toBe("[Image #1]");
65
66
  });
66
67
  });
@@ -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;
@@ -154,6 +154,8 @@ interface ContextUsageMemo {
154
154
  }
155
155
 
156
156
  const EMPTY_MESSAGES: readonly AgentMessage[] = [];
157
+ const STATUS_USAGE_START_DELAY_MS = 0;
158
+ const STATUS_USAGE_REFRESH_TIMEOUT_MS = 2_000;
157
159
 
158
160
  function hasContextSegment(segments: readonly StatusLineSegmentId[]): boolean {
159
161
  return segments.includes("context_pct") || segments.includes("context_total");
@@ -212,6 +214,7 @@ export class StatusLineComponent implements Component {
212
214
  } | null = null;
213
215
  #usageFetchedAt = 0;
214
216
  #usageInFlight = false;
217
+ #usageStartTimer: Timer | null = null;
215
218
  // Context-usage memo. The status line redraws on every agent event, so the
216
219
  // hot path must not recompute context tokens unless an input changed.
217
220
  // `getContextUsage()` anchors on the last assistant's real prompt-token
@@ -344,16 +347,24 @@ export class StatusLineComponent implements Component {
344
347
  dispose(): void {
345
348
  this.#disposed = true;
346
349
  this.#onBranchChange = null;
350
+ this.#clearUsageStartTimer();
347
351
  if (this.#gitWatcher) {
348
352
  this.#gitWatcher.close();
349
353
  this.#gitWatcher = null;
350
354
  }
351
355
  }
352
356
 
357
+ #clearUsageStartTimer(): void {
358
+ if (!this.#usageStartTimer) return;
359
+ clearTimeout(this.#usageStartTimer);
360
+ this.#usageStartTimer = null;
361
+ }
362
+
353
363
  invalidate(): void {
354
364
  this.#invalidateGitCaches();
355
365
  }
356
366
  #invalidateSessionCaches(): void {
367
+ this.#clearUsageStartTimer();
357
368
  this.#cachedUsage = null;
358
369
  this.#usageFetchedAt = 0;
359
370
  this.#usageInFlight = false;
@@ -521,38 +532,73 @@ export class StatusLineComponent implements Component {
521
532
  }
522
533
 
523
534
  /**
524
- * Background-refresh the Anthropic OAuth quota report. Guarded by a 5-min
525
- * TTL on both success (cache lifetime) and error (backoff). Exposed
526
- * (non-private) so unit tests can verify the backoff invariant.
535
+ * Startup redraws only arm a short-delayed task; timeout releases the render
536
+ * cadence while a late successful fetch can still refresh the cached segment.
527
537
  */
528
538
  refreshUsageInBackground(): void {
529
539
  const now = Date.now();
530
- if (this.#usageInFlight) return;
540
+ if (this.#usageInFlight || this.#usageStartTimer) return;
531
541
  if (this.#usageFetchedAt > 0 && now - this.#usageFetchedAt < 5 * 60_000) return;
532
542
  const session = this.session;
533
- const fetcher = (session as { fetchUsageReports?: () => Promise<unknown> }).fetchUsageReports;
543
+ const fetcher = (session as { fetchUsageReports?: (signal?: AbortSignal) => Promise<unknown> }).fetchUsageReports;
534
544
  if (typeof fetcher !== "function") return;
535
545
  this.#usageInFlight = true;
536
- void fetcher
537
- .call(session)
546
+ this.#usageStartTimer = setTimeout(() => {
547
+ this.#usageStartTimer = null;
548
+ void this.#runUsageRefresh(session, fetcher);
549
+ }, STATUS_USAGE_START_DELAY_MS);
550
+ }
551
+
552
+ async #runUsageRefresh(session: AgentSession, fetcher: (signal?: AbortSignal) => Promise<unknown>): Promise<void> {
553
+ if (this.#disposed || this.session !== session) {
554
+ this.#usageInFlight = false;
555
+ return;
556
+ }
557
+ const signal = AbortSignal.timeout(STATUS_USAGE_REFRESH_TIMEOUT_MS);
558
+ let reportsPromise: Promise<unknown> | undefined;
559
+ try {
560
+ reportsPromise = fetcher.call(session, signal);
561
+ this.#applyUsageRefreshReports(session, await this.#raceUsageRefreshWithSignal(reportsPromise, signal));
562
+ } catch {
563
+ if (this.session !== session) return;
564
+ this.#usageFetchedAt = Date.now();
565
+ if (signal.aborted && reportsPromise) {
566
+ this.#observeLateUsageRefresh(session, reportsPromise);
567
+ }
568
+ } finally {
569
+ if (this.session === session) this.#usageInFlight = false;
570
+ }
571
+ }
572
+
573
+ #applyUsageRefreshReports(session: AgentSession, reports: unknown): void {
574
+ if (this.#disposed || this.session !== session) return;
575
+ this.#cachedUsage = this.#normalizeUsageReports(reports);
576
+ this.#usageFetchedAt = Date.now();
577
+ }
578
+
579
+ #observeLateUsageRefresh(session: AgentSession, reportsPromise: Promise<unknown>): void {
580
+ void reportsPromise
538
581
  .then(reports => {
539
- if (this.session !== session) return;
540
- this.#cachedUsage = this.#normalizeUsageReports(reports);
541
- this.#usageFetchedAt = Date.now();
582
+ this.#applyUsageRefreshReports(session, reports);
542
583
  })
543
584
  .catch(() => {
544
- if (this.session !== session) return;
545
- // Backoff on error: stamp the fetch time so the 5-min TTL guard
546
- // also acts as an error budget. Without this, every render
547
- // kicks off another fetch (gated only by #usageInFlight),
548
- // which hammers the endpoint during a network outage / 5xx.
585
+ if (this.#disposed || this.session !== session) return;
549
586
  this.#usageFetchedAt = Date.now();
550
- })
551
- .finally(() => {
552
- if (this.session === session) this.#usageInFlight = false;
553
587
  });
554
588
  }
555
589
 
590
+ async #raceUsageRefreshWithSignal(promise: Promise<unknown>, signal: AbortSignal): Promise<unknown> {
591
+ if (signal.aborted) throw signal.reason;
592
+ const aborted = Promise.withResolvers<never>();
593
+ const onAbort = () => aborted.reject(signal.reason);
594
+ signal.addEventListener("abort", onAbort, { once: true });
595
+ try {
596
+ return await Promise.race([promise, aborted.promise]);
597
+ } finally {
598
+ signal.removeEventListener("abort", onAbort);
599
+ }
600
+ }
601
+
556
602
  #normalizeUsageReports(reports: unknown): {
557
603
  fiveHour?: { percent: number; resetMinutes?: number };
558
604
  sevenDay?: { percent: number; resetHours?: number };
@@ -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
@@ -687,6 +687,37 @@ export async function loadSessionExtensions(
687
687
  return result;
688
688
  }
689
689
 
690
+ /**
691
+ * Load discovered/configured extensions and register their providers into
692
+ * `modelRegistry`, then discover the dynamic provider catalogs. One-shot CLIs
693
+ * (`omp bench`, dry-balance) build a bare {@link ModelRegistry} that only knows
694
+ * built-in catalog providers; without this, providers contributed by an
695
+ * extension (e.g. a custom OpenAI-compatible provider under
696
+ * `~/.omp/agent/extensions/`) never reach model resolution. Mirrors the
697
+ * session / `omp models` path: drain the queued provider registrations, then
698
+ * `refreshRuntimeProviders` so dynamically-discovered models exist before
699
+ * selectors are resolved.
700
+ */
701
+ export async function loadCliExtensionProviders(
702
+ modelRegistry: ModelRegistry,
703
+ settings: Settings,
704
+ cwd: string,
705
+ options: Pick<CreateAgentSessionOptions, "disableExtensionDiscovery" | "additionalExtensionPaths"> = {},
706
+ ): Promise<void> {
707
+ const eventBus = new EventBus();
708
+ const extensionsResult = await loadSessionExtensions(options, cwd, settings, eventBus);
709
+ const activeSources = extensionsResult.extensions.map(extension => extension.path);
710
+ modelRegistry.syncExtensionSources(activeSources);
711
+ for (const sourceId of new Set(activeSources)) {
712
+ modelRegistry.clearSourceRegistrations(sourceId);
713
+ }
714
+ for (const { name, config, sourceId } of extensionsResult.runtime.pendingProviderRegistrations) {
715
+ modelRegistry.registerProvider(name, config, sourceId);
716
+ }
717
+ extensionsResult.runtime.pendingProviderRegistrations = [];
718
+ await modelRegistry.refreshRuntimeProviders();
719
+ }
720
+
690
721
  /**
691
722
  * Discover skills from cwd and agentDir.
692
723
  */
@@ -1051,6 +1082,9 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
1051
1082
  const modelRegistry =
1052
1083
  options.modelRegistry ??
1053
1084
  new ModelRegistry(options.authStorage ?? (await logger.time("discoverModels", discoverAuthStorage, agentDir)));
1085
+ // Track whether we internally created the authStorage so we can close it
1086
+ // if construction fails before the session takes ownership.
1087
+ const ownsAuthStorage = !options.authStorage && !options.modelRegistry;
1054
1088
  const authStorage = modelRegistry.authStorage;
1055
1089
  if (options.authStorage && options.authStorage !== authStorage) {
1056
1090
  throw new Error(
@@ -1515,6 +1549,8 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
1515
1549
  timestamp: Date.now(),
1516
1550
  }),
1517
1551
  peekQueueInvoker: () => session.peekQueueInvoker(),
1552
+ peekPendingInvoker: () => session.peekPendingInvoker(),
1553
+ clearPendingInvokers: () => session.clearPendingInvokers(),
1518
1554
  peekStandingResolveHandler: () => session.peekStandingResolveHandler(),
1519
1555
  setStandingResolveHandler: handler => session.setStandingResolveHandler(handler),
1520
1556
  allocateOutputArtifact: async toolType => {
@@ -2854,6 +2890,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
2854
2890
  await asyncJobManager.dispose({ timeoutMs: 3_000 });
2855
2891
  }
2856
2892
  await disposeKernelSessionsByOwner(evalKernelOwnerId);
2893
+ if (ownsAuthStorage) authStorage.close();
2857
2894
  }
2858
2895
  } catch (cleanupError) {
2859
2896
  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";
@@ -2179,6 +2181,11 @@ export class AgentSession {
2179
2181
  return this.#toolChoiceQueue.peekPendingInvoker();
2180
2182
  }
2181
2183
 
2184
+ /** Clear stale non-forcing pending preview invokers after `resolve` proves none can run. */
2185
+ clearPendingInvokers(): void {
2186
+ this.#toolChoiceQueue.clearPendingInvokers();
2187
+ }
2188
+
2182
2189
  /**
2183
2190
  * Force the next model call to target a specific active tool, then terminate
2184
2191
  * the agent loop. Pushes a two-step sequence [forced, "none"] so the model
@@ -4208,6 +4215,11 @@ export class AgentSession {
4208
4215
  hindsightState?.dispose();
4209
4216
  const mnemopiState = setMnemopiSessionState(this, undefined);
4210
4217
  await mnemopiState?.dispose();
4218
+ // Tear down the embeddings subprocess AFTER mnemopi state.dispose:
4219
+ // consolidate-on-dispose may still call `embed()` to store the final
4220
+ // memories, and that round-trips through the worker we are about to
4221
+ // hard-kill (issue #3031).
4222
+ await shutdownMnemopiEmbedClient();
4211
4223
  this.#disconnectFromAgent();
4212
4224
  if (this.#unsubscribeAppendOnly) {
4213
4225
  this.#unsubscribeAppendOnly();
@@ -9977,6 +9989,7 @@ export class AgentSession {
9977
9989
  if (this.#isProviderErrorFinishReasonBeforeToolUse(message)) return true;
9978
9990
  if (this.#isMalformedFunctionCallError(message)) return true;
9979
9991
  if (this.#hasReplayUnsafeToolOutput(message)) return false;
9992
+ if (message.errorMessage.includes(THINKING_LOOP_ERROR_MARKER)) return true;
9980
9993
  if (this.#isStaleOpenAIResponsesReplayError(message)) return true;
9981
9994
 
9982
9995
  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 {
@@ -231,6 +231,12 @@ export class ToolChoiceQueue {
231
231
  this.#pendingInvokers = this.#pendingInvokers.filter(p => p.id !== id);
232
232
  }
233
233
 
234
+ /** Drop every pending preview invoker without touching hard tool-choice directives. */
235
+ clearPendingInvokers(): void {
236
+ if (this.#pendingInvokers.length === 0) return;
237
+ this.#pendingInvokers = [];
238
+ }
239
+
234
240
  /** True when at least one non-forcing pending preview is registered. */
235
241
  get hasPendingInvoker(): boolean {
236
242
  return this.#pendingInvokers.length > 0;
@@ -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
  }
@@ -316,6 +316,8 @@ export interface ToolSession {
316
316
  * tool dispatches to it so a staged preview resolves WITHOUT forcing tool_choice — the
317
317
  * agent-loop's SoftToolRequirement lifecycle owns reminder injection and escalation. */
318
318
  peekPendingInvoker?(): ((input: unknown) => Promise<unknown> | unknown) | undefined;
319
+ /** Clear stale pending preview markers when `resolve` cannot dispatch them. */
320
+ clearPendingInvokers?(): void;
319
321
  /** Peek the long-lived "standing" resolve handler registered by a mode (e.g. plan mode).
320
322
  * Consulted by the `resolve` tool as a fallback when no queue invoker is in flight,
321
323
  * letting modes accept `resolve` invocations without forcing the tool choice every turn. */
@@ -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
  }
@@ -212,6 +212,7 @@ export class ResolveTool implements AgentTool<typeof resolveSchema, ResolveToolD
212
212
  this.session.peekPendingInvoker?.() ??
213
213
  this.session.peekStandingResolveHandler?.();
214
214
  if (!invoker) {
215
+ this.session.clearPendingInvokers?.();
215
216
  // `discard` is a request to cancel/abort a staged action. When nothing is
216
217
  // pending, the desired end-state (no staged change) already holds, so honor
217
218
  // it as a successful cancellation instead of surfacing a hard error to the
@@ -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);