@oh-my-pi/pi-coding-agent 15.0.1 → 15.1.0

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 (168) hide show
  1. package/CHANGELOG.md +94 -1
  2. package/examples/custom-tools/README.md +11 -7
  3. package/examples/custom-tools/hello/index.ts +2 -2
  4. package/examples/extensions/README.md +19 -8
  5. package/examples/extensions/api-demo.ts +15 -19
  6. package/examples/extensions/hello.ts +5 -6
  7. package/examples/extensions/plan-mode.ts +1 -1
  8. package/examples/extensions/reload-runtime.ts +4 -3
  9. package/examples/extensions/with-deps/index.ts +4 -3
  10. package/examples/sdk/06-extensions.ts +4 -2
  11. package/package.json +8 -18
  12. package/src/autoresearch/tools/init-experiment.ts +38 -41
  13. package/src/autoresearch/tools/log-experiment.ts +32 -41
  14. package/src/autoresearch/tools/run-experiment.ts +3 -3
  15. package/src/autoresearch/tools/update-notes.ts +11 -11
  16. package/src/commands/commit.ts +10 -0
  17. package/src/commit/agentic/tools/analyze-file.ts +4 -4
  18. package/src/commit/agentic/tools/git-file-diff.ts +4 -4
  19. package/src/commit/agentic/tools/git-hunk.ts +5 -5
  20. package/src/commit/agentic/tools/git-overview.ts +4 -4
  21. package/src/commit/agentic/tools/propose-changelog.ts +13 -13
  22. package/src/commit/agentic/tools/propose-commit.ts +6 -6
  23. package/src/commit/agentic/tools/recent-commits.ts +3 -3
  24. package/src/commit/agentic/tools/schemas.ts +28 -28
  25. package/src/commit/agentic/tools/split-commit.ts +22 -21
  26. package/src/commit/analysis/summary.ts +4 -4
  27. package/src/commit/changelog/generate.ts +7 -11
  28. package/src/commit/shared-llm.ts +22 -34
  29. package/src/config/config-file.ts +35 -13
  30. package/src/config/model-registry.ts +40 -191
  31. package/src/config/models-config-schema.ts +166 -0
  32. package/src/config/settings-schema.ts +29 -0
  33. package/src/discovery/claude-plugins.ts +19 -7
  34. package/src/edit/index.ts +2 -2
  35. package/src/edit/modes/apply-patch.ts +7 -6
  36. package/src/edit/modes/patch.ts +18 -25
  37. package/src/edit/modes/replace.ts +18 -20
  38. package/src/eval/js/shared/rewrite-imports.ts +131 -10
  39. package/src/eval/py/executor.ts +233 -623
  40. package/src/eval/py/kernel.ts +27 -2
  41. package/src/eval/py/runner.py +42 -11
  42. package/src/eval/py/runtime.ts +1 -0
  43. package/src/exa/factory.ts +5 -4
  44. package/src/exa/mcp-client.ts +1 -1
  45. package/src/exa/researcher.ts +9 -20
  46. package/src/exa/search.ts +26 -52
  47. package/src/exa/types.ts +1 -1
  48. package/src/exa/websets.ts +54 -53
  49. package/src/exec/bash-executor.ts +2 -1
  50. package/src/extensibility/custom-commands/loader.ts +5 -3
  51. package/src/extensibility/custom-commands/types.ts +4 -2
  52. package/src/extensibility/custom-tools/loader.ts +5 -3
  53. package/src/extensibility/custom-tools/types.ts +7 -6
  54. package/src/extensibility/custom-tools/wrapper.ts +1 -1
  55. package/src/extensibility/extensions/get-commands-handler.ts +77 -0
  56. package/src/extensibility/extensions/loader.ts +7 -3
  57. package/src/extensibility/extensions/types.ts +9 -5
  58. package/src/extensibility/extensions/wrapper.ts +1 -2
  59. package/src/extensibility/hooks/loader.ts +3 -1
  60. package/src/extensibility/hooks/tool-wrapper.ts +1 -1
  61. package/src/extensibility/hooks/types.ts +4 -2
  62. package/src/extensibility/plugins/legacy-pi-compat.ts +78 -31
  63. package/src/extensibility/shared-events.ts +1 -1
  64. package/src/extensibility/typebox.ts +391 -0
  65. package/src/goals/tools/goal-tool.ts +6 -12
  66. package/src/hashline/input.ts +2 -1
  67. package/src/hashline/parser.ts +27 -3
  68. package/src/hashline/types.ts +4 -4
  69. package/src/hindsight/state.ts +2 -2
  70. package/src/index.ts +0 -2
  71. package/src/internal-urls/docs-index.generated.ts +15 -15
  72. package/src/internal-urls/router.ts +8 -0
  73. package/src/internal-urls/types.ts +21 -0
  74. package/src/lsp/config.ts +15 -6
  75. package/src/lsp/defaults.json +6 -2
  76. package/src/lsp/types.ts +30 -38
  77. package/src/mcp/manager.ts +1 -1
  78. package/src/mcp/tool-bridge.ts +1 -1
  79. package/src/modes/acp/acp-agent.ts +248 -50
  80. package/src/modes/components/session-observer-overlay.ts +12 -1
  81. package/src/modes/components/status-line/segments.ts +39 -4
  82. package/src/modes/controllers/command-controller.ts +27 -2
  83. package/src/modes/controllers/event-controller.ts +3 -4
  84. package/src/modes/controllers/extension-ui-controller.ts +3 -2
  85. package/src/modes/interactive-mode.ts +1 -1
  86. package/src/modes/rpc/host-tools.ts +1 -1
  87. package/src/modes/rpc/host-uris.ts +235 -0
  88. package/src/modes/rpc/rpc-client.ts +1 -1
  89. package/src/modes/rpc/rpc-mode.ts +27 -1
  90. package/src/modes/rpc/rpc-types.ts +58 -1
  91. package/src/modes/runtime-init.ts +2 -1
  92. package/src/modes/theme/defaults/dark-poimandres.json +1 -0
  93. package/src/modes/theme/defaults/light-poimandres.json +1 -0
  94. package/src/modes/theme/theme.ts +117 -117
  95. package/src/modes/types.ts +1 -1
  96. package/src/modes/utils/context-usage.ts +2 -2
  97. package/src/prompts/tools/github.md +4 -4
  98. package/src/prompts/tools/hashline.md +22 -26
  99. package/src/prompts/tools/read.md +55 -37
  100. package/src/sdk.ts +31 -8
  101. package/src/session/agent-session.ts +74 -104
  102. package/src/session/messages.ts +16 -51
  103. package/src/session/session-manager.ts +22 -2
  104. package/src/session/streaming-output.ts +16 -6
  105. package/src/task/discovery.ts +5 -2
  106. package/src/task/executor.ts +210 -87
  107. package/src/task/index.ts +15 -11
  108. package/src/task/render.ts +32 -5
  109. package/src/task/types.ts +54 -39
  110. package/src/tools/ask.ts +12 -12
  111. package/src/tools/ast-edit.ts +11 -15
  112. package/src/tools/ast-grep.ts +9 -10
  113. package/src/tools/bash-command-fixup.ts +47 -0
  114. package/src/tools/bash.ts +48 -38
  115. package/src/tools/browser/render.ts +2 -2
  116. package/src/tools/browser.ts +39 -53
  117. package/src/tools/calculator.ts +12 -11
  118. package/src/tools/checkpoint.ts +7 -7
  119. package/src/tools/debug.ts +40 -43
  120. package/src/tools/eval.ts +16 -10
  121. package/src/tools/find.ts +10 -13
  122. package/src/tools/gh.ts +108 -132
  123. package/src/tools/hindsight-recall.ts +4 -6
  124. package/src/tools/hindsight-reflect.ts +5 -5
  125. package/src/tools/hindsight-retain.ts +15 -17
  126. package/src/tools/image-gen.ts +31 -81
  127. package/src/tools/index.ts +4 -1
  128. package/src/tools/inspect-image.ts +8 -9
  129. package/src/tools/irc.ts +15 -27
  130. package/src/tools/job.ts +30 -28
  131. package/src/tools/output-meta.ts +26 -0
  132. package/src/tools/read.ts +39 -12
  133. package/src/tools/recipe/index.ts +7 -9
  134. package/src/tools/render-mermaid.ts +12 -12
  135. package/src/tools/report-tool-issue.ts +4 -4
  136. package/src/tools/resolve.ts +11 -11
  137. package/src/tools/review.ts +14 -26
  138. package/src/tools/search-tool-bm25.ts +7 -9
  139. package/src/tools/search.ts +19 -22
  140. package/src/tools/ssh.ts +10 -9
  141. package/src/tools/todo-write.ts +26 -34
  142. package/src/tools/vim.ts +10 -26
  143. package/src/tools/write.ts +25 -5
  144. package/src/tools/yield.ts +100 -54
  145. package/src/web/search/index.ts +9 -24
  146. package/src/web/search/providers/anthropic.ts +5 -0
  147. package/src/web/search/providers/exa.ts +3 -0
  148. package/src/web/search/providers/gemini.ts +5 -0
  149. package/src/web/search/providers/jina.ts +5 -2
  150. package/src/web/search/providers/zai.ts +5 -2
  151. package/src/prompts/compaction/branch-summary-context.md +0 -5
  152. package/src/prompts/compaction/branch-summary-preamble.md +0 -2
  153. package/src/prompts/compaction/branch-summary.md +0 -30
  154. package/src/prompts/compaction/compaction-short-summary.md +0 -9
  155. package/src/prompts/compaction/compaction-summary-context.md +0 -5
  156. package/src/prompts/compaction/compaction-summary.md +0 -38
  157. package/src/prompts/compaction/compaction-turn-prefix.md +0 -17
  158. package/src/prompts/compaction/compaction-update-summary.md +0 -45
  159. package/src/prompts/system/auto-handoff-threshold-focus.md +0 -1
  160. package/src/prompts/system/file-operations.md +0 -10
  161. package/src/prompts/system/handoff-document.md +0 -49
  162. package/src/prompts/system/summarization-system.md +0 -3
  163. package/src/session/compaction/branch-summarization.ts +0 -324
  164. package/src/session/compaction/compaction.ts +0 -1420
  165. package/src/session/compaction/errors.ts +0 -31
  166. package/src/session/compaction/index.ts +0 -8
  167. package/src/session/compaction/pruning.ts +0 -91
  168. package/src/session/compaction/utils.ts +0 -184
@@ -2,7 +2,7 @@ import * as os from "node:os";
2
2
  import * as path from "node:path";
3
3
  import { ThinkingLevel } from "@oh-my-pi/pi-agent-core";
4
4
  import { TERMINAL } from "@oh-my-pi/pi-tui";
5
- import { formatDuration, formatNumber, getProjectDir, relativePathWithinRoot } from "@oh-my-pi/pi-utils";
5
+ import { formatDuration, formatNumber, getProjectDir, pathIsWithin, relativePathWithinRoot } from "@oh-my-pi/pi-utils";
6
6
  import { type ThemeColor, theme } from "../../../modes/theme/theme";
7
7
  import { shortenPath } from "../../../tools/render-utils";
8
8
  import { getSessionAccentAnsi, getSessionAccentHex } from "../../../utils/session-color";
@@ -32,6 +32,33 @@ function normalizePremiumRequests(value: number): number {
32
32
  return Math.round((value + Number.EPSILON) * 100) / 100;
33
33
  }
34
34
 
35
+ const SCRATCH_ROOTS: readonly string[] = (() => {
36
+ const roots = new Set<string>([os.tmpdir(), path.join(os.homedir(), "tmp")]);
37
+ if (process.platform === "win32") {
38
+ const { TEMP, TMP, SystemRoot } = process.env;
39
+ if (TEMP) roots.add(TEMP);
40
+ if (TMP) roots.add(TMP);
41
+ if (SystemRoot) roots.add(path.join(SystemRoot, "Temp"));
42
+ } else {
43
+ roots.add("/tmp");
44
+ roots.add("/var/tmp");
45
+ if (process.platform === "darwin") {
46
+ roots.add("/private/tmp");
47
+ roots.add("/private/var/tmp");
48
+ }
49
+ }
50
+ return [...roots];
51
+ })();
52
+
53
+ function classifyProjectDir(pwd: string): { scratch: boolean; relative: string | null } {
54
+ for (const root of SCRATCH_ROOTS) {
55
+ if (pathIsWithin(root, pwd)) {
56
+ return { scratch: true, relative: relativePathWithinRoot(root, pwd) };
57
+ }
58
+ }
59
+ return { scratch: false, relative: null };
60
+ }
61
+
35
62
  // ═══════════════════════════════════════════════════════════════════════════
36
63
  // Segment Implementations
37
64
  // ═══════════════════════════════════════════════════════════════════════════
@@ -150,10 +177,16 @@ const pathSegment: StatusLineSegment = {
150
177
  render(ctx) {
151
178
  const opts = ctx.options.path ?? {};
152
179
 
153
- let pwd = getProjectDir();
180
+ const projectDir = getProjectDir();
181
+ const { scratch, relative } = classifyProjectDir(projectDir);
182
+ let pwd = projectDir;
154
183
 
155
184
  if (opts.stripWorkPrefix !== false) {
156
- pwd = stripDisplayRoot(pwd);
185
+ if (scratch) {
186
+ if (relative) pwd = relative;
187
+ } else {
188
+ pwd = stripDisplayRoot(pwd);
189
+ }
157
190
  }
158
191
  if (opts.abbreviate !== false) {
159
192
  pwd = shortenPath(pwd);
@@ -166,7 +199,9 @@ const pathSegment: StatusLineSegment = {
166
199
  pwd = `${ellipsis}${pwd.slice(-sliceLen)}`;
167
200
  }
168
201
 
169
- const content = withIcon(theme.icon.folder, pwd);
202
+ const showScratchIcon = scratch && opts.stripWorkPrefix !== false;
203
+ const icon = showScratchIcon ? theme.icon.scratchFolder : theme.icon.folder;
204
+ const content = withIcon(icon, pwd);
170
205
  return { content: theme.fg("statusLinePath", content), visible: true };
171
206
  },
172
207
  };
@@ -1,6 +1,7 @@
1
1
  import * as fs from "node:fs/promises";
2
2
  import * as os from "node:os";
3
3
  import * as path from "node:path";
4
+ import { CompactionCancelledError, type CompactionOutcome } from "@oh-my-pi/pi-agent-core/compaction";
4
5
  import {
5
6
  getEnvApiKey,
6
7
  getProviderDetails,
@@ -37,7 +38,6 @@ import { buildHotkeysMarkdown } from "../../modes/utils/hotkeys-markdown";
37
38
  import { buildToolsMarkdown } from "../../modes/utils/tools-markdown";
38
39
  import type { AsyncJobSnapshotItem } from "../../session/agent-session";
39
40
  import type { AuthStorage } from "../../session/auth-storage";
40
- import { CompactionCancelledError, type CompactionOutcome } from "../../session/compaction";
41
41
  import type { NewSessionOptions } from "../../session/session-manager";
42
42
  import { outputMeta } from "../../tools/output-meta";
43
43
  import { resolveToCwd, stripOuterDoubleQuotes } from "../../tools/path-utils";
@@ -1175,8 +1175,29 @@ export class CommandController {
1175
1175
  return;
1176
1176
  }
1177
1177
 
1178
+ if (this.ctx.loadingAnimation) {
1179
+ this.ctx.loadingAnimation.stop();
1180
+ this.ctx.loadingAnimation = undefined;
1181
+ }
1182
+ this.ctx.statusContainer.clear();
1183
+
1184
+ const originalOnEscape = this.ctx.editor.onEscape;
1185
+ this.ctx.editor.onEscape = () => {
1186
+ this.ctx.session.abortHandoff();
1187
+ };
1188
+
1189
+ const handoffLoader = new Loader(
1190
+ this.ctx.ui,
1191
+ spinner => theme.fg("accent", spinner),
1192
+ text => theme.fg("muted", text),
1193
+ "Generating handoff… (esc to cancel)",
1194
+ getSymbolTheme().spinnerFrames,
1195
+ );
1196
+ this.ctx.statusContainer.addChild(handoffLoader);
1197
+ this.ctx.ui.requestRender();
1198
+
1178
1199
  try {
1179
- // The agent will visibly generate the handoff document in chat
1200
+ // Handoff generation runs as a oneshot request; the new session is shown after it completes.
1180
1201
  const result = await this.ctx.session.handoff(customInstructions);
1181
1202
 
1182
1203
  if (!result) {
@@ -1206,6 +1227,10 @@ export class CommandController {
1206
1227
  } else {
1207
1228
  this.ctx.showError(`Handoff failed: ${message}`);
1208
1229
  }
1230
+ } finally {
1231
+ handoffLoader.stop();
1232
+ this.ctx.statusContainer.clear();
1233
+ this.ctx.editor.onEscape = originalOnEscape;
1209
1234
  }
1210
1235
  this.ctx.ui.requestRender();
1211
1236
  }
@@ -1,4 +1,5 @@
1
1
  import { INTENT_FIELD } from "@oh-my-pi/pi-agent-core";
2
+ import { calculatePromptTokens } from "@oh-my-pi/pi-agent-core/compaction/compaction";
2
3
  import type { AssistantMessage, ImageContent } from "@oh-my-pi/pi-ai";
3
4
  import { type Component, Loader, TERMINAL, Text } from "@oh-my-pi/pi-tui";
4
5
  import { settings } from "../../config/settings";
@@ -15,7 +16,6 @@ import { getSymbolTheme, theme } from "../../modes/theme/theme";
15
16
  import type { InteractiveModeContext, TodoPhase } from "../../modes/types";
16
17
  import type { PlanApprovalDetails } from "../../plan-mode/approved-plan";
17
18
  import type { AgentSessionEvent } from "../../session/agent-session";
18
- import { calculatePromptTokens } from "../../session/compaction/compaction";
19
19
  import { isSilentAbort, readPendingDisplayTag } from "../../session/messages";
20
20
  import type { ResolveToolDetails } from "../../tools/resolve";
21
21
 
@@ -719,9 +719,8 @@ export class EventController {
719
719
 
720
720
  #scheduleIdleCompaction(): void {
721
721
  this.#cancelIdleCompaction();
722
- // Don't schedule while compaction/handoff is already running the agent_end from a
723
- // handoff agent turn still has the old session's bloated token counts, and scheduling
724
- // here would fire after the session resets, trying to handoff an empty session.
722
+ // Don't schedule idle work while context maintenance is already running; the
723
+ // maintenance flow may reset the session before this timer fires.
725
724
  if (this.ctx.session.isCompacting) return;
726
725
 
727
726
  const idleSettings = settings.getGroup("compaction");
@@ -16,6 +16,7 @@ import type {
16
16
  SendUserMessageHandler,
17
17
  TerminalInputHandler,
18
18
  } from "../../extensibility/extensions";
19
+ import { getSessionSlashCommands } from "../../extensibility/extensions/get-commands-handler";
19
20
  import { HookEditorComponent } from "../../modes/components/hook-editor";
20
21
  import { HookInputComponent } from "../../modes/components/hook-input";
21
22
  import { HookSelectorComponent } from "../../modes/components/hook-selector";
@@ -109,7 +110,7 @@ export class ExtensionUiController {
109
110
  },
110
111
  getThinkingLevel: () => this.ctx.session.thinkingLevel,
111
112
  setThinkingLevel: level => this.ctx.session.setThinkingLevel(level),
112
- getCommands: () => [],
113
+ getCommands: () => getSessionSlashCommands(this.ctx.session),
113
114
  getSessionName: () => this.ctx.sessionManager.getSessionName(),
114
115
  setSessionName: name => this.#updateSessionName(name),
115
116
  };
@@ -349,7 +350,7 @@ export class ExtensionUiController {
349
350
  },
350
351
  getThinkingLevel: () => this.ctx.session.thinkingLevel,
351
352
  setThinkingLevel: (level, persist) => this.ctx.session.setThinkingLevel(level, persist),
352
- getCommands: () => [],
353
+ getCommands: () => getSessionSlashCommands(this.ctx.session),
353
354
  getSessionName: () => this.ctx.sessionManager.getSessionName(),
354
355
  setSessionName: name => this.#updateSessionName(name),
355
356
  };
@@ -5,6 +5,7 @@
5
5
  import * as fs from "node:fs/promises";
6
6
  import * as path from "node:path";
7
7
  import { type Agent, type AgentMessage, type AgentToolResult, ThinkingLevel } from "@oh-my-pi/pi-agent-core";
8
+ import type { CompactionOutcome } from "@oh-my-pi/pi-agent-core/compaction";
8
9
  import {
9
10
  type AssistantMessage,
10
11
  type ImageContent,
@@ -46,7 +47,6 @@ import planModeCompactInstructionsPrompt from "../prompts/system/plan-mode-compa
46
47
  type: "text",
47
48
  };
48
49
  import type { AgentSession, AgentSessionEvent } from "../session/agent-session";
49
- import type { CompactionOutcome } from "../session/compaction";
50
50
  import { HistoryStorage } from "../session/history-storage";
51
51
  import type { SessionContext, SessionManager } from "../session/session-manager";
52
52
  import { getRecentSessions } from "../session/session-manager";
@@ -1,6 +1,6 @@
1
1
  import type { AgentTool, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core";
2
+ import type { Static, TSchema } from "@oh-my-pi/pi-ai";
2
3
  import { Snowflake } from "@oh-my-pi/pi-utils";
3
- import type { Static, TSchema } from "@sinclair/typebox";
4
4
  import { applyToolProxy } from "../../extensibility/tool-proxy";
5
5
  import type { Theme } from "../../modes/theme/theme";
6
6
  import type {
@@ -0,0 +1,235 @@
1
+ import { Snowflake } from "@oh-my-pi/pi-utils";
2
+ import { InternalUrlRouter } from "../../internal-urls";
3
+ import type {
4
+ InternalResource,
5
+ InternalUrl,
6
+ ProtocolHandler,
7
+ ResolveContext,
8
+ WriteContext,
9
+ } from "../../internal-urls/types";
10
+ import type {
11
+ RpcHostUriCancelRequest,
12
+ RpcHostUriRequest,
13
+ RpcHostUriResult,
14
+ RpcHostUriSchemeDefinition,
15
+ } from "./rpc-types";
16
+
17
+ type RpcHostUriOutput = (frame: RpcHostUriRequest | RpcHostUriCancelRequest) => void;
18
+
19
+ type PendingUriRequest = {
20
+ operation: "read" | "write";
21
+ url: string;
22
+ resolve: (frame: RpcHostUriResult) => void;
23
+ reject: (error: Error) => void;
24
+ };
25
+
26
+ /** Type guard for inbound `host_uri_result` frames coming from the host. */
27
+ export function isRpcHostUriResult(value: unknown): value is RpcHostUriResult {
28
+ if (!value || typeof value !== "object") return false;
29
+ const frame = value as { type?: unknown; id?: unknown };
30
+ return frame.type === "host_uri_result" && typeof frame.id === "string";
31
+ }
32
+
33
+ /**
34
+ * One handler instance per host-registered scheme. Delegates reads and (when
35
+ * the scheme was registered as writable) writes to the bridge, which serializes
36
+ * them over the RPC transport.
37
+ */
38
+ class RpcHostUriProtocolHandler implements ProtocolHandler {
39
+ readonly scheme: string;
40
+ readonly immutable: boolean;
41
+ readonly write?: (url: InternalUrl, content: string, context?: WriteContext) => Promise<void>;
42
+ readonly #bridge: RpcHostUriBridge;
43
+
44
+ constructor(definition: RpcHostUriSchemeDefinition, bridge: RpcHostUriBridge) {
45
+ this.scheme = definition.scheme;
46
+ this.immutable = definition.immutable === true;
47
+ this.#bridge = bridge;
48
+ if (definition.writable === true) {
49
+ this.write = (url, content, context) => this.#bridge.requestWrite(this.scheme, url, content, context);
50
+ }
51
+ }
52
+
53
+ resolve(url: InternalUrl, context?: ResolveContext): Promise<InternalResource> {
54
+ return this.#bridge.requestRead(this.scheme, url, context);
55
+ }
56
+ }
57
+
58
+ /**
59
+ * Bidirectional bridge that lets the RPC host own a set of URI schemes.
60
+ *
61
+ * The host registers schemes via `set_host_uri_schemes`; the bridge installs
62
+ * a `RpcHostUriProtocolHandler` per scheme into the process-global
63
+ * {@link InternalUrlRouter}. Reads land on the read tool through the existing
64
+ * router; writes are intercepted by the write tool and dispatched through
65
+ * `requestWrite`.
66
+ */
67
+ export class RpcHostUriBridge {
68
+ #output: RpcHostUriOutput;
69
+ #router: InternalUrlRouter;
70
+ #definitions = new Map<string, RpcHostUriSchemeDefinition>();
71
+ #pending = new Map<string, PendingUriRequest>();
72
+
73
+ constructor(output: RpcHostUriOutput, router: InternalUrlRouter = InternalUrlRouter.instance()) {
74
+ this.#output = output;
75
+ this.#router = router;
76
+ }
77
+
78
+ getSchemes(): string[] {
79
+ return Array.from(this.#definitions.keys());
80
+ }
81
+
82
+ /**
83
+ * Replace the registered set of host URI schemes. Previously registered
84
+ * schemes that no longer appear in the new set are unregistered from the
85
+ * router; surviving and new schemes get fresh handler instances.
86
+ */
87
+ setSchemes(schemes: RpcHostUriSchemeDefinition[]): string[] {
88
+ const normalized = new Map<string, RpcHostUriSchemeDefinition>();
89
+ for (const raw of schemes) {
90
+ const scheme = typeof raw?.scheme === "string" ? raw.scheme.trim().toLowerCase() : "";
91
+ if (!scheme) {
92
+ throw new Error("Host URI scheme must be a non-empty string");
93
+ }
94
+ if (!/^[a-z][a-z0-9+.-]*$/.test(scheme)) {
95
+ throw new Error(`Host URI scheme contains invalid characters: ${raw.scheme}`);
96
+ }
97
+ normalized.set(scheme, {
98
+ scheme,
99
+ description: typeof raw.description === "string" ? raw.description : undefined,
100
+ writable: raw.writable === true,
101
+ immutable: raw.immutable === true,
102
+ });
103
+ }
104
+
105
+ for (const previous of this.#definitions.keys()) {
106
+ if (!normalized.has(previous)) {
107
+ this.#router.unregister(previous);
108
+ }
109
+ }
110
+ for (const definition of normalized.values()) {
111
+ this.#router.register(new RpcHostUriProtocolHandler(definition, this));
112
+ }
113
+ this.#definitions = normalized;
114
+ return Array.from(normalized.keys());
115
+ }
116
+
117
+ /**
118
+ * Unregister every host scheme from the router and reject any in-flight
119
+ * requests. Called on RPC shutdown to keep the global router clean for
120
+ * subsequent sessions in the same process (used by tests).
121
+ */
122
+ clear(message: string = "Host URI bridge shut down"): void {
123
+ for (const scheme of this.#definitions.keys()) {
124
+ this.#router.unregister(scheme);
125
+ }
126
+ this.#definitions.clear();
127
+ this.rejectAllPending(message);
128
+ }
129
+
130
+ /** Resolve a pending request by id; called by `rpc-mode` on inbound results. */
131
+ handleResult(frame: RpcHostUriResult): boolean {
132
+ const pending = this.#pending.get(frame.id);
133
+ if (!pending) return false;
134
+ this.#pending.delete(frame.id);
135
+ pending.resolve(frame);
136
+ return true;
137
+ }
138
+
139
+ rejectAllPending(message: string): void {
140
+ const error = new Error(message);
141
+ const pending = Array.from(this.#pending.values());
142
+ this.#pending.clear();
143
+ for (const entry of pending) {
144
+ entry.reject(error);
145
+ }
146
+ }
147
+
148
+ async requestRead(scheme: string, url: InternalUrl, context?: ResolveContext): Promise<InternalResource> {
149
+ const result = await this.#dispatch("read", url.href, undefined, context?.signal);
150
+ if (result.isError) {
151
+ throw new Error(result.error || result.content || `Host URI read failed for ${url.href}`);
152
+ }
153
+ const content = result.content ?? "";
154
+ const contentType = result.contentType ?? "text/plain";
155
+ const definition = this.#definitions.get(scheme);
156
+ return {
157
+ url: url.href,
158
+ content,
159
+ contentType,
160
+ size: Buffer.byteLength(content, "utf-8"),
161
+ notes: result.notes && result.notes.length > 0 ? [...result.notes] : undefined,
162
+ immutable: result.immutable ?? definition?.immutable === true,
163
+ };
164
+ }
165
+
166
+ async requestWrite(_scheme: string, url: InternalUrl, content: string, context?: WriteContext): Promise<void> {
167
+ const result = await this.#dispatch("write", url.href, content, context?.signal);
168
+ if (result.isError) {
169
+ throw new Error(result.error || result.content || `Host URI write failed for ${url.href}`);
170
+ }
171
+ }
172
+
173
+ #dispatch(
174
+ operation: "read" | "write",
175
+ url: string,
176
+ content: string | undefined,
177
+ signal: AbortSignal | undefined,
178
+ ): Promise<RpcHostUriResult> {
179
+ if (signal?.aborted) {
180
+ return Promise.reject(new Error(`Host URI ${operation} for ${url} was aborted`));
181
+ }
182
+
183
+ const id = Snowflake.next() as string;
184
+ const { promise, resolve, reject } = Promise.withResolvers<RpcHostUriResult>();
185
+ let settled = false;
186
+
187
+ const cleanup = () => {
188
+ signal?.removeEventListener("abort", onAbort);
189
+ this.#pending.delete(id);
190
+ };
191
+
192
+ const onAbort = () => {
193
+ if (settled) return;
194
+ settled = true;
195
+ cleanup();
196
+ this.#output({
197
+ type: "host_uri_cancel",
198
+ id: Snowflake.next() as string,
199
+ targetId: id,
200
+ });
201
+ reject(new Error(`Host URI ${operation} for ${url} was aborted`));
202
+ };
203
+
204
+ signal?.addEventListener("abort", onAbort, { once: true });
205
+ this.#pending.set(id, {
206
+ operation,
207
+ url,
208
+ resolve: frame => {
209
+ if (settled) return;
210
+ settled = true;
211
+ cleanup();
212
+ resolve(frame);
213
+ },
214
+ reject: err => {
215
+ if (settled) return;
216
+ settled = true;
217
+ cleanup();
218
+ reject(err);
219
+ },
220
+ });
221
+
222
+ const frame: RpcHostUriRequest = {
223
+ type: "host_uri_request",
224
+ id,
225
+ operation,
226
+ url,
227
+ };
228
+ if (operation === "write") {
229
+ frame.content = content ?? "";
230
+ }
231
+ this.#output(frame);
232
+
233
+ return promise;
234
+ }
235
+ }
@@ -4,11 +4,11 @@
4
4
  * Spawns the agent in RPC mode and provides a typed API for all operations.
5
5
  */
6
6
  import type { AgentEvent, AgentMessage, AgentToolResult, ThinkingLevel } from "@oh-my-pi/pi-agent-core";
7
+ import type { CompactionResult } from "@oh-my-pi/pi-agent-core/compaction";
7
8
  import type { ImageContent, Model } from "@oh-my-pi/pi-ai";
8
9
  import { isRecord, ptree, readJsonl } from "@oh-my-pi/pi-utils";
9
10
  import type { BashResult } from "../../exec/bash-executor";
10
11
  import type { SessionStats } from "../../session/agent-session";
11
- import type { CompactionResult } from "../../session/compaction";
12
12
  import type {
13
13
  RpcCommand,
14
14
  RpcExtensionUIRequest,
@@ -21,6 +21,7 @@ import { type Theme, theme } from "../../modes/theme/theme";
21
21
  import type { AgentSession } from "../../session/agent-session";
22
22
  import { initializeExtensions } from "../runtime-init";
23
23
  import { isRpcHostToolResult, isRpcHostToolUpdate, RpcHostToolBridge } from "./host-tools";
24
+ import { isRpcHostUriResult, RpcHostUriBridge } from "./host-uris";
24
25
  import type {
25
26
  RpcCommand,
26
27
  RpcExtensionUIRequest,
@@ -28,6 +29,8 @@ import type {
28
29
  RpcHostToolCallRequest,
29
30
  RpcHostToolCancelRequest,
30
31
  RpcHostToolDefinition,
32
+ RpcHostUriCancelRequest,
33
+ RpcHostUriRequest,
31
34
  RpcResponse,
32
35
  RpcSessionState,
33
36
  } from "./rpc-types";
@@ -41,7 +44,14 @@ export type PendingExtensionRequest = {
41
44
  };
42
45
 
43
46
  type RpcOutput = (
44
- obj: RpcResponse | RpcExtensionUIRequest | RpcHostToolCallRequest | RpcHostToolCancelRequest | object,
47
+ obj:
48
+ | RpcResponse
49
+ | RpcExtensionUIRequest
50
+ | RpcHostToolCallRequest
51
+ | RpcHostToolCancelRequest
52
+ | RpcHostUriRequest
53
+ | RpcHostUriCancelRequest
54
+ | object,
45
55
  ) => void;
46
56
 
47
57
  function normalizeHostToolDefinitions(tools: RpcHostToolDefinition[]): RpcHostToolDefinition[] {
@@ -188,6 +198,7 @@ export async function runRpcMode(
188
198
 
189
199
  const pendingExtensionRequests = new Map<string, PendingExtensionRequest>();
190
200
  const hostToolBridge = new RpcHostToolBridge(output);
201
+ const hostUriBridge = new RpcHostUriBridge(output);
191
202
 
192
203
  // Shutdown request flag (wrapped in object to allow mutation with const)
193
204
  const shutdownState = { requested: false };
@@ -533,6 +544,15 @@ export async function runRpcMode(
533
544
  return success(id, "set_host_tools", { toolNames: tools.map(tool => tool.name) });
534
545
  }
535
546
 
547
+ case "set_host_uri_schemes": {
548
+ try {
549
+ const schemes = hostUriBridge.setSchemes(command.schemes);
550
+ return success(id, "set_host_uri_schemes", { schemes });
551
+ } catch (err) {
552
+ return error(id, "set_host_uri_schemes", err instanceof Error ? err.message : String(err));
553
+ }
554
+ }
555
+
536
556
  // =================================================================
537
557
  // Model
538
558
  // =================================================================
@@ -807,6 +827,11 @@ export async function runRpcMode(
807
827
  continue;
808
828
  }
809
829
 
830
+ if (isRpcHostUriResult(parsed)) {
831
+ hostUriBridge.handleResult(parsed);
832
+ continue;
833
+ }
834
+
810
835
  // Handle regular commands
811
836
  const command = parsed as RpcCommand;
812
837
  const response = await handleCommand(command);
@@ -821,5 +846,6 @@ export async function runRpcMode(
821
846
 
822
847
  // stdin closed — RPC client is gone, exit cleanly
823
848
  hostToolBridge.rejectAllPending("RPC client disconnected before host tool execution completed");
849
+ hostUriBridge.clear("RPC client disconnected before host URI request completed");
824
850
  process.exit(0);
825
851
  }
@@ -5,11 +5,11 @@
5
5
  * Responses and events are emitted as JSON lines on stdout.
6
6
  */
7
7
  import type { AgentMessage, AgentToolResult, ThinkingLevel } from "@oh-my-pi/pi-agent-core";
8
+ import type { CompactionResult } from "@oh-my-pi/pi-agent-core/compaction";
8
9
  import type { Effort, ImageContent, Model } from "@oh-my-pi/pi-ai";
9
10
  import type { BashResult } from "../../exec/bash-executor";
10
11
  import type { ContextUsage } from "../../extensibility/extensions/types";
11
12
  import type { SessionStats } from "../../session/agent-session";
12
- import type { CompactionResult } from "../../session/compaction";
13
13
  import type { TodoPhase } from "../../tools/todo-write";
14
14
 
15
15
  // ============================================================================
@@ -29,6 +29,7 @@ export type RpcCommand =
29
29
  | { id?: string; type: "get_state" }
30
30
  | { id?: string; type: "set_todos"; phases: TodoPhase[] }
31
31
  | { id?: string; type: "set_host_tools"; tools: RpcHostToolDefinition[] }
32
+ | { id?: string; type: "set_host_uri_schemes"; schemes: RpcHostUriSchemeDefinition[] }
32
33
 
33
34
  // Model
34
35
  | { id?: string; type: "set_model"; provider: string; modelId: string }
@@ -121,6 +122,7 @@ export type RpcResponse =
121
122
  | { id?: string; type: "response"; command: "get_state"; success: true; data: RpcSessionState }
122
123
  | { id?: string; type: "response"; command: "set_todos"; success: true; data: { todoPhases: TodoPhase[] } }
123
124
  | { id?: string; type: "response"; command: "set_host_tools"; success: true; data: { toolNames: string[] } }
125
+ | { id?: string; type: "response"; command: "set_host_uri_schemes"; success: true; data: { schemes: string[] } }
124
126
 
125
127
  // Model
126
128
  | {
@@ -304,6 +306,61 @@ export interface RpcHostToolResult {
304
306
  isError?: boolean;
305
307
  }
306
308
 
309
+ // ============================================================================
310
+ // Host URI Frames (bidirectional)
311
+ // ============================================================================
312
+
313
+ export interface RpcHostUriSchemeDefinition {
314
+ /** URL scheme without trailing `://` (e.g. `db`, `notion`). */
315
+ scheme: string;
316
+ /** Optional human-readable description for logs/diagnostics. */
317
+ description?: string;
318
+ /** When true, the write tool is allowed to dispatch writes to this scheme. */
319
+ writable?: boolean;
320
+ /** When true, downstream callers suppress hashline anchors for resolved content. */
321
+ immutable?: boolean;
322
+ }
323
+
324
+ export type RpcHostUriOperation = "read" | "write";
325
+
326
+ /** Emitted by the RPC server when it needs the host to satisfy a URI operation. */
327
+ export interface RpcHostUriRequest {
328
+ type: "host_uri_request";
329
+ id: string;
330
+ operation: RpcHostUriOperation;
331
+ url: string;
332
+ /** Present for write operations. */
333
+ content?: string;
334
+ }
335
+
336
+ /** Emitted by the RPC server when a pending URI request should be aborted. */
337
+ export interface RpcHostUriCancelRequest {
338
+ type: "host_uri_cancel";
339
+ id: string;
340
+ targetId: string;
341
+ }
342
+
343
+ /** Sent by the host to complete a pending URI request. */
344
+ export interface RpcHostUriResult {
345
+ type: "host_uri_result";
346
+ id: string;
347
+ /**
348
+ * Required for successful `read` results. Ignored for `write` success.
349
+ * Set on errors when a textual explanation accompanies `isError`.
350
+ */
351
+ content?: string;
352
+ /** Defaults to `text/plain` when omitted. */
353
+ contentType?: "text/markdown" | "application/json" | "text/plain";
354
+ /** Optional resolution notes propagated to the read tool. */
355
+ notes?: string[];
356
+ /** Overrides the scheme-level `immutable` flag for this single resolution. */
357
+ immutable?: boolean;
358
+ /** When true, surface the result content as an error to the caller. */
359
+ isError?: boolean;
360
+ /** Optional error message; preferred over `content` for error surfacing. */
361
+ error?: string;
362
+ }
363
+
307
364
  // ============================================================================
308
365
  // Extension UI Commands (stdin)
309
366
  // ============================================================================
@@ -7,6 +7,7 @@
7
7
  * caller-supplied hooks.
8
8
  */
9
9
  import { runExtensionCompact, runExtensionSetModel } from "../extensibility/extensions/compact-handler";
10
+ import { getSessionSlashCommands } from "../extensibility/extensions/get-commands-handler";
10
11
  import type { ExtensionError, ExtensionUIContext } from "../extensibility/extensions/types";
11
12
  import type { AgentSession } from "../session/agent-session";
12
13
 
@@ -59,7 +60,7 @@ export async function initializeExtensions(session: AgentSession, options: Initi
59
60
  getActiveTools: () => session.getActiveToolNames(),
60
61
  getAllTools: () => session.getAllToolNames(),
61
62
  setActiveTools: (toolNames: string[]) => session.setActiveToolsByName(toolNames),
62
- getCommands: () => [],
63
+ getCommands: () => getSessionSlashCommands(session),
63
64
  setModel: model => runExtensionSetModel(session, model),
64
65
  getThinkingLevel: () => session.thinkingLevel,
65
66
  setThinkingLevel: level => session.setThinkingLevel(level),
@@ -133,6 +133,7 @@
133
133
  "icon.pause": "‖",
134
134
  "icon.loop": "↻",
135
135
  "icon.folder": "▸",
136
+ "icon.scratchFolder": "◌",
136
137
  "icon.pi": "π",
137
138
  "format.bullet": "◦",
138
139
  "md.bullet": "◦"
@@ -133,6 +133,7 @@
133
133
  "icon.pause": "‖",
134
134
  "icon.loop": "↻",
135
135
  "icon.folder": "▸",
136
+ "icon.scratchFolder": "◌",
136
137
  "icon.pi": "π",
137
138
  "format.bullet": "◦",
138
139
  "md.bullet": "◦"