@oh-my-pi/pi-coding-agent 17.0.1 → 17.0.2

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 (126) hide show
  1. package/CHANGELOG.md +92 -20
  2. package/dist/cli.js +3485 -3448
  3. package/dist/types/advisor/config.d.ts +14 -6
  4. package/dist/types/advisor/runtime.d.ts +20 -11
  5. package/dist/types/autolearn/controller.d.ts +1 -0
  6. package/dist/types/config/model-discovery.d.ts +17 -0
  7. package/dist/types/config/model-resolver.d.ts +6 -2
  8. package/dist/types/config/settings-schema.d.ts +32 -7
  9. package/dist/types/config/settings.d.ts +50 -0
  10. package/dist/types/cursor.d.ts +11 -0
  11. package/dist/types/discovery/helpers.d.ts +5 -2
  12. package/dist/types/exec/bash-executor.d.ts +2 -0
  13. package/dist/types/extensibility/extensions/managed-timers.d.ts +15 -0
  14. package/dist/types/extensibility/extensions/runner.d.ts +7 -0
  15. package/dist/types/extensibility/extensions/types.d.ts +18 -0
  16. package/dist/types/extensibility/legacy-pi-coding-agent-shim.d.ts +40 -0
  17. package/dist/types/extensibility/shared-events.d.ts +6 -0
  18. package/dist/types/extensibility/utils.d.ts +2 -2
  19. package/dist/types/mcp/transports/stdio.d.ts +13 -1
  20. package/dist/types/modes/components/advisor-config.d.ts +8 -1
  21. package/dist/types/modes/components/hook-editor.d.ts +7 -0
  22. package/dist/types/modes/components/model-hub.d.ts +5 -2
  23. package/dist/types/modes/components/session-selector.d.ts +2 -0
  24. package/dist/types/modes/controllers/command-controller.d.ts +8 -0
  25. package/dist/types/modes/controllers/selector-controller.d.ts +3 -1
  26. package/dist/types/modes/print-mode.d.ts +1 -1
  27. package/dist/types/modes/warp-events.d.ts +24 -0
  28. package/dist/types/modes/warp-events.test.d.ts +1 -0
  29. package/dist/types/plan-mode/model-transition.d.ts +47 -0
  30. package/dist/types/plan-mode/model-transition.test.d.ts +1 -0
  31. package/dist/types/registry/agent-lifecycle.d.ts +26 -1
  32. package/dist/types/sdk.d.ts +13 -2
  33. package/dist/types/session/agent-session.d.ts +34 -10
  34. package/dist/types/session/session-history-format.d.ts +10 -0
  35. package/dist/types/session/session-manager.d.ts +14 -0
  36. package/dist/types/slash-commands/helpers/active-oauth-account.d.ts +9 -0
  37. package/dist/types/task/label.d.ts +1 -1
  38. package/dist/types/telemetry-export.d.ts +34 -9
  39. package/dist/types/tools/approval.d.ts +8 -0
  40. package/dist/types/tools/bash.d.ts +2 -0
  41. package/dist/types/tools/essential-tools.d.ts +29 -0
  42. package/dist/types/tools/image-gen.d.ts +2 -1
  43. package/dist/types/tools/index.d.ts +1 -0
  44. package/dist/types/tools/xdev.d.ts +11 -2
  45. package/dist/types/utils/title-generator.d.ts +2 -1
  46. package/dist/types/web/search/providers/kimi.d.ts +4 -1
  47. package/dist/types/web/search/types.d.ts +1 -1
  48. package/package.json +21 -16
  49. package/src/advisor/__tests__/advisor.test.ts +1304 -42
  50. package/src/advisor/__tests__/config.test.ts +58 -2
  51. package/src/advisor/config.ts +76 -24
  52. package/src/advisor/runtime.ts +445 -92
  53. package/src/autolearn/controller.ts +23 -28
  54. package/src/cli.ts +5 -1
  55. package/src/config/model-discovery.ts +81 -21
  56. package/src/config/model-registry.ts +25 -6
  57. package/src/config/model-resolver.ts +14 -7
  58. package/src/config/settings-schema.ts +42 -6
  59. package/src/config/settings.ts +405 -25
  60. package/src/cursor.ts +20 -3
  61. package/src/debug/report-bundle.ts +40 -4
  62. package/src/discovery/helpers.ts +28 -5
  63. package/src/exec/bash-executor.ts +14 -5
  64. package/src/extensibility/custom-tools/loader.ts +3 -3
  65. package/src/extensibility/custom-tools/wrapper.ts +2 -1
  66. package/src/extensibility/extensions/loader.ts +3 -3
  67. package/src/extensibility/extensions/managed-timers.ts +83 -0
  68. package/src/extensibility/extensions/runner.ts +26 -0
  69. package/src/extensibility/extensions/types.ts +18 -0
  70. package/src/extensibility/extensions/wrapper.ts +2 -1
  71. package/src/extensibility/hooks/loader.ts +3 -3
  72. package/src/extensibility/legacy-pi-coding-agent-shim.ts +96 -1
  73. package/src/extensibility/plugins/legacy-pi-compat.ts +225 -22
  74. package/src/extensibility/plugins/manager.ts +2 -2
  75. package/src/extensibility/shared-events.ts +6 -0
  76. package/src/extensibility/utils.ts +91 -25
  77. package/src/irc/bus.ts +22 -3
  78. package/src/launch/broker.ts +3 -2
  79. package/src/launch/client.ts +2 -2
  80. package/src/launch/presence.ts +2 -2
  81. package/src/lsp/client.ts +1 -1
  82. package/src/main.ts +11 -8
  83. package/src/mcp/manager.ts +9 -3
  84. package/src/mcp/transports/stdio.ts +103 -23
  85. package/src/modes/components/advisor-config.ts +65 -3
  86. package/src/modes/components/ask-dialog.ts +1 -1
  87. package/src/modes/components/hook-editor.ts +18 -3
  88. package/src/modes/components/model-hub.ts +138 -42
  89. package/src/modes/components/session-selector.ts +4 -0
  90. package/src/modes/components/status-line/component.test.ts +1 -0
  91. package/src/modes/components/status-line/segments.ts +21 -6
  92. package/src/modes/controllers/command-controller.ts +167 -47
  93. package/src/modes/controllers/event-controller.ts +5 -0
  94. package/src/modes/controllers/extension-ui-controller.ts +4 -22
  95. package/src/modes/controllers/input-controller.ts +12 -12
  96. package/src/modes/controllers/selector-controller.ts +191 -31
  97. package/src/modes/interactive-mode.ts +139 -54
  98. package/src/modes/print-mode.ts +3 -3
  99. package/src/modes/rpc/host-tools.ts +2 -1
  100. package/src/modes/rpc/rpc-mode.ts +19 -4
  101. package/src/modes/warp-events.test.ts +794 -0
  102. package/src/modes/warp-events.ts +232 -0
  103. package/src/plan-mode/model-transition.test.ts +60 -0
  104. package/src/plan-mode/model-transition.ts +51 -0
  105. package/src/registry/agent-lifecycle.ts +133 -18
  106. package/src/sdk.ts +221 -42
  107. package/src/session/agent-session.ts +1285 -348
  108. package/src/session/session-history-format.ts +20 -5
  109. package/src/session/session-manager.ts +48 -0
  110. package/src/slash-commands/builtin-registry.ts +7 -0
  111. package/src/slash-commands/helpers/active-oauth-account.ts +16 -0
  112. package/src/task/executor.ts +1 -1
  113. package/src/task/label.ts +2 -0
  114. package/src/telemetry-export.ts +453 -97
  115. package/src/tools/approval.ts +11 -0
  116. package/src/tools/bash.ts +71 -38
  117. package/src/tools/essential-tools.ts +45 -0
  118. package/src/tools/gh.ts +169 -2
  119. package/src/tools/image-gen.ts +69 -7
  120. package/src/tools/index.ts +7 -5
  121. package/src/tools/read.ts +48 -3
  122. package/src/tools/write.ts +22 -4
  123. package/src/tools/xdev.ts +14 -3
  124. package/src/utils/title-generator.ts +15 -4
  125. package/src/web/search/providers/kimi.ts +18 -12
  126. package/src/web/search/types.ts +6 -1
@@ -741,13 +741,16 @@ export function buildExtensionModuleItems(
741
741
  * Entry for an installed Claude Code plugin.
742
742
  */
743
743
  export interface ClaudePluginEntry {
744
- scope: "user" | "project";
744
+ /** Claude registry scope; local entries are restricted to their project path. */
745
+ scope?: "user" | "project" | "local";
745
746
  installPath: string;
746
747
  version: string;
747
748
  installedAt: string;
748
749
  lastUpdated: string;
749
750
  gitCommitSha?: string;
750
751
  enabled?: boolean;
752
+ /** Project root recorded by Claude for a local installation. */
753
+ projectPath?: string;
751
754
  }
752
755
 
753
756
  /**
@@ -862,6 +865,14 @@ export async function resolveOrDefaultProjectRegistryPath(cwd: string): Promise<
862
865
  return path.join(cwd, getConfigDirName(), "plugins", "installed_plugins.json");
863
866
  }
864
867
 
868
+ async function canonicalClaudeProjectPath(projectPath: string): Promise<string | null> {
869
+ try {
870
+ return await fs.promises.realpath(path.resolve(projectPath));
871
+ } catch {
872
+ return null;
873
+ }
874
+ }
875
+
865
876
  const pluginRootsCache = new Map<string, { roots: ClaudePluginRoot[]; warnings: string[] }>();
866
877
 
867
878
  const pluginCacheInvalidators = new Set<() => void>();
@@ -876,20 +887,23 @@ export function registerPluginCacheInvalidator(invalidator: () => void): void {
876
887
  * Reads ~/.claude/plugins/installed_plugins.json and ~/.omp/plugins/installed_plugins.json,
877
888
  * and optionally the nearest project-scoped registry resolved from `cwd`.
878
889
  *
879
- * Results are cached per `home:resolvedProjectPath` key to avoid repeated parsing.
890
+ * Results are cached per home, project registry, and canonical active project.
880
891
  */
881
892
  export async function listClaudePluginRoots(
882
893
  home: string,
883
894
  cwd?: string,
884
895
  ): Promise<{ roots: ClaudePluginRoot[]; warnings: string[] }> {
885
896
  const resolvedProjectPath = cwd ? await resolveActiveProjectRegistryPath(cwd) : null;
886
- const cacheKey = `${home}:${resolvedProjectPath ?? ""}`;
897
+ const projectRoot = resolvedProjectPath ? path.dirname(path.dirname(path.dirname(resolvedProjectPath))) : cwd;
898
+ const activeClaudeProjectPath = projectRoot ? await canonicalClaudeProjectPath(projectRoot) : null;
899
+ const cacheKey = `${home}:${resolvedProjectPath ?? ""}:${activeClaudeProjectPath ?? ""}`;
887
900
  const cached = pluginRootsCache.get(cacheKey);
888
901
  if (cached) return cached;
889
902
 
890
903
  const roots: ClaudePluginRoot[] = [];
891
904
  const warnings: string[] = [];
892
905
  const projectRoots: ClaudePluginRoot[] = [];
906
+ const canonicalClaudeProjectPaths = new Map<string, string | null>();
893
907
 
894
908
  // ── Claude Code registry ──────────────────────────────────────────────────
895
909
  const registryPath = path.join(home, ".claude", "plugins", "installed_plugins.json");
@@ -921,6 +935,15 @@ export async function listClaudePluginRoots(
921
935
  continue;
922
936
  }
923
937
  if (entry.enabled === false) continue;
938
+ if (entry.scope === "local") {
939
+ if (!entry.projectPath || !activeClaudeProjectPath) continue;
940
+ let entryProjectPath = canonicalClaudeProjectPaths.get(entry.projectPath);
941
+ if (entryProjectPath === undefined) {
942
+ entryProjectPath = await canonicalClaudeProjectPath(entry.projectPath);
943
+ canonicalClaudeProjectPaths.set(entry.projectPath, entryProjectPath);
944
+ }
945
+ if (entryProjectPath !== activeClaudeProjectPath) continue;
946
+ }
924
947
 
925
948
  roots.push({
926
949
  id: pluginId,
@@ -928,7 +951,7 @@ export async function listClaudePluginRoots(
928
951
  plugin: pluginName,
929
952
  version: entry.version || "unknown",
930
953
  path: entry.installPath,
931
- scope: entry.scope || "user",
954
+ scope: entry.scope === "local" ? "project" : entry.scope || "user",
932
955
  });
933
956
  }
934
957
  }
@@ -976,7 +999,7 @@ export async function listClaudePluginRoots(
976
999
  plugin: pluginName,
977
1000
  version: entry.version || "unknown",
978
1001
  path: entry.installPath,
979
- scope: entry.scope || "user",
1002
+ scope: entry.scope === "local" ? "project" : entry.scope || "user",
980
1003
  });
981
1004
  }
982
1005
  }
@@ -45,6 +45,8 @@ export interface BashResult {
45
45
  output: string;
46
46
  exitCode: number | undefined;
47
47
  cancelled: boolean;
48
+ /** True when the command was killed by its timeout deadline (not a user abort). */
49
+ timedOut?: boolean;
48
50
  truncated: boolean;
49
51
  totalLines: number;
50
52
  totalBytes: number;
@@ -70,6 +72,9 @@ const shellSessionsInUse = new Set<string>();
70
72
  */
71
73
  const retainedShells = new Set<Shell>();
72
74
  const RETAIN_REAP_INTERVAL_MS = 5_000;
75
+ // Native cancellation may spend two seconds unwinding the shell before its
76
+ // N-API chunk bridge drains. The JS watchdog must not race that teardown.
77
+ const NATIVE_TIMEOUT_FALLBACK_GRACE_MS = 5_000;
73
78
 
74
79
  async function retainShellWithLiveBackgroundJobs(shell: Shell): Promise<void> {
75
80
  let live: number;
@@ -306,16 +311,18 @@ export async function executeBash(command: string, options?: BashExecutorOptions
306
311
  const nativeTimeoutMs = requestedTimeoutMs !== undefined && requestedTimeoutMs > 0 ? requestedTimeoutMs : undefined;
307
312
  const nativeOwnsTimeout = nativeTimeoutMs !== undefined;
308
313
  if (deadlineTimeoutMs !== undefined) {
314
+ const fallbackTimeoutMs = nativeOwnsTimeout
315
+ ? deadlineTimeoutMs + NATIVE_TIMEOUT_FALLBACK_GRACE_MS
316
+ : deadlineTimeoutMs;
309
317
  timeoutTimer = setTimeout(() => {
310
- // Explicit timeouts are already enforced inside pi-natives via
311
- // `timeoutMs`. Do not also abort the JS AbortSignal here: on Windows,
312
- // aborting that signal while a piped command is still forwarding output
313
- // can terminate the Bun host before the native timeout result resolves.
318
+ // Explicit timeouts are enforced inside pi-natives via `timeoutMs`.
319
+ // Give native cancellation time to flush pipeline output and drain the
320
+ // N-API bridge before this result-only watchdog quarantines the run.
314
321
  if (!nativeOwnsTimeout) {
315
322
  abortCurrentExecution();
316
323
  }
317
324
  timeoutDeferred.resolve("timeout");
318
- }, deadlineTimeoutMs);
325
+ }, fallbackTimeoutMs);
319
326
  }
320
327
 
321
328
  let resetSession = false;
@@ -357,6 +364,7 @@ export async function executeBash(command: string, options?: BashExecutorOptions
357
364
  return {
358
365
  exitCode: undefined,
359
366
  cancelled: true,
367
+ ...(winner.kind === "timeout" ? { timedOut: true } : {}),
360
368
  ...(await sink.dump(
361
369
  winner.kind === "timeout" && deadlineTimeoutMs !== undefined
362
370
  ? `Command timed out after ${Math.round(deadlineTimeoutMs / 1000)} seconds`
@@ -381,6 +389,7 @@ export async function executeBash(command: string, options?: BashExecutorOptions
381
389
  return {
382
390
  exitCode: undefined,
383
391
  cancelled: true,
392
+ timedOut: true,
384
393
  ...(await sink.dump(annotation)),
385
394
  };
386
395
  }
@@ -18,7 +18,7 @@ import { getAllPluginToolPaths } from "../../extensibility/plugins/loader";
18
18
  // Runtime self-reference: dereference this namespace only inside loader functions to keep the index.ts cycle safe.
19
19
  import * as PiCodingAgent from "../../index";
20
20
  import * as typebox from "../typebox";
21
- import { createNoOpUIContext, resolvePath, withExitGuard } from "../utils";
21
+ import { createNoOpUIContext, resolvePath, withHostGuard } from "../utils";
22
22
  import type { CustomToolAPI, CustomToolFactory, LoadedCustomTool, ToolLoadError } from "./types";
23
23
 
24
24
  interface LoadToolResult {
@@ -75,14 +75,14 @@ async function loadTool(
75
75
  }
76
76
 
77
77
  try {
78
- const module = await withExitGuard(() => import(resolvedPath));
78
+ const module = await withHostGuard(() => import(resolvedPath));
79
79
  const factory = (module.default ?? module) as CustomToolFactory;
80
80
 
81
81
  if (typeof factory !== "function") {
82
82
  return { tools: [], errors: [{ path: toolPath, error: "Tool must export a default function", source }] };
83
83
  }
84
84
 
85
- const toolResult: unknown = await withExitGuard(async () => factory(sharedApi));
85
+ const toolResult: unknown = await withHostGuard(async () => factory(sharedApi));
86
86
  const toolsArray = Array.isArray(toolResult) ? toolResult : [toolResult];
87
87
 
88
88
  const loadedTools: LoadedCustomTool[] = [];
@@ -4,6 +4,7 @@
4
4
  import type { AgentTool, AgentToolUpdateCallback, ToolLoadMode } from "@oh-my-pi/pi-agent-core";
5
5
  import type { Static, TSchema } from "@oh-my-pi/pi-ai";
6
6
  import type { Theme } from "../../modes/theme/theme";
7
+ import { defaultLoadModeForToolName } from "../../tools/essential-tools";
7
8
  import { applyToolProxy } from "../tool-proxy";
8
9
  import type { CustomTool, CustomToolContext } from "./types";
9
10
 
@@ -23,7 +24,7 @@ export class CustomToolAdapter<TParams extends TSchema = TSchema, TDetails = any
23
24
  ) {
24
25
  applyToolProxy(tool, this);
25
26
  this.strict = tool.strict;
26
- this.loadMode = tool.loadMode ?? "discoverable";
27
+ this.loadMode = defaultLoadModeForToolName(tool.name, tool.loadMode);
27
28
  }
28
29
 
29
30
  execute(
@@ -24,7 +24,7 @@ import { installLegacyPiSpecifierShim, loadLegacyPiModule } from "../plugins/leg
24
24
  import { getAllPluginExtensionPaths } from "../plugins/loader";
25
25
  import * as TypeBox from "../typebox";
26
26
 
27
- import { resolvePath, withExitGuard } from "../utils";
27
+ import { resolvePath, withHostGuard } from "../utils";
28
28
  import type {
29
29
  AssistantThinkingRenderer,
30
30
  Extension,
@@ -290,7 +290,7 @@ async function loadExtension(
290
290
  ): Promise<{ extension: Extension | null; error: string | null }> {
291
291
  const resolvedPath = resolvePath(extensionPath, cwd);
292
292
  try {
293
- const module = (await withExitGuard(() => loadLegacyPiModule(resolvedPath))) as LoadedExtensionModule;
293
+ const module = (await withHostGuard(() => loadLegacyPiModule(resolvedPath))) as LoadedExtensionModule;
294
294
  const factory = getExtensionFactory(module);
295
295
 
296
296
  if (typeof factory !== "function") {
@@ -302,7 +302,7 @@ async function loadExtension(
302
302
 
303
303
  const extension = createExtension(extensionPath, resolvedPath);
304
304
  const api = new ConcreteExtensionAPI(PiCodingAgent, extension, runtime, cwd, eventBus);
305
- await withExitGuard(async () => {
305
+ await withHostGuard(async () => {
306
306
  await factory(api);
307
307
  });
308
308
 
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Managed timers for extensions.
3
+ *
4
+ * Extensions scheduling their own background work through raw `setInterval` /
5
+ * `setTimeout` used to be able to take down the whole session: a throw inside
6
+ * the callback runs on a fresh stack outside the handler-dispatch try/catch,
7
+ * surfaces as a process-level `uncaughtException`, and the global postmortem
8
+ * handler treats that as fatal (issue #5664).
9
+ *
10
+ * {@link ManagedTimers} backs the sanctioned `ctx.setInterval` /
11
+ * `ctx.setTimeout` helpers. Each callback runs inside the same isolation the
12
+ * runner already applies to handler dispatch — a synchronous throw or a
13
+ * rejected promise is reported through `onError` and swallowed — and every
14
+ * outstanding handle is `unref`'d (never keeps the process alive) and cleared
15
+ * on session teardown via {@link clearAll}.
16
+ */
17
+ import { logger } from "@oh-my-pi/pi-utils";
18
+
19
+ /** Callback invoked when a managed timer's callback throws or rejects. */
20
+ export type ManagedTimerErrorHandler = (event: string, error: string, stack?: string) => void;
21
+
22
+ export class ManagedTimers {
23
+ readonly #timers = new Set<Timer>();
24
+
25
+ constructor(private readonly onError: ManagedTimerErrorHandler) {}
26
+
27
+ /** Schedule a repeating callback whose throws are contained. */
28
+ setInterval(callback: (...args: unknown[]) => void, ms?: number, ...args: unknown[]): Timer {
29
+ const timer = setInterval(() => this.#run("interval", callback, args), ms, ...args);
30
+ timer.unref?.();
31
+ this.#timers.add(timer);
32
+ return timer;
33
+ }
34
+
35
+ /** Schedule a one-shot callback whose throws are contained. Deregisters after it fires. */
36
+ setTimeout(callback: (...args: unknown[]) => void, ms?: number, ...args: unknown[]): Timer {
37
+ const timer = setTimeout(
38
+ () => {
39
+ this.#timers.delete(timer);
40
+ this.#run("timeout", callback, args);
41
+ },
42
+ ms,
43
+ ...args,
44
+ );
45
+ timer.unref?.();
46
+ this.#timers.add(timer);
47
+ return timer;
48
+ }
49
+
50
+ /** Clear one managed timer. Accepts an interval or timeout handle. */
51
+ clear(timer: Timer): void {
52
+ if (!this.#timers.delete(timer)) return;
53
+ clearInterval(timer);
54
+ clearTimeout(timer);
55
+ }
56
+
57
+ /** Clear every outstanding managed timer. Called on session teardown. */
58
+ clearAll(): void {
59
+ for (const timer of this.#timers) {
60
+ clearInterval(timer);
61
+ clearTimeout(timer);
62
+ }
63
+ this.#timers.clear();
64
+ }
65
+
66
+ #run(kind: "interval" | "timeout", callback: (...args: unknown[]) => void, args: unknown[]): void {
67
+ try {
68
+ const result = callback(...args) as unknown;
69
+ if (result instanceof Promise) {
70
+ result.catch((err: unknown) => this.#report(kind, err));
71
+ }
72
+ } catch (err) {
73
+ this.#report(kind, err);
74
+ }
75
+ }
76
+
77
+ #report(kind: "interval" | "timeout", err: unknown): void {
78
+ const message = err instanceof Error ? err.message : String(err);
79
+ const stack = err instanceof Error ? err.stack : undefined;
80
+ logger.warn("Extension timer callback threw", { event: `${kind}_callback`, error: message });
81
+ this.onError(`${kind}_callback`, message, stack);
82
+ }
83
+ }
@@ -12,6 +12,7 @@ import type { MemoryRuntimeContext } from "../../memory-backend";
12
12
  import { type Theme, theme } from "../../modes/theme/theme";
13
13
  import type { SessionManager } from "../../session/session-manager";
14
14
  import type { BranchHandler, NavigateTreeHandler, NewSessionHandler } from "../session-handler-types";
15
+ import { ManagedTimers } from "./managed-timers";
15
16
  import { createExtensionModelQuery } from "./model-api";
16
17
  import type {
17
18
  AfterProviderResponseEvent,
@@ -249,6 +250,18 @@ export class ExtensionRunner {
249
250
  */
250
251
  #pendingCredentialDisabled: CredentialDisabledEvent[] = [];
251
252
 
253
+ /**
254
+ * Timers scheduled by extensions through the sanctioned `ctx.setInterval` /
255
+ * `ctx.setTimeout` helpers. Callbacks run with the same isolation as handler
256
+ * dispatch — a throw is logged and routed through {@link onError} instead of
257
+ * escaping to the process `uncaughtException` handler and tearing down the
258
+ * whole session (issue #5664). Handles are `unref`'d and every outstanding
259
+ * timer is cleared on session teardown via {@link clearManagedTimers}.
260
+ */
261
+ #managedTimers = new ManagedTimers((event, error, stack) =>
262
+ this.emitError({ extensionPath: "<timer>", event, error, stack }),
263
+ );
264
+
252
265
  constructor(
253
266
  private readonly extensions: Extension[],
254
267
  private readonly runtime: ExtensionRuntime,
@@ -542,6 +555,9 @@ export class ExtensionRunner {
542
555
  getSystemPrompt: () => this.#getSystemPromptFn(),
543
556
  localProtocolOptions: this.localProtocolOptions,
544
557
  memory: this.#getMemoryFn?.(),
558
+ setInterval: (callback, ms, ...args) => this.#managedTimers.setInterval(callback, ms, ...args),
559
+ setTimeout: (callback, ms, ...args) => this.#managedTimers.setTimeout(callback, ms, ...args),
560
+ clearTimer: timer => this.#managedTimers.clear(timer),
545
561
  };
546
562
  }
547
563
 
@@ -552,6 +568,16 @@ export class ExtensionRunner {
552
568
  this.#shutdownHandler();
553
569
  }
554
570
 
571
+ /**
572
+ * Clear every timer scheduled through `ctx.setInterval` / `ctx.setTimeout`.
573
+ * Called during session teardown so extension background work does not
574
+ * outlive the session (a self-scheduling interval would otherwise keep
575
+ * firing against a disposed session).
576
+ */
577
+ clearManagedTimers(): void {
578
+ this.#managedTimers.clearAll();
579
+ }
580
+
555
581
  createCommandContext(): ExtensionCommandContext {
556
582
  return {
557
583
  ...this.createContext(),
@@ -440,6 +440,24 @@ export interface ExtensionContext {
440
440
  getSystemPrompt(): string[];
441
441
  /** Structured memory runtime for status/search/save across the configured backend. */
442
442
  memory?: MemoryRuntimeContext;
443
+ /**
444
+ * Schedule a repeating callback whose throws are contained. Unlike raw
445
+ * `setInterval`, a synchronous throw or rejected promise from `callback` is
446
+ * logged and surfaced through the extension error channel instead of
447
+ * escaping as a process-fatal `uncaughtException` — one misbehaving timer
448
+ * can no longer take down the whole session. The handle is `unref`'d and
449
+ * cleared automatically on `session_shutdown`. Prefer this over raw
450
+ * `setInterval` for any extension background work.
451
+ */
452
+ setInterval(callback: (...args: unknown[]) => void, ms?: number, ...args: unknown[]): Timer;
453
+ /**
454
+ * Schedule a one-shot callback whose throws are contained, mirroring
455
+ * {@link setInterval}. Cleared automatically on `session_shutdown` if it has
456
+ * not yet fired.
457
+ */
458
+ setTimeout(callback: (...args: unknown[]) => void, ms?: number, ...args: unknown[]): Timer;
459
+ /** Clear a timer scheduled via {@link setInterval} or {@link setTimeout}. */
460
+ clearTimer(timer: Timer): void;
443
461
  }
444
462
 
445
463
  /**
@@ -12,6 +12,7 @@ import type { ImageContent, Static, TextContent, TSchema } from "@oh-my-pi/pi-ai
12
12
  import type { Settings } from "../../config/settings";
13
13
  import type { Theme } from "../../modes/theme/theme";
14
14
  import { type ApprovalMode, formatApprovalPrompt, resolveApproval } from "../../tools/approval";
15
+ import { defaultLoadModeForToolName } from "../../tools/essential-tools";
15
16
  import { normalizeToolEventInput, resolveToolEventInput } from "../tool-event-input";
16
17
  import { applyToolProxy } from "../tool-proxy";
17
18
  import type { ExtensionRunner } from "./runner";
@@ -36,7 +37,7 @@ export class RegisteredToolAdapter implements AgentTool<any, any, any> {
36
37
  private runner: ExtensionRunner,
37
38
  ) {
38
39
  applyToolProxy(registeredTool.definition, this);
39
- this.loadMode = registeredTool.definition.loadMode ?? "discoverable";
40
+ this.loadMode = defaultLoadModeForToolName(registeredTool.definition.name, registeredTool.definition.loadMode);
40
41
 
41
42
  // Only define render methods when the underlying definition provides them.
42
43
  // If these exist unconditionally on the prototype, ToolExecutionComponent
@@ -12,7 +12,7 @@ import { loadCapability } from "../../discovery";
12
12
  import * as PiCodingAgent from "../../index";
13
13
  import type { CustomMessagePayload } from "../../session/messages";
14
14
  import * as typebox from "../typebox";
15
- import { resolvePath, withExitGuard } from "../utils";
15
+ import { resolvePath, withHostGuard } from "../utils";
16
16
  import { execCommand } from "./runner";
17
17
  import type { ExecOptions, HookAPI, HookFactory, HookMessageRenderer, RegisteredCommand } from "./types";
18
18
 
@@ -149,7 +149,7 @@ async function loadHook(hookPath: string, cwd: string): Promise<{ hook: LoadedHo
149
149
 
150
150
  try {
151
151
  // Import the module using native Bun import
152
- const module = await withExitGuard(() => import(resolvedPath));
152
+ const module = await withHostGuard(() => import(resolvedPath));
153
153
  const factory = module.default as HookFactory;
154
154
 
155
155
  if (typeof factory !== "function") {
@@ -164,7 +164,7 @@ async function loadHook(hookPath: string, cwd: string): Promise<{ hook: LoadedHo
164
164
  );
165
165
 
166
166
  // Call factory to register handlers
167
- await withExitGuard(async () => factory(api));
167
+ await withHostGuard(async () => factory(api));
168
168
 
169
169
  return {
170
170
  hook: {
@@ -44,9 +44,10 @@ import { ReadTool } from "../tools/read";
44
44
  import { formatBytes } from "../tools/render-utils";
45
45
  import { WriteTool } from "../tools/write";
46
46
  import { EventBus } from "../utils/event-bus";
47
- import { loadExtensionFromFactory, loadExtensions } from "./extensions";
47
+ import { discoverExtensionPaths, loadExtensionFromFactory, loadExtensions } from "./extensions";
48
48
  import { ExtensionRuntime } from "./extensions/loader";
49
49
  import type { ExtensionFactory, ToolDefinition } from "./extensions/types";
50
+ import { getEnabledPlugins, resolvePluginExtensionPaths, type ScopedInstalledPlugin } from "./plugins/loader";
50
51
  import type { Skill } from "./skills";
51
52
  import { loadSkillsFromDir } from "./skills";
52
53
  import { Type } from "./typebox";
@@ -655,6 +656,100 @@ export const SettingsManager = {
655
656
  },
656
657
  } as const;
657
658
 
659
+ /** Scope used by the legacy package manager for discovered resources. */
660
+ export type SourceScope = "user" | "project" | "temporary";
661
+
662
+ /** Discovery metadata exposed alongside a legacy package resource path. */
663
+ export interface PathMetadata {
664
+ source: string;
665
+ scope: SourceScope;
666
+ origin: "package" | "top-level";
667
+ baseDir?: string;
668
+ }
669
+
670
+ /** One extension, skill, prompt, or theme resolved by the legacy package manager. */
671
+ export interface ResolvedResource {
672
+ path: string;
673
+ enabled: boolean;
674
+ metadata: PathMetadata;
675
+ }
676
+
677
+ /** Resource groups returned by {@link DefaultPackageManager.resolve}. */
678
+ export interface ResolvedPaths {
679
+ extensions: ResolvedResource[];
680
+ skills: ResolvedResource[];
681
+ prompts: ResolvedResource[];
682
+ themes: ResolvedResource[];
683
+ }
684
+
685
+ /** Action a legacy caller requests when a configured package is unavailable. */
686
+ export type MissingSourceAction = "install" | "skip" | "error";
687
+
688
+ /** Construction inputs accepted by the legacy package manager. */
689
+ export interface DefaultPackageManagerOptions {
690
+ cwd: string;
691
+ agentDir: string;
692
+ settingsManager: Settings | Promise<Settings>;
693
+ }
694
+
695
+ /**
696
+ * Enumerates the extensions OMP would load through the historical package
697
+ * manager surface used by legacy extensions.
698
+ */
699
+ export class DefaultPackageManager {
700
+ #cwd: string;
701
+ #agentDir: string;
702
+ #settingsManager: Settings | Promise<Settings>;
703
+
704
+ constructor(options: DefaultPackageManagerOptions) {
705
+ this.#cwd = options.cwd;
706
+ this.#agentDir = options.agentDir;
707
+ this.#settingsManager = options.settingsManager;
708
+ }
709
+
710
+ /** Resolve enabled extension paths with their OMP plugin provenance. */
711
+ async resolve(_onMissing?: (source: string) => Promise<MissingSourceAction>): Promise<ResolvedPaths> {
712
+ const settings = await this.#settingsManager;
713
+ const configuredPaths = settings.get("extensions") ?? [];
714
+ const disabledExtensionIds = settings.get("disabledExtensions") ?? [];
715
+ const [extensionPaths, plugins] = await Promise.all([
716
+ discoverExtensionPaths(configuredPaths, this.#cwd, disabledExtensionIds),
717
+ getEnabledPlugins(this.#cwd),
718
+ ]);
719
+ const pluginByExtensionPath = new Map<string, ScopedInstalledPlugin>();
720
+ for (const plugin of plugins) {
721
+ for (const extensionPath of resolvePluginExtensionPaths(plugin)) {
722
+ pluginByExtensionPath.set(path.resolve(extensionPath), plugin);
723
+ }
724
+ }
725
+
726
+ const extensions = extensionPaths.map(extensionPath => {
727
+ const resolvedPath = path.resolve(extensionPath);
728
+ const plugin = pluginByExtensionPath.get(resolvedPath);
729
+ const agentDirRelative = path.relative(path.resolve(this.#agentDir), resolvedPath);
730
+ const metadata: PathMetadata = plugin
731
+ ? {
732
+ source: `npm:${plugin.name}`,
733
+ scope: plugin.scope,
734
+ origin: "package",
735
+ baseDir: plugin.path,
736
+ }
737
+ : {
738
+ source: "auto",
739
+ scope:
740
+ agentDirRelative === "" ||
741
+ (!agentDirRelative.startsWith("..") && !path.isAbsolute(agentDirRelative))
742
+ ? "user"
743
+ : "project",
744
+ origin: "top-level",
745
+ };
746
+ return { path: resolvedPath, enabled: true, metadata };
747
+ });
748
+
749
+ return { extensions, skills: [], prompts: [], themes: [] };
750
+ }
751
+ }
752
+
658
753
  /**
659
754
  * Resource-loader compatibility layer for legacy pi extensions.
660
755
  *