@oh-my-pi/pi-coding-agent 17.1.7 → 17.1.8

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 (84) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/dist/{CHANGELOG-5k4dq4g6.md → CHANGELOG-vt8ene9g.md} +35 -0
  3. package/dist/cli.js +4087 -7122
  4. package/dist/template-dys3vk5b.js +1671 -0
  5. package/dist/template-f8wx9vfn.css +1355 -0
  6. package/dist/template-qat058wr.html +55 -0
  7. package/dist/tool-views.generated-jdfmzwmn.js +35 -0
  8. package/dist/types/advisor/advise-tool.d.ts +0 -7
  9. package/dist/types/advisor/runtime.d.ts +0 -9
  10. package/dist/types/async/job-manager.d.ts +11 -0
  11. package/dist/types/cleanse/agent.d.ts +19 -0
  12. package/dist/types/cleanse/balance.d.ts +7 -0
  13. package/dist/types/cleanse/checkers.d.ts +13 -0
  14. package/dist/types/cleanse/index.d.ts +16 -0
  15. package/dist/types/cleanse/loop.d.ts +16 -0
  16. package/dist/types/cleanse/parsers.d.ts +13 -0
  17. package/dist/types/cleanse/progress.d.ts +14 -0
  18. package/dist/types/cleanse/types.d.ts +64 -0
  19. package/dist/types/commands/cleanse.d.ts +23 -0
  20. package/dist/types/export/html/index.d.ts +2 -0
  21. package/dist/types/extensibility/legacy-pi-ai-shim.d.ts +27 -1
  22. package/dist/types/modes/interactive-mode.d.ts +4 -3
  23. package/dist/types/session/async-job-delivery.d.ts +8 -0
  24. package/dist/types/session/model-controls.d.ts +3 -3
  25. package/dist/types/task/isolation-ownership.d.ts +34 -0
  26. package/dist/types/tools/browser/cmux/cmux-tab.d.ts +1 -2
  27. package/dist/types/tools/browser/tab-worker.d.ts +1 -4
  28. package/dist/types/tui/output-block.d.ts +5 -5
  29. package/package.json +16 -12
  30. package/scripts/bundle-dist.ts +3 -1
  31. package/src/advisor/advise-tool.ts +0 -11
  32. package/src/advisor/runtime.ts +0 -12
  33. package/src/async/job-manager.ts +30 -7
  34. package/src/cleanse/agent.ts +226 -0
  35. package/src/cleanse/balance.ts +79 -0
  36. package/src/cleanse/checkers.ts +996 -0
  37. package/src/cleanse/index.ts +190 -0
  38. package/src/cleanse/loop.ts +51 -0
  39. package/src/cleanse/parsers.ts +726 -0
  40. package/src/cleanse/progress.ts +50 -0
  41. package/src/cleanse/prompts/assignment.md +47 -0
  42. package/src/cleanse/types.ts +72 -0
  43. package/src/cli/update-cli.ts +3 -0
  44. package/src/cli/usage-cli.ts +29 -4
  45. package/src/cli/worktree-cli.ts +28 -11
  46. package/src/cli-commands.ts +1 -0
  47. package/src/cli.ts +2 -1
  48. package/src/commands/cleanse.ts +45 -0
  49. package/src/discovery/claude-plugins.ts +144 -34
  50. package/src/export/html/index.ts +17 -10
  51. package/src/extensibility/legacy-pi-ai-shim.ts +46 -1
  52. package/src/launch/broker.ts +14 -4
  53. package/src/modes/acp/acp-agent.ts +12 -9
  54. package/src/modes/acp/acp-event-mapper.ts +38 -4
  55. package/src/modes/components/settings-selector.ts +9 -1
  56. package/src/modes/interactive-mode.ts +36 -47
  57. package/src/modes/prompt-action-autocomplete.ts +5 -3
  58. package/src/prompts/goals/guided-goal-interview.md +41 -6
  59. package/src/prompts/system/vibe-mode-active.md +4 -1
  60. package/src/prompts/tools/browser.md +1 -0
  61. package/src/prompts/tools/goal.md +1 -1
  62. package/src/session/agent-session.ts +28 -4
  63. package/src/session/async-job-delivery.ts +8 -0
  64. package/src/session/model-controls.ts +4 -4
  65. package/src/session/session-advisors.ts +2 -7
  66. package/src/session/session-history-format.ts +31 -6
  67. package/src/slash-commands/builtin-registry.ts +5 -2
  68. package/src/task/isolation-ownership.ts +106 -0
  69. package/src/task/worktree.ts +8 -0
  70. package/src/tools/ask.ts +3 -3
  71. package/src/tools/ast-edit.ts +9 -2
  72. package/src/tools/browser/cmux/cmux-tab.ts +9 -14
  73. package/src/tools/browser/tab-worker.ts +12 -35
  74. package/src/tools/browser.ts +2 -2
  75. package/src/tools/gh-renderer.ts +3 -3
  76. package/src/tools/index.ts +10 -1
  77. package/src/tools/write.ts +35 -0
  78. package/src/tui/code-cell.ts +4 -4
  79. package/src/tui/output-block.ts +25 -8
  80. package/src/utils/shell-snapshot-fn-env.sh +5 -2
  81. package/src/web/search/render.ts +2 -2
  82. package/dist/types/goals/guided-setup.d.ts +0 -30
  83. package/src/goals/guided-setup.ts +0 -171
  84. package/src/prompts/goals/guided-goal-system.md +0 -33
@@ -0,0 +1,50 @@
1
+ const BAR_WIDTH = 16;
2
+
3
+ /** Minimal output contract used by the interactive cleanse progress reporter. */
4
+ export interface CleanseProgressOutput {
5
+ isTTY?: boolean;
6
+ write(text: string): boolean;
7
+ }
8
+
9
+ /** Renders completed repair workers on one transient terminal line. */
10
+ export interface CleanseProgressReporter {
11
+ readonly interactive: boolean;
12
+ start(total: number): void;
13
+ complete(): void;
14
+ finish(): void;
15
+ }
16
+
17
+ /** Create the TTY-only worker completion reporter used by `omp cleanse`. */
18
+ export function createCleanseProgressReporter(output: CleanseProgressOutput = process.stdout): CleanseProgressReporter {
19
+ const interactive = output.isTTY === true;
20
+ let total = 0;
21
+ let completed = 0;
22
+ let rendered = false;
23
+
24
+ const render = (): void => {
25
+ if (!interactive || total === 0) return;
26
+ const ratio = Math.min(completed / total, 1);
27
+ const filled = Math.round(ratio * BAR_WIDTH);
28
+ const bar = `${"█".repeat(filled)}${"░".repeat(BAR_WIDTH - filled)}`;
29
+ output.write(`\rRepairing [${bar}] ${completed}/${total}\x1b[K`);
30
+ rendered = true;
31
+ };
32
+
33
+ return {
34
+ interactive,
35
+ start(nextTotal) {
36
+ total = Math.max(nextTotal, 0);
37
+ completed = 0;
38
+ render();
39
+ },
40
+ complete() {
41
+ completed = Math.min(completed + 1, total);
42
+ render();
43
+ },
44
+ finish() {
45
+ if (!rendered) return;
46
+ output.write("\n");
47
+ rendered = false;
48
+ },
49
+ };
50
+ }
@@ -0,0 +1,47 @@
1
+ <critical>
2
+ - You MUST fix every assigned diagnostic at its root cause.
3
+ - You MUST stay inside the write scope below.
4
+ - You NEVER suppress valid diagnostics to make checks pass.
5
+ - You NEVER delegate or spawn another agent.
6
+ - You NEVER run project-wide checks or formatters; the orchestrator reruns them.
7
+ </critical>
8
+
9
+ # Assignment
10
+
11
+ Repair wave {{wave}}, worker {{worker}}.
12
+
13
+ ## Write scope
14
+
15
+ {{write_scope}}
16
+
17
+ Read related code freely. Project-level diagnostics MAY require the smallest necessary edit outside named files; use `hub` before touching a peer-owned file.
18
+
19
+ ## Diagnostics
20
+
21
+ {{diagnostics}}
22
+
23
+ ## Checker commands
24
+
25
+ {{checker_commands}}
26
+
27
+ ## Concurrent peer ownership
28
+
29
+ {{peer_assignments}}
30
+
31
+ <workflow>
32
+ 1. Inspect every assigned location and its local context.
33
+ 2. Fix causes, not emitted text or checker configuration.
34
+ 3. Preserve behavior unless a diagnostic proves it wrong.
35
+ 4. Run only targeted checks for assigned files when available.
36
+ 5. Re-read changed sections and resolve every assigned item.
37
+ </workflow>
38
+
39
+ <completeness>
40
+ - Done means every assigned diagnostic has a concrete fix.
41
+ - Blocked items MUST name the missing prerequisite in the final result.
42
+ - Final output MUST list changed files and unresolved items.
43
+ </completeness>
44
+
45
+ <critical>
46
+ You MUST leave peer-owned files untouched and complete the full assignment.
47
+ </critical>
@@ -0,0 +1,72 @@
1
+ /** Severity normalized across checker-specific output formats. */
2
+ export type CleanseSeverity = "error" | "warning" | "info";
3
+
4
+ /** One actionable problem reported by a project checker. */
5
+ export interface CleanseDiagnostic {
6
+ checker: string;
7
+ file?: string;
8
+ line?: number;
9
+ column?: number;
10
+ endLine?: number;
11
+ endColumn?: number;
12
+ code?: string;
13
+ severity: CleanseSeverity;
14
+ message: string;
15
+ suggestion?: string;
16
+ }
17
+
18
+ /** Result metadata for one checker invocation. */
19
+ export interface CleanseCheckResult {
20
+ id: string;
21
+ label: string;
22
+ language: string;
23
+ cwd: string;
24
+ command: string;
25
+ exitCode: number | null;
26
+ diagnostics: CleanseDiagnostic[];
27
+ }
28
+
29
+ /** Checker discovery result omitted because its required executable was unavailable. */
30
+ export interface SkippedCleanseCheck {
31
+ label: string;
32
+ language: string;
33
+ reason: string;
34
+ }
35
+
36
+ /** Aggregate diagnostics from every checker selected for a project. */
37
+ export interface CleanseDiagnosticReport {
38
+ checks: CleanseCheckResult[];
39
+ diagnostics: CleanseDiagnostic[];
40
+ skipped: SkippedCleanseCheck[];
41
+ }
42
+
43
+ /** All diagnostics attached to one file, which scheduling never splits across agents. */
44
+ export interface CleanseFileIssues {
45
+ file?: string;
46
+ diagnostics: CleanseDiagnostic[];
47
+ weight: number;
48
+ }
49
+
50
+ /** One weighted, file-disjoint workload handed to a subagent. */
51
+ export interface CleanseAssignment {
52
+ index: number;
53
+ groups: CleanseFileIssues[];
54
+ weight: number;
55
+ }
56
+
57
+ /** Settled result from one cleanse subagent. */
58
+ export interface CleanseAgentOutcome {
59
+ name: string;
60
+ success: boolean;
61
+ output: string;
62
+ error?: string;
63
+ resolvedModel?: string;
64
+ }
65
+
66
+ /** Final state after one bounded repair wave and verification pass. */
67
+ export interface CleanseLoopResult {
68
+ status: "clean" | "stalled" | "cancelled";
69
+ waves: number;
70
+ report: CleanseDiagnosticReport;
71
+ outcomes: CleanseAgentOutcome[];
72
+ }
@@ -231,6 +231,9 @@ export async function downloadVerifiedBinary(options: VerifiedBinaryDownloadOpti
231
231
  await fs.promises.chmod(options.targetPath, 0o755);
232
232
  } catch (err) {
233
233
  await unlinkIfExists(options.targetPath);
234
+ if (isTimeoutError(err)) {
235
+ throw new Error("Timed out downloading release binary after 15 minutes", { cause: err });
236
+ }
234
237
  throw err;
235
238
  }
236
239
  }
@@ -560,9 +560,34 @@ function formatReloginDeadline(
560
560
  * automatically (refresh failure, upstream invalidation). Rows the user
561
561
  * replaced or deleted deliberately are lifecycle noise, not lost capacity.
562
562
  */
563
- function isActionableDisable(summary: DisabledCredentialSummary): boolean {
563
+ function isActionableDisable(summary: DisabledCredentialSummary, activeAccounts: UsageAccountIdentity[] = []): boolean {
564
564
  if (summary.type !== "oauth") return false;
565
- return !/^(replaced by|deleted by user)/i.test(summary.cause);
565
+ if (/^(replaced by|deleted by user)/i.test(summary.cause)) return false;
566
+
567
+ // Do not display tombstone if there is an active account for the same provider
568
+ // matching the same identity (email, accountId, or org).
569
+ const summaryEmail = summary.email?.toLowerCase();
570
+ const summaryAccountId = summary.accountId?.toLowerCase();
571
+ const summaryOrgId = summary.orgId?.toLowerCase();
572
+
573
+ const matchesActive = activeAccounts.some(account => {
574
+ if (account.provider !== summary.provider) return false;
575
+
576
+ const accountEmail = account.email?.toLowerCase();
577
+ const accountAccountId = account.accountId?.toLowerCase();
578
+ const accountOrgId = account.orgId?.toLowerCase();
579
+
580
+ // If email or accountId match, it's the same identity
581
+ if (summaryEmail && accountEmail && summaryEmail === accountEmail) return true;
582
+ if (summaryAccountId && accountAccountId && summaryAccountId === accountAccountId) return true;
583
+
584
+ // Fallback: if orgId matches and neither email nor accountId contradicts
585
+ if (summaryOrgId && accountOrgId && summaryOrgId === accountOrgId) return true;
586
+
587
+ return false;
588
+ });
589
+
590
+ return !matchesActive;
566
591
  }
567
592
 
568
593
  /** Human-sized disable cause: the upstream `error_description` when embedded, else the first clause. */
@@ -610,7 +635,7 @@ export function formatUsageBreakdown(
610
635
  }
611
636
  const disabledByProvider = new Map<string, DisabledCredentialSummary[]>();
612
637
  for (const summary of disabled) {
613
- if (!isActionableDisable(summary)) continue;
638
+ if (!isActionableDisable(summary, accounts)) continue;
614
639
  const list = disabledByProvider.get(summary.provider) ?? [];
615
640
  list.push(summary);
616
641
  disabledByProvider.set(summary.provider, list);
@@ -1018,7 +1043,7 @@ export async function runUsageCommand(cmd: UsageCommandArgs): Promise<void> {
1018
1043
  const stats = computeProviderWindowStats(filteredReports.filter(peer => peer.provider === report.provider));
1019
1044
  if (stats.length > 0) capacity[report.provider] = stats;
1020
1045
  }
1021
- let disabledForJson = disabled.filter(isActionableDisable);
1046
+ let disabledForJson = disabled.filter(summary => isActionableDisable(summary, accounts));
1022
1047
  if (redaction) {
1023
1048
  disabledForJson = disabledForJson.map(summary => ({
1024
1049
  ...summary,
@@ -8,8 +8,10 @@
8
8
  * `<parent-repo>/.git/worktrees/<name>/`.
9
9
  * - **Task-isolation dirs** (`task/worktree.ts`): a wrapper dir with a
10
10
  * compact `m` subdir mounted/cloned by `natives.isoStart`. Legacy `merged`
11
- * subdirs are still recognized. These are ephemeral; `ensureIsolation`
12
- * removes the base before re-creating it, so leftovers are crashed runs.
11
+ * subdirs are still recognized. `ensureIsolation` writes an ownership
12
+ * marker naming the live omp process; a
13
+ * sandbox whose owner is still running is reported `live` and never
14
+ * removed without `--all`, so `clear` reclaims only crashed leftovers.
13
15
  *
14
16
  * Legacy entries from before the encoding change keep working because git still
15
17
  * tracks them by branch name. This command exists to GC them on demand.
@@ -18,6 +20,7 @@ import * as fs from "node:fs/promises";
18
20
  import * as path from "node:path";
19
21
  import { getWorktreesDir, isEnoent } from "@oh-my-pi/pi-utils";
20
22
  import chalk from "chalk";
23
+ import { hasLiveIsolationOwner, ISOLATION_OWNER_FILE } from "../task/isolation-ownership";
21
24
  import * as git from "../utils/git";
22
25
 
23
26
  type WorktreeKind = "pr-checkout" | "task-isolation" | "empty" | "stray";
@@ -211,16 +214,30 @@ async function classifyDir(dir: string): Promise<WorktreeEntry | null> {
211
214
  if (gitStat?.isFile()) {
212
215
  return classifyPrCheckout(dir, gitEntry);
213
216
  }
214
- for (const mountDir of TASK_ISOLATION_MOUNT_DIRS) {
215
- const mountStat = await fs.stat(path.join(dir, mountDir)).catch(() => null);
216
- if (!mountStat?.isDirectory()) continue;
217
- return {
218
- path: dir,
219
- kind: "task-isolation",
220
- orphanReason: "task-isolation leftover (no live task owns it)",
221
- };
217
+ // A task-isolation sandbox is identified by its ownership marker — written
218
+ // before the backend materialises the mount — or by the `m`/`merged` mount
219
+ // dir itself (legacy dirs and crashed pre-marker runs). Recognizing the
220
+ // marker alone keeps an in-progress sandbox from being mistaken for a stray
221
+ // during the window between marker creation and mount materialisation.
222
+ let isIsolation = await Bun.file(path.join(dir, ISOLATION_OWNER_FILE)).exists();
223
+ if (!isIsolation) {
224
+ for (const mountDir of TASK_ISOLATION_MOUNT_DIRS) {
225
+ const mountStat = await fs.stat(path.join(dir, mountDir)).catch(() => null);
226
+ if (mountStat?.isDirectory()) {
227
+ isIsolation = true;
228
+ break;
229
+ }
230
+ }
222
231
  }
223
- return null;
232
+ if (!isIsolation) return null;
233
+ const live = await hasLiveIsolationOwner(dir);
234
+ return {
235
+ path: dir,
236
+ kind: "task-isolation",
237
+ // Only after confirming no live owner is the "no live task" claim true.
238
+ // A running subagent's sandbox stays live so `clear` won't delete it.
239
+ orphanReason: live ? undefined : "task-isolation leftover (no live task owns it)",
240
+ };
224
241
  }
225
242
 
226
243
  async function classifyPrCheckout(dir: string, gitEntry: string): Promise<WorktreeEntry> {
@@ -18,6 +18,7 @@ export const commands: CommandEntry[] = [
18
18
  { name: "auth-gateway", load: () => import("./commands/auth-gateway").then(m => m.default) },
19
19
  { name: "agents", load: () => import("./commands/agents").then(m => m.default) },
20
20
  { name: "bench", load: () => import("./commands/bench").then(m => m.default) },
21
+ { name: "cleanse", load: () => import("./commands/cleanse").then(m => m.default) },
21
22
  { name: "commit", load: () => import("./commands/commit").then(m => m.default) },
22
23
  { name: "completions", load: () => import("./commands/completions").then(m => m.default) },
23
24
  { name: "__complete", load: () => import("./commands/complete").then(m => m.default) },
package/src/cli.ts CHANGED
@@ -25,6 +25,7 @@ import {
25
25
  VERSION,
26
26
  } from "@oh-my-pi/pi-utils/dirs";
27
27
  import { interceptUnhandledRejections } from "@oh-my-pi/pi-utils/postmortem";
28
+ import { setProcessName } from "@oh-my-pi/pi-utils/process-name";
28
29
  import { declareWorkerHostEntry, installWorkerInbox, isWorkerHostSelector } from "@oh-my-pi/pi-utils/worker-host";
29
30
  import { installProfileAlias, resolveProfileAliasCommandFromProcess } from "./cli/profile-alias";
30
31
  import { extractProfileFlags } from "./cli/profile-bootstrap";
@@ -42,7 +43,7 @@ if (Bun.semver.order(Bun.version, MIN_BUN_VERSION) < 0) {
42
43
  process.exit(1);
43
44
  }
44
45
 
45
- process.title = APP_NAME;
46
+ setProcessName(APP_NAME);
46
47
 
47
48
  // `Bun.build`-API compiled Windows executables report `import.meta.main ===
48
49
  // false`: the standalone loader keys the entry module with native backslashes
@@ -0,0 +1,45 @@
1
+ import { postmortem } from "@oh-my-pi/pi-utils";
2
+ import { Command, Flags } from "@oh-my-pi/pi-utils/cli";
3
+ import { runCleanseCommand } from "../cleanse";
4
+ import { CliUsageError } from "../cli/usage-error";
5
+
6
+ export default class Cleanse extends Command {
7
+ static description = "Detect and fix project diagnostics with weighted parallel subagents";
8
+
9
+ static flags = {
10
+ agents: Flags.integer({
11
+ char: "n",
12
+ description: "Maximum number of file-disjoint subagents",
13
+ default: 8,
14
+ }),
15
+ model: Flags.string({
16
+ char: "m",
17
+ description: "Subagent model selector",
18
+ default: "@smol",
19
+ }),
20
+ tests: Flags.boolean({
21
+ char: "t",
22
+ description: "Also run configured project test suites",
23
+ default: false,
24
+ }),
25
+ };
26
+
27
+ static examples = [
28
+ "omp cleanse",
29
+ "omp cleanse -n 4",
30
+ "omp cleanse -m opus",
31
+ "omp cleanse -t",
32
+ "omp cleanse --agents 12 --model anthropic/claude-opus-4-6",
33
+ ];
34
+
35
+ async run(): Promise<void> {
36
+ const { flags } = await this.parse(Cleanse);
37
+ if (flags.agents <= 0) throw new CliUsageError("--agents must be a positive integer");
38
+ const result = await runCleanseCommand({
39
+ maxAgents: flags.agents,
40
+ model: flags.model,
41
+ includeTests: flags.tests,
42
+ });
43
+ await postmortem.quit(result.exitCode);
44
+ }
45
+ }
@@ -41,6 +41,20 @@ interface ResolvedPluginDir {
41
41
  warnings: string[];
42
42
  }
43
43
 
44
+ interface ResolvedMCPConfig {
45
+ /** On-disk config file to read, or null when servers are inline or nothing applies. */
46
+ path: string | null;
47
+ /** Server map declared inline in the plugin manifest, or null when the source is a file. */
48
+ inlineServers: Record<string, unknown> | null;
49
+ /** Path recorded as each discovered server's capability source. */
50
+ sourcePath: string;
51
+ /** Directory that relative stdio `command`/`cwd` values resolve against. */
52
+ baseDir: string;
53
+ /** True when a plugin manifest named this source, false for the conventional fallback. */
54
+ declared: boolean;
55
+ warnings: string[];
56
+ }
57
+
44
58
  async function readPluginManifest(root: ClaudePluginRoot): Promise<ClaudePluginManifest | null> {
45
59
  const manifestPath = path.join(root.path, ".claude-plugin", "plugin.json");
46
60
  const raw = await readFile(manifestPath);
@@ -375,50 +389,144 @@ async function loadTools(ctx: LoadContext): Promise<LoadResult<CustomTool>> {
375
389
  // MCP Servers
376
390
  // =============================================================================
377
391
 
378
- async function loadMCPServers(ctx: LoadContext): Promise<LoadResult<MCPServer>> {
379
- const items: MCPServer[] = [];
380
- const warnings: string[] = [];
381
-
382
- const { roots, warnings: rootWarnings } = await listClaudePluginRoots(ctx.home, ctx.cwd);
383
- warnings.push(...rootWarnings);
392
+ /**
393
+ * Unwrap a parsed MCP config file to its server map. Supports the nested
394
+ * `{ mcpServers: { } }` project shape and the flat `{ name: cfg, … }`
395
+ * marketplace-plugin shape. Returns null when a `mcpServers` field is present
396
+ * but not an object map (malformed) so the caller skips the file.
397
+ */
398
+ function extractServerMap(obj: Record<string, unknown>): Record<string, unknown> | null {
399
+ if (isRecord(obj.mcpServers)) return obj.mcpServers;
400
+ if (!("mcpServers" in obj)) return obj;
401
+ return null;
402
+ }
384
403
 
385
- for (const root of roots) {
386
- const mcpPath = path.join(root.path, ".mcp.json");
387
- const raw = await readFile(mcpPath);
388
- if (raw === null) continue; // file absent — skip silently
404
+ /**
405
+ * Resolve where a plugin's MCP servers come from, honoring the manifest's
406
+ * `mcpServers` field before the conventional root `.mcp.json`.
407
+ *
408
+ * `.omp-plugin/plugin.json` takes precedence over `.claude-plugin/plugin.json`.
409
+ * The field may be an inline object (the server map itself) or a string path to
410
+ * a config file within the plugin root; a path escaping the root is rejected
411
+ * with a warning. When no manifest declares the field, `<root>/.mcp.json` is the
412
+ * fallback source.
413
+ */
414
+ async function resolvePluginMCPConfig(root: ClaudePluginRoot): Promise<ResolvedMCPConfig> {
415
+ const fallback = path.join(root.path, ".mcp.json");
416
+ for (const manifestDir of [".omp-plugin", ".claude-plugin"]) {
417
+ const manifestPath = path.join(root.path, manifestDir, "plugin.json");
418
+ const raw = await readFile(manifestPath);
419
+ if (raw === null) continue;
389
420
 
390
421
  let parsed: unknown;
391
422
  try {
392
423
  parsed = JSON.parse(raw);
393
424
  } catch {
394
- warnings.push(`[claude-plugins] Invalid JSON in ${mcpPath}`);
395
- logger.warn(`[claude-plugins] Invalid JSON in ${mcpPath}`);
396
425
  continue;
397
426
  }
427
+ if (!isRecord(parsed)) continue;
428
+ const pointer = parsed.mcpServers;
429
+
430
+ // Inline object form: the manifest value is the server map itself, rooted
431
+ // at the plugin directory (Claude's ${CLAUDE_PLUGIN_ROOT} base).
432
+ if (isRecord(pointer)) {
433
+ return {
434
+ path: null,
435
+ inlineServers: pointer,
436
+ sourcePath: manifestPath,
437
+ baseDir: root.path,
438
+ declared: true,
439
+ warnings: [],
440
+ };
441
+ }
442
+
443
+ // File-pointer form: resolve the named config file within the plugin root.
444
+ if (typeof pointer === "string") {
445
+ const configured = pointer.trim();
446
+ if (configured.length === 0) continue;
447
+ const resolved = path.resolve(root.path, configured);
448
+ if (!isWithinPluginRoot(root.path, resolved)) {
449
+ return {
450
+ path: null,
451
+ inlineServers: null,
452
+ sourcePath: manifestPath,
453
+ baseDir: root.path,
454
+ declared: true,
455
+ warnings: [
456
+ `[claude-plugins] Ignoring mcpServers path outside plugin root for ${root.id}: ${configured}`,
457
+ ],
458
+ };
459
+ }
460
+ return {
461
+ path: resolved,
462
+ inlineServers: null,
463
+ sourcePath: resolved,
464
+ baseDir: path.dirname(resolved),
465
+ declared: true,
466
+ warnings: [],
467
+ };
468
+ }
469
+ }
470
+
471
+ return {
472
+ path: fallback,
473
+ inlineServers: null,
474
+ sourcePath: fallback,
475
+ baseDir: path.dirname(fallback),
476
+ declared: false,
477
+ warnings: [],
478
+ };
479
+ }
480
+
481
+ async function loadMCPServers(ctx: LoadContext): Promise<LoadResult<MCPServer>> {
482
+ const items: MCPServer[] = [];
483
+ const warnings: string[] = [];
398
484
 
399
- if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) continue;
400
- const obj = parsed as Record<string, unknown>;
401
-
402
- // Two shapes are supported:
403
- // nested: { "mcpServers": { name: cfg, ... } } (OMP/Claude Code project shape)
404
- // flat: { name: cfg, ... } (Claude marketplace plugin shape)
405
- // If "mcpServers" is present and an object, treat it as the canonical map.
406
- // Otherwise, treat the whole object as the server map.
407
- let servers: Record<string, unknown>;
408
- if (
409
- obj.mcpServers !== undefined &&
410
- obj.mcpServers !== null &&
411
- typeof obj.mcpServers === "object" &&
412
- !Array.isArray(obj.mcpServers)
413
- ) {
414
- servers = obj.mcpServers as Record<string, unknown>;
415
- } else if (!("mcpServers" in obj)) {
416
- servers = obj;
485
+ const { roots, warnings: rootWarnings } = await listClaudePluginRoots(ctx.home, ctx.cwd);
486
+ warnings.push(...rootWarnings);
487
+
488
+ for (const root of roots) {
489
+ const resolved = await resolvePluginMCPConfig(root);
490
+ warnings.push(...resolved.warnings);
491
+
492
+ let servers: Record<string, unknown> | null;
493
+ if (resolved.inlineServers) {
494
+ servers = resolved.inlineServers;
495
+ } else if (resolved.path !== null) {
496
+ const raw = await readFile(resolved.path);
497
+ if (raw === null) {
498
+ // The conventional fallback is optional, but a manifest that names a
499
+ // missing file is an authoring error that would otherwise register
500
+ // zero servers with no explanation.
501
+ if (resolved.declared) {
502
+ const warning = `[claude-plugins] Missing mcpServers file declared by ${root.id}: ${resolved.path}`;
503
+ warnings.push(warning);
504
+ logger.warn(warning);
505
+ }
506
+ continue;
507
+ }
508
+
509
+ let parsed: unknown;
510
+ try {
511
+ parsed = JSON.parse(raw);
512
+ } catch {
513
+ warnings.push(`[claude-plugins] Invalid JSON in ${resolved.path}`);
514
+ logger.warn(`[claude-plugins] Invalid JSON in ${resolved.path}`);
515
+ continue;
516
+ }
517
+ // Two file shapes are supported:
518
+ // nested: { "mcpServers": { name: cfg, ... } } (OMP/Claude Code project shape)
519
+ // flat: { name: cfg, ... } (Claude marketplace plugin shape)
520
+ if (!isRecord(parsed)) continue;
521
+ servers = extractServerMap(parsed);
417
522
  } else {
418
523
  continue;
419
524
  }
525
+ if (servers === null) continue;
420
526
 
421
- for (const [serverName, serverCfg] of Object.entries(servers)) {
527
+ const { sourcePath, baseDir } = resolved;
528
+ for (const serverName in servers) {
529
+ const serverCfg = servers[serverName];
422
530
  if (!serverCfg || typeof serverCfg !== "object" || Array.isArray(serverCfg)) continue;
423
531
  const raw = serverCfg as {
424
532
  enabled?: boolean;
@@ -437,7 +545,9 @@ async function loadMCPServers(ctx: LoadContext): Promise<LoadResult<MCPServer>>
437
545
  // occasionally ship .mcp.json entries with neither, which would register a useless
438
546
  // server and surface as a connection error at runtime.
439
547
  if (typeof raw.command !== "string" && typeof raw.url !== "string") {
440
- warnings.push(`[claude-plugins] Skipping MCP server "${serverName}" in ${mcpPath}: missing command or url`);
548
+ warnings.push(
549
+ `[claude-plugins] Skipping MCP server "${serverName}" in ${sourcePath}: missing command or url`,
550
+ );
441
551
  continue;
442
552
  }
443
553
  const namespacedName = root.plugin ? `${root.plugin}:${serverName}` : serverName;
@@ -446,7 +556,7 @@ async function loadMCPServers(ctx: LoadContext): Promise<LoadResult<MCPServer>>
446
556
  const substitutedCwd = raw.cwd !== undefined ? substitutePluginRoot(raw.cwd, root.path) : undefined;
447
557
  // Root relative command/cwd at the plugin's config directory, not the
448
558
  // session cwd (MCP stdio spawning resolves relative values there).
449
- const rooted = resolvePluginStdioPaths({ command: substitutedCommand, cwd: substitutedCwd }, root.path);
559
+ const rooted = resolvePluginStdioPaths({ command: substitutedCommand, cwd: substitutedCwd }, baseDir);
450
560
  const server: MCPServer = {
451
561
  name: namespacedName,
452
562
  ...(raw.enabled !== undefined && { enabled: raw.enabled }),
@@ -460,7 +570,7 @@ async function loadMCPServers(ctx: LoadContext): Promise<LoadResult<MCPServer>>
460
570
  ...(raw.auth !== undefined && { auth: raw.auth }),
461
571
  ...(raw.oauth !== undefined && { oauth: raw.oauth }),
462
572
  ...(raw.type !== undefined && { transport: raw.type as MCPServer["transport"] }),
463
- _source: createSourceMeta(PROVIDER_ID, mcpPath, root.scope),
573
+ _source: createSourceMeta(PROVIDER_ID, sourcePath, root.scope),
464
574
  };
465
575
  items.push(server);
466
576
  }
@@ -1,4 +1,4 @@
1
- import * as fs from "node:fs/promises";
1
+ import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
3
  import type { AgentState } from "@oh-my-pi/pi-agent-core";
4
4
  import { APP_NAME, isEnoent } from "@oh-my-pi/pi-utils";
@@ -7,31 +7,38 @@ import type { SessionEntry, SessionHeader } from "../../session/session-entries"
7
7
  import { loadEntriesFromFile } from "../../session/session-loader";
8
8
  import { SessionManager } from "../../session/session-manager";
9
9
  import type { ExportThemeNames } from "./args";
10
- import templateCss from "./template.css" with { type: "text" };
11
- import templateHtml from "./template.html" with { type: "text" };
12
- import templateJs from "./template.js" with { type: "text" };
10
+ import templateCssPath from "./template.css" with { type: "file" };
11
+ import templateHtmlPath from "./template.html" with { type: "file" };
12
+ import templateJsPath from "./template.js" with { type: "file" };
13
13
  // Pre-built React tool renderers: built by `gen:tool-views` (`bun run gen:tool-views`),
14
14
  // run automatically by root `prepare` on install and by `prepack` at publish.
15
- import toolViewsJs from "./tool-views.generated.js" with { type: "text" };
15
+ import toolViewsJsPath from "./tool-views.generated.js" with { type: "file" };
16
16
  import { webExportThemeVars } from "./web-palette";
17
17
 
18
18
  export { type ExportThemeNames, parseExportArgs } from "./args";
19
19
 
20
20
  let cachedTemplate: string | undefined;
21
+ /** Resolve a Bun file-loader value without parsing Windows drive letters as URL schemes. */
22
+ export function resolveBundledHtmlAssetPath(assetPath: string, moduleDir: string = import.meta.dir): string {
23
+ if (path.isAbsolute(assetPath) || path.win32.isAbsolute(assetPath)) return assetPath;
24
+ return path.resolve(moduleDir, assetPath);
25
+ }
21
26
 
22
27
  /** Compose the standalone export template: minified CSS, tool renderers, and viewer JS inlined. */
23
28
  export function getTemplate(): string {
24
29
  if (cachedTemplate) return cachedTemplate;
30
+ const templateCss = fs.readFileSync(resolveBundledHtmlAssetPath(templateCssPath), "utf8");
31
+ const templateHtml = fs.readFileSync(resolveBundledHtmlAssetPath(templateHtmlPath as unknown as string), "utf8");
32
+ const templateJs = fs.readFileSync(resolveBundledHtmlAssetPath(templateJsPath), "utf8");
33
+ const toolViewsJs = fs.readFileSync(resolveBundledHtmlAssetPath(toolViewsJsPath), "utf8");
25
34
  const minifiedCss = templateCss
26
35
  .replace(/\/\*[\s\S]*?\*\//g, "")
27
36
  .replace(/\s+/g, " ")
28
37
  .replace(/\s*([{}:;,])\s*/g, "$1")
29
38
  .trim();
30
39
  // Function replacements so `$'`, `$&`, `$$`, etc. inside the embedded
31
- // CSS/JS are not interpreted as substitution patterns. The cast is safe:
32
- // `with { type: "text" }` yields a string at runtime; bun-types just types
33
- // every *.html import as HTMLBundle (TS can't vary types by import attribute).
34
- cachedTemplate = (templateHtml as unknown as string)
40
+ // CSS/JS are not interpreted as substitution patterns.
41
+ cachedTemplate = templateHtml
35
42
  .replace("<template-css/>", () => `<style>${minifiedCss}</style>`)
36
43
  .replace("<template-tool-views/>", () => `<script>${toolViewsJs}</script>`)
37
44
  .replace("<template-js/>", () => `<script>${templateJs}</script>`);
@@ -207,7 +214,7 @@ async function collectSubSessionsFromDir(
207
214
  ): Promise<void> {
208
215
  let names: string[];
209
216
  try {
210
- names = await fs.readdir(dir);
217
+ names = await fs.promises.readdir(dir);
211
218
  } catch (err) {
212
219
  if (isEnoent(err)) return;
213
220
  throw err;