@oh-my-pi/pi-coding-agent 15.7.6 → 15.8.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (117) hide show
  1. package/CHANGELOG.md +172 -198
  2. package/dist/types/async/job-manager.d.ts +3 -3
  3. package/dist/types/cli/args.d.ts +1 -0
  4. package/dist/types/cli/claude-trace-cli.d.ts +54 -0
  5. package/dist/types/cli/session-picker.d.ts +10 -3
  6. package/dist/types/cli/update-cli.d.ts +17 -0
  7. package/dist/types/commands/launch.d.ts +3 -0
  8. package/dist/types/config/keybindings.d.ts +10 -1
  9. package/dist/types/config/settings-schema.d.ts +2 -2
  10. package/dist/types/config/settings.d.ts +13 -0
  11. package/dist/types/edit/index.d.ts +6 -0
  12. package/dist/types/edit/streaming.d.ts +8 -0
  13. package/dist/types/eval/concurrency-bridge.d.ts +26 -0
  14. package/dist/types/eval/js/tool-bridge.d.ts +2 -1
  15. package/dist/types/export/ttsr.d.ts +9 -0
  16. package/dist/types/extensibility/plugins/legacy-pi-compat.d.ts +11 -0
  17. package/dist/types/main.d.ts +5 -0
  18. package/dist/types/mcp/transports/stdio.d.ts +19 -0
  19. package/dist/types/modes/components/custom-editor.d.ts +3 -1
  20. package/dist/types/modes/components/hook-selector.d.ts +3 -0
  21. package/dist/types/modes/components/session-selector.d.ts +32 -5
  22. package/dist/types/modes/components/tool-execution.d.ts +8 -0
  23. package/dist/types/modes/controllers/extension-ui-controller.d.ts +2 -2
  24. package/dist/types/modes/controllers/input-controller.d.ts +1 -0
  25. package/dist/types/modes/interactive-mode.d.ts +9 -2
  26. package/dist/types/modes/types.d.ts +4 -2
  27. package/dist/types/registry/agent-registry.d.ts +1 -1
  28. package/dist/types/sdk.d.ts +2 -2
  29. package/dist/types/session/agent-session.d.ts +4 -2
  30. package/dist/types/session/history-storage.d.ts +16 -1
  31. package/dist/types/session/session-manager.d.ts +4 -0
  32. package/dist/types/task/output-manager.d.ts +6 -15
  33. package/dist/types/tools/find.d.ts +0 -9
  34. package/dist/types/tools/index.d.ts +1 -1
  35. package/dist/types/tools/path-utils.d.ts +16 -0
  36. package/dist/types/tools/sqlite-reader.d.ts +25 -8
  37. package/dist/types/tools/write.d.ts +2 -0
  38. package/dist/types/utils/clipboard.d.ts +4 -0
  39. package/dist/types/utils/jj.d.ts +49 -0
  40. package/dist/types/web/kagi.d.ts +76 -0
  41. package/dist/types/web/search/providers/exa.d.ts +7 -1
  42. package/dist/types/web/search/providers/kagi.d.ts +1 -0
  43. package/package.json +9 -9
  44. package/src/async/job-manager.ts +3 -3
  45. package/src/cli/args.ts +6 -2
  46. package/src/cli/claude-trace-cli.ts +783 -0
  47. package/src/cli/session-picker.ts +36 -10
  48. package/src/cli/update-cli.ts +35 -2
  49. package/src/commands/launch.ts +3 -0
  50. package/src/config/keybindings.ts +14 -1
  51. package/src/config/model-registry.ts +18 -7
  52. package/src/config/settings-schema.ts +2 -2
  53. package/src/config/settings.ts +23 -0
  54. package/src/discovery/builtin-rules/index.ts +2 -0
  55. package/src/discovery/builtin-rules/ts-no-deprecated-leftovers.md +44 -0
  56. package/src/discovery/claude-plugins.ts +7 -9
  57. package/src/edit/index.ts +10 -0
  58. package/src/edit/streaming.ts +65 -0
  59. package/src/eval/__tests__/agent-bridge.test.ts +58 -4
  60. package/src/eval/concurrency-bridge.ts +34 -0
  61. package/src/eval/js/shared/prelude.txt +20 -17
  62. package/src/eval/js/tool-bridge.ts +5 -0
  63. package/src/eval/py/prelude.py +23 -15
  64. package/src/export/ttsr.ts +18 -1
  65. package/src/extensibility/custom-commands/bundled/review/index.ts +74 -45
  66. package/src/extensibility/plugins/legacy-pi-compat.ts +115 -131
  67. package/src/extensibility/skills.ts +0 -1
  68. package/src/internal-urls/docs-index.generated.ts +12 -11
  69. package/src/main.ts +92 -24
  70. package/src/mcp/transports/stdio.ts +55 -22
  71. package/src/modes/acp/acp-event-mapper.ts +54 -4
  72. package/src/modes/components/custom-editor.ts +10 -0
  73. package/src/modes/components/hook-selector.ts +89 -31
  74. package/src/modes/components/oauth-selector.ts +12 -6
  75. package/src/modes/components/session-selector.ts +179 -24
  76. package/src/modes/components/tool-execution.ts +16 -3
  77. package/src/modes/controllers/command-controller.ts +2 -11
  78. package/src/modes/controllers/extension-ui-controller.ts +3 -2
  79. package/src/modes/controllers/input-controller.ts +19 -1
  80. package/src/modes/controllers/selector-controller.ts +61 -21
  81. package/src/modes/interactive-mode.ts +125 -15
  82. package/src/modes/types.ts +5 -2
  83. package/src/prompts/agents/reviewer.md +2 -2
  84. package/src/prompts/review-request.md +1 -1
  85. package/src/prompts/system/empty-stop-retry.md +6 -0
  86. package/src/prompts/system/orchestrate-notice.md +5 -3
  87. package/src/prompts/system/workflow-notice.md +2 -2
  88. package/src/prompts/tools/eval.md +5 -5
  89. package/src/prompts/tools/find.md +1 -1
  90. package/src/prompts/tools/irc.md +6 -6
  91. package/src/prompts/tools/search-tool-bm25.md +9 -2
  92. package/src/prompts/tools/search.md +1 -1
  93. package/src/prompts/tools/task.md +1 -1
  94. package/src/registry/agent-registry.ts +1 -1
  95. package/src/sdk.ts +85 -31
  96. package/src/session/agent-session.ts +209 -54
  97. package/src/session/history-storage.ts +56 -12
  98. package/src/session/session-manager.ts +34 -0
  99. package/src/task/output-manager.ts +40 -48
  100. package/src/task/render.ts +3 -8
  101. package/src/tools/browser/tab-worker.ts +8 -5
  102. package/src/tools/find.ts +5 -29
  103. package/src/tools/index.ts +1 -1
  104. package/src/tools/path-utils.ts +144 -1
  105. package/src/tools/read.ts +47 -0
  106. package/src/tools/search-tool-bm25.ts +7 -1
  107. package/src/tools/search.ts +2 -27
  108. package/src/tools/sqlite-reader.ts +92 -9
  109. package/src/tools/write.ts +6 -0
  110. package/src/utils/clipboard.ts +38 -1
  111. package/src/utils/git.ts +19 -23
  112. package/src/utils/jj.ts +225 -0
  113. package/src/utils/open.ts +37 -2
  114. package/src/web/kagi.ts +168 -49
  115. package/src/web/search/providers/anthropic.ts +1 -1
  116. package/src/web/search/providers/exa.ts +20 -86
  117. package/src/web/search/providers/kagi.ts +4 -0
@@ -1,23 +1,43 @@
1
1
  import { ProcessTerminal, TUI } from "@oh-my-pi/pi-tui";
2
+ import { logger } from "@oh-my-pi/pi-utils";
2
3
  import { SessionSelectorComponent } from "../modes/components/session-selector";
3
- import type { SessionInfo } from "../session/session-manager";
4
+ import { HistoryStorage } from "../session/history-storage";
5
+ import { type SessionInfo, SessionManager } from "../session/session-manager";
4
6
  import { FileSessionStorage } from "../session/session-storage";
5
7
 
6
- /** Show TUI session selector and return selected session path or null if cancelled */
7
- export async function selectSession(sessions: SessionInfo[]): Promise<string | null> {
8
- const { promise, resolve } = Promise.withResolvers<string | null>();
8
+ /**
9
+ * Show the TUI session selector and return the selected session, or null if
10
+ * cancelled. Tab toggles between current-folder and all-projects scope; the
11
+ * all-projects list is loaded lazily via `SessionManager.listAll`.
12
+ */
13
+ export async function selectSession(
14
+ sessions: SessionInfo[],
15
+ options?: { allSessions?: SessionInfo[]; startInAllScope?: boolean },
16
+ ): Promise<SessionInfo | null> {
17
+ const { promise, resolve } = Promise.withResolvers<SessionInfo | null>();
9
18
  const ui = new TUI(new ProcessTerminal());
10
19
  let resolved = false;
11
20
  const storage = new FileSessionStorage();
12
21
 
22
+ // Rank sessions with prompt-history matches too, recovering prompts the 4KB
23
+ // session-list prefix never sees. Best-effort: a missing/locked history.db
24
+ // must not break the picker.
25
+ let historyMatcher: ((query: string) => string[]) | undefined;
26
+ try {
27
+ const history = HistoryStorage.open();
28
+ historyMatcher = (query: string) => history.matchingSessionIds(query);
29
+ } catch (error) {
30
+ logger.warn("History storage unavailable for session ranking", { error: String(error) });
31
+ }
32
+
13
33
  const showSelector = () => {
14
34
  const selector = new SessionSelectorComponent(
15
35
  sessions,
16
- (path: string) => {
36
+ (session: SessionInfo) => {
17
37
  if (!resolved) {
18
38
  resolved = true;
19
39
  ui.stop();
20
- resolve(path);
40
+ resolve(session);
21
41
  }
22
42
  },
23
43
  () => {
@@ -34,10 +54,16 @@ export async function selectSession(sessions: SessionInfo[]): Promise<string | n
34
54
  process.exit(0);
35
55
  }
36
56
  },
37
- async (session: SessionInfo) => {
38
- // Delete handler - SessionList will show confirmation internally
39
- await storage.deleteSessionWithArtifacts(session.path);
40
- return true;
57
+ {
58
+ onDelete: async (session: SessionInfo) => {
59
+ // Delete handler - SessionList will show confirmation internally
60
+ await storage.deleteSessionWithArtifacts(session.path);
61
+ return true;
62
+ },
63
+ historyMatcher,
64
+ loadAllSessions: () => SessionManager.listAll(storage),
65
+ allSessions: options?.allSessions,
66
+ startInAllScope: options?.startInAllScope,
41
67
  },
42
68
  );
43
69
  return selector;
@@ -14,6 +14,18 @@ import { theme } from "../modes/theme/theme";
14
14
 
15
15
  const REPO = "can1357/oh-my-pi";
16
16
  const PACKAGE = "@oh-my-pi/pi-coding-agent";
17
+ /**
18
+ * Official npm registry origin.
19
+ *
20
+ * Pinned across both the version check and the bun install step so the two
21
+ * agree on which catalog they are talking to. A user's bun may be pointed at
22
+ * an unofficial mirror (corporate proxy, Taobao, etc.) that lags the upstream
23
+ * registry by minutes-to-hours, in which case `getLatestRelease` would resolve
24
+ * a version the mirror has not yet replicated and the install would fail with
25
+ * `No version matching "X" found for specifier "<pkg>" (but package exists)`.
26
+ * See #1686.
27
+ */
28
+ const NPM_REGISTRY = "https://registry.npmjs.org/";
17
29
 
18
30
  interface ReleaseInfo {
19
31
  tag: string;
@@ -130,7 +142,7 @@ async function resolveUpdateTarget(): Promise<UpdateTarget> {
130
142
  * Uses npm instead of GitHub API to avoid unauthenticated rate limiting.
131
143
  */
132
144
  async function getLatestRelease(): Promise<ReleaseInfo> {
133
- const response = await fetch(`https://registry.npmjs.org/${PACKAGE}/latest`);
145
+ const response = await fetch(`${NPM_REGISTRY}${PACKAGE}/latest`);
134
146
  if (!response.ok) {
135
147
  throw new Error(`Failed to fetch release info: ${response.statusText}`);
136
148
  }
@@ -292,12 +304,33 @@ export async function replaceBinaryForUpdate(options: BinaryReplacementOptions):
292
304
  }
293
305
  }
294
306
 
307
+ /**
308
+ * Build the bun argv used to globally install a specific omp version.
309
+ *
310
+ * The version is selected by hitting {@link NPM_REGISTRY} directly in
311
+ * {@link getLatestRelease}, so the install MUST observe the same catalog:
312
+ *
313
+ * - `--registry=${NPM_REGISTRY}` pins the install to the official registry
314
+ * regardless of the user's bunfig/`.npmrc`. A mirror (corporate proxy,
315
+ * Taobao, …) that hasn't yet replicated the release would otherwise reject
316
+ * a version the upstream registry already advertises.
317
+ * - `--no-cache` tells bun to ignore its on-disk manifest snapshot so it
318
+ * re-fetches metadata from that registry on every invocation.
319
+ *
320
+ * Together these two flags make `omp update` produce exactly the registry
321
+ * lookup the version check just performed. See #1686.
322
+ */
323
+ export function buildBunInstallArgs(expectedVersion: string): string[] {
324
+ return ["install", "-g", "--no-cache", `--registry=${NPM_REGISTRY}`, `${PACKAGE}@${expectedVersion}`];
325
+ }
326
+
295
327
  /**
296
328
  * Update via bun package manager.
297
329
  */
298
330
  async function updateViaBun(expectedVersion: string): Promise<void> {
299
331
  console.log(chalk.dim("Updating via bun..."));
300
- const result = await $`bun install -g ${PACKAGE}@${expectedVersion}`.nothrow();
332
+ const args = buildBunInstallArgs(expectedVersion);
333
+ const result = await $`bun ${args}`.nothrow();
301
334
  if (result.exitCode !== 0) {
302
335
  throw new Error(`bun install failed with exit code ${result.exitCode}`);
303
336
  }
@@ -90,6 +90,9 @@ export default class Index extends Command {
90
90
  description: `Set thinking level: ${THINKING_EFFORTS.join(", ")}`,
91
91
  options: [...THINKING_EFFORTS],
92
92
  }),
93
+ "hide-thinking": Flags.boolean({
94
+ description: "Hide thinking blocks in TUI output (display only, does not disable model thinking)",
95
+ }),
93
96
  hook: Flags.string({
94
97
  description: "Load a hook/extension file (can be used multiple times)",
95
98
  multiple: true,
@@ -32,6 +32,7 @@ interface AppKeybindings {
32
32
  "app.message.followUp": true;
33
33
  "app.message.dequeue": true;
34
34
  "app.clipboard.pasteImage": true;
35
+ "app.clipboard.pasteTextRaw": true;
35
36
  "app.clipboard.copyLine": true;
36
37
  "app.clipboard.copyPrompt": true;
37
38
  "app.session.new": true;
@@ -57,6 +58,13 @@ declare module "@oh-my-pi/pi-tui" {
57
58
  interface Keybindings extends AppKeybindings {}
58
59
  }
59
60
 
61
+ /**
62
+ * Resolve default image-paste shortcuts for the current terminal platform.
63
+ */
64
+ export function getDefaultPasteImageKeys(platform: NodeJS.Platform = process.platform): KeyId[] {
65
+ return platform === "win32" ? ["ctrl+v", "alt+v"] : ["ctrl+v"];
66
+ }
67
+
60
68
  /**
61
69
  * All keybindings definitions: TUI + app-specific.
62
70
  */
@@ -119,9 +127,13 @@ export const KEYBINDINGS = {
119
127
  description: "Dequeue message",
120
128
  },
121
129
  "app.clipboard.pasteImage": {
122
- defaultKeys: process.platform === "win32" ? "alt+v" : "ctrl+v",
130
+ defaultKeys: getDefaultPasteImageKeys(),
123
131
  description: "Paste image from clipboard",
124
132
  },
133
+ "app.clipboard.pasteTextRaw": {
134
+ defaultKeys: ["ctrl+shift+v", "alt+shift+v"],
135
+ description: "Paste text from clipboard as raw text (no collapse)",
136
+ },
125
137
  "app.clipboard.copyLine": {
126
138
  defaultKeys: "alt+shift+l",
127
139
  description: "Copy current line",
@@ -214,6 +226,7 @@ const KEYBINDING_NAME_MIGRATIONS = {
214
226
  followUp: "app.message.followUp",
215
227
  dequeue: "app.message.dequeue",
216
228
  pasteImage: "app.clipboard.pasteImage",
229
+ pasteTextRaw: "app.clipboard.pasteTextRaw",
217
230
  copyLine: "app.clipboard.copyLine",
218
231
  copyPrompt: "app.clipboard.copyPrompt",
219
232
  newSession: "app.session.new",
@@ -53,6 +53,17 @@ function discoveryDefaultMaxTokens(api: Api | undefined): number {
53
53
  return api === "anthropic-messages" ? DISCOVERY_DEFAULT_MAX_TOKENS_ANTHROPIC : DISCOVERY_DEFAULT_MAX_TOKENS;
54
54
  }
55
55
 
56
+ const SPECIAL_MODEL_MANAGER_PROVIDER_IDS: readonly string[] = [
57
+ "google-antigravity",
58
+ "google-gemini-cli",
59
+ "openai-codex",
60
+ ];
61
+
62
+ const STARTUP_MODEL_CACHE_PROVIDER_IDS: readonly string[] = [
63
+ ...PROVIDER_DESCRIPTORS.map(descriptor => descriptor.providerId),
64
+ ...SPECIAL_MODEL_MANAGER_PROVIDER_IDS,
65
+ ];
66
+
56
67
  import { registerOAuthProvider, unregisterOAuthProviders } from "@oh-my-pi/pi-ai/utils/oauth";
57
68
  import type { OAuthCredentials, OAuthLoginCallbacks } from "@oh-my-pi/pi-ai/utils/oauth/types";
58
69
  import { isRecord, logger } from "@oh-my-pi/pi-utils";
@@ -1133,28 +1144,28 @@ export class ModelRegistry {
1133
1144
  const configuredDiscoveryProviders = new Set(this.#discoverableProviders.map(provider => provider.provider));
1134
1145
  const cachedModels: Model<Api>[] = [];
1135
1146
  const authoritativeFreshProviders = new Set<string>();
1136
- for (const descriptor of PROVIDER_DESCRIPTORS) {
1137
- if (configuredDiscoveryProviders.has(descriptor.providerId)) {
1147
+ for (const providerId of STARTUP_MODEL_CACHE_PROVIDER_IDS) {
1148
+ if (configuredDiscoveryProviders.has(providerId)) {
1138
1149
  continue;
1139
1150
  }
1140
- const cache = readModelCache<Api>(descriptor.providerId, 24 * 60 * 60 * 1000, Date.now, this.#cacheDbPath);
1151
+ const cache = readModelCache<Api>(providerId, 24 * 60 * 60 * 1000, Date.now, this.#cacheDbPath);
1141
1152
  if (!cache) {
1142
1153
  continue;
1143
1154
  }
1144
1155
  if (cache.fresh && cache.authoritative) {
1145
- authoritativeFreshProviders.add(descriptor.providerId);
1156
+ authoritativeFreshProviders.add(providerId);
1146
1157
  }
1147
1158
  const models = cache.models.map(model =>
1148
- model.provider === descriptor.providerId ? model : { ...model, provider: descriptor.providerId },
1159
+ model.provider === providerId ? model : { ...model, provider: providerId },
1149
1160
  );
1150
- const providerOverride = this.#providerOverrides.get(descriptor.providerId);
1161
+ const providerOverride = this.#providerOverrides.get(providerId);
1151
1162
  const withTransport = providerOverride
1152
1163
  ? models.map(model => this.#applyProviderTransportOverride(model, providerOverride))
1153
1164
  : models;
1154
1165
  const withCompat = providerOverride?.compat
1155
1166
  ? withTransport.map(model => ({ ...model, compat: mergeCompat(model.compat, providerOverride.compat) }))
1156
1167
  : withTransport;
1157
- cachedModels.push(...this.#applyProviderModelOverrides(descriptor.providerId, withCompat));
1168
+ cachedModels.push(...this.#applyProviderModelOverrides(providerId, withCompat));
1158
1169
  }
1159
1170
  return { models: cachedModels, authoritativeFreshProviders };
1160
1171
  }
@@ -2894,7 +2894,7 @@ export const SETTINGS_SCHEMA = {
2894
2894
  label: "Auto",
2895
2895
  description: "Preferred web-search provider",
2896
2896
  },
2897
- { value: "exa", label: "Exa", description: "Uses Exa API when EXA_API_KEY is set; falls back to Exa MCP" },
2897
+ { value: "exa", label: "Exa", description: "Requires EXA_API_KEY" },
2898
2898
  { value: "brave", label: "Brave", description: "Requires BRAVE_API_KEY" },
2899
2899
  { value: "jina", label: "Jina", description: "Requires JINA_API_KEY" },
2900
2900
  { value: "kimi", label: "Kimi", description: "Requires MOONSHOT_SEARCH_API_KEY or MOONSHOT_API_KEY" },
@@ -2920,7 +2920,7 @@ export const SETTINGS_SCHEMA = {
2920
2920
  },
2921
2921
  { value: "zai", label: "Z.AI", description: "Calls Z.AI webSearchPrime MCP" },
2922
2922
  { value: "tavily", label: "Tavily", description: "Requires TAVILY_API_KEY" },
2923
- { value: "kagi", label: "Kagi", description: "Requires KAGI_API_KEY and Kagi Search API beta access" },
2923
+ { value: "kagi", label: "Kagi", description: "Requires KAGI_API_KEY (Kagi V1 Search API)" },
2924
2924
  { value: "synthetic", label: "Synthetic", description: "Requires SYNTHETIC_API_KEY" },
2925
2925
  { value: "parallel", label: "Parallel", description: "Requires PARALLEL_API_KEY" },
2926
2926
  { value: "searxng", label: "SearXNG", description: "Requires SEARXNG_ENDPOINT or searxng.endpoint" },
@@ -365,6 +365,29 @@ export class Settings {
365
365
  return cloned;
366
366
  }
367
367
 
368
+ /**
369
+ * Re-scope this instance to a new working directory *in place*: reload the
370
+ * project layer (`.claude/settings.yml` etc.) from `cwd`, re-resolve
371
+ * path-scoped settings against it, and re-fire side-effect hooks (theme,
372
+ * symbols, tab width, …). Global settings and runtime overrides are preserved.
373
+ *
374
+ * Unlike {@link cloneForCwd}, this mutates the live instance, so every holder
375
+ * (the `settings` proxy, the active session, controllers) observes the new
376
+ * project scope without swapping references — used when the process changes
377
+ * directory mid-run (`/move`, cross-project resume). No-op when `cwd` is
378
+ * already the current scope.
379
+ */
380
+ async reloadForCwd(cwd: string): Promise<void> {
381
+ const normalized = path.normalize(cwd);
382
+ if (normalized === this.#cwd) return;
383
+ this.#cwd = normalized;
384
+ if (this.#persist) {
385
+ this.#project = await this.#loadProjectSettings();
386
+ }
387
+ this.#rebuildMerged();
388
+ this.#fireAllHooks();
389
+ }
390
+
368
391
  // ─────────────────────────────────────────────────────────────────────────
369
392
  // Accessors
370
393
  // ─────────────────────────────────────────────────────────────────────────
@@ -17,6 +17,7 @@ import rsResultType from "./rs-result-type.md" with { type: "text" };
17
17
  import tsBareCatch from "./ts-bare-catch.md" with { type: "text" };
18
18
  import tsImportType from "./ts-import-type.md" with { type: "text" };
19
19
  import tsNoAny from "./ts-no-any.md" with { type: "text" };
20
+ import tsNoDeprecatedLeftovers from "./ts-no-deprecated-leftovers.md" with { type: "text" };
20
21
  import tsNoDynamicImport from "./ts-no-dynamic-import.md" with { type: "text" };
21
22
  import tsNoReturnType from "./ts-no-return-type.md" with { type: "text" };
22
23
  import tsNoTinyFunctions from "./ts-no-tiny-functions.md" with { type: "text" };
@@ -40,6 +41,7 @@ export const BUILTIN_RULE_SOURCES: readonly BuiltinRuleSource[] = [
40
41
  { name: "ts-bare-catch", content: tsBareCatch },
41
42
  { name: "ts-import-type", content: tsImportType },
42
43
  { name: "ts-no-any", content: tsNoAny },
44
+ { name: "ts-no-deprecated-leftovers", content: tsNoDeprecatedLeftovers },
43
45
  { name: "ts-no-dynamic-import", content: tsNoDynamicImport },
44
46
  { name: "ts-no-return-type", content: tsNoReturnType },
45
47
  { name: "ts-no-tiny-functions", content: tsNoTinyFunctions },
@@ -0,0 +1,44 @@
1
+ ---
2
+ description: "Do not leave `@deprecated` shims behind after refactors — update call sites and remove the old API"
3
+ condition: "@deprecated"
4
+ scope: "tool:edit(*.ts), tool:edit(*.tsx), tool:write(*.ts), tool:write(*.tsx)"
5
+ ---
6
+
7
+ Do not use `@deprecated` as a substitute for finishing a refactor. If an API is obsolete inside the code you control, update every call site and remove the old name in the same change.
8
+
9
+ ## Why
10
+
11
+ - Deprecated aliases keep two contracts alive.
12
+ - Future maintainers must preserve behavior nobody should call.
13
+ - Tests can pass while production code keeps using the old path.
14
+ - The next refactor has to unwind both the real API and the compatibility layer.
15
+
16
+ ## Avoid
17
+
18
+ ```typescript
19
+ // Bad — leaves a stale compatibility name instead of finishing the cutover.
20
+ /** @deprecated Use loadSettings instead. */
21
+ export const loadConfig = loadSettings;
22
+
23
+ // Bad — preserves an obsolete wrapper after callers can be updated.
24
+ /** @deprecated Use createClient instead. */
25
+ export function makeClient(options: ClientOptions): Client {
26
+ return createClient(options);
27
+ }
28
+ ```
29
+
30
+ ## Use
31
+
32
+ ```typescript
33
+ // Update all imports and call sites to the durable name.
34
+ export function loadSettings(path: string): Settings { ... }
35
+ export function createClient(options: ClientOptions): Client { ... }
36
+ ```
37
+
38
+ ## Exceptions
39
+
40
+ - Public package APIs with a documented migration window.
41
+ - Third-party declarations where the deprecated marker reflects an external contract.
42
+ - Tests that intentionally verify deprecated API behavior during a supported transition.
43
+
44
+ If an exception applies, state the external compatibility requirement. Otherwise, finish the refactor and delete the deprecated symbol.
@@ -99,10 +99,8 @@ async function resolvePluginDir(
99
99
  async function loadSkills(ctx: LoadContext): Promise<LoadResult<Skill>> {
100
100
  const items: Skill[] = [];
101
101
  const warnings: string[] = [];
102
-
103
102
  const { roots, warnings: rootWarnings } = await listClaudePluginRoots(ctx.home, ctx.cwd);
104
103
  warnings.push(...rootWarnings);
105
-
106
104
  const results = await Promise.all(
107
105
  roots.map(async root => {
108
106
  const { dir: skillsDir, warning } = await resolvePluginDir(root, ["skills"], "skills");
@@ -114,16 +112,16 @@ async function loadSkills(ctx: LoadContext): Promise<LoadResult<Skill>> {
114
112
  return { root, result, warning };
115
113
  }),
116
114
  );
117
-
118
- for (const { root, result, warning } of results) {
115
+ for (const { result, warning } of results) {
119
116
  if (warning) warnings.push(warning);
120
- for (const skill of result.items) {
121
- if (root.plugin) skill.name = `${root.plugin}:${skill.name}`;
122
- items.push(skill);
123
- }
117
+ // Intentionally do NOT prefix skill names with `root.plugin`.
118
+ // The `plugin:name` format breaks skill:// URL parsing (colons are
119
+ // ambiguous with port separators) and is unintuitive for callers.
120
+ // Dedup-by-key in the capability layer already handles name collisions
121
+ // across providers using priority ordering.
122
+ items.push(...result.items);
124
123
  if (result.warnings) warnings.push(...result.warnings);
125
124
  }
126
-
127
125
  return { items, warnings };
128
126
  }
129
127
 
package/src/edit/index.ts CHANGED
@@ -24,6 +24,7 @@ import applyPatchGrammar from "./modes/apply-patch.lark" with { type: "text" };
24
24
  import { executePatchSingle, type PatchEditEntry, type PatchParams, patchEditSchema } from "./modes/patch";
25
25
  import { executeReplaceSingle, type ReplaceEditEntry, type ReplaceParams, replaceEditSchema } from "./modes/replace";
26
26
  import { type EditToolDetails, type EditToolPerFileResult, getLspBatchRequest, type LspBatchRequest } from "./renderer";
27
+ import { EDIT_MODE_STRATEGIES } from "./streaming";
27
28
 
28
29
  export * from "@oh-my-pi/hashline";
29
30
  export { DEFAULT_EDIT_MODE, type EditMode, normalizeEditMode } from "../utils/edit-mode";
@@ -360,6 +361,15 @@ export class EditTool implements AgentTool<TInput> {
360
361
  return "apply_patch";
361
362
  }
362
363
 
364
+ /**
365
+ * Normalize streamed args into the source text this edit introduces, so
366
+ * stream matchers (TTSR rules) run against real file content instead of the
367
+ * mode-specific patch grammar.
368
+ */
369
+ matcherDigest(args: unknown): string | undefined {
370
+ return EDIT_MODE_STRATEGIES[this.mode].matcherDigest(args);
371
+ }
372
+
363
373
  async execute(
364
374
  _toolCallId: string,
365
375
  params: EditParams,
@@ -69,6 +69,14 @@ export interface EditStreamingStrategy<Args = unknown> {
69
69
  * compute returned `null` because args are still too partial).
70
70
  */
71
71
  renderStreamingFallback(args: Args, uiTheme: Theme): string;
72
+ /**
73
+ * Project the (potentially partial) args onto the plain text the edit
74
+ * introduces into files — added lines without patch grammar — so stream
75
+ * matchers (TTSR rules) can run source-level patterns against real content
76
+ * instead of the mode-specific wire format. Returns `undefined` when the
77
+ * args don't yet carry any content.
78
+ */
79
+ matcherDigest(args: Args): string | undefined;
72
80
  }
73
81
 
74
82
  // -----------------------------------------------------------------------------
@@ -161,6 +169,28 @@ function groupApplyPatchEntriesByPath(entries: readonly ApplyPatchEntry[]): Map<
161
169
  return groups;
162
170
  }
163
171
 
172
+ /**
173
+ * Extract the lines a patch-style payload adds (`+` prefix, excluding `+++ `
174
+ * file headers), stripped of the prefix. When the text carries no added lines,
175
+ * returns the whole text if `fallbackToWhole` (full-content payloads such as a
176
+ * `create` op), otherwise an empty string (grammar-only payloads).
177
+ */
178
+ function extractAddedLines(text: string, fallbackToWhole: boolean): string {
179
+ let added: string | undefined;
180
+ let lineStart = 0;
181
+ while (lineStart <= text.length) {
182
+ let lineEnd = text.indexOf("\n", lineStart);
183
+ if (lineEnd === -1) lineEnd = text.length;
184
+ if (text.charCodeAt(lineStart) === 43 /* + */ && !text.startsWith("+++ ", lineStart)) {
185
+ const line = text.slice(lineStart + 1, lineEnd);
186
+ added = added === undefined ? line : `${added}\n${line}`;
187
+ }
188
+ lineStart = lineEnd + 1;
189
+ }
190
+ if (added === undefined) return fallbackToWhole ? text : "";
191
+ return added;
192
+ }
193
+
164
194
  // -----------------------------------------------------------------------------
165
195
  // Strategies
166
196
  // -----------------------------------------------------------------------------
@@ -196,6 +226,16 @@ const replaceStrategy: EditStreamingStrategy<ReplaceArgs> = {
196
226
  renderStreamingFallback() {
197
227
  return "";
198
228
  },
229
+ matcherDigest(args) {
230
+ const edits = args?.edits;
231
+ if (!Array.isArray(edits)) return undefined;
232
+ let digest: string | undefined;
233
+ for (const edit of edits) {
234
+ if (typeof edit?.new_text !== "string") continue;
235
+ digest = digest === undefined ? edit.new_text : `${digest}\n${edit.new_text}`;
236
+ }
237
+ return digest;
238
+ },
199
239
  };
200
240
 
201
241
  interface PatchArgs {
@@ -225,6 +265,19 @@ const patchStrategy: EditStreamingStrategy<PatchArgs> = {
225
265
  renderStreamingFallback() {
226
266
  return "";
227
267
  },
268
+ matcherDigest(args) {
269
+ const edits = args?.edits;
270
+ if (!Array.isArray(edits)) return undefined;
271
+ let digest: string | undefined;
272
+ for (const edit of edits) {
273
+ if (typeof edit?.diff !== "string") continue;
274
+ // `create` ops carry full file content in `diff` with no +/- markers;
275
+ // pass that content through whole.
276
+ const added = extractAddedLines(edit.diff, true);
277
+ digest = digest === undefined ? added : `${digest}\n${added}`;
278
+ }
279
+ return digest;
280
+ },
228
281
  };
229
282
 
230
283
  interface HashlineArgs {
@@ -378,6 +431,12 @@ const hashlineStrategy: EditStreamingStrategy<HashlineArgs> = {
378
431
  // than a sigil dump.
379
432
  return "";
380
433
  },
434
+ matcherDigest(args) {
435
+ const input = args?.input;
436
+ if (typeof input !== "string") return undefined;
437
+ // Body rows are `+TEXT`; headers and op lines are grammar, never content.
438
+ return extractAddedLines(input, false);
439
+ },
381
440
  };
382
441
 
383
442
  interface ApplyPatchArgs {
@@ -430,6 +489,12 @@ const applyPatchStrategy: EditStreamingStrategy<ApplyPatchArgs> = {
430
489
  renderStreamingFallback() {
431
490
  return "";
432
491
  },
492
+ matcherDigest(args) {
493
+ const input = args?.input;
494
+ if (typeof input !== "string") return undefined;
495
+ // Envelope markers and `@@` hunk headers are grammar, never content.
496
+ return extractAddedLines(input, false);
497
+ },
433
498
  };
434
499
  export const EDIT_MODE_STRATEGIES: Record<EditMode, EditStreamingStrategy<unknown>> = {
435
500
  replace: replaceStrategy as EditStreamingStrategy<unknown>,
@@ -106,6 +106,7 @@ function singleResult(options: ExecutorOptions, overrides: Partial<SingleResult>
106
106
  function makeEvalSession(
107
107
  tempDir: TempDir,
108
108
  prefix: string,
109
+ settings?: Settings,
109
110
  ): { session: ToolSession; sessionFile: string; sessionId: string } {
110
111
  const sessionFile = path.join(tempDir.path(), "session.jsonl");
111
112
  const artifactsDir = sessionFile.slice(0, -6);
@@ -113,6 +114,7 @@ function makeEvalSession(
113
114
  cwd: tempDir.path(),
114
115
  sessionFile,
115
116
  artifactsDir,
117
+ settings,
116
118
  outputManager: new AgentOutputManager(() => artifactsDir),
117
119
  });
118
120
  return { session, sessionFile, sessionId: `${prefix}:${crypto.randomUUID()}` };
@@ -261,9 +263,15 @@ describe("agent() through eval runtimes", () => {
261
263
  expect(JSON.parse(result.output.trim())).toEqual(["hello from agent", { ok: true, n: 3 }]);
262
264
  });
263
265
 
264
- it("runs JavaScript parallel() with bounded concurrency while preserving order", async () => {
266
+ it("bounds JavaScript parallel() by the task.maxConcurrency setting while preserving order", async () => {
265
267
  using tempDir = TempDir.createSync("@omp-eval-agent-js-parallel-");
266
- const { session, sessionFile, sessionId } = makeEvalSession(tempDir, "js-agent-parallel");
268
+ const settings = Settings.isolated({
269
+ "async.enabled": false,
270
+ "task.isolation.mode": "none",
271
+ "task.enableLsp": true,
272
+ "task.maxConcurrency": 2,
273
+ });
274
+ const { session, sessionFile, sessionId } = makeEvalSession(tempDir, "js-agent-parallel", settings);
267
275
  mockAgents();
268
276
  let inFlight = 0;
269
277
  let maxInFlight = 0;
@@ -279,7 +287,7 @@ describe("agent() through eval runtimes", () => {
279
287
  });
280
288
 
281
289
  const result = await executeJs(
282
- 'const values = await parallel(["a", "b", "c", "d"].map(name => () => agent(name)), { concurrency: 2 }); return JSON.stringify(values);',
290
+ 'const values = await parallel(["a", "b", "c", "d"].map(name => () => agent(name))); return JSON.stringify(values);',
283
291
  { cwd: tempDir.path(), sessionId, session, sessionFile },
284
292
  );
285
293
 
@@ -300,7 +308,7 @@ describe("agent() through eval runtimes", () => {
300
308
  return singleResult(options, { output: options.assignment ?? "" });
301
309
  });
302
310
 
303
- const result = await executeJs('await parallel([() => agent("ok"), () => agent("bad")], { concurrency: 2 });', {
311
+ const result = await executeJs('await parallel([() => agent("ok"), () => agent("bad")]);', {
304
312
  cwd: tempDir.path(),
305
313
  sessionId,
306
314
  session,
@@ -343,6 +351,52 @@ describe("agent() through eval runtimes", () => {
343
351
  expect(result.output.trim()).toBe("hello from python");
344
352
  });
345
353
 
354
+ it("bounds Python parallel() by the task.maxConcurrency setting while preserving order", async () => {
355
+ using tempDir = TempDir.createSync("@omp-eval-agent-py-parallel-");
356
+ const settings = Settings.isolated({
357
+ "async.enabled": false,
358
+ "task.isolation.mode": "none",
359
+ "task.enableLsp": true,
360
+ "task.maxConcurrency": 2,
361
+ });
362
+ const { session, sessionFile, sessionId } = makeEvalSession(tempDir, "py-agent-parallel", settings);
363
+ mockAgents();
364
+ let inFlight = 0;
365
+ let maxInFlight = 0;
366
+ vi.spyOn(taskExecutor, "runSubprocess").mockImplementation(async options => {
367
+ inFlight++;
368
+ maxInFlight = Math.max(maxInFlight, inFlight);
369
+ try {
370
+ await Bun.sleep(options.assignment === "a" ? 30 : 10);
371
+ return singleResult(options, { output: options.assignment ?? "" });
372
+ } finally {
373
+ inFlight--;
374
+ }
375
+ });
376
+
377
+ const probe = await executePython('print("probe")', {
378
+ cwd: tempDir.path(),
379
+ sessionId: `${sessionId}:probe`,
380
+ sessionFile,
381
+ kernelMode: "per-call",
382
+ });
383
+ if (probe.exitCode === undefined && probe.cancelled) {
384
+ expect(probe.output).toBe("");
385
+ return;
386
+ }
387
+ expect(probe.exitCode).toBe(0);
388
+
389
+ const result = await executePython(
390
+ 'import json\nprint(json.dumps(parallel([lambda n=n: agent(n) for n in ["a", "b", "c", "d"]])))',
391
+ { cwd: tempDir.path(), sessionId, sessionFile, kernelMode: "per-call", toolSession: session },
392
+ );
393
+
394
+ expect(result.exitCode).toBe(0);
395
+ expect(JSON.parse(result.output.trim())).toEqual(["a", "b", "c", "d"]);
396
+ expect(maxInFlight).toBeGreaterThan(1);
397
+ expect(maxInFlight).toBeLessThanOrEqual(2);
398
+ });
399
+
346
400
  it("streams enriched agent progress through onStatus before the cell finishes", async () => {
347
401
  using tempDir = TempDir.createSync("@omp-eval-agent-progress-");
348
402
  const { session, sessionFile, sessionId } = makeEvalSession(tempDir, "js-agent-progress");
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Host-side handler for the eval `parallel()` / `pipeline()` worker pool.
3
+ *
4
+ * The pool ceiling is not a kernel-side knob: it tracks the `task.maxConcurrency`
5
+ * setting so an eval fan-out runs as wide as a `task` tool batch would. `0` means
6
+ * unbounded — run every item at once, exactly like `task.maxConcurrency = 0`.
7
+ */
8
+ import type { ToolSession } from "../tools";
9
+ import type { JsStatusEvent } from "./js/shared/types";
10
+
11
+ /** Synthetic bridge name reserved for the parallel-pool ceiling across both runtimes. */
12
+ export const EVAL_CONCURRENCY_BRIDGE_NAME = "__concurrency__";
13
+
14
+ export interface EvalConcurrencyBridgeOptions {
15
+ session: ToolSession;
16
+ signal?: AbortSignal;
17
+ emitStatus?: (event: JsStatusEvent) => void;
18
+ }
19
+
20
+ export interface EvalConcurrencyResult {
21
+ /** Worker-pool ceiling; `0` means unbounded (run every item at once). */
22
+ limit: number;
23
+ }
24
+
25
+ /**
26
+ * Resolve the worker-pool ceiling for an eval cell's `parallel()`/`pipeline()`
27
+ * helpers from the live `task.maxConcurrency` setting. Negative/non-finite
28
+ * values collapse to `0` (unbounded), matching the `task` tool's own handling.
29
+ */
30
+ export function runEvalConcurrency(_args: unknown, options: EvalConcurrencyBridgeOptions): EvalConcurrencyResult {
31
+ const raw = options.session.settings.get("task.maxConcurrency");
32
+ const limit = Number.isFinite(raw) ? Math.trunc(raw) : 0;
33
+ return { limit: limit > 0 ? limit : 0 };
34
+ }