@oh-my-pi/pi-coding-agent 16.3.11 → 16.3.13

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 (113) hide show
  1. package/CHANGELOG.md +65 -0
  2. package/dist/cli.js +3176 -3087
  3. package/dist/types/advisor/runtime.d.ts +11 -0
  4. package/dist/types/config/keybindings.d.ts +9 -4
  5. package/dist/types/config/model-registry.d.ts +4 -0
  6. package/dist/types/config/settings-schema.d.ts +6 -0
  7. package/dist/types/config/settings.d.ts +3 -1
  8. package/dist/types/discovery/helpers.d.ts +9 -0
  9. package/dist/types/exec/bash-executor.d.ts +1 -0
  10. package/dist/types/extensibility/extensions/types.d.ts +11 -2
  11. package/dist/types/extensibility/shared-events.d.ts +2 -2
  12. package/dist/types/internal-urls/__tests__/agent-protocol-nested.test.d.ts +1 -0
  13. package/dist/types/internal-urls/registry-helpers.d.ts +7 -5
  14. package/dist/types/mnemopi/state.d.ts +7 -3
  15. package/dist/types/modes/acp/acp-event-mapper.d.ts +1 -0
  16. package/dist/types/modes/components/model-selector.d.ts +2 -1
  17. package/dist/types/modes/components/read-tool-group.d.ts +1 -0
  18. package/dist/types/modes/github-ref-autocomplete.d.ts +35 -0
  19. package/dist/types/modes/interactive-mode.d.ts +3 -1
  20. package/dist/types/modes/rpc/rpc-client.d.ts +11 -5
  21. package/dist/types/modes/rpc/rpc-mode.d.ts +1 -1
  22. package/dist/types/modes/types.d.ts +3 -1
  23. package/dist/types/modes/utils/context-usage.d.ts +0 -12
  24. package/dist/types/modes/workflow.d.ts +5 -1
  25. package/dist/types/session/agent-session.d.ts +8 -4
  26. package/dist/types/system-prompt.d.ts +1 -1
  27. package/dist/types/tools/bash-interactive.d.ts +1 -1
  28. package/dist/types/tools/bash-skill-urls.d.ts +1 -0
  29. package/dist/types/tools/bash.d.ts +2 -1
  30. package/dist/types/tools/browser/launch.d.ts +1 -0
  31. package/dist/types/tools/grep.d.ts +2 -0
  32. package/dist/types/tools/index.d.ts +4 -0
  33. package/dist/types/tools/path-utils.d.ts +24 -0
  34. package/dist/types/tools/read.d.ts +3 -0
  35. package/dist/types/tools/renderers.d.ts +12 -5
  36. package/dist/types/tools/ssh.d.ts +4 -1
  37. package/dist/types/tools/write.d.ts +1 -0
  38. package/dist/types/utils/local-date.d.ts +2 -0
  39. package/package.json +12 -12
  40. package/src/advisor/__tests__/advisor.test.ts +145 -0
  41. package/src/advisor/runtime.ts +19 -0
  42. package/src/config/api-key-resolver.ts +7 -2
  43. package/src/config/keybindings.ts +62 -10
  44. package/src/config/model-registry.ts +94 -20
  45. package/src/config/settings-schema.ts +11 -1
  46. package/src/config/settings.ts +59 -21
  47. package/src/discovery/builtin.ts +2 -1
  48. package/src/discovery/claude-plugins.ts +167 -46
  49. package/src/discovery/helpers.ts +16 -1
  50. package/src/edit/renderer.ts +20 -6
  51. package/src/eval/js/worker-core.ts +163 -6
  52. package/src/exec/bash-executor.ts +14 -9
  53. package/src/extensibility/extensions/runner.ts +1 -0
  54. package/src/extensibility/extensions/types.ts +13 -2
  55. package/src/extensibility/plugins/legacy-pi-compat.ts +6 -2
  56. package/src/extensibility/plugins/marketplace/fetcher.ts +15 -14
  57. package/src/extensibility/shared-events.ts +2 -2
  58. package/src/internal-urls/__tests__/agent-protocol-nested.test.ts +68 -0
  59. package/src/internal-urls/docs-index.generated.txt +1 -1
  60. package/src/internal-urls/registry-helpers.ts +9 -6
  61. package/src/mnemopi/state.ts +19 -5
  62. package/src/modes/acp/acp-agent.ts +69 -8
  63. package/src/modes/acp/acp-event-mapper.ts +1 -1
  64. package/src/modes/components/model-selector.ts +30 -6
  65. package/src/modes/components/read-tool-group.ts +5 -1
  66. package/src/modes/components/settings-defs.ts +1 -1
  67. package/src/modes/components/status-line/component.ts +14 -2
  68. package/src/modes/components/tool-execution.ts +28 -24
  69. package/src/modes/controllers/command-controller.ts +13 -23
  70. package/src/modes/controllers/event-controller.ts +12 -12
  71. package/src/modes/controllers/extension-ui-controller.test.ts +16 -0
  72. package/src/modes/controllers/extension-ui-controller.ts +7 -35
  73. package/src/modes/controllers/input-controller.ts +23 -57
  74. package/src/modes/controllers/mcp-command-controller.ts +10 -9
  75. package/src/modes/controllers/selector-controller.ts +16 -5
  76. package/src/modes/github-ref-autocomplete.ts +75 -0
  77. package/src/modes/interactive-mode.ts +97 -12
  78. package/src/modes/prompt-action-autocomplete.ts +35 -0
  79. package/src/modes/rpc/rpc-client.ts +42 -13
  80. package/src/modes/rpc/rpc-mode.ts +21 -19
  81. package/src/modes/types.ts +3 -0
  82. package/src/modes/utils/context-usage.ts +58 -5
  83. package/src/modes/utils/hotkeys-markdown.ts +2 -1
  84. package/src/modes/utils/ui-helpers.ts +2 -2
  85. package/src/modes/workflow.ts +14 -8
  86. package/src/prompts/agents/plan.md +0 -1
  87. package/src/prompts/agents/reviewer.md +0 -1
  88. package/src/prompts/system/plan-mode-active.md +5 -2
  89. package/src/prompts/system/system-prompt.md +1 -2
  90. package/src/prompts/system/workflow-notice.md +69 -50
  91. package/src/prompts/tools/bash.md +18 -7
  92. package/src/prompts/tools/grep.md +2 -1
  93. package/src/prompts/tools/memory-edit.md +2 -0
  94. package/src/prompts/tools/read.md +4 -3
  95. package/src/sdk.ts +11 -0
  96. package/src/session/agent-session.ts +136 -19
  97. package/src/system-prompt.ts +3 -2
  98. package/src/tools/bash-interactive.ts +1 -1
  99. package/src/tools/bash-skill-urls.ts +39 -7
  100. package/src/tools/bash.ts +69 -39
  101. package/src/tools/browser/launch.ts +31 -4
  102. package/src/tools/grep.ts +105 -21
  103. package/src/tools/image-gen.ts +1 -1
  104. package/src/tools/index.ts +11 -0
  105. package/src/tools/memory-edit.ts +3 -1
  106. package/src/tools/path-utils.ts +46 -1
  107. package/src/tools/read.ts +135 -57
  108. package/src/tools/renderers.ts +13 -5
  109. package/src/tools/ssh.ts +10 -3
  110. package/src/tools/tts.ts +1 -1
  111. package/src/tools/write.ts +26 -0
  112. package/src/utils/local-date.ts +7 -0
  113. package/src/utils/open.ts +36 -10
@@ -22,6 +22,7 @@ import {
22
22
  getProjectDir,
23
23
  isEnoent,
24
24
  logger,
25
+ MAIN_CONFIG_FILENAMES,
25
26
  procmgr,
26
27
  setWorktreesDir,
27
28
  } from "@oh-my-pi/pi-utils";
@@ -60,7 +61,7 @@ export interface RawSettings {
60
61
  export interface SettingsOptions {
61
62
  /** Current working directory for project settings discovery */
62
63
  cwd?: string;
63
- /** Agent directory for config.yml storage */
64
+ /** Agent directory for config.yml/config.yaml storage */
64
65
  agentDir?: string;
65
66
  /** Don't persist to disk (for tests) */
66
67
  inMemory?: boolean;
@@ -234,7 +235,7 @@ export class Settings {
234
235
  #storage: AgentStorage | null = null;
235
236
 
236
237
  #configFiles: string[] = [];
237
- /** Global settings from config.yml */
238
+ /** Global settings from config.yml/config.yaml */
238
239
  #global: RawSettings = {};
239
240
  /** Project settings from .claude/settings.yml etc */
240
241
  #project: RawSettings = {};
@@ -264,7 +265,7 @@ export class Settings {
264
265
  private constructor(options: SettingsOptions = {}) {
265
266
  this.#cwd = path.normalize(options.cwd ?? getProjectDir());
266
267
  this.#agentDir = path.normalize(options.agentDir ?? getAgentDir());
267
- this.#configPath = options.inMemory ? null : path.join(this.#agentDir, "config.yml");
268
+ this.#configPath = options.inMemory ? null : path.join(this.#agentDir, MAIN_CONFIG_FILENAMES[0]);
268
269
  this.#configFiles = options.configFiles?.map(file => path.resolve(this.#cwd, expandTilde(file))) ?? [];
269
270
  this.#persist = !options.inMemory && options.readOnly !== true;
270
271
 
@@ -429,6 +430,9 @@ export class Settings {
429
430
  if (path === "statusLine.sessionAccent") {
430
431
  statusLineSessionAccentSignal.fire();
431
432
  }
433
+ if (path === "modelRoles") {
434
+ modelRolesSignal.fire();
435
+ }
432
436
  }
433
437
 
434
438
  /**
@@ -455,6 +459,7 @@ export class Settings {
455
459
  inMemory: !this.#persist,
456
460
  });
457
461
  cloned.#storage = this.#storage;
462
+ cloned.#configPath = this.#configPath;
458
463
  cloned.#global = structuredClone(this.#global);
459
464
  cloned.#project = this.#persist ? await cloned.#loadProjectSettings() : structuredClone(this.#project);
460
465
  cloned.#configFiles = [...this.#configFiles];
@@ -480,11 +485,13 @@ export class Settings {
480
485
  async reloadForCwd(cwd: string): Promise<void> {
481
486
  const normalized = path.normalize(cwd);
482
487
  if (normalized === this.#cwd) return;
488
+ const prevModelRoles = this.get("modelRoles");
483
489
  this.#cwd = normalized;
484
490
  if (this.#persist) {
485
491
  this.#project = await this.#loadProjectSettings();
486
492
  }
487
493
  this.#rebuildMerged();
494
+ this.#fireEffectiveSettingChanged("modelRoles", this.get("modelRoles"), prevModelRoles);
488
495
  this.#fireAllHooks();
489
496
  }
490
497
 
@@ -671,16 +678,22 @@ export class Settings {
671
678
 
672
679
  async #load(): Promise<Settings> {
673
680
  // Project settings load (loadCapability scans cwd) is independent of the
674
- // persist chain (storage open → legacy migration → global config.yml read),
675
- // so kick it off first and await after the persist chain completes. The
676
- // persist steps remain sequential: migration may write config.yml, which
677
- // #loadYaml then reads; migration's db fallback needs #storage opened.
681
+ // persist chain (storage open → legacy migration → global config read), so
682
+ // kick it off first and await after the persist chain completes. The
683
+ // persist steps remain sequential: existing config discovery decides
684
+ // whether migration may write config.yml before the global config is read;
685
+ // migration's db fallback needs #storage opened.
678
686
  const projectPromise = this.#loadProjectSettings();
679
687
 
680
688
  if (this.#persist) {
681
689
  this.#storage = await AgentStorage.open(getAgentDbPath(this.#agentDir));
682
- await this.#migrateFromLegacy();
683
- this.#global = await this.#loadYaml(this.#configPath!);
690
+ const existingConfig = await this.#loadExistingMainYaml();
691
+ if (existingConfig) {
692
+ this.#global = existingConfig;
693
+ } else {
694
+ await this.#migrateFromLegacy();
695
+ this.#global = await this.#loadYaml(this.#configPath!);
696
+ }
684
697
  await this.#seedLastChangelogVersionMarker();
685
698
  }
686
699
 
@@ -696,8 +709,9 @@ export class Settings {
696
709
  async #loadReadOnly(): Promise<Settings> {
697
710
  const projectPromise = this.#loadProjectSettings();
698
711
 
699
- if (this.#configPath) {
700
- this.#global = await this.#loadYaml(this.#configPath);
712
+ const existingConfig = await this.#loadExistingMainYaml();
713
+ if (existingConfig) {
714
+ this.#global = existingConfig;
701
715
  }
702
716
 
703
717
  this.#project = await projectPromise;
@@ -707,20 +721,46 @@ export class Settings {
707
721
  }
708
722
 
709
723
  async #loadYaml(filePath: string): Promise<RawSettings> {
724
+ const loaded = await this.#loadYamlIfPresent(filePath);
725
+ return loaded ?? {};
726
+ }
727
+
728
+ async #loadYamlIfPresent(filePath: string): Promise<RawSettings | null> {
729
+ let content: string;
730
+ try {
731
+ content = await Bun.file(filePath).text();
732
+ } catch (error) {
733
+ if (isEnoent(error)) return null;
734
+ logger.warn("Settings: failed to load", { path: filePath, error: String(error) });
735
+ return {};
736
+ }
737
+
710
738
  try {
711
- const content = await Bun.file(filePath).text();
712
739
  const parsed = YAML.parse(content);
713
740
  if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
714
741
  return {};
715
742
  }
716
743
  return this.#migrateRawSettings(parsed as RawSettings);
717
744
  } catch (error) {
718
- if (isEnoent(error)) return {};
719
745
  logger.warn("Settings: failed to load", { path: filePath, error: String(error) });
720
746
  return {};
721
747
  }
722
748
  }
723
749
 
750
+ async #loadExistingMainYaml(): Promise<RawSettings | null> {
751
+ if (!this.#configPath) return null;
752
+ for (const filename of MAIN_CONFIG_FILENAMES) {
753
+ const configPath = path.join(this.#agentDir, filename);
754
+ const loaded = await this.#loadYamlIfPresent(configPath);
755
+ if (loaded) {
756
+ this.#configPath = configPath;
757
+ return loaded;
758
+ }
759
+ }
760
+ this.#configPath = path.join(this.#agentDir, MAIN_CONFIG_FILENAMES[0]);
761
+ return null;
762
+ }
763
+
724
764
  async #loadProjectSettings(): Promise<RawSettings> {
725
765
  try {
726
766
  const result = await loadCapability(settingsCapability.id, { cwd: this.#cwd });
@@ -776,14 +816,6 @@ export class Settings {
776
816
  async #migrateFromLegacy(): Promise<void> {
777
817
  if (!this.#configPath) return;
778
818
 
779
- // Check if config.yml already exists
780
- try {
781
- await Bun.file(this.#configPath).text();
782
- return; // Already exists, no migration needed
783
- } catch (err) {
784
- if (!isEnoent(err)) return;
785
- }
786
-
787
819
  let settings: RawSettings = {};
788
820
  let migrated = false;
789
821
 
@@ -1477,6 +1509,12 @@ const appendOnlyModeSignal = new SettingSignal<[value: string]>("provider.append
1477
1509
  */
1478
1510
  export const onAppendOnlyModeChanged = (cb: (value: string) => void) => appendOnlyModeSignal.on(cb);
1479
1511
 
1512
+ /** Fires when any model role changes at runtime. */
1513
+ const modelRolesSignal = new SettingSignal("modelRoles");
1514
+
1515
+ /** Subscribe to model role changes. Returns an unsubscribe function. */
1516
+ export const onModelRolesChanged: (cb: () => void) => () => void = modelRolesSignal.on.bind(modelRolesSignal);
1517
+
1480
1518
  /** Fires when `statusLine.sessionAccent` changes at runtime. */
1481
1519
  const statusLineSessionAccentSignal = new SettingSignal("statusLine.sessionAccent");
1482
1520
 
@@ -401,7 +401,8 @@ async function loadStickyRulesFile(filePath: string, level: "user" | "project"):
401
401
  const content = await readFile(filePath);
402
402
  if (!content) return null;
403
403
  const source = createSourceMeta(PROVIDER_ID, filePath, level);
404
- const rule = buildRuleFromMarkdown("RULES.md", content, filePath, source, { ruleName: "RULES" });
404
+ const ruleName = level === "project" ? "RULES@project" : "RULES";
405
+ const rule = buildRuleFromMarkdown("RULES.md", content, filePath, source, { ruleName });
405
406
  // Force alwaysApply regardless of frontmatter — the whole point of RULES.md
406
407
  // is to be reattached every turn.
407
408
  return { ...rule, alwaysApply: true };
@@ -4,6 +4,7 @@
4
4
  * Loads configuration from ~/.claude/plugins/cache/ based on installed_plugins.json registry.
5
5
  * Priority: 70 (below claude.ts at 80, so user overrides in .claude/ take precedence)
6
6
  */
7
+ import * as fs from "node:fs/promises";
7
8
  import * as path from "node:path";
8
9
  import { logger } from "@oh-my-pi/pi-utils";
9
10
  import { registerProvider } from "../capability";
@@ -30,14 +31,14 @@ const DISPLAY_NAME = "Claude Code Marketplace";
30
31
  const PRIORITY = 70; // Below claude.ts (80) so user .claude/ overrides win
31
32
 
32
33
  interface ClaudePluginManifest {
33
- skills?: string;
34
- "slash-commands"?: string;
35
- commands?: string;
34
+ skills?: string | string[];
35
+ "slash-commands"?: string | string[];
36
+ commands?: string | string[];
36
37
  }
37
38
 
38
39
  interface ResolvedPluginDir {
39
- dir: string;
40
- warning?: string;
40
+ dirs: string[];
41
+ warnings: string[];
41
42
  }
42
43
 
43
44
  async function readPluginManifest(root: ClaudePluginRoot): Promise<ClaudePluginManifest | null> {
@@ -54,43 +55,116 @@ async function readPluginManifest(root: ClaudePluginRoot): Promise<ClaudePluginM
54
55
  }
55
56
  }
56
57
 
58
+ function isRecord(value: unknown): value is Record<string, unknown> {
59
+ return value !== null && typeof value === "object" && !Array.isArray(value);
60
+ }
61
+
62
+ async function skillsManifestReplacesFallback(root: ClaudePluginRoot): Promise<boolean> {
63
+ const raw = await readFile(path.join(root.path, "marketplace.json"));
64
+ if (raw === null) return false;
65
+
66
+ try {
67
+ const parsed: unknown = JSON.parse(raw);
68
+ if (!isRecord(parsed)) return false;
69
+ const plugins = parsed.plugins;
70
+ return (
71
+ Array.isArray(plugins) &&
72
+ plugins.some(entry => isRecord(entry) && entry.name === root.plugin && entry.source === "./")
73
+ );
74
+ } catch {
75
+ return false;
76
+ }
77
+ }
78
+
57
79
  function isWithinPluginRoot(rootPath: string, targetPath: string): boolean {
58
80
  const relative = path.relative(rootPath, targetPath);
59
81
  return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
60
82
  }
61
83
 
84
+ /**
85
+ * Resolve a manifest-declared directory field to absolute paths within the
86
+ * plugin root.
87
+ *
88
+ * Manifest path fields may be `string` or `string[]`
89
+ * (https://code.claude.com/docs/en/plugins-reference#path-behavior-rules);
90
+ * both shapes are normalized here. The first `manifestKeys` entry that
91
+ * supplies at least one non-empty path wins (later keys are ignored — used for
92
+ * the `commands` > `slash-commands` legacy fallback).
93
+ *
94
+ * `fallback` is the default subdirectory (e.g. `skills/`, `commands/`) and
95
+ * `includeFallback` controls the Claude-documented merge semantic per field:
96
+ *
97
+ * - `skills` **adds to** the default: `fallback` is always scanned, and any
98
+ * manifest entries load alongside it. Callers pass `includeFallback: true`.
99
+ * - `commands` / `slash-commands` **replace** the default: an explicit
100
+ * manifest key means the default `commands/` directory is not scanned.
101
+ * Callers pass `includeFallback: false` (the manifest itself may still
102
+ * list `./commands` explicitly to keep it).
103
+ *
104
+ * When no matching key is set, the fallback is used regardless. Entries that
105
+ * resolve outside the plugin root are dropped with a warning so misconfigured
106
+ * manifests remain observable and cannot escape via traversal.
107
+ */
62
108
  async function resolvePluginDir(
63
109
  root: ClaudePluginRoot,
64
110
  manifestKeys: ReadonlyArray<keyof ClaudePluginManifest>,
65
111
  fallback: string,
112
+ includeFallback: boolean,
66
113
  ): Promise<ResolvedPluginDir> {
67
114
  const manifest = await readPluginManifest(root);
68
115
  const fallbackDir = path.join(root.path, fallback);
69
116
 
70
- let configured: string | undefined;
117
+ let configured: string[] | undefined;
71
118
  let matchedKey: keyof ClaudePluginManifest | undefined;
72
119
  for (const key of manifestKeys) {
73
120
  const val = manifest?.[key];
74
- if (typeof val === "string" && val.trim()) {
75
- configured = val.trim();
121
+ const candidates: string[] = [];
122
+ if (typeof val === "string") {
123
+ const trimmed = val.trim();
124
+ if (trimmed) candidates.push(trimmed);
125
+ } else if (Array.isArray(val)) {
126
+ for (const entry of val) {
127
+ if (typeof entry !== "string") continue;
128
+ const trimmed = entry.trim();
129
+ if (trimmed) candidates.push(trimmed);
130
+ }
131
+ }
132
+ if (candidates.length > 0) {
133
+ configured = candidates;
76
134
  matchedKey = key;
77
135
  break;
78
136
  }
79
137
  }
80
138
 
81
139
  if (configured === undefined) {
82
- return { dir: fallbackDir };
140
+ return { dirs: [fallbackDir], warnings: [] };
83
141
  }
84
142
 
85
- const resolved = path.resolve(root.path, configured);
86
- if (isWithinPluginRoot(root.path, resolved)) {
87
- return { dir: resolved };
143
+ // Dedup preserves order: default entry (when included) first, then declared
144
+ // entries in manifest order. Deduping the paths themselves means a plugin
145
+ // author can still list `./commands` explicitly when they want the default
146
+ // alongside extras without producing double-loads.
147
+ const seen = new Set<string>();
148
+ const dirs: string[] = [];
149
+ const warnings: string[] = [];
150
+ if (includeFallback) {
151
+ seen.add(fallbackDir);
152
+ dirs.push(fallbackDir);
153
+ }
154
+ for (const entry of configured) {
155
+ const resolved = path.resolve(root.path, entry);
156
+ if (!isWithinPluginRoot(root.path, resolved)) {
157
+ warnings.push(
158
+ `[claude-plugins] Ignoring ${String(matchedKey)} path outside plugin root for ${root.id}: ${entry}`,
159
+ );
160
+ continue;
161
+ }
162
+ if (seen.has(resolved)) continue;
163
+ seen.add(resolved);
164
+ dirs.push(resolved);
88
165
  }
89
166
 
90
- return {
91
- dir: fallbackDir,
92
- warning: `[claude-plugins] Ignoring ${String(matchedKey)} path outside plugin root for ${root.id}: ${configured}`,
93
- };
167
+ return { dirs, warnings };
94
168
  }
95
169
 
96
170
  // =============================================================================
@@ -104,24 +178,37 @@ async function loadSkills(ctx: LoadContext): Promise<LoadResult<Skill>> {
104
178
  warnings.push(...rootWarnings);
105
179
  const results = await Promise.all(
106
180
  roots.map(async root => {
107
- const { dir: skillsDir, warning } = await resolvePluginDir(root, ["skills"], "skills");
108
- const result = await scanSkillsFromDir(ctx, {
109
- dir: skillsDir,
110
- providerId: PROVIDER_ID,
111
- level: root.scope,
112
- });
113
- return { root, result, warning };
181
+ const includeFallback = !(await skillsManifestReplacesFallback(root));
182
+ const { dirs: skillsDirs, warnings: resolveWarnings } = await resolvePluginDir(
183
+ root,
184
+ ["skills"],
185
+ "skills",
186
+ includeFallback,
187
+ );
188
+ const scanResults = await Promise.all(
189
+ skillsDirs.map(dir =>
190
+ scanSkillsFromDir(ctx, {
191
+ dir,
192
+ providerId: PROVIDER_ID,
193
+ level: root.scope,
194
+ includeSelf: true,
195
+ }),
196
+ ),
197
+ );
198
+ return { scanResults, resolveWarnings };
114
199
  }),
115
200
  );
116
- for (const { result, warning } of results) {
117
- if (warning) warnings.push(warning);
201
+ for (const { scanResults, resolveWarnings } of results) {
202
+ warnings.push(...resolveWarnings);
118
203
  // Intentionally do NOT prefix skill names with `root.plugin`.
119
204
  // The `plugin:name` format breaks skill:// URL parsing (colons are
120
205
  // ambiguous with port separators) and is unintuitive for callers.
121
206
  // Dedup-by-key in the capability layer already handles name collisions
122
207
  // across providers using priority ordering.
123
- items.push(...result.items);
124
- if (result.warnings) warnings.push(...result.warnings);
208
+ for (const result of scanResults) {
209
+ items.push(...result.items);
210
+ if (result.warnings) warnings.push(...result.warnings);
211
+ }
125
212
  }
126
213
  return { items, warnings };
127
214
  }
@@ -139,28 +226,62 @@ async function loadSlashCommands(ctx: LoadContext): Promise<LoadResult<SlashComm
139
226
 
140
227
  const results = await Promise.all(
141
228
  roots.map(async root => {
142
- const { dir: commandsDir, warning } = await resolvePluginDir(root, ["commands", "slash-commands"], "commands");
143
- const commandResult = await loadFilesFromDir<SlashCommand>(ctx, commandsDir, PROVIDER_ID, root.scope, {
144
- extensions: ["md"],
145
- transform: (name, content, filePath, source) => {
146
- const cmdName = name.replace(/\.md$/, "");
147
- return {
148
- name: root.plugin ? `${root.plugin}:${cmdName}` : cmdName,
149
- path: filePath,
150
- content,
151
- level: root.scope,
152
- _source: source,
153
- };
154
- },
155
- });
156
- return { commandResult, warning };
229
+ const { dirs: commandsDirs, warnings: resolveWarnings } = await resolvePluginDir(
230
+ root,
231
+ ["commands", "slash-commands"],
232
+ "commands",
233
+ false,
234
+ );
235
+ const commandResults = await Promise.all(
236
+ commandsDirs.map(async dir => {
237
+ try {
238
+ const stats = await fs.stat(dir);
239
+ if (stats.isFile()) {
240
+ if (path.extname(dir) !== ".md") return { items: [], warnings: [] };
241
+ const content = await readFile(dir);
242
+ if (content === null) return { items: [], warnings: [`Failed to read file: ${dir}`] };
243
+ const cmdName = path.basename(dir).replace(/\.md$/, "");
244
+ return {
245
+ items: [
246
+ {
247
+ name: root.plugin ? `${root.plugin}:${cmdName}` : cmdName,
248
+ path: dir,
249
+ content,
250
+ level: root.scope,
251
+ _source: createSourceMeta(PROVIDER_ID, dir, root.scope),
252
+ },
253
+ ],
254
+ warnings: [],
255
+ };
256
+ }
257
+ } catch {
258
+ // Missing entries behave like missing directories: no items, no warning.
259
+ }
260
+ return loadFilesFromDir<SlashCommand>(ctx, dir, PROVIDER_ID, root.scope, {
261
+ extensions: ["md"],
262
+ transform: (name, content, filePath, source) => {
263
+ const cmdName = name.replace(/\.md$/, "");
264
+ return {
265
+ name: root.plugin ? `${root.plugin}:${cmdName}` : cmdName,
266
+ path: filePath,
267
+ content,
268
+ level: root.scope,
269
+ _source: source,
270
+ };
271
+ },
272
+ });
273
+ }),
274
+ );
275
+ return { commandResults, resolveWarnings };
157
276
  }),
158
277
  );
159
278
 
160
- for (const { commandResult, warning } of results) {
161
- if (warning) warnings.push(warning);
162
- items.push(...commandResult.items);
163
- if (commandResult.warnings) warnings.push(...commandResult.warnings);
279
+ for (const { commandResults, resolveWarnings } of results) {
280
+ warnings.push(...resolveWarnings);
281
+ for (const commandResult of commandResults) {
282
+ items.push(...commandResult.items);
283
+ if (commandResult.warnings) warnings.push(...commandResult.warnings);
284
+ }
164
285
  }
165
286
 
166
287
  return { items, warnings };
@@ -312,6 +312,15 @@ export interface ScanSkillsFromDirOptions {
312
312
  providerId: string;
313
313
  level: "user" | "project";
314
314
  requireDescription?: boolean;
315
+ /**
316
+ * When true, treat a `SKILL.md` sitting directly under `dir` as a single skill in addition to
317
+ * scanning `<dir>/<name>/SKILL.md` children. Matches the Claude plugin manifest convention
318
+ * that lets a skill path point at a directory containing `SKILL.md` directly (e.g.
319
+ * `"skills": ["./"]`), where the frontmatter `name` determines the invocation name and the
320
+ * directory basename is the fallback. Default `false` preserves the strict child-scan
321
+ * semantic every non-Claude provider relies on.
322
+ */
323
+ includeSelf?: boolean;
315
324
  }
316
325
 
317
326
  // Stable ordering used for skill lists in prompts: name (case-insensitive), then name, then path.
@@ -368,7 +377,13 @@ export async function scanSkillsFromDir(
368
377
  }
369
378
  };
370
379
 
371
- const work = [];
380
+ const work: Promise<void>[] = [];
381
+ if (options.includeSelf) {
382
+ const selfSkillPath = path.join(dir, "SKILL.md");
383
+ if (fs.existsSync(selfSkillPath)) {
384
+ work.push(loadSkill(selfSkillPath));
385
+ }
386
+ }
372
387
  for (const entry of entries) {
373
388
  if (entry.name.startsWith(".")) continue;
374
389
  if (!entry.isDirectory() && !entry.isSymbolicLink()) continue;
@@ -679,21 +679,35 @@ function wrapEditRendererLine(line: string, width: number): string[] {
679
679
  const startAnsi = line.match(/^((?:\x1b\[[0-9;]*m)*)/)?.[1] ?? "";
680
680
  const bodyWithReset = line.slice(startAnsi.length);
681
681
  const body = bodyWithReset.endsWith("\x1b[39m") ? bodyWithReset.slice(0, -"\x1b[39m".length) : bodyWithReset;
682
- const diffMatch = /^([+\-\s])(\s*\d+)([|│])(.*)$/s.exec(body);
683
-
684
- if (!diffMatch) {
682
+ // Gutter shapes produced by formatCodeFrameLine: "-315│", " 313│", "+322│",
683
+ // plus the deduplicated forms " +│" and " │" whose repeated line number
684
+ // renderDiff blanked (single-line replacement pairs and insert-then-context
685
+ // runs) — all │-separated. ASCII "|" gutters exist only in raw canonical
686
+ // diff rows passed through by the plain fallback ("-42|old", " 42|ctx"),
687
+ // which always carry a marker column ("+"/"-"/space) and a line number. So
688
+ // the number is optional for "│", while "|" requires the full canonical
689
+ // shape; anything else (a body line merely starting with "|", error text
690
+ // like "123|…") is not a diff row and wraps generically.
691
+ const diffMatch = /^(\s*[+-]?\s*\d*)([|│])(.*)$/s.exec(body);
692
+
693
+ if (!diffMatch || diffMatch[1].length === 0 || (diffMatch[2] === "|" && !/^[+\-\s]\s*\d+$/.test(diffMatch[1]))) {
685
694
  return wrapTextWithAnsi(line, width);
686
695
  }
687
696
 
688
- const [, marker, lineNum, separator, content] = diffMatch;
689
- const prefix = `${marker}${lineNum}${separator}`;
697
+ const [, gutter, separator, content] = diffMatch;
698
+ const prefix = `${gutter}${separator}`;
690
699
  const prefixWidth = visibleWidth(prefix);
691
700
  const contentWidth = Math.max(1, width - prefixWidth);
692
701
  const continuationPrefix = `${" ".repeat(Math.max(0, prefixWidth - 1))}${separator}`;
693
702
  const wrappedContent = wrapTextWithAnsi(content ?? "", contentWidth);
694
703
 
704
+ // Each visual row is a standalone terminal line: wrapTextWithAnsi re-opens
705
+ // active SGR state at the next row's start, so a row that breaks inside an
706
+ // intra-line diff highlight still ends with inverse video active. Close it
707
+ // alongside the foreground reset — otherwise the frame padding appended
708
+ // after the row is painted as an inverse block (default-foreground cells).
695
709
  return wrappedContent.map(
696
- (segment, index) => `${startAnsi}${index === 0 ? prefix : continuationPrefix}${segment}\x1b[39m`,
710
+ (segment, index) => `${startAnsi}${index === 0 ? prefix : continuationPrefix}${segment}\x1b[27m\x1b[39m`,
697
711
  );
698
712
  }
699
713