@oh-my-pi/pi-coding-agent 15.0.1 → 15.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 (47) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/package.json +8 -8
  3. package/src/commands/commit.ts +10 -0
  4. package/src/config/model-registry.ts +31 -1
  5. package/src/config/settings-schema.ts +11 -0
  6. package/src/discovery/claude-plugins.ts +19 -7
  7. package/src/eval/py/runner.py +42 -11
  8. package/src/eval/py/runtime.ts +1 -0
  9. package/src/extensibility/extensions/get-commands-handler.ts +77 -0
  10. package/src/extensibility/plugins/legacy-pi-compat.ts +48 -31
  11. package/src/hashline/input.ts +2 -1
  12. package/src/hashline/parser.ts +27 -3
  13. package/src/internal-urls/docs-index.generated.ts +8 -8
  14. package/src/internal-urls/router.ts +8 -0
  15. package/src/internal-urls/types.ts +21 -0
  16. package/src/lsp/config.ts +15 -6
  17. package/src/lsp/defaults.json +6 -2
  18. package/src/modes/acp/acp-agent.ts +248 -50
  19. package/src/modes/components/status-line/segments.ts +38 -4
  20. package/src/modes/controllers/extension-ui-controller.ts +3 -2
  21. package/src/modes/rpc/host-uris.ts +235 -0
  22. package/src/modes/rpc/rpc-mode.ts +27 -1
  23. package/src/modes/rpc/rpc-types.ts +57 -0
  24. package/src/modes/runtime-init.ts +2 -1
  25. package/src/modes/theme/defaults/dark-poimandres.json +1 -0
  26. package/src/modes/theme/defaults/light-poimandres.json +1 -0
  27. package/src/modes/theme/theme.ts +6 -0
  28. package/src/prompts/tools/github.md +4 -4
  29. package/src/prompts/tools/hashline.md +22 -26
  30. package/src/prompts/tools/read.md +55 -37
  31. package/src/task/discovery.ts +5 -2
  32. package/src/task/executor.ts +2 -1
  33. package/src/tools/bash-command-fixup.ts +47 -0
  34. package/src/tools/bash.ts +39 -15
  35. package/src/tools/browser/render.ts +2 -2
  36. package/src/tools/eval.ts +10 -2
  37. package/src/tools/gh.ts +37 -4
  38. package/src/tools/job.ts +16 -7
  39. package/src/tools/output-meta.ts +26 -0
  40. package/src/tools/read.ts +32 -4
  41. package/src/tools/ssh.ts +3 -2
  42. package/src/tools/write.ts +20 -0
  43. package/src/web/search/providers/anthropic.ts +5 -0
  44. package/src/web/search/providers/exa.ts +3 -0
  45. package/src/web/search/providers/gemini.ts +5 -0
  46. package/src/web/search/providers/jina.ts +5 -2
  47. package/src/web/search/providers/zai.ts +5 -2
package/CHANGELOG.md CHANGED
@@ -2,6 +2,43 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [15.0.2] - 2026-05-15
6
+
7
+ ### Added
8
+
9
+ - Added the `set_host_uri_schemes` RPC command so hosts can register and replace writable/read-only internal URI schemes with scheme metadata (`writable`, `immutable`) at runtime
10
+ - Enabled the `write` tool to dispatch `write(url, content)` to registered internal URL handlers, allowing edits to non-filesystem resources via host-managed URI schemes
11
+ - Added host-owned internal URI read/write over RPC, including abort support, so URI operations are resolved by the host transport for `read` and `write` requests
12
+ - Added handling of host URI request results in RPC mode so host services can stream completion frames for internal URI operations
13
+ - Added scratch-directory awareness to the status-line `path` segment. When the project directory is inside an OS-level scratch root (the platform `os.tmpdir()`, `/tmp` and `/var/tmp` plus their macOS `/private/...` aliases, `~/tmp`, or — on Windows — `%TEMP%` / `%TMP%` / `%SystemRoot%\Temp`), the segment now (1) renders the new `icon.scratchFolder` symbol instead of `icon.folder`, and (2) strips the scratch root from the displayed path so only the trailing folder (and any subpath beneath it) is shown — mirroring how `/work` and `~/Projects` are already abbreviated. Both behaviors honor the existing `stripWorkPrefix` option. Icon defaults: 🗑 (emoji), `` (nf-fa-trash) for Nerd Font, `[T]` for ASCII, `◌` in the poimandres themes; themes can override `icon.scratchFolder` independently of `icon.folder`.
14
+
15
+ ### Changed
16
+
17
+ - Changed the `github` tool's search ops (`search_issues`, `search_prs`, `search_code`, `search_commits`) to default the `repo` scope to the current checkout's `owner/repo` when `repo` is omitted. The auto-scope is skipped when the query already carries an explicit `repo:`/`org:`/`user:`/`owner:` qualifier or when `gh repo view` cannot resolve a github remote (in which case the search proceeds across all of GitHub as before). `search_repos` is unchanged — repository-scoping there must live in the query.
18
+
19
+ - Changed bash command preprocessing to strip trailing `| head` and `| tail` pipelines (including `|&`) from each top-level segment in command chains separated by `;`, `&&`, `||`, or `&`
20
+ - Changed bash fixup notices to state that stderr is already merged into stdout and to reflect that fixes were applied for multiple stripped segments when several transforms fire
21
+ - Changed shell-minimizer per-line truncation marker from a bare `…` to `…[+N]`, where `N` is the count of dropped Unicode scalars. The bracketed tally disambiguates minimizer-driven cuts from genuine `…` characters in the source (paths, JSON, stack traces, etc.) and gives the agent an exact count so it can decide whether the missing tail is recoverable inline or warrants reading the `[raw output: artifact://<id>]` footer the bash wrapper already emits when the minimizer rewrites output. Affects pipeline Stage 5 (`truncate_lines_at` in `defs/*.toml`) and the internal callers in `filters/git.rs`, `filters/listing.rs`, and `filters/lint.rs`. ([#1046](https://github.com/can1357/oh-my-pi/issues/1046))
22
+ - Changed bash command preprocessing to use the real `brush-parser` AST via `pi-natives` `applyBashFixups` instead of a hand-rolled top-level mask scanner. The previous regex/character-walking implementation reimplemented quote/heredoc/`$(...)` tracking with conservative bail-outs (notably refusing to fixup commands containing here-strings); the AST-driven version inherits the full shell parser, so semantics-preserving rewrites like stripping `| head -5` off `cat <<<'content' | head -5` now succeed instead of being skipped. No public API change — `applyBashFixups(command)` returns the same `{ command, stripped }` shape.
23
+
24
+ ### Fixed
25
+
26
+ - Fixed bash command fixups to remove a redundant standalone trailing `2>&1` redirect when no other pipe or redirection remains
27
+ - Fixed command-fixup notices to list all stripped segments instead of reporting only one
28
+ - Fixed summarized `read` output stalling agents on elided regions by appending an explicit footer like `[NN lines across MM elided regions; read <path>:raw or a line range like <path>:1-9999 for verbatim content]`. The footer fires whenever the structural summarizer elided at least one span, so the model gets a concrete recovery selector instead of having to guess from a bare `...` / `{ .. }` marker. Surfaces `elidedLines` on `ReadToolDetails.summary` alongside the existing `elidedSpans`. ([#1046](https://github.com/can1357/oh-my-pi/issues/1046))
29
+ - Updated the `read` tool prompt to describe the new elision footer and instruct the model to follow `:raw` (or an explicit line range) when the elided body is actually needed, rather than guessing.
30
+ - Fixed plugin extensions failing to load when their `peerDependencies` reference internal `pi-*` packages under any scope other than `@mariozechner` (e.g. `Cannot find module '@earendil-works/pi-tui'` from `@juicesharp/rpiv-ask-user-question`, or `Cannot find module '@oh-my-pi/pi-utils'` from `@oh-my-pi/swarm-extension`). The legacy-pi specifier shim now treats `@mariozechner`, `@earendil-works`, **and** the canonical `@oh-my-pi` itself as aliases for the same set of bundled in-process packages (`pi-agent-core`, `pi-ai`, `pi-coding-agent`, `pi-natives`, `pi-tui`, `pi-utils`), and additionally rewrites the upstream-only `pi-ai/oauth` subpath onto our `pi-ai/utils/oauth` layout. Restored the `Key` runtime helper export on `@oh-my-pi/pi-tui` to match upstream — plugins using `Key.enter` / `Key.ctrl("c")` (e.g. `@plannotator/pi-extension`, `@juicesharp/rpiv-ask-user-question`) no longer fail with `Export named 'Key' not found`. End-to-end verified against `@juicesharp/rpiv-ask-user-question`, `@oh-my-pi/swarm-extension`, and `@plannotator/pi-extension` — each now loads cleanly with all of its tools/commands/handlers registered. Plugins importing any of those scopes are remapped to the omp binary's own copy at load time, so peer deps are no longer dragged in from npm and there is exactly one module instance per package regardless of which scope name the plugin's manifest happened to declare.
31
+ - Fixed `omp commit` hanging after a successful commit instead of returning to the shell. The command now mirrors the `runPrintMode` exit pattern and calls `postmortem.quit(0)` once the pipeline resolves so lingering HTTP/2 keep-alive sockets, the Settings autosave timer, and other AgentSession background handles don't keep the event loop pinned. ([#1041](https://github.com/can1357/oh-my-pi/issues/1041))
32
+ - Fixed hashline payload parsing to silently treat truly-blank lines as empty `~`-prefixed payload lines when more payload follows in the same run. The previous behavior broke at the blank ("payload line has no preceding +, <, or = operation.") even though the intent is obvious — the only ambiguity is between in-payload blanks and end-of-section blanks, and a one-line lookahead resolves it: blanks that precede a non-payload op still end the run cleanly as section separators. Recovers the common case of forgetting the leading separator on a blank inserted line without changing how trailing blanks between ops behave.
33
+ - Rewrote the hashline edit prompt examples to use an ASCII-only `TITLE = "Mr"` → `"Mrs"` / `"Dr"` motif instead of the previous `" • "` and `"·"` separators. Some agents had been copying the middle-dot literal characters into real edits as if they were format scaffolding (e.g. emitting payload lines like `~ ·`), since the demo inserts were near-twins of the existing string. The new example keeps every original op shape (single-line replace, multiline replace, insert AFTER/BEFORE, append, delete, blank, plus both anti-patterns) but uses content that is obviously domain-specific and clearly distinct from any payload separator. Pure prompt change; no parser, schema, or runtime behavior is affected.
34
+ - Fixed startup fallback-chain validation to recognize cached runtime-discovered standard provider models, including Ollama Cloud models listed by `--list-models`, so `retry.fallbackChains` no longer warns that valid `ollama-cloud/<model>` selectors are unknown. ([#1052](https://github.com/can1357/oh-my-pi/issues/1052))
35
+ - Fixed `discoverAgents()` ignoring `disabledProviders` for the `claude-plugins` provider. Plugin roots from `~/.claude/plugins/` were scanned unconditionally, so agents from Claude Code marketplace plugins continued to appear in `/agents` and the Agent Control Center even when `disabledProviders: [claude-plugins]` was set. The discovery path now checks `isProviderEnabled("claude-plugins")` before calling `listClaudePluginRoots()`, matching how every other capability respects the disabled-providers set. ([#1075](https://github.com/can1357/oh-my-pi/issues/1075))
36
+
37
+ ### Fixed
38
+
39
+ - Fixed `$env:VAR` PowerShell variables being mangled on Windows when commands invoked PowerShell as a subprocess (e.g. `powershell -Command "Write-Host $env:SystemRoot"`). Brush-core applied POSIX parameter expansion to `$env` before spawning the child, leaving a dangling `:NAME`. The fix lives in `pi-shell` at env-var application time: every brush session now defines `env=$env` as an internal shell variable so `$env:NAME` expands to the literal `$env:NAME` token that PowerShell expects. The fallback is not exported, only influences brush's own expansion, and is shadowed by any user assignment to `env` (e.g. `env=prod; echo "$env:8080"` still prints `prod:8080`), so the POSIX bash contract is preserved. ([#1079](https://github.com/can1357/oh-my-pi/issues/1079))
40
+
41
+
5
42
  ## [15.0.1] - 2026-05-14
6
43
  ### Breaking Changes
7
44
 
@@ -15,6 +52,7 @@
15
52
  - Added per-line column cap shared across streaming tool outputs (`bash`, `ssh`, `python`, `js eval`) and the `read` tool. Lines wider than `tools.outputMaxColumns` bytes (default **768**) are ellipsis-truncated at write time and remaining bytes up to the next `\n` are dropped — bounded memory even on multi-MB single-line outputs (e.g. `cat /dev/urandom`). The cap lives on `OutputSink` as the new `maxColumns` option, persists state across chunk boundaries so split-mid-line writes still respect the budget, and exposes `columnDroppedBytes` / `columnTruncatedLines` on `OutputSummary`. Middle-elision byte math subtracts column drops so the "elided from middle" count stays honest. `read` reuses the same setting but trims its already-collected lines via `truncateLine`. Skipped when the read selector is `:raw`. The artifact file (`artifact://<id>`) keeps the full uncapped stream. Set `tools.outputMaxColumns = 0` to disable.
16
53
  - Added Bun HTTP/2 fetch opt-in. Dev scripts (`bun run dev`, `bun run stats`) now pass `bun --experimental-http2-fetch` so every `fetch()` advertises `h2` in the TLS ALPN list and falls back to HTTP/1.1 when the server doesn't select it. Multiplexing collapses parallel requests to the same origin onto one TLS connection. For the installed `omp` binary, export `BUN_FEATURE_FLAG_EXPERIMENTAL_HTTP2_CLIENT=1` in your shell to enable the same behavior (the flag has to be set before Bun starts; `process.env` from inside JS is too late). Requires Bun **1.3.14**.
17
54
  - Added per-subagent cost display (`$X.XX` in the task progress tree and the session-observer stats line). Cost is accumulated incrementally from `message_end` events and shown only when non-zero, using the `statusLineCost` theme color. Providers that do not report per-turn cost data (e.g. subscription/OAuth usage) continue to show nothing.
55
+ - Added ACP elicitation bridge so skills/extensions calling `select`, `confirm`, or `input` on the extension UI context now produce real `unstable_createElicitation` form requests to the ACP client (rather than always resolving to `undefined` / `false`). The `acpExtensionUiContext` constant is promoted to `createAcpExtensionUiContext(connection, getSessionId, clientCapabilities)` — invoked once per session inside `#configureExtensions`, with `getSessionId: () => string` so the live `record.session.sessionId` is read on every elicitation (the underlying id mutates when an extension command calls `ctx.newSession` / `ctx.switchSession`). Each method maps to a single-property `value` schema: `select` → `{type: "string", enum}`, `confirm` → `{type: "boolean"}` (joined `title` + `message` when the trimmed message is non-empty; otherwise just `title`), `input` → `{type: "string", description: placeholder?}` (ACP has no `placeholder` field on `StringPropertySchema`; empty / whitespace-only placeholders are treated as absent). `accept` responses narrow the returned `ElicitationContentValue` back to the method's declared type with a runtime `typeof` guard; `decline` / `cancel` / transport failures fall back to the prior stub return values. `dialogOptions.signal` is honored: an already-aborted signal short-circuits before any SDK round-trip, and an abort mid-flight races against the elicitation so the caller's promise resolves to the stub fallback (the ACP request itself keeps running on the client side — the SDK exposes no form-mode cancel surface; `unstable_completeElicitation` is URL-mode only — matching the in-flight pattern used by `requestRpcEditor`). `dialogOptions.timeout` is honored on parity with `RpcExtensionUIContext`: when the timer fires before the client responds, `onTimeout` is invoked and the caller resolves to the stub fallback. A throwing `onTimeout` is caught and logged (`logger.warn`) so the elicitation promise still settles. Late SDK rejections that arrive after abort/timeout are dropped silently to keep operator logs clean; transport failures still emit `logger.warn` with `{ sessionId, method, error }`. Calls are skipped when the client did not advertise `clientCapabilities.elicitation.form` during `initialize`, so non-elicitation clients are unaffected. `createAcpExtensionUiContext` is exported for tests.
18
56
 
19
57
  ### Changed
20
58
 
package/package.json CHANGED
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/pi-coding-agent",
4
- "version": "15.0.1",
4
+ "version": "15.0.2",
5
5
  "description": "Coding agent CLI with read, bash, edit, write tools and session management",
6
- "homepage": "https://github.com/can1357/oh-my-pi",
6
+ "homepage": "https://omp.sh",
7
7
  "author": "Can Boluk",
8
8
  "contributors": [
9
9
  "Mario Zechner"
@@ -47,12 +47,12 @@
47
47
  "@agentclientprotocol/sdk": "0.21.0",
48
48
  "@babel/parser": "^7.29.3",
49
49
  "@mozilla/readability": "^0.6.0",
50
- "@oh-my-pi/omp-stats": "15.0.1",
51
- "@oh-my-pi/pi-agent-core": "15.0.1",
52
- "@oh-my-pi/pi-ai": "15.0.1",
53
- "@oh-my-pi/pi-natives": "15.0.1",
54
- "@oh-my-pi/pi-tui": "15.0.1",
55
- "@oh-my-pi/pi-utils": "15.0.1",
50
+ "@oh-my-pi/omp-stats": "15.0.2",
51
+ "@oh-my-pi/pi-agent-core": "15.0.2",
52
+ "@oh-my-pi/pi-ai": "15.0.2",
53
+ "@oh-my-pi/pi-natives": "15.0.2",
54
+ "@oh-my-pi/pi-tui": "15.0.2",
55
+ "@oh-my-pi/pi-utils": "15.0.2",
56
56
  "@puppeteer/browsers": "^2.13.0",
57
57
  "@sinclair/typebox": "^0.34.49",
58
58
  "@types/turndown": "5.0.6",
@@ -1,6 +1,7 @@
1
1
  /**
2
2
  * Generate and optionally push a commit with changelog updates.
3
3
  */
4
+ import { postmortem } from "@oh-my-pi/pi-utils";
4
5
  import { Command, Flags } from "@oh-my-pi/pi-utils/cli";
5
6
  import { runCommitCommand } from "../commit";
6
7
  import type { CommitCommandArgs } from "../commit/types";
@@ -31,6 +32,15 @@ export default class Commit extends Command {
31
32
  };
32
33
 
33
34
  await initTheme();
35
+ // The agentic commit flow opens HTTP/2 keep-alive sockets to the model
36
+ // provider (via `installH2Fetch`) and spins up an AgentSession with
37
+ // background async-job + extension machinery. `session.dispose()` releases
38
+ // what it can, but Bun's fetch keeps idle connections warm and a few
39
+ // timers (Settings autosave, OAuth refresh) stay armed long enough to
40
+ // pin the event loop after the commit is already written. Mirror the
41
+ // `runPrintMode` exit pattern from `main.ts` so the CLI returns to the
42
+ // shell instead of stranding the user on Ctrl+C (issue #1041).
34
43
  await runCommitCommand(cmd);
44
+ await postmortem.quit(0);
35
45
  }
36
46
  }
@@ -1015,8 +1015,12 @@ export class ModelRegistry {
1015
1015
 
1016
1016
  this.#addImplicitDiscoverableProviders(configuredProviders);
1017
1017
  const builtInModels = this.#applyHardcodedModelPolicies(this.#loadBuiltInModels(overrides));
1018
+ const cachedStandardModels = this.#applyHardcodedModelPolicies(this.#loadCachedStandardProviderModels());
1018
1019
  const cachedDiscoveries = this.#applyHardcodedModelPolicies(this.#loadCachedDiscoverableModels());
1019
- const resolvedDefaults = this.#mergeResolvedModels(builtInModels, cachedDiscoveries);
1020
+ const resolvedDefaults = this.#mergeResolvedModels(
1021
+ this.#mergeResolvedModels(builtInModels, cachedStandardModels),
1022
+ cachedDiscoveries,
1023
+ );
1020
1024
  const withConfigModels = this.#mergeCustomModels(resolvedDefaults, this.#customModelOverlays);
1021
1025
  // Merge runtime extension models so they survive refresh() cycles
1022
1026
  const combined = this.#mergeCustomModels(withConfigModels, this.#runtimeModelOverlays);
@@ -1115,6 +1119,32 @@ export class ModelRegistry {
1115
1119
  return merged;
1116
1120
  }
1117
1121
 
1122
+ #loadCachedStandardProviderModels(): Model<Api>[] {
1123
+ const configuredDiscoveryProviders = new Set(this.#discoverableProviders.map(provider => provider.provider));
1124
+ const cachedModels: Model<Api>[] = [];
1125
+ for (const descriptor of PROVIDER_DESCRIPTORS) {
1126
+ if (configuredDiscoveryProviders.has(descriptor.providerId)) {
1127
+ continue;
1128
+ }
1129
+ const cache = readModelCache<Api>(descriptor.providerId, 24 * 60 * 60 * 1000, Date.now, this.#cacheDbPath);
1130
+ if (!cache) {
1131
+ continue;
1132
+ }
1133
+ const models = cache.models.map(model =>
1134
+ model.provider === descriptor.providerId ? model : { ...model, provider: descriptor.providerId },
1135
+ );
1136
+ const providerOverride = this.#providerOverrides.get(descriptor.providerId);
1137
+ const withTransport = providerOverride
1138
+ ? models.map(model => this.#applyProviderTransportOverride(model, providerOverride))
1139
+ : models;
1140
+ const withCompat = providerOverride?.compat
1141
+ ? withTransport.map(model => ({ ...model, compat: mergeCompat(model.compat, providerOverride.compat) }))
1142
+ : withTransport;
1143
+ cachedModels.push(...this.#applyProviderModelOverrides(descriptor.providerId, withCompat));
1144
+ }
1145
+ return cachedModels;
1146
+ }
1147
+
1118
1148
  #loadCachedDiscoverableModels(): Model<Api>[] {
1119
1149
  const cachedModels: Model<Api>[] = [];
1120
1150
  for (const providerConfig of this.#discoverableProviders) {
@@ -1648,6 +1648,17 @@ export const SETTINGS_SCHEMA = {
1648
1648
  },
1649
1649
  "bashInterceptor.patterns": { type: "array", default: DEFAULT_BASH_INTERCEPTOR_RULES },
1650
1650
 
1651
+ "bash.stripTrailingHeadTail": {
1652
+ type: "boolean",
1653
+ default: true,
1654
+ ui: {
1655
+ tab: "editing",
1656
+ label: "Strip Trailing head/tail",
1657
+ description:
1658
+ "Silently drop trailing `| head`/`| tail` pipes from single-line bash commands. Output is already truncated automatically.",
1659
+ },
1660
+ },
1661
+
1651
1662
  // Shell output minimizer
1652
1663
  "shellMinimizer.enabled": {
1653
1664
  type: "boolean",
@@ -31,6 +31,7 @@ const PRIORITY = 70; // Below claude.ts (80) so user .claude/ overrides win
31
31
  interface ClaudePluginManifest {
32
32
  skills?: string;
33
33
  "slash-commands"?: string;
34
+ commands?: string;
34
35
  }
35
36
 
36
37
  interface ResolvedPluginDir {
@@ -59,24 +60,35 @@ function isWithinPluginRoot(rootPath: string, targetPath: string): boolean {
59
60
 
60
61
  async function resolvePluginDir(
61
62
  root: ClaudePluginRoot,
62
- manifestKey: keyof ClaudePluginManifest,
63
+ manifestKeys: ReadonlyArray<keyof ClaudePluginManifest>,
63
64
  fallback: string,
64
65
  ): Promise<ResolvedPluginDir> {
65
66
  const manifest = await readPluginManifest(root);
66
67
  const fallbackDir = path.join(root.path, fallback);
67
- const configured = manifest?.[manifestKey];
68
- if (typeof configured !== "string" || !configured.trim()) {
68
+
69
+ let configured: string | undefined;
70
+ let matchedKey: keyof ClaudePluginManifest | undefined;
71
+ for (const key of manifestKeys) {
72
+ const val = manifest?.[key];
73
+ if (typeof val === "string" && val.trim()) {
74
+ configured = val.trim();
75
+ matchedKey = key;
76
+ break;
77
+ }
78
+ }
79
+
80
+ if (configured === undefined) {
69
81
  return { dir: fallbackDir };
70
82
  }
71
83
 
72
- const resolved = path.resolve(root.path, configured.trim());
84
+ const resolved = path.resolve(root.path, configured);
73
85
  if (isWithinPluginRoot(root.path, resolved)) {
74
86
  return { dir: resolved };
75
87
  }
76
88
 
77
89
  return {
78
90
  dir: fallbackDir,
79
- warning: `[claude-plugins] Ignoring ${String(manifestKey)} path outside plugin root for ${root.id}: ${configured}`,
91
+ warning: `[claude-plugins] Ignoring ${String(matchedKey)} path outside plugin root for ${root.id}: ${configured}`,
80
92
  };
81
93
  }
82
94
 
@@ -93,7 +105,7 @@ async function loadSkills(ctx: LoadContext): Promise<LoadResult<Skill>> {
93
105
 
94
106
  const results = await Promise.all(
95
107
  roots.map(async root => {
96
- const { dir: skillsDir, warning } = await resolvePluginDir(root, "skills", "skills");
108
+ const { dir: skillsDir, warning } = await resolvePluginDir(root, ["skills"], "skills");
97
109
  const result = await scanSkillsFromDir(ctx, {
98
110
  dir: skillsDir,
99
111
  providerId: PROVIDER_ID,
@@ -128,7 +140,7 @@ async function loadSlashCommands(ctx: LoadContext): Promise<LoadResult<SlashComm
128
140
 
129
141
  const results = await Promise.all(
130
142
  roots.map(async root => {
131
- const { dir: commandsDir, warning } = await resolvePluginDir(root, "slash-commands", "commands");
143
+ const { dir: commandsDir, warning } = await resolvePluginDir(root, ["commands", "slash-commands"], "commands");
132
144
  const result = await loadFilesFromDir<SlashCommand>(ctx, commandsDir, PROVIDER_ID, root.scope, {
133
145
  extensions: ["md"],
134
146
  transform: (name, content, filePath, source) => {
@@ -25,9 +25,11 @@ when installed.
25
25
 
26
26
  from __future__ import annotations
27
27
 
28
+ import asyncio
28
29
  import ast
29
30
  import base64
30
31
  import builtins
32
+ import inspect
31
33
  import io
32
34
  import json
33
35
  import os
@@ -120,6 +122,7 @@ class _RunnerState:
120
122
  "__builtins__": builtins,
121
123
  }
122
124
  self.last_install_marker: int = 0
125
+ self.loop: asyncio.AbstractEventLoop | None = None
123
126
 
124
127
 
125
128
  _STATE = _RunnerState()
@@ -688,13 +691,41 @@ _install_builtins(_STATE.user_ns)
688
691
  # ---------------------------------------------------------------------------
689
692
 
690
693
 
694
+ _TLA_FLAG = getattr(ast, "PyCF_ALLOW_TOP_LEVEL_AWAIT", 0x2000)
695
+
696
+
697
+ def _get_event_loop() -> asyncio.AbstractEventLoop:
698
+ loop = _STATE.loop
699
+ if loop is None or loop.is_closed():
700
+ loop = asyncio.new_event_loop()
701
+ asyncio.set_event_loop(loop)
702
+ _STATE.loop = loop
703
+ return loop
704
+
705
+
706
+ def _run_compiled(code, ns: dict, *, want_value: bool) -> Any:
707
+ """Execute a code object, awaiting it if compiled as a coroutine.
708
+
709
+ ``want_value`` is True for the trailing expression — we return ``eval``'s
710
+ result (or the awaited coroutine's value). For statement blocks the
711
+ return is always ``None``.
712
+ """
713
+ if code.co_flags & inspect.CO_COROUTINE:
714
+ coro = eval(code, ns)
715
+ result = _get_event_loop().run_until_complete(coro)
716
+ return result if want_value else None
717
+ if want_value:
718
+ return eval(code, ns)
719
+ exec(code, ns)
720
+ return None
721
+
722
+
691
723
  def _exec_source(source: str, ns: dict) -> None:
692
724
  """Compile + execute ``source``; if the last node is an expression, route
693
- its value through ``__omp_display`` so dataframes/figures render rich."""
694
- try:
695
- module = ast.parse(source, mode="exec")
696
- except SyntaxError:
697
- raise
725
+ its value through ``__omp_display`` so dataframes/figures render rich.
726
+ Top-level ``await`` / ``async for`` / ``async with`` is permitted; the
727
+ cell is driven through the runner's persistent event loop."""
728
+ module = ast.parse(source, mode="exec")
698
729
 
699
730
  if not module.body:
700
731
  return
@@ -704,16 +735,16 @@ def _exec_source(source: str, ns: dict) -> None:
704
735
  body_module = ast.Module(body=module.body[:-1], type_ignores=[])
705
736
  expr_module = ast.Expression(body=last.value)
706
737
  ast.copy_location(expr_module, last)
707
- body_code = compile(body_module, "<cell>", "exec")
708
- expr_code = compile(expr_module, "<cell>", "eval")
709
- exec(body_code, ns)
710
- value = eval(expr_code, ns)
738
+ body_code = compile(body_module, "<cell>", "exec", flags=_TLA_FLAG)
739
+ expr_code = compile(expr_module, "<cell>", "eval", flags=_TLA_FLAG)
740
+ _run_compiled(body_code, ns, want_value=False)
741
+ value = _run_compiled(expr_code, ns, want_value=True)
711
742
  if value is not None:
712
743
  __omp_display(value, kind="result")
713
744
  return
714
745
 
715
- code = compile(module, "<cell>", "exec")
716
- exec(code, ns)
746
+ code = compile(module, "<cell>", "exec", flags=_TLA_FLAG)
747
+ _run_compiled(code, ns, want_value=False)
717
748
 
718
749
 
719
750
  # ---------------------------------------------------------------------------
@@ -151,6 +151,7 @@ export function filterEnv(env: Record<string, string | undefined>): Record<strin
151
151
  */
152
152
  export function resolveVenvPath(cwd: string): string | undefined {
153
153
  if ($env.VIRTUAL_ENV) return $env.VIRTUAL_ENV;
154
+ if ($env.CONDA_PREFIX) return $env.CONDA_PREFIX;
154
155
  const candidates = [path.join(cwd, ".venv"), path.join(cwd, "venv")];
155
156
  for (const candidate of candidates) {
156
157
  if (fs.existsSync(candidate)) {
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Helper for wiring the `getCommands` action of {@link ExtensionAPI}.
3
+ *
4
+ * Centralizes the union over the three slash-command sources the runtime
5
+ * exposes so the five wiring sites (interactive UI, ACP, RPC, print, child
6
+ * task executor) cannot drift:
7
+ * - extension-registered hook commands (`source: "extension"`)
8
+ * - prompt commands loaded as `LoadedCustomCommand` — user/project/bundled
9
+ * custom commands and MCP prompts (`source: "prompt"`)
10
+ * - skill commands derived from `session.skills`, gated on
11
+ * `skillsSettings.enableSkillCommands` (`source: "skill"`)
12
+ *
13
+ * Built-in slash commands are intentionally excluded; `getCommands()` is the
14
+ * surface extensions use to discover dynamic commands they did not register
15
+ * themselves. Each frontend (interactive-mode, ACP) prepends its own builtins.
16
+ */
17
+ import type { SkillsSettings } from "../../config/settings";
18
+ import type { CustomCommandSource, LoadedCustomCommand } from "../custom-commands";
19
+ import { getSkillSlashCommandName, type Skill } from "../skills";
20
+ import type { SlashCommandInfo, SlashCommandLocation } from "../slash-commands";
21
+ import type { ExtensionRunner } from "./runner";
22
+
23
+ interface CommandsCapableSession {
24
+ readonly extensionRunner?: ExtensionRunner;
25
+ readonly customCommands: ReadonlyArray<LoadedCustomCommand>;
26
+ readonly skills: ReadonlyArray<Skill>;
27
+ readonly skillsSettings?: SkillsSettings;
28
+ }
29
+
30
+ export function getSessionSlashCommands(session: CommandsCapableSession): SlashCommandInfo[] {
31
+ const out: SlashCommandInfo[] = [];
32
+
33
+ const runner = session.extensionRunner;
34
+ if (runner) {
35
+ for (const cmd of runner.getRegisteredCommands()) {
36
+ out.push({
37
+ name: cmd.name,
38
+ description: cmd.description,
39
+ source: "extension",
40
+ });
41
+ }
42
+ }
43
+
44
+ for (const cmd of session.customCommands) {
45
+ out.push({
46
+ name: cmd.command.name,
47
+ description: cmd.command.description,
48
+ source: "prompt",
49
+ location: customCommandLocation(cmd.source),
50
+ path: cmd.resolvedPath,
51
+ });
52
+ }
53
+
54
+ if (session.skillsSettings?.enableSkillCommands) {
55
+ for (const skill of session.skills) {
56
+ out.push({
57
+ name: getSkillSlashCommandName(skill),
58
+ description: skill.description || undefined,
59
+ source: "skill",
60
+ path: skill.filePath,
61
+ });
62
+ }
63
+ }
64
+
65
+ return out;
66
+ }
67
+
68
+ function customCommandLocation(source: CustomCommandSource): SlashCommandLocation | undefined {
69
+ switch (source) {
70
+ case "user":
71
+ return "user";
72
+ case "project":
73
+ return "project";
74
+ case "bundled":
75
+ return undefined;
76
+ }
77
+ }
@@ -3,21 +3,46 @@ import * as os from "node:os";
3
3
  import * as path from "node:path";
4
4
  import * as url from "node:url";
5
5
 
6
- const LEGACY_PI_PACKAGE_MAP = {
7
- "@mariozechner/pi-agent-core": "@oh-my-pi/pi-agent-core",
8
- "@mariozechner/pi-ai": "@oh-my-pi/pi-ai",
9
- "@mariozechner/pi-coding-agent": "@oh-my-pi/pi-coding-agent",
10
- "@mariozechner/pi-tui": "@oh-my-pi/pi-tui",
11
- } as const;
12
-
13
- const LEGACY_PI_CODING_AGENT_SUBPATH_MAP = {
14
- "extensibility/extensions": "@oh-my-pi/pi-coding-agent/extensibility/extensions",
15
- "extensibility/hooks": "@oh-my-pi/pi-coding-agent/extensibility/hooks",
16
- } as const;
17
-
18
- const LEGACY_PI_SPECIFIER_FILTER = /^@mariozechner\/pi-(agent-core|ai|coding-agent|tui)(\/.*)?$/;
19
- const LEGACY_PI_IMPORT_SPECIFIER_REGEX =
20
- /((?:from\s+|import\s*\(\s*)["'])(@mariozechner\/pi-(?:agent-core|ai|coding-agent|tui)(?:\/[^"'()\s]+)?)(["'])/g;
6
+ // Canonical scope for in-process pi packages. Plugins published against any of
7
+ // the aliased scopes below (mariozechner's original publish, earendil-works'
8
+ // fork, or the canonical @oh-my-pi scope itself) are remapped to this scope and
9
+ // resolved against the bundled copy that ships inside the omp binary. This
10
+ // keeps plugins running against the exact runtime state of the host (single
11
+ // module registry, single tool registry, etc.) regardless of which historical
12
+ // scope name they happened to declare in their peerDependencies.
13
+ const CANONICAL_PI_SCOPE = "@oh-my-pi";
14
+
15
+ // Scopes that have historically been used to publish (or alias) the same set
16
+ // of internal pi-* packages. `@oh-my-pi` is intentionally included so that
17
+ // direct imports of the canonical name still flow through `Bun.resolveSync`
18
+ // against the host binary, avoiding a duplicate copy being pulled in from a
19
+ // plugin's own node_modules tree at install time.
20
+ const PI_SCOPE_ALIASES = ["oh-my-pi", "mariozechner", "earendil-works"] as const;
21
+
22
+ // Internal pi-* package basenames bundled inside the omp binary.
23
+ const PI_PACKAGE_NAMES = ["pi-agent-core", "pi-ai", "pi-coding-agent", "pi-natives", "pi-tui", "pi-utils"] as const;
24
+
25
+ const PI_SCOPE_ALTERNATION = PI_SCOPE_ALIASES.join("|");
26
+ const PI_PACKAGE_ALTERNATION = PI_PACKAGE_NAMES.join("|");
27
+
28
+ // Upstream `@mariozechner/*` packages exposed a few subpaths at the package
29
+ // root that we relocated under a different folder. Each entry rewrites
30
+ // `<pkg>/<from>` → `<pkg>/<to>` after the scope has been canonicalised, so
31
+ // plugins importing the upstream layout still resolve to a real file in our
32
+ // bundled copy. Add new entries as `pkg/from -> pkg/to` whenever a plugin
33
+ // surfaces another upstream-only subpath that breaks resolution.
34
+ const PI_SUBPATH_REMAPS: ReadonlyMap<string, string> = new Map<string, string>([
35
+ // `@mariozechner/pi-ai/oauth` re-exported `./utils/oauth/index.js`.
36
+ // Our pi-ai keeps the implementation under `utils/oauth` but never added a
37
+ // root-level re-export, so map the upstream subpath onto it directly.
38
+ ["pi-ai/oauth", "pi-ai/utils/oauth"],
39
+ ]);
40
+
41
+ const LEGACY_PI_SPECIFIER_FILTER = new RegExp(`^@(?:${PI_SCOPE_ALTERNATION})/(?:${PI_PACKAGE_ALTERNATION})(?:/.*)?$`);
42
+ const LEGACY_PI_IMPORT_SPECIFIER_REGEX = new RegExp(
43
+ `((?:from\\s+|import\\s*\\(\\s*)["'])(@(?:${PI_SCOPE_ALTERNATION})/(?:${PI_PACKAGE_ALTERNATION})(?:/[^"'()\\s]+)?)(["'])`,
44
+ "g",
45
+ );
21
46
  const LEGACY_PI_FILE_PREFIX = "omp-legacy-pi-file:";
22
47
  const LEGACY_PI_FILE_NAMESPACE = "omp-legacy-pi-file";
23
48
  const resolvedSpecifierFallbacks = new Map<string, string>();
@@ -25,25 +50,17 @@ const resolvedSpecifierFallbacks = new Map<string, string>();
25
50
  let isLegacyPiSpecifierShimInstalled = false;
26
51
 
27
52
  function remapLegacyPiSpecifier(specifier: string): string | null {
28
- const [legacyScope, packageName, ...subpathParts] = specifier.split("/");
29
- const legacyPackageName = `${legacyScope}/${packageName}`;
30
- const mappedPackageName = LEGACY_PI_PACKAGE_MAP[legacyPackageName as keyof typeof LEGACY_PI_PACKAGE_MAP];
31
- if (!mappedPackageName) {
53
+ if (!LEGACY_PI_SPECIFIER_FILTER.test(specifier)) {
32
54
  return null;
33
55
  }
34
- if (subpathParts.length === 0) {
35
- return mappedPackageName;
36
- }
37
-
38
- const subpath = subpathParts.join("/");
39
- if (legacyPackageName === "@mariozechner/pi-coding-agent") {
40
- return (
41
- LEGACY_PI_CODING_AGENT_SUBPATH_MAP[subpath as keyof typeof LEGACY_PI_CODING_AGENT_SUBPATH_MAP] ??
42
- `${mappedPackageName}/${subpath}`
43
- );
56
+ const slashIdx = specifier.indexOf("/", 1);
57
+ // Filter guarantees a slash exists, but guard anyway to keep the type narrow.
58
+ if (slashIdx === -1) {
59
+ return null;
44
60
  }
45
-
46
- return `${mappedPackageName}/${subpath}`;
61
+ const rest = specifier.slice(slashIdx + 1);
62
+ const remappedSubpath = PI_SUBPATH_REMAPS.get(rest) ?? rest;
63
+ return `${CANONICAL_PI_SCOPE}/${remappedSubpath}`;
47
64
  }
48
65
 
49
66
  function getResolvedSpecifier(specifier: string): string {
@@ -108,7 +108,8 @@ export function splitHashlineInputs(input: string, options: SplitHashlineOptions
108
108
 
109
109
  const flush = () => {
110
110
  if (currentPath.length === 0) return;
111
- sections.push({ path: currentPath, diff: currentLines.join("\n") });
111
+ const hasOps = currentLines.some(rawLine => stripTrailingCarriageReturn(rawLine).trim().length > 0);
112
+ if (hasOps) sections.push({ path: currentPath, diff: currentLines.join("\n") });
112
113
  currentLines = [];
113
114
  };
114
115
 
@@ -86,9 +86,33 @@ function collectPayload(
86
86
  let index = startIndex;
87
87
  while (index < lines.length) {
88
88
  const line = stripTrailingCarriageReturn(lines[index]);
89
- if (!line.startsWith(HL_EDIT_SEP)) break;
90
- payload.push(line.slice(1));
91
- index++;
89
+ if (line.startsWith(HL_EDIT_SEP)) {
90
+ payload.push(line.slice(1));
91
+ index++;
92
+ continue;
93
+ }
94
+ // Silently recover from a missing payload prefix on an otherwise blank
95
+ // line: if more payload follows (possibly past further blanks), treat
96
+ // each intervening blank as an empty `${HL_EDIT_SEP}` payload line.
97
+ // Additionally, when the op explicitly requires payload (`+`/`<`) and
98
+ // we have not collected any yet, accept the blank(s) themselves as the
99
+ // empty payload — common typo of forgetting the `${HL_EDIT_SEP}` prefix
100
+ // when inserting a blank line.
101
+ if (line.length === 0) {
102
+ let lookahead = index + 1;
103
+ while (lookahead < lines.length && stripTrailingCarriageReturn(lines[lookahead]).length === 0) {
104
+ lookahead++;
105
+ }
106
+ const followedByPayload =
107
+ lookahead < lines.length && stripTrailingCarriageReturn(lines[lookahead]).startsWith(HL_EDIT_SEP);
108
+ const acceptBareBlank = requirePayload && payload.length === 0;
109
+ if (followedByPayload || acceptBareBlank) {
110
+ for (let j = index; j < lookahead; j++) payload.push("");
111
+ index = lookahead;
112
+ continue;
113
+ }
114
+ }
115
+ break;
92
116
  }
93
117
  if (payload.length === 0 && requirePayload) {
94
118
  throw new Error(`line ${opLineNum}: + and < operations require at least one ${HL_EDIT_SEP}TEXT payload line.`);