@oh-my-pi/pi-coding-agent 17.0.0 → 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 (202) hide show
  1. package/CHANGELOG.md +128 -0
  2. package/dist/cli.js +4006 -3966
  3. package/dist/types/advisor/advise-tool.d.ts +5 -2
  4. package/dist/types/advisor/config.d.ts +14 -6
  5. package/dist/types/advisor/runtime.d.ts +20 -11
  6. package/dist/types/autolearn/controller.d.ts +1 -0
  7. package/dist/types/cli-commands.d.ts +14 -1
  8. package/dist/types/config/model-discovery.d.ts +17 -0
  9. package/dist/types/config/model-registry.d.ts +4 -0
  10. package/dist/types/config/model-resolver.d.ts +6 -2
  11. package/dist/types/config/models-config-schema.d.ts +10 -0
  12. package/dist/types/config/models-config.d.ts +6 -0
  13. package/dist/types/config/settings-schema.d.ts +32 -7
  14. package/dist/types/config/settings.d.ts +50 -0
  15. package/dist/types/cursor.d.ts +11 -0
  16. package/dist/types/discovery/helpers.d.ts +5 -2
  17. package/dist/types/discovery/substitute-plugin-root.d.ts +20 -7
  18. package/dist/types/exec/bash-executor.d.ts +2 -0
  19. package/dist/types/extensibility/extensions/managed-timers.d.ts +15 -0
  20. package/dist/types/extensibility/extensions/runner.d.ts +7 -0
  21. package/dist/types/extensibility/extensions/types.d.ts +18 -0
  22. package/dist/types/extensibility/legacy-pi-coding-agent-shim.d.ts +40 -0
  23. package/dist/types/extensibility/shared-events.d.ts +6 -0
  24. package/dist/types/extensibility/utils.d.ts +2 -2
  25. package/dist/types/internal-urls/agent-protocol.d.ts +1 -0
  26. package/dist/types/lib/xai-http.d.ts +11 -0
  27. package/dist/types/mcp/transports/stdio.d.ts +13 -1
  28. package/dist/types/modes/acp/acp-agent.d.ts +3 -0
  29. package/dist/types/modes/acp/acp-mode.d.ts +6 -2
  30. package/dist/types/modes/components/advisor-config.d.ts +8 -1
  31. package/dist/types/modes/components/assistant-message.d.ts +2 -0
  32. package/dist/types/modes/components/custom-editor-plugin-ctor.test.d.ts +1 -0
  33. package/dist/types/modes/components/custom-editor.d.ts +23 -1
  34. package/dist/types/modes/components/hook-editor.d.ts +16 -2
  35. package/dist/types/modes/components/login-dialog.test.d.ts +1 -0
  36. package/dist/types/modes/components/model-hub.d.ts +5 -2
  37. package/dist/types/modes/components/session-selector.d.ts +4 -0
  38. package/dist/types/modes/components/tool-execution.d.ts +8 -1
  39. package/dist/types/modes/components/tool-execution.test.d.ts +1 -0
  40. package/dist/types/modes/controllers/command-controller.d.ts +8 -0
  41. package/dist/types/modes/controllers/selector-controller.d.ts +3 -1
  42. package/dist/types/modes/interactive-mode.d.ts +6 -0
  43. package/dist/types/modes/noninteractive-dispose.test.d.ts +1 -0
  44. package/dist/types/modes/print-mode.d.ts +1 -1
  45. package/dist/types/modes/types.d.ts +10 -0
  46. package/dist/types/modes/utils/transcript-render-helpers.d.ts +2 -2
  47. package/dist/types/modes/warp-events.d.ts +24 -0
  48. package/dist/types/modes/warp-events.test.d.ts +1 -0
  49. package/dist/types/plan-mode/model-transition.d.ts +47 -0
  50. package/dist/types/plan-mode/model-transition.test.d.ts +1 -0
  51. package/dist/types/registry/agent-lifecycle.d.ts +26 -1
  52. package/dist/types/sdk.d.ts +13 -2
  53. package/dist/types/session/agent-session.d.ts +43 -11
  54. package/dist/types/session/session-history-format.d.ts +10 -0
  55. package/dist/types/session/session-manager.d.ts +14 -0
  56. package/dist/types/session/streaming-output.d.ts +2 -0
  57. package/dist/types/slash-commands/helpers/active-oauth-account.d.ts +9 -0
  58. package/dist/types/slash-commands/types.d.ts +4 -4
  59. package/dist/types/system-prompt.d.ts +1 -1
  60. package/dist/types/task/label.d.ts +1 -1
  61. package/dist/types/telemetry-export.d.ts +34 -9
  62. package/dist/types/tools/approval.d.ts +8 -0
  63. package/dist/types/tools/bash.d.ts +2 -0
  64. package/dist/types/tools/browser/registry.d.ts +7 -3
  65. package/dist/types/tools/browser/tab-supervisor.d.ts +2 -0
  66. package/dist/types/tools/essential-tools.d.ts +29 -0
  67. package/dist/types/tools/image-gen.d.ts +2 -1
  68. package/dist/types/tools/index.d.ts +4 -1
  69. package/dist/types/tools/manage-skill.d.ts +2 -0
  70. package/dist/types/tools/xdev.d.ts +11 -2
  71. package/dist/types/utils/title-generator.d.ts +2 -1
  72. package/dist/types/web/search/index.d.ts +2 -0
  73. package/dist/types/web/search/providers/base.d.ts +3 -0
  74. package/dist/types/web/search/providers/kimi.d.ts +4 -1
  75. package/dist/types/web/search/types.d.ts +1 -1
  76. package/package.json +21 -16
  77. package/src/advisor/__tests__/advisor.test.ts +1358 -86
  78. package/src/advisor/__tests__/config.test.ts +58 -2
  79. package/src/advisor/advise-tool.ts +7 -3
  80. package/src/advisor/config.ts +76 -24
  81. package/src/advisor/runtime.ts +445 -92
  82. package/src/autolearn/controller.ts +23 -28
  83. package/src/cli/bench-cli.ts +4 -1
  84. package/src/cli/grep-cli.ts +2 -1
  85. package/src/cli-commands.ts +57 -23
  86. package/src/cli.ts +5 -1
  87. package/src/config/model-discovery.ts +81 -21
  88. package/src/config/model-registry.ts +34 -6
  89. package/src/config/model-resolver.ts +57 -12
  90. package/src/config/models-config-schema.ts +1 -0
  91. package/src/config/settings-schema.ts +43 -6
  92. package/src/config/settings.ts +405 -25
  93. package/src/cursor.ts +20 -3
  94. package/src/debug/report-bundle.ts +40 -4
  95. package/src/discovery/codex.ts +14 -4
  96. package/src/discovery/helpers.ts +28 -5
  97. package/src/discovery/substitute-plugin-root.ts +23 -7
  98. package/src/exec/bash-executor.ts +14 -5
  99. package/src/export/html/template.js +2 -0
  100. package/src/extensibility/custom-tools/loader.ts +3 -3
  101. package/src/extensibility/custom-tools/wrapper.ts +2 -1
  102. package/src/extensibility/extensions/loader.ts +3 -3
  103. package/src/extensibility/extensions/managed-timers.ts +83 -0
  104. package/src/extensibility/extensions/runner.ts +26 -0
  105. package/src/extensibility/extensions/types.ts +18 -0
  106. package/src/extensibility/extensions/wrapper.ts +2 -1
  107. package/src/extensibility/hooks/loader.ts +3 -3
  108. package/src/extensibility/legacy-pi-coding-agent-shim.ts +96 -1
  109. package/src/extensibility/plugins/legacy-pi-compat.ts +225 -22
  110. package/src/extensibility/plugins/manager.ts +2 -2
  111. package/src/extensibility/shared-events.ts +6 -0
  112. package/src/extensibility/utils.ts +91 -25
  113. package/src/internal-urls/__tests__/agent-protocol-nested.test.ts +73 -0
  114. package/src/internal-urls/agent-protocol.ts +73 -39
  115. package/src/irc/bus.ts +22 -3
  116. package/src/launch/broker.ts +3 -2
  117. package/src/launch/client.ts +2 -2
  118. package/src/launch/presence.ts +2 -2
  119. package/src/lib/xai-http.ts +28 -2
  120. package/src/lsp/client.ts +1 -1
  121. package/src/main.ts +11 -8
  122. package/src/mcp/manager.ts +9 -3
  123. package/src/mcp/transports/stdio.ts +103 -23
  124. package/src/modes/acp/acp-agent.ts +28 -9
  125. package/src/modes/acp/acp-event-mapper.ts +8 -0
  126. package/src/modes/acp/acp-mode.ts +18 -4
  127. package/src/modes/components/advisor-config.ts +65 -3
  128. package/src/modes/components/ask-dialog.ts +1 -1
  129. package/src/modes/components/assistant-message.ts +35 -14
  130. package/src/modes/components/chat-transcript-builder.ts +2 -0
  131. package/src/modes/components/custom-editor-plugin-ctor.test.ts +36 -0
  132. package/src/modes/components/custom-editor.ts +62 -1
  133. package/src/modes/components/hook-editor.ts +35 -5
  134. package/src/modes/components/login-dialog.test.ts +56 -0
  135. package/src/modes/components/login-dialog.ts +7 -3
  136. package/src/modes/components/model-hub.ts +138 -42
  137. package/src/modes/components/plan-review-overlay.ts +51 -10
  138. package/src/modes/components/session-selector.ts +10 -0
  139. package/src/modes/components/status-line/component.test.ts +1 -0
  140. package/src/modes/components/status-line/component.ts +2 -4
  141. package/src/modes/components/status-line/segments.ts +21 -6
  142. package/src/modes/components/tool-execution.test.ts +101 -0
  143. package/src/modes/components/tool-execution.ts +117 -9
  144. package/src/modes/components/transcript-container.ts +30 -2
  145. package/src/modes/controllers/command-controller-shared.ts +1 -1
  146. package/src/modes/controllers/command-controller.ts +168 -48
  147. package/src/modes/controllers/event-controller.ts +6 -0
  148. package/src/modes/controllers/extension-ui-controller.ts +4 -22
  149. package/src/modes/controllers/input-controller.ts +12 -12
  150. package/src/modes/controllers/selector-controller.ts +208 -46
  151. package/src/modes/interactive-mode.ts +205 -65
  152. package/src/modes/noninteractive-dispose.test.ts +60 -0
  153. package/src/modes/print-mode.ts +10 -5
  154. package/src/modes/rpc/host-tools.ts +2 -1
  155. package/src/modes/rpc/rpc-mode.ts +31 -7
  156. package/src/modes/theme/theme.ts +2 -2
  157. package/src/modes/types.ts +10 -0
  158. package/src/modes/utils/interactive-context-helpers.ts +3 -1
  159. package/src/modes/utils/transcript-render-helpers.ts +3 -2
  160. package/src/modes/warp-events.test.ts +794 -0
  161. package/src/modes/warp-events.ts +232 -0
  162. package/src/plan-mode/model-transition.test.ts +60 -0
  163. package/src/plan-mode/model-transition.ts +51 -0
  164. package/src/prompts/system/system-prompt.md +1 -1
  165. package/src/registry/agent-lifecycle.ts +133 -18
  166. package/src/sdk.ts +227 -44
  167. package/src/session/agent-session.ts +1364 -382
  168. package/src/session/session-history-format.ts +20 -5
  169. package/src/session/session-listing.ts +6 -5
  170. package/src/session/session-loader.ts +9 -0
  171. package/src/session/session-manager.ts +48 -0
  172. package/src/session/session-persistence.ts +11 -0
  173. package/src/session/streaming-output.ts +3 -1
  174. package/src/slash-commands/builtin-registry.ts +9 -0
  175. package/src/slash-commands/helpers/active-oauth-account.ts +16 -0
  176. package/src/slash-commands/types.ts +4 -4
  177. package/src/system-prompt.ts +2 -2
  178. package/src/task/executor.ts +1 -1
  179. package/src/task/label.ts +2 -0
  180. package/src/telemetry-export.ts +453 -97
  181. package/src/tools/approval.ts +11 -0
  182. package/src/tools/bash.ts +71 -38
  183. package/src/tools/browser/registry.ts +9 -2
  184. package/src/tools/browser/tab-supervisor.ts +65 -10
  185. package/src/tools/browser.ts +4 -3
  186. package/src/tools/essential-tools.ts +45 -0
  187. package/src/tools/eval.ts +3 -0
  188. package/src/tools/gh.ts +169 -2
  189. package/src/tools/image-gen.ts +624 -513
  190. package/src/tools/index.ts +10 -6
  191. package/src/tools/manage-skill.ts +5 -3
  192. package/src/tools/output-meta.ts +7 -0
  193. package/src/tools/path-utils.ts +8 -5
  194. package/src/tools/read.ts +48 -3
  195. package/src/tools/write.ts +22 -4
  196. package/src/tools/xdev.ts +14 -3
  197. package/src/utils/title-generator.ts +15 -4
  198. package/src/web/search/index.ts +21 -6
  199. package/src/web/search/providers/base.ts +3 -0
  200. package/src/web/search/providers/kimi.ts +18 -12
  201. package/src/web/search/providers/xai.ts +40 -9
  202. package/src/web/search/types.ts +6 -1
package/src/cursor.ts CHANGED
@@ -18,9 +18,20 @@ import { resolveToCwd } from "./tools/path-utils";
18
18
 
19
19
  interface CursorExecBridgeOptions {
20
20
  cwd: string;
21
+ getCwd?: () => string;
21
22
  tools: Map<string, AgentTool>;
23
+ getTool?: (name: string) => AgentTool | undefined;
22
24
  getToolContext?: () => AgentToolContext | undefined;
23
25
  emitEvent?: (event: AgentEvent) => void;
26
+ /**
27
+ * Whether the Cursor native `delete` frame may remove files. Unlike every
28
+ * other exec handler, `executeDelete` mutates the filesystem directly instead
29
+ * of consulting {@link tools}, so a background read-only advisor could delete
30
+ * workspace files it was never granted a mutating tool for (issue #5680
31
+ * review). Defaults to allowed to preserve the primary agent's behavior;
32
+ * callers with a restricted tool set (advisors) opt out.
33
+ */
34
+ allowNativeDelete?: boolean;
24
35
  }
25
36
 
26
37
  function createToolResultMessage(
@@ -53,7 +64,7 @@ async function executeTool(
53
64
  toolCallId: string,
54
65
  args: Record<string, unknown>,
55
66
  ): Promise<ToolResultMessage> {
56
- const tool = options.tools.get(toolName);
67
+ const tool = options.tools.get(toolName) ?? options.getTool?.(toolName);
57
68
  if (!tool) {
58
69
  const result = buildToolErrorResult(`Tool "${toolName}" not available`);
59
70
  return createToolResultMessage(toolCallId, toolName, result, true);
@@ -106,9 +117,15 @@ async function executeTool(
106
117
 
107
118
  async function executeDelete(options: CursorExecBridgeOptions, pathArg: string, toolCallId: string) {
108
119
  const toolName = "delete";
120
+
121
+ if (options.allowNativeDelete === false) {
122
+ const result = buildToolErrorResult(`Tool "${toolName}" not available`);
123
+ return createToolResultMessage(toolCallId, toolName, result, true);
124
+ }
125
+
109
126
  options.emitEvent?.({ type: "tool_execution_start", toolCallId, toolName, args: { path: pathArg } });
110
127
 
111
- const absolutePath = resolveToCwd(pathArg, options.cwd);
128
+ const absolutePath = resolveToCwd(pathArg, options.getCwd?.() ?? options.cwd);
112
129
  let isError = false;
113
130
  let result: AgentToolResult<unknown>;
114
131
 
@@ -327,7 +344,7 @@ export class CursorExecHandlers implements ICursorExecHandlers {
327
344
  async mcp(call: CursorMcpCall) {
328
345
  const toolName = call.toolName || call.name;
329
346
  const toolCallId = decodeToolCallId(call.toolCallId);
330
- const tool = this.options.tools.get(toolName);
347
+ const tool = this.options.tools.get(toolName) ?? this.options.getTool?.(toolName);
331
348
  if (!tool) {
332
349
  const availableTools = Array.from(this.options.tools.keys()).filter(name => name.startsWith("mcp__"));
333
350
  const message = formatMcpToolErrorMessage(toolName, availableTools);
@@ -105,9 +105,10 @@ export async function createReportBundle(options: ReportBundleOptions): Promise<
105
105
  files.push("config.json");
106
106
  }
107
107
 
108
- // Recent logs (last 1000 lines)
109
- const logPath = getLogPath();
110
- const logs = await readLastLines(logPath, 1000);
108
+ // Recent logs (last 1000 lines) across every same-day process. PID-qualified
109
+ // filenames mean a report generated from a later invocation must still gather
110
+ // the crashed process's log, so read all of today's files, not just our own.
111
+ const logs = await collectSameDayLogs(1000);
111
112
  if (logs) {
112
113
  data["logs.txt"] = logs;
113
114
  files.push("logs.txt");
@@ -241,7 +242,42 @@ export async function getLogText(): Promise<string> {
241
242
  return readLastLines(getLogPath(), MAX_LOG_LINES);
242
243
  }
243
244
 
244
- const LOG_FILE_PATTERN = new RegExp(`^${APP_NAME}\\.(\\d{4}-\\d{2}-\\d{2})\\.log$`);
245
+ /**
246
+ * Concatenate the tail of every same-day process log so a report generated
247
+ * after a crash still captures the fatal PID's `omp.<date>.<pid>.log`. Files
248
+ * are ordered oldest-first by mtime and separated by a filename header.
249
+ */
250
+ async function collectSameDayLogs(linesPerFile: number): Promise<string> {
251
+ const logsDir = getLogsDir();
252
+ const today = new Date().toISOString().slice(0, 10);
253
+ const sameDay: Array<{ name: string; mtimeMs: number }> = [];
254
+ try {
255
+ const entries = await fs.readdir(logsDir, { withFileTypes: true });
256
+ for (const entry of entries) {
257
+ if (!entry.isFile()) continue;
258
+ const match = LOG_FILE_PATTERN.exec(entry.name);
259
+ if (!match || match[1] !== today) continue;
260
+ try {
261
+ const stat = await fs.stat(path.join(logsDir, entry.name));
262
+ sameDay.push({ name: entry.name, mtimeMs: stat.mtimeMs });
263
+ } catch {
264
+ // File may have rotated away between readdir and stat.
265
+ }
266
+ }
267
+ } catch {
268
+ return "";
269
+ }
270
+ sameDay.sort((a, b) => a.mtimeMs - b.mtimeMs);
271
+
272
+ const chunks: string[] = [];
273
+ for (const { name } of sameDay) {
274
+ const text = await readLastLines(path.join(logsDir, name), linesPerFile);
275
+ if (text) chunks.push(`===== ${name} =====\n${text}`);
276
+ }
277
+ return chunks.join("\n\n");
278
+ }
279
+
280
+ const LOG_FILE_PATTERN = new RegExp(`^${APP_NAME}\\.(\\d{4}-\\d{2}-\\d{2})\\.\\d+\\.log(?:\\.\\d+)?$`);
245
281
 
246
282
  export async function createDebugLogSource(): Promise<DebugLogSource> {
247
283
  const logsDir = getLogsDir();
@@ -37,6 +37,7 @@ import {
37
37
  SOURCE_PATHS,
38
38
  scanSkillsFromDir,
39
39
  } from "./helpers";
40
+ import { resolvePluginStdioPaths } from "./substitute-plugin-root";
40
41
 
41
42
  const PROVIDER_ID = "codex";
42
43
  const DISPLAY_NAME = "OpenAI Codex";
@@ -87,7 +88,7 @@ async function loadMCPServers(ctx: LoadContext): Promise<LoadResult<MCPServer>>
87
88
 
88
89
  const items: MCPServer[] = [];
89
90
  if (userConfig) {
90
- const servers = extractMCPServersFromToml(userConfig);
91
+ const servers = extractMCPServersFromToml(userConfig, path.dirname(userConfigPath));
91
92
  for (const [name, config] of Object.entries(servers)) {
92
93
  items.push({
93
94
  name,
@@ -97,7 +98,7 @@ async function loadMCPServers(ctx: LoadContext): Promise<LoadResult<MCPServer>>
97
98
  }
98
99
  }
99
100
  if (projectConfig) {
100
- const servers = extractMCPServersFromToml(projectConfig);
101
+ const servers = extractMCPServersFromToml(projectConfig, path.dirname(projectConfigPath));
101
102
  for (const [name, config] of Object.entries(servers)) {
102
103
  items.push({
103
104
  name,
@@ -139,7 +140,10 @@ interface CodexMCPConfig {
139
140
  disabled_tools?: string[];
140
141
  }
141
142
 
142
- function extractMCPServersFromToml(toml: Record<string, unknown>): Record<string, Partial<MCPServer>> {
143
+ function extractMCPServersFromToml(
144
+ toml: Record<string, unknown>,
145
+ configDir: string,
146
+ ): Record<string, Partial<MCPServer>> {
143
147
  // Check for [mcp_servers.*] sections (Codex format)
144
148
  if (!toml.mcp_servers || typeof toml.mcp_servers !== "object") {
145
149
  return {};
@@ -149,10 +153,16 @@ function extractMCPServersFromToml(toml: Record<string, unknown>): Record<string
149
153
  const result: Record<string, Partial<MCPServer>> = {};
150
154
 
151
155
  for (const [name, config] of Object.entries(codexServers)) {
156
+ // Root relative cwd/command against the Codex config directory. Codex
157
+ // spawns the process with the resolved cwd, so a relative command is
158
+ // resolved by the OS from there — pass "cwd" so e.g. cwd="server",
159
+ // command="./bin/mcp" resolves to <configDir>/server/bin/mcp.
160
+ const rooted = resolvePluginStdioPaths({ command: config.command, cwd: config.cwd }, configDir, "cwd");
152
161
  const server: Partial<MCPServer> = {
153
- command: config.command,
162
+ ...(rooted.command !== undefined && { command: rooted.command }),
154
163
  args: config.args,
155
164
  url: config.url,
165
+ ...(rooted.cwd !== undefined && { cwd: rooted.cwd }),
156
166
  };
157
167
 
158
168
  // Build env by merging explicit env and forwarded env_vars
@@ -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
  }
@@ -31,23 +31,38 @@ export function substitutePluginRoot<T>(value: T, rootPath: string): T {
31
31
  }
32
32
 
33
33
  /**
34
- * Rebase relative filesystem values in a discovered plugin stdio config against
35
- * the directory of the `.mcp.json` that declared them.
34
+ * Where a relative, path-like `command` is rooted by {@link resolvePluginStdioPaths}.
36
35
  *
37
- * External plugin configs (bundled ChatGPT/Codex plugins, Claude marketplace
38
- * plugins) express `command`/`cwd` relative to their own config file, but MCP
39
- * stdio spawning roots relative values at the session cwd — so a plugin shipping
36
+ * - `"config-dir"` (default): the directory of the config file that declared the
37
+ * server the plugin package root for `.mcp.json`. A plugin can ship its
38
+ * executable at the package root (`command: "./bin/server"`) yet run from a
39
+ * data subdir (`cwd: "work"`); the command stays `<pkg>/bin/server`.
40
+ * - `"cwd"`: the rooted `cwd`, falling back to the config dir when no `cwd` is
41
+ * set. This matches how the OS resolves a relative command against the
42
+ * subprocess's working directory, which is the Codex `config.toml` contract:
43
+ * `cwd = "server"`, `command = "./bin/mcp"` → `<configDir>/server/bin/mcp`.
44
+ */
45
+ export type StdioCommandBase = "config-dir" | "cwd";
46
+
47
+ /**
48
+ * Rebase relative filesystem values in a discovered stdio server config against
49
+ * the directory of the config file (`.mcp.json`/`config.toml`) that declared them.
50
+ *
51
+ * External configs (bundled ChatGPT/Codex plugins, Claude marketplace plugins)
52
+ * express `command`/`cwd` relative to their own config file, but MCP stdio
53
+ * spawning roots relative values at the session cwd — so a server shipping
40
54
  * `command: "./bin/server"`, `cwd: "."` launches from the wrong directory and
41
55
  * fails with ENOENT. This resolves those against `configDir` instead:
42
56
  *
43
57
  * - relative `cwd` → resolved against `configDir`;
44
58
  * - path-like `command` (`./`, `../`, or the Windows `.\`/`..\` forms) →
45
- * resolved against `configDir`;
59
+ * resolved against the base selected by `commandBase` (see {@link StdioCommandBase});
46
60
  * - bare executables (`npx`, `uvx`, …) and absolute paths are left untouched.
47
61
  */
48
62
  export function resolvePluginStdioPaths(
49
63
  config: { command?: string; cwd?: string },
50
64
  configDir: string,
65
+ commandBase: StdioCommandBase = "config-dir",
51
66
  ): { command?: string; cwd?: string } {
52
67
  const resolved: { command?: string; cwd?: string } = {};
53
68
  if (typeof config.cwd === "string") {
@@ -55,7 +70,8 @@ export function resolvePluginStdioPaths(
55
70
  }
56
71
  if (config.command !== undefined) {
57
72
  const isPathLike = /^\.\.?[/\\]/.test(config.command);
58
- resolved.command = isPathLike ? path.resolve(configDir, config.command) : config.command;
73
+ const base = commandBase === "cwd" ? (resolved.cwd ?? configDir) : configDir;
74
+ resolved.command = isPathLike ? path.resolve(base, config.command) : config.command;
59
75
  }
60
76
  return resolved;
61
77
  }
@@ -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
  }
@@ -1082,6 +1082,8 @@
1082
1082
  <div class="thinking-text">${escapeHtml(thinking)}</div>
1083
1083
  <div class="thinking-collapsed">Thinking ...</div>
1084
1084
  </div>`;
1085
+ } else if (block.type === 'image') {
1086
+ html += `<div class="message-images"><img src="data:${block.mimeType};base64,${block.data}" class="message-image" /></div>`;
1085
1087
  }
1086
1088
  }
1087
1089
  for (const block of msg.content) {
@@ -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