@oh-my-pi/pi-coding-agent 16.2.1 → 16.2.3

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 (187) hide show
  1. package/CHANGELOG.md +72 -0
  2. package/dist/cli.js +3621 -3579
  3. package/dist/types/advisor/__tests__/config.test.d.ts +1 -0
  4. package/dist/types/advisor/advise-tool.d.ts +8 -4
  5. package/dist/types/advisor/config.d.ts +88 -0
  6. package/dist/types/advisor/index.d.ts +1 -0
  7. package/dist/types/advisor/runtime.d.ts +15 -1
  8. package/dist/types/advisor/transcript-recorder.d.ts +13 -2
  9. package/dist/types/advisor/watchdog.d.ts +20 -0
  10. package/dist/types/collab/guest.d.ts +29 -0
  11. package/dist/types/config/model-roles.d.ts +1 -1
  12. package/dist/types/config/settings-schema.d.ts +113 -12
  13. package/dist/types/debug/log-viewer.d.ts +1 -0
  14. package/dist/types/debug/raw-sse.d.ts +1 -0
  15. package/dist/types/discovery/omp-extension-roots.d.ts +3 -3
  16. package/dist/types/edit/hashline/diff.d.ts +0 -11
  17. package/dist/types/edit/index.d.ts +18 -0
  18. package/dist/types/edit/streaming.d.ts +30 -0
  19. package/dist/types/extensibility/custom-tools/types.d.ts +1 -0
  20. package/dist/types/extensibility/shared-events.d.ts +1 -0
  21. package/dist/types/extensibility/tool-event-input.d.ts +7 -0
  22. package/dist/types/extensibility/utils.d.ts +12 -0
  23. package/dist/types/mcp/oauth-discovery.d.ts +0 -11
  24. package/dist/types/mcp/transports/index.d.ts +1 -0
  25. package/dist/types/mcp/transports/sse.d.ts +20 -0
  26. package/dist/types/modes/components/advisor-config.d.ts +59 -0
  27. package/dist/types/modes/components/index.d.ts +1 -0
  28. package/dist/types/modes/components/model-selector.d.ts +9 -1
  29. package/dist/types/modes/components/settings-selector.d.ts +1 -0
  30. package/dist/types/modes/components/status-line/component.d.ts +30 -3
  31. package/dist/types/modes/components/status-line/types.d.ts +13 -1
  32. package/dist/types/modes/controllers/selector-controller.d.ts +1 -0
  33. package/dist/types/modes/interactive-mode.d.ts +10 -4
  34. package/dist/types/modes/skill-command.d.ts +32 -0
  35. package/dist/types/modes/types.d.ts +7 -2
  36. package/dist/types/sdk.d.ts +1 -1
  37. package/dist/types/session/agent-session.d.ts +84 -12
  38. package/dist/types/session/indexed-session-storage.d.ts +7 -1
  39. package/dist/types/session/messages.d.ts +32 -7
  40. package/dist/types/session/messages.test.d.ts +1 -0
  41. package/dist/types/session/session-entries.d.ts +31 -3
  42. package/dist/types/session/session-history-format.d.ts +6 -0
  43. package/dist/types/session/session-loader.d.ts +9 -1
  44. package/dist/types/session/session-manager.d.ts +6 -4
  45. package/dist/types/session/session-storage.d.ts +11 -0
  46. package/dist/types/session/session-title-slot.d.ts +19 -0
  47. package/dist/types/session/settings-stream-fn.d.ts +21 -0
  48. package/dist/types/session/turn-persistence.d.ts +88 -0
  49. package/dist/types/ssh/connection-manager.d.ts +47 -0
  50. package/dist/types/ssh/utils.d.ts +16 -0
  51. package/dist/types/task/executor.d.ts +3 -16
  52. package/dist/types/task/render.d.ts +0 -5
  53. package/dist/types/task/renderer.d.ts +13 -0
  54. package/dist/types/task/types.d.ts +16 -0
  55. package/dist/types/task/yield-assembly.d.ts +28 -0
  56. package/dist/types/tiny/text.d.ts +8 -0
  57. package/dist/types/tools/render-utils.d.ts +2 -0
  58. package/dist/types/tools/review.d.ts +6 -4
  59. package/dist/types/tools/ssh.d.ts +1 -1
  60. package/dist/types/tools/todo.d.ts +6 -0
  61. package/dist/types/tools/yield.d.ts +8 -3
  62. package/dist/types/utils/thinking-display.d.ts +4 -0
  63. package/package.json +12 -12
  64. package/src/advisor/__tests__/advisor.test.ts +438 -10
  65. package/src/advisor/__tests__/config.test.ts +173 -0
  66. package/src/advisor/advise-tool.ts +11 -6
  67. package/src/advisor/config.ts +256 -0
  68. package/src/advisor/index.ts +1 -0
  69. package/src/advisor/runtime.ts +77 -4
  70. package/src/advisor/transcript-recorder.ts +25 -2
  71. package/src/advisor/watchdog.ts +57 -31
  72. package/src/auto-thinking/classifier.ts +2 -2
  73. package/src/autoresearch/index.ts +7 -2
  74. package/src/cli/gc-cli.ts +17 -10
  75. package/src/collab/guest.ts +43 -7
  76. package/src/config/model-registry.ts +80 -18
  77. package/src/config/model-resolver.ts +5 -1
  78. package/src/config/model-roles.ts +3 -3
  79. package/src/config/settings-schema.ts +107 -8
  80. package/src/debug/index.ts +32 -7
  81. package/src/debug/log-viewer.ts +111 -53
  82. package/src/debug/raw-sse.ts +68 -48
  83. package/src/discovery/codex.ts +13 -5
  84. package/src/discovery/omp-extension-roots.ts +38 -13
  85. package/src/edit/hashline/diff.ts +57 -4
  86. package/src/edit/index.ts +21 -0
  87. package/src/edit/streaming.ts +170 -0
  88. package/src/eval/js/shared/local-module-loader.ts +23 -1
  89. package/src/export/html/template.js +13 -7
  90. package/src/extensibility/custom-tools/types.ts +1 -0
  91. package/src/extensibility/extensions/loader.ts +5 -3
  92. package/src/extensibility/extensions/wrapper.ts +9 -3
  93. package/src/extensibility/hooks/loader.ts +3 -3
  94. package/src/extensibility/hooks/tool-wrapper.ts +13 -4
  95. package/src/extensibility/plugins/legacy-pi-bundled-keys.ts +18 -1
  96. package/src/extensibility/plugins/legacy-pi-bundled-registry.ts +59 -2
  97. package/src/extensibility/plugins/manager.ts +76 -5
  98. package/src/extensibility/shared-events.ts +1 -0
  99. package/src/extensibility/tool-event-input.ts +23 -0
  100. package/src/extensibility/utils.ts +74 -0
  101. package/src/internal-urls/docs-index.generated.txt +1 -1
  102. package/src/mcp/client.ts +3 -1
  103. package/src/mcp/manager.ts +12 -5
  104. package/src/mcp/oauth-discovery.ts +5 -29
  105. package/src/mcp/transports/http.ts +3 -1
  106. package/src/mcp/transports/index.ts +1 -0
  107. package/src/mcp/transports/sse.ts +377 -0
  108. package/src/memories/index.ts +15 -6
  109. package/src/mnemopi/backend.ts +2 -2
  110. package/src/modes/acp/acp-agent.ts +1 -1
  111. package/src/modes/components/advisor-config.ts +555 -0
  112. package/src/modes/components/advisor-message.ts +9 -2
  113. package/src/modes/components/agent-hub.ts +9 -4
  114. package/src/modes/components/assistant-message.ts +5 -5
  115. package/src/modes/components/index.ts +2 -0
  116. package/src/modes/components/model-selector.ts +79 -48
  117. package/src/modes/components/settings-selector.ts +1 -0
  118. package/src/modes/components/status-line/component.ts +145 -14
  119. package/src/modes/components/status-line/segments.ts +47 -22
  120. package/src/modes/components/status-line/types.ts +13 -1
  121. package/src/modes/components/tool-execution.ts +47 -6
  122. package/src/modes/controllers/command-controller.ts +23 -2
  123. package/src/modes/controllers/event-controller.ts +114 -11
  124. package/src/modes/controllers/extension-ui-controller.ts +1 -1
  125. package/src/modes/controllers/input-controller.ts +61 -61
  126. package/src/modes/controllers/selector-controller.ts +100 -9
  127. package/src/modes/interactive-mode.ts +65 -10
  128. package/src/modes/print-mode.ts +1 -1
  129. package/src/modes/skill-command.ts +116 -0
  130. package/src/modes/types.ts +7 -2
  131. package/src/modes/utils/transcript-render-helpers.ts +2 -2
  132. package/src/modes/utils/ui-helpers.ts +46 -27
  133. package/src/prompts/agents/reviewer.md +11 -10
  134. package/src/prompts/review-custom-request.md +1 -2
  135. package/src/prompts/review-request.md +1 -2
  136. package/src/prompts/system/interrupted-thinking.md +7 -0
  137. package/src/prompts/system/recap-user.md +9 -0
  138. package/src/prompts/system/subagent-system-prompt.md +8 -5
  139. package/src/prompts/system/subagent-yield-reminder.md +6 -5
  140. package/src/prompts/system/system-prompt.md +0 -1
  141. package/src/prompts/tools/irc.md +2 -2
  142. package/src/prompts/tools/read.md +2 -2
  143. package/src/sdk.ts +40 -50
  144. package/src/session/agent-session.ts +1139 -600
  145. package/src/session/indexed-session-storage.ts +86 -13
  146. package/src/session/messages.test.ts +125 -0
  147. package/src/session/messages.ts +192 -21
  148. package/src/session/redis-session-storage.ts +49 -2
  149. package/src/session/session-entries.ts +39 -2
  150. package/src/session/session-history-format.ts +29 -2
  151. package/src/session/session-listing.ts +54 -24
  152. package/src/session/session-loader.ts +66 -3
  153. package/src/session/session-manager.ts +113 -19
  154. package/src/session/session-persistence.ts +96 -3
  155. package/src/session/session-storage.ts +36 -0
  156. package/src/session/session-title-slot.ts +141 -0
  157. package/src/session/settings-stream-fn.ts +49 -0
  158. package/src/session/sql-session-storage.ts +71 -11
  159. package/src/session/turn-persistence.ts +142 -0
  160. package/src/session/unexpected-stop-classifier.ts +2 -2
  161. package/src/slash-commands/builtin-registry.ts +16 -3
  162. package/src/slash-commands/helpers/mcp.ts +2 -1
  163. package/src/ssh/__tests__/connection-manager-args.test.ts +123 -1
  164. package/src/ssh/__tests__/file-transfer-posix-guard.test.ts +55 -18
  165. package/src/ssh/connection-manager.ts +139 -12
  166. package/src/ssh/file-transfer.ts +23 -18
  167. package/src/ssh/ssh-executor.ts +2 -13
  168. package/src/ssh/utils.ts +19 -0
  169. package/src/task/executor.ts +21 -23
  170. package/src/task/render.ts +162 -20
  171. package/src/task/renderer.ts +14 -0
  172. package/src/task/types.ts +17 -0
  173. package/src/task/yield-assembly.ts +207 -0
  174. package/src/tiny/models.ts +8 -6
  175. package/src/tiny/text.ts +37 -7
  176. package/src/tools/ask.ts +55 -4
  177. package/src/tools/image-gen.ts +2 -1
  178. package/src/tools/render-utils.ts +2 -0
  179. package/src/tools/renderers.ts +8 -2
  180. package/src/tools/review.ts +17 -7
  181. package/src/tools/ssh.ts +8 -4
  182. package/src/tools/todo.ts +17 -1
  183. package/src/tools/tts.ts +2 -1
  184. package/src/tools/yield.ts +140 -31
  185. package/src/utils/thinking-display.ts +15 -0
  186. package/src/utils/title-generator.ts +1 -1
  187. package/src/web/search/providers/tavily.ts +36 -19
@@ -1,13 +1,6 @@
1
- import {
2
- type Component,
3
- matchesKey,
4
- padding,
5
- replaceTabs,
6
- ScrollView,
7
- truncateToWidth,
8
- visibleWidth,
9
- } from "@oh-my-pi/pi-tui";
1
+ import { type Component, matchesKey, parseSgrMouse, replaceTabs, ScrollView, truncateToWidth } from "@oh-my-pi/pi-tui";
10
2
  import { sanitizeText } from "@oh-my-pi/pi-utils";
3
+ import { bottomBorder, divider, row, topBorder } from "../modes/components/overlay-box";
11
4
  import { theme } from "../modes/theme/theme";
12
5
  import { copyToClipboard } from "../utils/clipboard";
13
6
  import {
@@ -17,8 +10,8 @@ import {
17
10
  rawSseRecordLines,
18
11
  } from "./raw-sse-buffer";
19
12
 
20
- const MIN_VIEWER_WIDTH = 20;
21
- const VIEWER_FRAME_LINES = 5;
13
+ const MIN_VIEWER_WIDTH = 40;
14
+ const VIEWER_CHROME_LINES = 6;
22
15
  // `data:` lines below this width render fine on a single row; anything wider gets pretty-printed
23
16
  // across multiple `data:` lines so streamed JSON blobs stop getting clipped by `truncateToWidth`.
24
17
  const PRETTY_PRINT_DATA_THRESHOLD = 100;
@@ -79,6 +72,8 @@ export class RawSseViewerComponent implements Component {
79
72
  #followTail = true;
80
73
  #lastRenderWidth = MIN_VIEWER_WIDTH;
81
74
  #statusMessage: string | undefined;
75
+ #bodyRowStart = 0;
76
+ #bodyRowCount = 0;
82
77
  // Pretty-printed wire lines keyed by `record.sequence`. Pretty-printing is
83
78
  // the JSON.parse + JSON.stringify per `data:` line, so we cache the result —
84
79
  // the render path runs on every keypress and from `#maxScrollOffset()`.
@@ -98,9 +93,17 @@ export class RawSseViewerComponent implements Component {
98
93
  });
99
94
  }
100
95
 
96
+ dispose(): void {
97
+ this.#unsubscribe();
98
+ }
99
+
101
100
  handleInput(keyData: string): void {
101
+ if (keyData.startsWith("\x1b[<") && this.#handleMouse(keyData)) {
102
+ return;
103
+ }
104
+
102
105
  if (matchesKey(keyData, "escape") || matchesKey(keyData, "esc")) {
103
- this.#unsubscribe();
106
+ this.dispose();
104
107
  this.#onExit();
105
108
  return;
106
109
  }
@@ -145,15 +148,44 @@ export class RawSseViewerComponent implements Component {
145
148
  }
146
149
  }
147
150
 
151
+ #handleMouse(keyData: string): boolean {
152
+ const event = parseSgrMouse(keyData);
153
+ if (!event) return false;
154
+
155
+ const overBody = event.row >= this.#bodyRowStart && event.row < this.#bodyRowStart + this.#bodyRowCount;
156
+ if (event.wheel !== null && overBody) {
157
+ this.#followTail = false;
158
+ this.#scrollOffset = Math.max(0, Math.min(this.#maxScrollOffset(), this.#scrollOffset + event.wheel * 3));
159
+ this.#onUpdate?.();
160
+ return true;
161
+ }
162
+
163
+ if (!event.leftClick) return false;
164
+ if (event.row === 1) {
165
+ this.#followTail = !this.#followTail;
166
+ this.#followIfNeeded();
167
+ this.#onUpdate?.();
168
+ return true;
169
+ }
170
+ if (overBody) {
171
+ this.#followTail = false;
172
+ const clickedOffset = this.#scrollOffset + event.row - this.#bodyRowStart;
173
+ this.#scrollOffset = Math.max(0, Math.min(this.#maxScrollOffset(), clickedOffset));
174
+ this.#onUpdate?.();
175
+ return true;
176
+ }
177
+ return false;
178
+ }
179
+
148
180
  invalidate(): void {}
149
181
 
150
182
  render(width: number): readonly string[] {
151
183
  this.#lastRenderWidth = Math.max(MIN_VIEWER_WIDTH, width);
152
184
  this.#followIfNeeded();
153
185
 
154
- const innerWidth = Math.max(1, this.#lastRenderWidth - 2);
186
+ const contentWidth = Math.max(1, this.#lastRenderWidth - 4);
155
187
  const bodyHeight = this.#bodyHeight();
156
- const rawLines = this.#renderRawLines(innerWidth);
188
+ const rawLines = this.#renderRawLines(contentWidth);
157
189
  const sv = new ScrollView(rawLines.slice(this.#scrollOffset, this.#scrollOffset + bodyHeight), {
158
190
  height: bodyHeight,
159
191
  scrollbar: "auto",
@@ -161,15 +193,18 @@ export class RawSseViewerComponent implements Component {
161
193
  theme: { track: t => theme.fg("muted", t), thumb: t => theme.fg("accent", t) },
162
194
  });
163
195
  sv.setScrollOffset(this.#scrollOffset);
164
- const bodyRows = sv.render(innerWidth);
196
+ const bodyRows = sv.render(contentWidth);
197
+ this.#bodyRowStart = 3;
198
+ this.#bodyRowCount = bodyHeight;
165
199
 
166
200
  return [
167
- this.#frameTop(innerWidth),
168
- this.#frameLine(this.#summaryText(), innerWidth),
169
- this.#frameSeparator(innerWidth),
170
- ...bodyRows.map(line => this.#frameLine(line, innerWidth)),
171
- this.#frameLine(this.#statusText(), innerWidth),
172
- this.#frameBottom(innerWidth),
201
+ topBorder(this.#lastRenderWidth, "Raw Provider Stream"),
202
+ row(this.#summaryText(), this.#lastRenderWidth),
203
+ divider(this.#lastRenderWidth),
204
+ ...bodyRows.map(line => row(line, this.#lastRenderWidth)),
205
+ divider(this.#lastRenderWidth),
206
+ row(this.#statusText(), this.#lastRenderWidth),
207
+ bottomBorder(this.#lastRenderWidth),
173
208
  ];
174
209
  }
175
210
 
@@ -221,22 +256,25 @@ export class RawSseViewerComponent implements Component {
221
256
  if (key < firstSequence) this.#prettyLinesCache.delete(key);
222
257
  }
223
258
  }
224
-
225
259
  #summaryText(): string {
226
260
  const snapshot = this.#buffer.snapshot();
227
- const last = snapshot.lastUpdatedAt ? ` last=${formatRawSseIsoTime(snapshot.lastUpdatedAt)}` : "";
228
- const follow = this.#followTail ? "follow:on" : "follow:off";
229
- return ` # raw provider stream (SSE + WS) | events=${snapshot.totalEvents} records=${snapshot.records.length}${last} | ${follow} | Esc back Ctrl+C copy End follow`;
261
+ const last = snapshot.lastUpdatedAt
262
+ ? `${theme.fg("muted", "last")} ${theme.fg("accent", formatRawSseIsoTime(snapshot.lastUpdatedAt))}`
263
+ : theme.fg("muted", "waiting for first frame");
264
+ const follow = this.#followTail ? theme.fg("success", "follow on") : theme.fg("warning", "follow off");
265
+ return `${theme.fg("muted", "events")} ${theme.fg("accent", String(snapshot.totalEvents))} ${theme.fg("muted", "records")} ${theme.fg("accent", String(snapshot.records.length))} ${last} ${follow}`;
230
266
  }
231
267
 
232
268
  #statusText(): string {
233
- return this.#statusMessage ?? " Up/Down scroll PgUp/PgDn page";
269
+ const help = "Esc close · Ctrl+C copy raw · End follow tail · wheel scroll · click summary toggles follow";
270
+ return this.#statusMessage
271
+ ? `${theme.fg("success", this.#statusMessage)} ${theme.fg("dim", help)}`
272
+ : theme.fg("dim", help);
234
273
  }
235
274
 
236
275
  #bodyHeight(): number {
237
- return Math.max(3, this.#terminalRows - VIEWER_FRAME_LINES);
276
+ return Math.max(3, (process.stdout.rows || this.#terminalRows || 24) - VIEWER_CHROME_LINES);
238
277
  }
239
-
240
278
  #followIfNeeded(): void {
241
279
  if (this.#followTail) this.#scrollToTail();
242
280
  }
@@ -246,8 +284,8 @@ export class RawSseViewerComponent implements Component {
246
284
  }
247
285
 
248
286
  #maxScrollOffset(): number {
249
- const innerWidth = Math.max(1, this.#lastRenderWidth - 2);
250
- return Math.max(0, this.#renderRawLines(innerWidth).length - this.#bodyHeight());
287
+ const contentWidth = Math.max(1, this.#lastRenderWidth - 4);
288
+ return Math.max(0, this.#renderRawLines(contentWidth).length - this.#bodyHeight());
251
289
  }
252
290
 
253
291
  #copyAll(): void {
@@ -271,22 +309,4 @@ export class RawSseViewerComponent implements Component {
271
309
  }
272
310
  this.#onUpdate?.();
273
311
  }
274
-
275
- #frameTop(innerWidth: number): string {
276
- return `${theme.boxRound.topLeft}${theme.boxRound.horizontal.repeat(innerWidth)}${theme.boxRound.topRight}`;
277
- }
278
-
279
- #frameSeparator(innerWidth: number): string {
280
- return `${theme.boxRound.teeRight}${theme.boxRound.horizontal.repeat(innerWidth)}${theme.boxRound.teeLeft}`;
281
- }
282
-
283
- #frameBottom(innerWidth: number): string {
284
- return `${theme.boxRound.bottomLeft}${theme.boxRound.horizontal.repeat(innerWidth)}${theme.boxRound.bottomRight}`;
285
- }
286
-
287
- #frameLine(content: string, innerWidth: number): string {
288
- const truncated = truncateToWidth(content, innerWidth);
289
- const remaining = Math.max(0, innerWidth - visibleWidth(truncated));
290
- return `${theme.boxRound.vertical}${truncated}${padding(remaining)}${theme.boxRound.vertical}`;
291
- }
292
312
  }
@@ -342,12 +342,20 @@ async function loadHooks(ctx: LoadContext): Promise<LoadResult<Hook>> {
342
342
  const codexDir = getProjectCodexDir(ctx);
343
343
  const projectHooksDir = path.join(codexDir, "hooks");
344
344
 
345
+ // OMP hooks must be named `pre-<tool>.<ts|js>` or `post-<tool>.<ts|js>`.
346
+ // Files without that prefix are not OMP hooks (e.g. the standalone Codex
347
+ // hook scripts users keep alongside) — silently dropping the prefix and
348
+ // defaulting to `pre:<basename>` caused those scripts to be imported as
349
+ // extension factories and any top-level `process.exit()` killed startup
350
+ // (#3680).
345
351
  const transformHook =
346
- (level: "user" | "project") => (name: string, _content: string, path: string, source: SourceMeta) => {
352
+ (level: "user" | "project") =>
353
+ (name: string, _content: string, path: string, source: SourceMeta): Hook | null => {
347
354
  const baseName = name.replace(/\.(ts|js)$/, "");
348
355
  const match = baseName.match(/^(pre|post)-(.+)$/);
349
- const hookType = (match?.[1] as "pre" | "post") || "pre";
350
- const toolName = match?.[2] || baseName;
356
+ if (!match) return null;
357
+ const hookType = match[1] as "pre" | "post";
358
+ const toolName = match[2];
351
359
  return {
352
360
  name,
353
361
  path,
@@ -359,11 +367,11 @@ async function loadHooks(ctx: LoadContext): Promise<LoadResult<Hook>> {
359
367
  };
360
368
 
361
369
  const results = await Promise.all([
362
- loadFilesFromDir(ctx, userHooksDir, PROVIDER_ID, "user", {
370
+ loadFilesFromDir<Hook>(ctx, userHooksDir, PROVIDER_ID, "user", {
363
371
  extensions: ["ts", "js"],
364
372
  transform: transformHook("user"),
365
373
  }),
366
- loadFilesFromDir(ctx, projectHooksDir, PROVIDER_ID, "project", {
374
+ loadFilesFromDir<Hook>(ctx, projectHooksDir, PROVIDER_ID, "project", {
367
375
  extensions: ["ts", "js"],
368
376
  transform: transformHook("project"),
369
377
  }),
@@ -22,6 +22,7 @@ import { readDirEntries, readFile } from "../capability/fs";
22
22
  import type { LoadContext } from "../capability/types";
23
23
  import { getEnabledPlugins } from "../extensibility/plugins/loader";
24
24
  import { expandTilde } from "../tools/path-utils";
25
+ import { listClaudePluginRoots } from "./helpers";
25
26
 
26
27
  /** A resolved extension package directory wired into the discovery surfaces. */
27
28
  export interface OmpExtensionRoot {
@@ -123,9 +124,9 @@ async function isDirectory(p: string): Promise<boolean> {
123
124
  * 1. CLI roots injected via {@link injectOmpExtensionCliRoots}
124
125
  * 2. Project `<cwd>/.omp/settings.json#extensions`
125
126
  * 3. User `~/.omp/agent/settings.json#extensions`
126
- * 4. Enabled plugins installed under `<plugins>/node_modules/` (e.g. via
127
- * `omp install <pkg>` / `omp plugin install` / `omp plugin link`)
128
- *
127
+ * 4. Enabled npm/link plugins installed under `<plugins>/node_modules/` (for
128
+ * `omp install <pkg>` / `omp plugin install` / `omp plugin link`). Marketplace
129
+ * installs are loaded by the `claude-plugins` provider and are excluded here.
129
130
  * Only entries that resolve to a directory on disk are returned; file
130
131
  * entrypoints contribute zero sub-discovery surface and are filtered out.
131
132
  * Installed-plugin enumeration failures (missing lockfile, unreadable
@@ -167,20 +168,44 @@ export async function listOmpExtensionRoots(ctx: LoadContext): Promise<OmpExtens
167
168
  }
168
169
 
169
170
  /**
170
- * Enumerate every enabled installed plugin's package directory so its
171
- * conventional `skills/`, `hooks/`, `tools/`, `commands/`, `rules/`,
172
- * `prompts/`, and `.mcp.json` are wired into discovery — mirrors how
173
- * `getAllPluginExtensionPaths` already feeds the extension factory loader.
171
+ * Enumerate every enabled npm/link plugin's package directory so its conventional
172
+ * `skills/`, `hooks/`, `tools/`, `commands/`, `rules/`, `prompts/`, and
173
+ * `.mcp.json` are wired into discovery — mirrors how `getAllPluginExtensionPaths`
174
+ * already feeds the extension factory loader.
174
175
  *
175
- * Marketplace and `omp plugin link` installs write to the plugin manager's
176
- * `node_modules` (or symlink into it) rather than to `extensions:` in
177
- * settings; without this branch the sub-discovery provider would still miss
178
- * everything those install paths produce.
176
+ * Marketplace installs also create runtime symlinks for enable-state persistence,
177
+ * but their resources are discovered through the `claude-plugins` provider.
178
+ * Filtering them here prevents `/status` from showing the same plugin under both
179
+ * "Claude Code Marketplace" and "OMP Extension Packages".
179
180
  */
181
+ async function realpathOrResolved(p: string): Promise<string> {
182
+ try {
183
+ return await fs.realpath(p);
184
+ } catch (err) {
185
+ if (isEnoent(err)) return path.resolve(p);
186
+ throw err;
187
+ }
188
+ }
189
+
180
190
  async function listInstalledPluginRoots(ctx: LoadContext): Promise<InjectedRoot[]> {
181
191
  try {
182
- const plugins = await getEnabledPlugins(ctx.cwd, { home: ctx.home });
183
- return plugins.map(({ path: p, scope }) => ({ path: p, level: scope }));
192
+ const [plugins, marketplaceRoots] = await Promise.all([
193
+ getEnabledPlugins(ctx.cwd, { home: ctx.home }),
194
+ listClaudePluginRoots(ctx.home, ctx.cwd),
195
+ ]);
196
+ const marketplaceRealpaths = new Set(
197
+ await Promise.all(marketplaceRoots.roots.map(root => realpathOrResolved(root.path))),
198
+ );
199
+ const installedRoots = await Promise.all(
200
+ plugins.map(async plugin => ({
201
+ path: plugin.path,
202
+ scope: plugin.scope,
203
+ realpath: await realpathOrResolved(plugin.path),
204
+ })),
205
+ );
206
+ return installedRoots
207
+ .filter(root => !marketplaceRealpaths.has(root.realpath))
208
+ .map(({ path: p, scope }) => ({ path: p, level: scope }));
184
209
  } catch (err) {
185
210
  logger.debug("listInstalledPluginRoots: enumeration failed", { error: String(err) });
186
211
  return [];
@@ -9,6 +9,7 @@
9
9
  * match is accepted even when the tag was minted by a source that did not keep
10
10
  * history, and stale tags recover through the session snapshot store when possible.
11
11
  */
12
+ import * as path from "node:path";
12
13
  import {
13
14
  type ApplyResult,
14
15
  applyEdits,
@@ -30,6 +31,7 @@ import {
30
31
  } from "@oh-my-pi/hashline";
31
32
  import { resolveToCwd } from "../../tools/path-utils";
32
33
  import { generateDiffString } from "../diff";
34
+ import { canonicalSnapshotKey } from "../file-snapshot-store";
33
35
  import { readEditFileText } from "../read-file";
34
36
  import { nativeBlockResolver } from "./block-resolver";
35
37
 
@@ -90,6 +92,54 @@ async function readSectionTextCached(absolutePath: string, sectionPath: string):
90
92
  return rawContent;
91
93
  }
92
94
 
95
+ /**
96
+ * Resolve a missing authored path to a file read this session by matching its
97
+ * basename and snapshot tag, mirroring {@link Patcher}'s apply-time recovery so
98
+ * a bare/wrong-directory `[basename#tag]` header previews against the same file
99
+ * the edit will land on. Returns `undefined` when no unique basename+tag match
100
+ * exists, leaving the caller to surface the original read error.
101
+ */
102
+ function recoverSectionPathFromTag(
103
+ section: PatchSection,
104
+ authoredAbsolutePath: string,
105
+ snapshots: SnapshotStore,
106
+ ): string | undefined {
107
+ if (section.fileHash === undefined) return undefined;
108
+ const authoredName = path.basename(section.path);
109
+ const authoredKey = canonicalSnapshotKey(authoredAbsolutePath);
110
+ const candidates = [
111
+ ...new Set(
112
+ snapshots
113
+ .findByHash(section.fileHash)
114
+ .filter(snapshot => path.basename(snapshot.path) === authoredName)
115
+ .map(snapshot => snapshot.path),
116
+ ),
117
+ ].filter(candidate => candidate !== authoredKey);
118
+ return candidates.length === 1 ? candidates[0] : undefined;
119
+ }
120
+
121
+ /**
122
+ * Read the section's target file for a preview, recovering a bare/mis-typed
123
+ * `[basename#tag]` path onto the file its tag uniquely names. Recovery fires
124
+ * only when the authored path is absent — matching {@link Patcher}'s apply-time
125
+ * order — so a permission/parse error on an existing file surfaces against the
126
+ * authored path instead of silently previewing a different tagged file. Returns
127
+ * the path actually read so callers key snapshot lookups off the same file.
128
+ */
129
+ async function readSectionForPreview(
130
+ section: PatchSection,
131
+ authoredAbsolutePath: string,
132
+ snapshots: SnapshotStore,
133
+ streaming: boolean | undefined,
134
+ ): Promise<{ absolutePath: string; rawContent: string }> {
135
+ const read = streaming ? readSectionTextCached : readSectionText;
136
+ const recovered = (await Bun.file(authoredAbsolutePath).exists())
137
+ ? undefined
138
+ : recoverSectionPathFromTag(section, authoredAbsolutePath, snapshots);
139
+ const target = recovered ?? authoredAbsolutePath;
140
+ return { absolutePath: target, rawContent: await read(target, section.path) };
141
+ }
142
+
93
143
  function hasAnchorScopedEdit(edits: readonly Edit[]): boolean {
94
144
  return edits.some(edit => {
95
145
  if (edit.kind === "delete") return true;
@@ -258,10 +308,13 @@ export async function computeHashlineSectionDiff(
258
308
  options: HashlineDiffOptions = {},
259
309
  ): Promise<{ diff: string; firstChangedLine: number | undefined } | { error: string }> {
260
310
  try {
261
- const absolutePath = resolveToCwd(section.path, cwd);
262
- const rawContent = options.streaming
263
- ? await readSectionTextCached(absolutePath, section.path)
264
- : await readSectionText(absolutePath, section.path);
311
+ const authoredPath = resolveToCwd(section.path, cwd);
312
+ const { absolutePath, rawContent } = await readSectionForPreview(
313
+ section,
314
+ authoredPath,
315
+ snapshots,
316
+ options.streaming,
317
+ );
265
318
  const { text: content } = stripBom(rawContent);
266
319
  const normalized = normalizeToLF(content);
267
320
  // Streaming favors a stable, monotonic preview over an exact unified
package/src/edit/index.ts CHANGED
@@ -397,6 +397,27 @@ export class EditTool implements AgentTool<TInput> {
397
397
  return EDIT_MODE_STRATEGIES[this.mode].matcherDigest(args);
398
398
  }
399
399
 
400
+ /**
401
+ * Project the streamed args onto their target file paths so path-scoped
402
+ * stream matchers (e.g. TTSR `tool:edit(*.ts)` globs) match hashline and
403
+ * apply_patch edits even though the path lives in the wire payload (a
404
+ * section header / envelope marker) rather than a top-level argument.
405
+ */
406
+ matcherPaths(args: unknown): readonly string[] | undefined {
407
+ return EDIT_MODE_STRATEGIES[this.mode].matcherPaths(args);
408
+ }
409
+
410
+ /**
411
+ * Per-file projection of the streamed args, splitting multi-section
412
+ * hashline / multi-hunk apply_patch payloads into one (path, digest) entry
413
+ * per touched file. Path-scoped stream matchers (TTSR) then evaluate each
414
+ * file in isolation, so a `tool:edit(*.ts)` rule never fires on text that
415
+ * actually belongs to a sibling Markdown hunk.
416
+ */
417
+ matcherEntries(args: unknown): readonly { path: string; digest: string }[] | undefined {
418
+ return EDIT_MODE_STRATEGIES[this.mode].matcherEntries(args);
419
+ }
420
+
400
421
  async execute(
401
422
  _toolCallId: string,
402
423
  params: EditParams,
@@ -52,6 +52,17 @@ export interface StreamingDiffContext {
52
52
  isStreaming?: boolean;
53
53
  }
54
54
 
55
+ /**
56
+ * Per-file projection of a streamed edit payload. Pairs one target file path
57
+ * with the digest of only the lines added to that file, so path-scoped stream
58
+ * matchers (TTSR) evaluate each file in isolation — a `tool:edit(*.ts)` rule
59
+ * never fires on text that actually belongs to a sibling `README.md` hunk.
60
+ */
61
+ export interface EditMatcherEntry {
62
+ readonly path: string;
63
+ readonly digest: string;
64
+ }
65
+
55
66
  export interface EditStreamingStrategy<Args = unknown> {
56
67
  /**
57
68
  * Return the args restricted to edits that are "complete enough" to
@@ -77,6 +88,26 @@ export interface EditStreamingStrategy<Args = unknown> {
77
88
  * args don't yet carry any content.
78
89
  */
79
90
  matcherDigest(args: Args): string | undefined;
91
+ /**
92
+ * Surface the target file paths a (potentially partial) call would touch,
93
+ * so path-scoped stream matchers (e.g. TTSR `tool:edit(*.ts)` globs) match
94
+ * even when the path is not a top-level argument but lives inside the wire
95
+ * payload — `hashline` section headers, `apply_patch` envelope markers.
96
+ * Returns `undefined` (or an empty list) when no paths are recoverable.
97
+ */
98
+ matcherPaths(args: Args): readonly string[] | undefined;
99
+ /**
100
+ * Per-file projection of the (potentially partial) args: one entry per
101
+ * touched file pairing the path with the digest of only the lines added to
102
+ * that file. Multi-file payloads (multi-section hashline / multi-hunk
103
+ * apply_patch) MUST split here so callers can evaluate each file under its
104
+ * own path scope instead of leaking added lines from one file into the
105
+ * other's match context. Same-path sections / hunks are merged into one
106
+ * entry. Returns `undefined` (or empty) when no per-file split is
107
+ * recoverable yet — the caller falls back to {@link matcherDigest} +
108
+ * {@link matcherPaths}.
109
+ */
110
+ matcherEntries(args: Args): readonly EditMatcherEntry[] | undefined;
80
111
  }
81
112
 
82
113
  // -----------------------------------------------------------------------------
@@ -191,6 +222,103 @@ function extractAddedLines(text: string, fallbackToWhole: boolean): string {
191
222
  return added;
192
223
  }
193
224
 
225
+ /**
226
+ * Extract hashline `[path#TAG]` (and untagged `[path]`) section-header paths
227
+ * from a (possibly partial) hashline buffer. Tolerant of streaming chunks
228
+ * where `Patch.parse` would still throw on the trailing op — only fully
229
+ * closed header lines are recognised.
230
+ */
231
+ function extractHashlineHeaderPaths(input: string): string[] {
232
+ const paths: string[] = [];
233
+ const re = /^\s*\[([^\]\r\n]+?)(?:#[0-9a-fA-F]{4})?\]\s*$/gm;
234
+ for (const match of input.matchAll(re)) {
235
+ const candidate = stripApplyPatchPathNoise(match[1]).trim();
236
+ if (candidate.length > 0) paths.push(candidate);
237
+ }
238
+ return paths;
239
+ }
240
+
241
+ /**
242
+ * Strip the `*** Add/Update/Delete File:` / `*** Move to:` noise that the
243
+ * model sometimes pastes into a hashline header (the hashline tokenizer does
244
+ * the same in its recovery path).
245
+ */
246
+ function stripApplyPatchPathNoise(value: string): string {
247
+ return value
248
+ .replace(/^\s*\*{3}\s*(?:Add|Update|Delete)\s+File\s*:\s*/i, "")
249
+ .replace(/^\s*\*{3}\s*Move\s+to\s*:\s*/i, "");
250
+ }
251
+
252
+ /** Extract `*** Add/Update/Delete File:` paths from a (possibly partial) apply_patch envelope. */
253
+ function extractApplyPatchEnvelopePaths(input: string): string[] {
254
+ const paths: string[] = [];
255
+ const re = /^\s*\*{3}\s+(?:Add|Update|Delete)\s+File\s*:\s*(\S.*?)\s*$/gm;
256
+ for (const match of input.matchAll(re)) {
257
+ const candidate = match[1].trim();
258
+ if (candidate.length > 0) paths.push(candidate);
259
+ }
260
+ return paths;
261
+ }
262
+
263
+ /**
264
+ * Split a (possibly partial) hashline buffer into one matcher entry per
265
+ * touched file: pair the section header path with the added lines from that
266
+ * section's body, merging sections that target the same file into one entry.
267
+ * Header-line regex (not `Patch.parse`) so a mid-typed trailing op still
268
+ * yields entries for completed sections.
269
+ */
270
+ function splitHashlinePerFile(input: string): EditMatcherEntry[] {
271
+ const headerRe = /^\s*\[([^\]\r\n]+?)(?:#[0-9a-fA-F]{4})?\]\s*$/gm;
272
+ const sections: { path: string; headerStart: number; bodyStart: number }[] = [];
273
+ let match: RegExpExecArray | null = headerRe.exec(input);
274
+ while (match !== null) {
275
+ const candidate = stripApplyPatchPathNoise(match[1]).trim();
276
+ if (candidate.length > 0) {
277
+ sections.push({ path: candidate, headerStart: match.index, bodyStart: headerRe.lastIndex });
278
+ }
279
+ match = headerRe.exec(input);
280
+ }
281
+ if (sections.length === 0) return [];
282
+
283
+ const byPath = new Map<string, string>();
284
+ for (let i = 0; i < sections.length; i++) {
285
+ const { path: sectionPath, bodyStart } = sections[i];
286
+ const bodyEnd = i + 1 < sections.length ? sections[i + 1].headerStart : input.length;
287
+ const added = extractAddedLines(input.slice(bodyStart, bodyEnd), false);
288
+ if (added.length === 0) continue;
289
+ const existing = byPath.get(sectionPath);
290
+ byPath.set(sectionPath, existing === undefined ? added : `${existing}\n${added}`);
291
+ }
292
+ return Array.from(byPath, ([path, digest]) => ({ path, digest }));
293
+ }
294
+
295
+ /**
296
+ * Split a (possibly partial) apply_patch envelope into one matcher entry per
297
+ * touched file. Same-path hunks are merged into one entry. Falls back to the
298
+ * streaming-tolerant parser when the envelope hasn't reached `*** End Patch`.
299
+ */
300
+ function splitApplyPatchPerFile(input: string): EditMatcherEntry[] {
301
+ let entries: ApplyPatchEntry[];
302
+ try {
303
+ entries = expandApplyPatchToEntries({ input });
304
+ } catch {
305
+ try {
306
+ entries = expandApplyPatchToPreviewEntries({ input });
307
+ } catch {
308
+ return [];
309
+ }
310
+ }
311
+ const byPath = new Map<string, string>();
312
+ for (const entry of entries) {
313
+ if (typeof entry.diff !== "string") continue;
314
+ const added = extractAddedLines(entry.diff, false);
315
+ if (added.length === 0) continue;
316
+ const existing = byPath.get(entry.path);
317
+ byPath.set(entry.path, existing === undefined ? added : `${existing}\n${added}`);
318
+ }
319
+ return Array.from(byPath, ([path, digest]) => ({ path, digest }));
320
+ }
321
+
194
322
  // -----------------------------------------------------------------------------
195
323
  // Strategies
196
324
  // -----------------------------------------------------------------------------
@@ -236,6 +364,15 @@ const replaceStrategy: EditStreamingStrategy<ReplaceArgs> = {
236
364
  }
237
365
  return digest;
238
366
  },
367
+ matcherPaths(args) {
368
+ return typeof args?.path === "string" && args.path.length > 0 ? [args.path] : undefined;
369
+ },
370
+ matcherEntries(args) {
371
+ const path = args?.path;
372
+ if (typeof path !== "string" || path.length === 0) return undefined;
373
+ const digest = replaceStrategy.matcherDigest(args);
374
+ return digest === undefined ? undefined : [{ path, digest }];
375
+ },
239
376
  };
240
377
 
241
378
  interface PatchArgs {
@@ -278,6 +415,15 @@ const patchStrategy: EditStreamingStrategy<PatchArgs> = {
278
415
  }
279
416
  return digest;
280
417
  },
418
+ matcherPaths(args) {
419
+ return typeof args?.path === "string" && args.path.length > 0 ? [args.path] : undefined;
420
+ },
421
+ matcherEntries(args) {
422
+ const path = args?.path;
423
+ if (typeof path !== "string" || path.length === 0) return undefined;
424
+ const digest = patchStrategy.matcherDigest(args);
425
+ return digest === undefined ? undefined : [{ path, digest }];
426
+ },
281
427
  };
282
428
 
283
429
  interface HashlineArgs {
@@ -437,6 +583,18 @@ const hashlineStrategy: EditStreamingStrategy<HashlineArgs> = {
437
583
  // Body rows are `+TEXT`; headers and op lines are grammar, never content.
438
584
  return extractAddedLines(input, false);
439
585
  },
586
+ matcherPaths(args) {
587
+ const input = args?.input;
588
+ if (typeof input !== "string" || input.length === 0) return undefined;
589
+ const paths = extractHashlineHeaderPaths(input);
590
+ return paths.length > 0 ? paths : undefined;
591
+ },
592
+ matcherEntries(args) {
593
+ const input = args?.input;
594
+ if (typeof input !== "string" || input.length === 0) return undefined;
595
+ const entries = splitHashlinePerFile(input);
596
+ return entries.length > 0 ? entries : undefined;
597
+ },
440
598
  };
441
599
 
442
600
  interface ApplyPatchArgs {
@@ -495,6 +653,18 @@ const applyPatchStrategy: EditStreamingStrategy<ApplyPatchArgs> = {
495
653
  // Envelope markers and `@@` hunk headers are grammar, never content.
496
654
  return extractAddedLines(input, false);
497
655
  },
656
+ matcherPaths(args) {
657
+ const input = args?.input;
658
+ if (typeof input !== "string" || input.length === 0) return undefined;
659
+ const paths = extractApplyPatchEnvelopePaths(input);
660
+ return paths.length > 0 ? paths : undefined;
661
+ },
662
+ matcherEntries(args) {
663
+ const input = args?.input;
664
+ if (typeof input !== "string" || input.length === 0) return undefined;
665
+ const entries = splitApplyPatchPerFile(input);
666
+ return entries.length > 0 ? entries : undefined;
667
+ },
498
668
  };
499
669
  export const EDIT_MODE_STRATEGIES: Record<EditMode, EditStreamingStrategy<unknown>> = {
500
670
  replace: replaceStrategy as EditStreamingStrategy<unknown>,