@oh-my-pi/pi-coding-agent 15.1.2 → 15.1.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 (141) hide show
  1. package/CHANGELOG.md +42 -0
  2. package/dist/types/cli/auth-broker-cli.d.ts +25 -0
  3. package/dist/types/cli/auth-gateway-cli.d.ts +18 -0
  4. package/dist/types/cli/grievances-cli.d.ts +12 -0
  5. package/dist/types/commands/auth-broker.d.ts +54 -0
  6. package/dist/types/commands/auth-gateway.d.ts +32 -0
  7. package/dist/types/commands/grievances.d.ts +1 -1
  8. package/dist/types/commit/agentic/tools/propose-commit.d.ts +9 -1
  9. package/dist/types/commit/agentic/tools/schemas.d.ts +9 -1
  10. package/dist/types/commit/agentic/tools/split-commit.d.ts +9 -1
  11. package/dist/types/config/model-registry.d.ts +3 -0
  12. package/dist/types/config/models-config-schema.d.ts +1 -0
  13. package/dist/types/config/settings-schema.d.ts +46 -0
  14. package/dist/types/discovery/agents.d.ts +12 -1
  15. package/dist/types/edit/renderer.d.ts +3 -0
  16. package/dist/types/eval/index.d.ts +0 -2
  17. package/dist/types/goals/tools/goal-tool.d.ts +10 -2
  18. package/dist/types/index.d.ts +0 -1
  19. package/dist/types/internal-urls/index.d.ts +1 -1
  20. package/dist/types/internal-urls/{pi-protocol.d.ts → omp-protocol.d.ts} +3 -3
  21. package/dist/types/internal-urls/types.d.ts +1 -1
  22. package/dist/types/modes/acp/acp-agent.d.ts +1 -0
  23. package/dist/types/modes/emoji-autocomplete.d.ts +16 -0
  24. package/dist/types/modes/interactive-mode.d.ts +1 -1
  25. package/dist/types/modes/prompt-action-autocomplete.d.ts +4 -0
  26. package/dist/types/plan-mode/approved-plan.d.ts +4 -0
  27. package/dist/types/sdk.d.ts +10 -3
  28. package/dist/types/session/agent-session.d.ts +1 -1
  29. package/dist/types/session/auth-broker-config.d.ts +13 -0
  30. package/dist/types/session/auth-storage.d.ts +1 -1
  31. package/dist/types/tools/eval.d.ts +41 -7
  32. package/dist/types/tools/irc.d.ts +8 -2
  33. package/dist/types/tools/report-tool-issue.d.ts +118 -1
  34. package/dist/types/tools/resolve.d.ts +8 -2
  35. package/examples/custom-tools/README.md +3 -12
  36. package/examples/extensions/README.md +2 -15
  37. package/examples/extensions/api-demo.ts +1 -7
  38. package/package.json +7 -7
  39. package/src/autoresearch/tools/init-experiment.ts +11 -33
  40. package/src/autoresearch/tools/log-experiment.ts +10 -24
  41. package/src/autoresearch/tools/run-experiment.ts +1 -1
  42. package/src/autoresearch/tools/update-notes.ts +2 -9
  43. package/src/cli/auth-broker-cli.ts +746 -0
  44. package/src/cli/auth-gateway-cli.ts +342 -0
  45. package/src/cli/grievances-cli.ts +109 -16
  46. package/src/cli.ts +4 -2
  47. package/src/commands/auth-broker.ts +96 -0
  48. package/src/commands/auth-gateway.ts +61 -0
  49. package/src/commands/grievances.ts +13 -8
  50. package/src/commands/launch.ts +1 -1
  51. package/src/commit/agentic/agent.ts +2 -0
  52. package/src/commit/agentic/tools/analyze-file.ts +2 -2
  53. package/src/commit/agentic/tools/git-file-diff.ts +2 -2
  54. package/src/commit/agentic/tools/git-hunk.ts +3 -3
  55. package/src/commit/agentic/tools/git-overview.ts +2 -2
  56. package/src/commit/agentic/tools/propose-changelog.ts +1 -3
  57. package/src/commit/agentic/tools/recent-commits.ts +1 -1
  58. package/src/commit/agentic/tools/schemas.ts +1 -9
  59. package/src/config/model-equivalence.ts +279 -174
  60. package/src/config/model-registry.ts +37 -6
  61. package/src/config/model-resolver.ts +13 -8
  62. package/src/config/models-config-schema.ts +8 -0
  63. package/src/config/settings-schema.ts +52 -0
  64. package/src/cursor.ts +1 -1
  65. package/src/debug/log-formatting.ts +1 -1
  66. package/src/debug/log-viewer.ts +1 -1
  67. package/src/debug/profiler.ts +4 -0
  68. package/src/debug/raw-sse-buffer.ts +100 -59
  69. package/src/debug/raw-sse.ts +1 -1
  70. package/src/discovery/agents.ts +15 -4
  71. package/src/edit/modes/apply-patch.ts +1 -5
  72. package/src/edit/modes/patch.ts +5 -5
  73. package/src/edit/modes/replace.ts +5 -5
  74. package/src/edit/renderer.ts +2 -1
  75. package/src/edit/streaming.ts +1 -1
  76. package/src/eval/index.ts +0 -2
  77. package/src/eval/js/shared/runtime.ts +25 -0
  78. package/src/eval/py/kernel.ts +1 -1
  79. package/src/exa/researcher.ts +4 -4
  80. package/src/exa/search.ts +10 -22
  81. package/src/exa/websets.ts +33 -33
  82. package/src/goals/tools/goal-tool.ts +3 -3
  83. package/src/index.ts +0 -3
  84. package/src/internal-urls/docs-index.generated.ts +21 -18
  85. package/src/internal-urls/index.ts +1 -1
  86. package/src/internal-urls/{pi-protocol.ts → omp-protocol.ts} +10 -10
  87. package/src/internal-urls/router.ts +3 -3
  88. package/src/internal-urls/types.ts +1 -1
  89. package/src/lsp/types.ts +8 -11
  90. package/src/main.ts +3 -0
  91. package/src/mcp/tool-bridge.ts +3 -3
  92. package/src/modes/acp/acp-agent.ts +88 -25
  93. package/src/modes/components/bash-execution.ts +1 -1
  94. package/src/modes/components/diff.ts +1 -2
  95. package/src/modes/components/eval-execution.ts +1 -1
  96. package/src/modes/components/oauth-selector.ts +38 -2
  97. package/src/modes/components/tool-execution.ts +1 -2
  98. package/src/modes/controllers/command-controller.ts +95 -34
  99. package/src/modes/controllers/input-controller.ts +4 -3
  100. package/src/modes/data/emojis.json +1 -0
  101. package/src/modes/emoji-autocomplete.ts +285 -0
  102. package/src/modes/interactive-mode.ts +92 -19
  103. package/src/modes/print-mode.ts +3 -3
  104. package/src/modes/prompt-action-autocomplete.ts +14 -0
  105. package/src/plan-mode/approved-plan.ts +9 -0
  106. package/src/prompts/system/system-prompt.md +1 -1
  107. package/src/prompts/system/ttsr-tool-reminder.md +5 -0
  108. package/src/prompts/tools/eval.md +25 -26
  109. package/src/prompts/tools/read.md +1 -1
  110. package/src/prompts/tools/resolve.md +1 -1
  111. package/src/prompts/tools/search.md +1 -1
  112. package/src/prompts/tools/web-search.md +1 -1
  113. package/src/sdk.ts +78 -7
  114. package/src/session/agent-session.ts +176 -77
  115. package/src/session/agent-storage.ts +7 -2
  116. package/src/session/auth-broker-config.ts +102 -0
  117. package/src/session/auth-storage.ts +7 -1
  118. package/src/session/streaming-output.ts +1 -1
  119. package/src/task/types.ts +10 -35
  120. package/src/tools/bash-interactive.ts +4 -1
  121. package/src/tools/bash-pty-selection.ts +2 -2
  122. package/src/tools/browser.ts +12 -20
  123. package/src/tools/eval.ts +77 -100
  124. package/src/tools/gh.ts +21 -45
  125. package/src/tools/hindsight-recall.ts +1 -1
  126. package/src/tools/hindsight-reflect.ts +2 -2
  127. package/src/tools/hindsight-retain.ts +3 -7
  128. package/src/tools/index.ts +8 -1
  129. package/src/tools/inspect-image.ts +4 -1
  130. package/src/tools/irc.ts +4 -12
  131. package/src/tools/job.ts +3 -11
  132. package/src/tools/report-tool-issue.ts +462 -17
  133. package/src/tools/resolve.ts +2 -7
  134. package/src/tools/todo-write.ts +8 -15
  135. package/src/utils/title-generator.ts +3 -0
  136. package/src/web/search/index.ts +6 -6
  137. package/dist/types/eval/parse.d.ts +0 -28
  138. package/dist/types/eval/sniff.d.ts +0 -11
  139. package/src/eval/eval.lark +0 -36
  140. package/src/eval/parse.ts +0 -407
  141. package/src/eval/sniff.ts +0 -28
@@ -242,13 +242,14 @@ export const ModelsConfigFile = new ConfigFile<ModelsConfig>("models", ModelsCon
242
242
  },
243
243
  );
244
244
 
245
- /** Provider override config (baseUrl, headers, apiKey, compat) without custom models */
245
+ /** Provider override config (baseUrl, headers, apiKey, compat, transport) without custom models */
246
246
  interface ProviderOverride {
247
247
  baseUrl?: string;
248
248
  headers?: Record<string, string>;
249
249
  apiKey?: string;
250
250
  authHeader?: boolean;
251
251
  compat?: Model<Api>["compat"];
252
+ transport?: Model<Api>["transport"];
252
253
  }
253
254
 
254
255
  interface DiscoveryProviderConfig {
@@ -792,6 +793,10 @@ export class ModelRegistry {
792
793
  this.#customProviderApiKeys.clear();
793
794
  this.#keylessProviders.clear();
794
795
  this.#discoverableProviders = [];
796
+ // Drop config-sourced apiKeys from AuthStorage before reload; entries
797
+ // removed from models.yml must actually disappear from the resolver, not
798
+ // linger from the previous parse. The post-load setters below repopulate.
799
+ this.authStorage.clearConfigApiKeys();
795
800
  // Restore runtime API keys before #loadModels — survives because
796
801
  // #loadModels only calls .set() on #customProviderApiKeys, never reassigns it.
797
802
  for (const [k, v] of this.#runtimeProviderApiKeys) {
@@ -1081,14 +1086,15 @@ export class ModelRegistry {
1081
1086
  const configuredProviders = new Set(Object.keys(value.providers ?? {}));
1082
1087
 
1083
1088
  for (const [providerName, providerConfig] of providerEntries) {
1084
- // Always set overrides when baseUrl/headers/apiKey/authHeader/compat/disableStrictTools are present
1089
+ // Always set overrides when baseUrl/headers/apiKey/authHeader/compat/disableStrictTools/transport are present
1085
1090
  if (
1086
1091
  providerConfig.baseUrl ||
1087
1092
  providerConfig.headers ||
1088
1093
  providerConfig.apiKey ||
1089
1094
  providerConfig.authHeader !== undefined ||
1090
1095
  providerConfig.compat ||
1091
- providerConfig.disableStrictTools
1096
+ providerConfig.disableStrictTools ||
1097
+ providerConfig.transport
1092
1098
  ) {
1093
1099
  const disableStrictCompat = providerConfig.disableStrictTools ? { disableStrictTools: true } : undefined;
1094
1100
  overrides.set(providerName, {
@@ -1097,6 +1103,7 @@ export class ModelRegistry {
1097
1103
  apiKey: providerConfig.apiKey,
1098
1104
  authHeader: providerConfig.authHeader,
1099
1105
  compat: mergeCompat(providerConfig.compat, disableStrictCompat),
1106
+ transport: providerConfig.transport,
1100
1107
  });
1101
1108
  }
1102
1109
 
@@ -1117,9 +1124,14 @@ export class ModelRegistry {
1117
1124
  });
1118
1125
  }
1119
1126
 
1120
- // Always store API key for fallback resolver
1127
+ // Store API key for fallback resolver AND register as config override
1128
+ // so it wins over OAuth tokens from the broker — when the user pins a
1129
+ // bearer in models.yml (e.g. for an auth-gateway baseUrl), that bearer
1130
+ // must authenticate the outbound request.
1121
1131
  if (providerConfig.apiKey) {
1122
1132
  this.#customProviderApiKeys.set(providerName, providerConfig.apiKey);
1133
+ const resolved = resolveApiKeyConfig(providerConfig.apiKey);
1134
+ if (resolved) this.authStorage.setConfigApiKey(providerName, resolved);
1123
1135
  }
1124
1136
 
1125
1137
  // Parse per-model overrides
@@ -1183,6 +1195,7 @@ export class ModelRegistry {
1183
1195
  headers: providerOverride.headers
1184
1196
  ? { ...model.headers, ...providerOverride.headers }
1185
1197
  : model.headers,
1198
+ ...(providerOverride.transport !== undefined ? { transport: providerOverride.transport } : {}),
1186
1199
  }
1187
1200
  : model;
1188
1201
  }),
@@ -1684,11 +1697,12 @@ export class ModelRegistry {
1684
1697
  authHeader: override.authHeader ?? baseOverride?.authHeader,
1685
1698
  headers: override.headers ? { ...(baseOverride?.headers ?? {}), ...override.headers } : baseOverride?.headers,
1686
1699
  compat: override.compat ? mergeCompat(baseOverride?.compat, override.compat) : baseOverride?.compat,
1700
+ transport: override.transport ?? baseOverride?.transport,
1687
1701
  };
1688
1702
  }
1689
1703
  #applyProviderTransportOverride<T extends { baseUrl?: string; headers?: Record<string, string> }>(
1690
1704
  entry: T,
1691
- override: Pick<ProviderOverride, "baseUrl" | "headers" | "authHeader" | "apiKey">,
1705
+ override: Pick<ProviderOverride, "baseUrl" | "headers" | "authHeader" | "apiKey" | "transport">,
1692
1706
  ): T {
1693
1707
  const headers = mergeAuthHeader(
1694
1708
  override.headers ? { ...entry.headers, ...override.headers } : entry.headers,
@@ -1699,6 +1713,9 @@ export class ModelRegistry {
1699
1713
  ...entry,
1700
1714
  baseUrl: override.baseUrl ?? entry.baseUrl,
1701
1715
  headers,
1716
+ // Preserve the model's existing transport when the override omits one;
1717
+ // providers without a `transport` field keep the default per-API dispatch.
1718
+ ...(override.transport !== undefined ? { transport: override.transport } : {}),
1702
1719
  };
1703
1720
  }
1704
1721
  #applyRuntimeProviderOverrides(models: Model<Api>[]): Model<Api>[] {
@@ -1766,6 +1783,8 @@ export class ModelRegistry {
1766
1783
  if (modelDefs.length === 0) continue; // Override-only, no custom models
1767
1784
  if (providerConfig.apiKey) {
1768
1785
  this.#customProviderApiKeys.set(providerName, providerConfig.apiKey);
1786
+ const resolved = resolveApiKeyConfig(providerConfig.apiKey);
1787
+ if (resolved) this.authStorage.setConfigApiKey(providerName, resolved);
1769
1788
  }
1770
1789
  for (const modelDef of modelDefs) {
1771
1790
  const providerCompat = providerConfig.disableStrictTools
@@ -2008,6 +2027,7 @@ export class ModelRegistry {
2008
2027
  this.#runtimeProviderApiKeys.delete(providerName);
2009
2028
  this.#runtimeProviderOverrides.delete(providerName);
2010
2029
  this.#runtimeModelOverlays = this.#runtimeModelOverlays.filter(overlay => overlay.provider !== providerName);
2030
+ this.authStorage.removeConfigApiKey(providerName);
2011
2031
  }
2012
2032
 
2013
2033
  /**
@@ -2115,6 +2135,8 @@ export class ModelRegistry {
2115
2135
  this.#customProviderApiKeys.set(providerName, config.apiKey);
2116
2136
  // Persist runtime API keys so they survive #reloadStaticModels() cycles
2117
2137
  this.#runtimeProviderApiKeys.set(providerName, config.apiKey);
2138
+ const resolved = resolveApiKeyConfig(config.apiKey);
2139
+ if (resolved) this.authStorage.setConfigApiKey(providerName, resolved);
2118
2140
  }
2119
2141
 
2120
2142
  if (config.models && config.models.length > 0) {
@@ -2168,12 +2190,19 @@ export class ModelRegistry {
2168
2190
  return;
2169
2191
  }
2170
2192
 
2171
- if (config.baseUrl || config.headers || config.apiKey || config.authHeader !== undefined) {
2193
+ if (
2194
+ config.baseUrl ||
2195
+ config.headers ||
2196
+ config.apiKey ||
2197
+ config.authHeader !== undefined ||
2198
+ config.transport !== undefined
2199
+ ) {
2172
2200
  const transportOverride = {
2173
2201
  baseUrl: config.baseUrl,
2174
2202
  headers: config.headers,
2175
2203
  apiKey: config.apiKey,
2176
2204
  authHeader: config.authHeader,
2205
+ transport: config.transport,
2177
2206
  };
2178
2207
  const nextRuntimeOverride = this.#mergeProviderOverride(
2179
2208
  this.#runtimeProviderOverrides.get(providerName),
@@ -2221,6 +2250,8 @@ export interface ProviderConfigInput {
2221
2250
  headers?: Record<string, string>;
2222
2251
  compat?: Model<Api>["compat"];
2223
2252
  authHeader?: boolean;
2253
+ /** Streaming transport override — see {@link Model.transport}. */
2254
+ transport?: Model<Api>["transport"];
2224
2255
  oauth?: {
2225
2256
  name: string;
2226
2257
  login(callbacks: OAuthLoginCallbacks): Promise<OAuthCredentials | string>;
@@ -13,6 +13,7 @@ import {
13
13
  modelsAreEqual,
14
14
  } from "@oh-my-pi/pi-ai";
15
15
  import { fuzzyMatch } from "@oh-my-pi/pi-tui";
16
+ import { logger } from "@oh-my-pi/pi-utils";
16
17
  import chalk from "chalk";
17
18
  import MODEL_PRIO from "../priority.json" with { type: "json" };
18
19
  import { parseThinkingLevel, resolveThinkingLevelForModel } from "../thinking";
@@ -116,12 +117,16 @@ function cloneModelWithRequestedId(model: Model<Api>, requestedId: string): Mode
116
117
  };
117
118
  }
118
119
 
119
- const providerModelIndexCache = new WeakMap<readonly Model<Api>[], Map<string, Model<Api> | null>>();
120
+ const kProviderModelIndex = Symbol("model-resolver.providerIndex");
121
+ type ModelsWithProviderIndex = readonly Model<Api>[] & {
122
+ [kProviderModelIndex]?: Map<string, Model<Api> | null>;
123
+ };
120
124
 
121
125
  function getProviderModelIndex(availableModels: readonly Model<Api>[]): Map<string, Model<Api> | null> {
122
- let index = providerModelIndexCache.get(availableModels);
123
- if (index) return index;
124
- index = new Map<string, Model<Api> | null>();
126
+ const tagged = availableModels as ModelsWithProviderIndex;
127
+ const cached = tagged[kProviderModelIndex];
128
+ if (cached) return cached;
129
+ const index = new Map<string, Model<Api> | null>();
125
130
  for (const m of availableModels) {
126
131
  const key = `${m.provider.toLowerCase()}\u0000${m.id.toLowerCase()}`;
127
132
  if (index.has(key)) {
@@ -130,7 +135,7 @@ function getProviderModelIndex(availableModels: readonly Model<Api>[]): Map<stri
130
135
  index.set(key, m);
131
136
  }
132
137
  }
133
- providerModelIndexCache.set(availableModels, index);
138
+ tagged[kProviderModelIndex] = index;
134
139
  return index;
135
140
  }
136
141
 
@@ -887,7 +892,7 @@ export async function resolveModelScope(
887
892
  });
888
893
 
889
894
  if (matchingModels.length === 0) {
890
- console.warn(chalk.yellow(`Warning: No models match pattern "${pattern}"`));
895
+ logger.warn(`No models match pattern "${pattern}"`);
891
896
  continue;
892
897
  }
893
898
 
@@ -930,11 +935,11 @@ export async function resolveModelScope(
930
935
  );
931
936
 
932
937
  if (warning) {
933
- console.warn(chalk.yellow(`Warning: ${warning}`));
938
+ logger.warn(warning);
934
939
  }
935
940
 
936
941
  if (!model) {
937
- console.warn(chalk.yellow(`Warning: No models match pattern "${pattern}"`));
942
+ logger.warn(`No models match pattern "${pattern}"`);
938
943
  continue;
939
944
  }
940
945
 
@@ -151,6 +151,14 @@ const ProviderConfigSchema = z.object({
151
151
  models: z.array(ModelDefinitionSchema).optional(),
152
152
  modelOverrides: z.record(z.string(), ModelOverrideSchema).optional(),
153
153
  disableStrictTools: z.boolean().optional(),
154
+ /**
155
+ * Streaming transport override. When set to `"pi-native"`, omp dispatches
156
+ * every model under this provider via the auth-gateway's
157
+ * `POST /v1/pi/stream` endpoint instead of the per-provider SDK. The
158
+ * provider's `baseUrl` must point at a compatible `omp auth-gateway`
159
+ * and `apiKey` must carry the gateway bearer.
160
+ */
161
+ transport: z.literal("pi-native").optional(),
154
162
  });
155
163
 
156
164
  const EquivalenceConfigSchema = z.object({
@@ -234,6 +234,13 @@ export const SETTINGS_SCHEMA = {
234
234
  // ────────────────────────────────────────────────────────────────────────
235
235
  lastChangelogVersion: { type: "string", default: undefined },
236
236
 
237
+ // Auth broker — credentials proxied through a remote `omp auth-broker serve`
238
+ // host. Hidden from the UI; populate via env vars or hand-edited config.yml.
239
+ // Env (`OMP_AUTH_BROKER_URL` / `OMP_AUTH_BROKER_TOKEN`) takes precedence so
240
+ // per-machine overrides remain trivial.
241
+ "auth.broker.url": { type: "string", default: undefined },
242
+ "auth.broker.token": { type: "string", default: undefined },
243
+
237
244
  autoResume: {
238
245
  type: "boolean",
239
246
  default: false,
@@ -909,6 +916,16 @@ export const SETTINGS_SCHEMA = {
909
916
  },
910
917
  },
911
918
 
919
+ emojiAutocomplete: {
920
+ type: "boolean",
921
+ default: true,
922
+ ui: {
923
+ tab: "interaction",
924
+ label: "Emoji Autocomplete",
925
+ description: "Suggest emojis from `:name:` shortcodes and expand text emoticons like `:D` or `:-)`",
926
+ },
927
+ },
928
+
912
929
  "startup.quiet": {
913
930
  type: "boolean",
914
931
  default: false,
@@ -2636,6 +2653,41 @@ export const SETTINGS_SCHEMA = {
2636
2653
  },
2637
2654
  },
2638
2655
 
2656
+ "dev.autoqaPush.endpoint": {
2657
+ type: "string",
2658
+ // Bundled QA collector — runs `/work/pi-www/autoqa` behind qa.omp.sh.
2659
+ // Override via `PI_AUTO_QA_PUSH_URL` or `dev.autoqaPush.endpoint`
2660
+ // in `config.yml` to point at a self-hosted instance.
2661
+ default: "https://qa.omp.sh/v1/grievances" as const,
2662
+ ui: {
2663
+ tab: "tools",
2664
+ label: "Auto QA Push Endpoint",
2665
+ description: "Full URL that receives the JSON payload (default ships to https://qa.omp.sh/v1/grievances)",
2666
+ },
2667
+ },
2668
+
2669
+ "dev.autoqaPush.token": {
2670
+ type: "string",
2671
+ default: undefined,
2672
+ },
2673
+
2674
+ /**
2675
+ * User decision on sharing automatic `report_tool_issue` grievances.
2676
+ *
2677
+ * - `"unset"` — never asked; the first `report_tool_issue` invocation
2678
+ * pops a consent dialog and persists the answer here.
2679
+ * - `"granted"` — record and (when push is configured) ship grievances.
2680
+ * - `"denied"` — silently no-op every `report_tool_issue` call.
2681
+ *
2682
+ * Owned by `packages/coding-agent/src/tools/report-tool-issue.ts` via the
2683
+ * process-global consent handler registered by `InteractiveMode`.
2684
+ */
2685
+ "dev.autoqa.consent": {
2686
+ type: "enum",
2687
+ values: ["unset", "granted", "denied"] as const,
2688
+ default: "unset" as const,
2689
+ },
2690
+
2639
2691
  "thinkingBudgets.minimal": { type: "number", default: 1024 },
2640
2692
 
2641
2693
  "thinkingBudgets.low": { type: "number", default: 2048 },
package/src/cursor.ts CHANGED
@@ -13,7 +13,7 @@ import type {
13
13
  CursorExecHandlers as ICursorExecHandlers,
14
14
  ToolResultMessage,
15
15
  } from "@oh-my-pi/pi-ai";
16
- import { sanitizeText } from "@oh-my-pi/pi-natives";
16
+ import { sanitizeText } from "@oh-my-pi/pi-utils";
17
17
  import { resolveToCwd } from "./tools/path-utils";
18
18
 
19
19
  interface CursorExecBridgeOptions {
@@ -1,4 +1,4 @@
1
- import { sanitizeText } from "@oh-my-pi/pi-natives";
1
+ import { sanitizeText } from "@oh-my-pi/pi-utils";
2
2
  import { replaceTabs, truncateToWidth, wrapTextWithAnsi } from "../tools/render-utils";
3
3
 
4
4
  export function formatDebugLogLine(line: string, maxWidth: number): string {
@@ -1,4 +1,3 @@
1
- import { sanitizeText } from "@oh-my-pi/pi-natives";
2
1
  import {
3
2
  type Component,
4
3
  extractPrintableText,
@@ -8,6 +7,7 @@ import {
8
7
  truncateToWidth,
9
8
  visibleWidth,
10
9
  } from "@oh-my-pi/pi-tui";
10
+ import { sanitizeText } from "@oh-my-pi/pi-utils";
11
11
  import { theme } from "../modes/theme/theme";
12
12
  import { copyToClipboard } from "../utils/clipboard";
13
13
  import {
@@ -121,6 +121,10 @@ export async function startCpuProfile(): Promise<ProfilerSession> {
121
121
  session.connect();
122
122
 
123
123
  await session.post("Profiler.enable");
124
+ // Default CDP interval is 1ms, which mis-attributes await-resumption samples
125
+ // to the line after `await` (one sparse sample inherits the entire wait). 100µs
126
+ // scatters samples enough to keep CPU vs. async-wait attribution honest.
127
+ await session.post("Profiler.setSamplingInterval", { interval: 100 });
124
128
  await session.post("Profiler.start");
125
129
 
126
130
  return {
@@ -37,43 +37,50 @@ export interface RawSseDebugSnapshot {
37
37
  lastUpdatedAt?: number;
38
38
  }
39
39
 
40
- function modelProvider(model: Model | undefined): string | undefined {
41
- return model?.provider;
42
- }
43
-
44
- function modelId(model: Model | undefined): string | undefined {
45
- return model?.id;
46
- }
47
-
48
- function modelApi(model: Model | undefined): string | undefined {
49
- return model?.api;
50
- }
40
+ // Per-record char counts are stored in a parallel array (`#recordChars`) on
41
+ // the buffer rather than stamped onto each record via a symbol property.
42
+ // Stamping triggered hidden-class transitions in V8/JSC — the previous
43
+ // revision saw `trimRawLines` regress 4× (0.5s → 2.0s in a 50s profile)
44
+ // because every event-record allocation went through the slow dictionary
45
+ // path. The parallel array keeps records as plain monomorphic objects.
46
+ type TrimResult = { raw: string[]; truncated: boolean; originalChars: number; chars: number };
51
47
 
52
- function countRecordChars(record: RawSseDebugRecord): number {
53
- if (record.kind === "response") return formatRawSseResponseComment(record).length + 1;
54
- return record.raw.reduce((sum, line) => sum + line.length + 1, 1);
55
- }
48
+ // Single-pass trim. Returns the final `chars` count using the historical
49
+ // formula `reduce(line.length + 1, init = 1)` so the new accounting matches
50
+ // the previous `countRecordChars` byte-for-byte (the trailing +1 covers the
51
+ // record-level newline that `rawRecordText` appends in `toRawText`).
52
+ //
53
+ // When the event fits within budget the input `raw` array is returned
54
+ // **by reference** — see the ownership contract documented at
55
+ // `RawSseDebugBuffer.recordEvent` below.
56
+ function trimRawLines(raw: string[]): TrimResult {
57
+ let originalChars = 0;
58
+ for (let i = 0; i < raw.length; i++) originalChars += raw[i].length + 1;
56
59
 
57
- function trimRawLines(raw: string[]): { raw: string[]; truncated: boolean; originalChars: number } {
58
- const originalChars = raw.reduce((sum, line) => sum + line.length + 1, 0);
59
60
  if (originalChars <= MAX_RAW_SSE_EVENT_CHARS) {
60
- return { raw: [...raw], truncated: false, originalChars };
61
+ return { raw, truncated: false, originalChars, chars: originalChars + 1 };
61
62
  }
62
63
 
63
64
  const trimmed: string[] = [];
64
65
  let remaining = MAX_RAW_SSE_EVENT_CHARS;
66
+ let chars = 1; // matches reduce(.., init = 1)
65
67
  for (const line of raw) {
66
68
  if (remaining <= 0) break;
67
69
  if (line.length + 1 <= remaining) {
68
70
  trimmed.push(line);
71
+ chars += line.length + 1;
69
72
  remaining -= line.length + 1;
70
73
  continue;
71
74
  }
72
- trimmed.push(line.slice(0, Math.max(0, remaining)));
75
+ const slice = line.slice(0, Math.max(0, remaining));
76
+ trimmed.push(slice);
77
+ chars += slice.length + 1;
73
78
  remaining = 0;
74
79
  }
75
- trimmed.push(`: omp-debug-truncated originalChars=${originalChars}`);
76
- return { raw: trimmed, truncated: true, originalChars };
80
+ const tail = `: omp-debug-truncated originalChars=${originalChars}`;
81
+ trimmed.push(tail);
82
+ chars += tail.length + 1;
83
+ return { raw: trimmed, truncated: true, originalChars, chars };
77
84
  }
78
85
 
79
86
  export function formatRawSseIsoTime(timestamp: number): string {
@@ -110,6 +117,11 @@ function metadataTransport(response: ProviderResponseMetadata): string | undefin
110
117
 
111
118
  export class RawSseDebugBuffer {
112
119
  #records: RawSseDebugRecord[] = [];
120
+ // Parallel to `#records`: `#recordChars[i]` is the precomputed char count
121
+ // for `#records[i]`. Kept in lockstep by `#append` (push both) and
122
+ // `#enforceLimits` (shift both). See the comment above the class for why
123
+ // this is a sidecar array instead of a per-record property.
124
+ #recordChars: number[] = [];
113
125
  #totalChars = 0;
114
126
  #droppedRecords = 0;
115
127
  #droppedChars = 0;
@@ -117,6 +129,7 @@ export class RawSseDebugBuffer {
117
129
  #lastUpdatedAt: number | undefined;
118
130
  #nextSequence = 1;
119
131
  #listeners = new Set<() => void>();
132
+ #emitScheduled = false;
120
133
 
121
134
  subscribe(listener: () => void): () => void {
122
135
  this.#listeners.add(listener);
@@ -124,34 +137,46 @@ export class RawSseDebugBuffer {
124
137
  }
125
138
 
126
139
  recordResponse(response: ProviderResponseMetadata, model?: Model): void {
127
- this.#append({
140
+ const record: RawSseDebugRecord = {
128
141
  kind: "response",
129
142
  sequence: this.#nextSequence++,
130
143
  timestamp: Date.now(),
131
- provider: modelProvider(model),
132
- model: modelId(model),
133
- api: modelApi(model),
144
+ provider: model?.provider,
145
+ model: model?.id,
146
+ api: model?.api,
134
147
  status: response.status,
135
148
  requestId: response.requestId,
136
149
  transport: metadataTransport(response),
137
- });
150
+ };
151
+ this.#append(record, formatRawSseResponseComment(record).length + 1);
138
152
  }
139
153
 
154
+ // Ownership contract for `event.raw`:
155
+ // The caller (either `notifyRawSseEvent` in `packages/ai/src/utils/sse-debug.ts`
156
+ // or `SseTeeParser.#dispatch` directly) hands us a freshly-allocated
157
+ // `string[]` per event and never retains, mutates, or re-dispatches it.
158
+ // That lets `trimRawLines` keep the array by reference instead of
159
+ // cloning on every chunk — a measurable savings on the streaming hot
160
+ // path. If a future observer-chain mutates the array, restore the
161
+ // `raw.slice()` defensive copy inside `trimRawLines`.
140
162
  recordEvent(event: RawSseEvent, model?: Model): void {
141
163
  const trimmed = trimRawLines(event.raw);
142
164
  this.#totalEvents += 1;
143
- this.#append({
144
- kind: "event",
145
- sequence: this.#nextSequence++,
146
- timestamp: Date.now(),
147
- provider: modelProvider(model),
148
- model: modelId(model),
149
- api: modelApi(model),
150
- event: event.event,
151
- raw: trimmed.raw,
152
- truncated: trimmed.truncated,
153
- originalChars: trimmed.originalChars,
154
- });
165
+ this.#append(
166
+ {
167
+ kind: "event",
168
+ sequence: this.#nextSequence++,
169
+ timestamp: Date.now(),
170
+ provider: model?.provider,
171
+ model: model?.id,
172
+ api: model?.api,
173
+ event: event.event,
174
+ raw: trimmed.raw,
175
+ truncated: trimmed.truncated,
176
+ originalChars: trimmed.originalChars,
177
+ },
178
+ trimmed.chars,
179
+ );
155
180
  }
156
181
 
157
182
  snapshot(): RawSseDebugSnapshot {
@@ -165,12 +190,14 @@ export class RawSseDebugBuffer {
165
190
  }
166
191
 
167
192
  toRawText(): string {
193
+ // Reads the live array directly: `rawRecordText` only computes a string
194
+ // from each record, so no caller-visible mutation is possible.
168
195
  return this.#records.map(rawRecordText).join("\n");
169
196
  }
170
197
 
171
- #append(record: RawSseDebugRecord): void {
172
- const chars = countRecordChars(record);
198
+ #append(record: RawSseDebugRecord, chars: number): void {
173
199
  this.#records.push(record);
200
+ this.#recordChars.push(chars);
174
201
  this.#totalChars += chars;
175
202
  this.#lastUpdatedAt = record.timestamp;
176
203
  this.#enforceLimits();
@@ -179,9 +206,9 @@ export class RawSseDebugBuffer {
179
206
 
180
207
  #enforceLimits(): void {
181
208
  while (this.#records.length > MAX_RAW_SSE_EVENTS || this.#totalChars > MAX_RAW_SSE_CHARS) {
182
- const dropped = this.#records.shift();
183
- if (!dropped) return;
184
- const chars = countRecordChars(dropped);
209
+ if (this.#records.length === 0) return;
210
+ this.#records.shift();
211
+ const chars = this.#recordChars.shift() ?? 0;
185
212
  this.#totalChars = Math.max(0, this.#totalChars - chars);
186
213
  this.#droppedRecords += 1;
187
214
  this.#droppedChars += chars;
@@ -189,6 +216,26 @@ export class RawSseDebugBuffer {
189
216
  }
190
217
 
191
218
  #emit(): void {
219
+ const count = this.#listeners.size;
220
+ if (count === 0) return;
221
+ // With a single listener (the common case — RawSse debug viewer is the
222
+ // only subscriber), keep eager emit so per-event semantics are
223
+ // preserved. With multiple listeners, coalesce bursts of events into
224
+ // one microtask-deferred fan-out to avoid N×M listener invocations
225
+ // during a streaming response.
226
+ if (count === 1) {
227
+ this.#fanOut();
228
+ return;
229
+ }
230
+ if (this.#emitScheduled) return;
231
+ this.#emitScheduled = true;
232
+ queueMicrotask(() => {
233
+ this.#emitScheduled = false;
234
+ this.#fanOut();
235
+ });
236
+ }
237
+
238
+ #fanOut(): void {
192
239
  for (const listener of this.#listeners) {
193
240
  try {
194
241
  listener();
@@ -199,31 +246,25 @@ export class RawSseDebugBuffer {
199
246
  }
200
247
  }
201
248
 
202
- const fallbackBuffers = new WeakMap<object, RawSseDebugBuffer>();
203
249
  const globalFallbackBuffer = new RawSseDebugBuffer();
250
+ const kRawSseDebugBuffer = Symbol("debug.rawSseBuffer");
251
+ type OwnerWithBuffer = object & { rawSseDebugBuffer?: unknown; [kRawSseDebugBuffer]?: RawSseDebugBuffer };
204
252
 
205
253
  export function resolveRawSseDebugBuffer(owner?: object): RawSseDebugBuffer {
206
254
  if (!owner) return globalFallbackBuffer;
207
255
 
208
- const candidate = (owner as { rawSseDebugBuffer?: unknown }).rawSseDebugBuffer;
209
- if (candidate instanceof RawSseDebugBuffer) return candidate;
256
+ const tagged = owner as OwnerWithBuffer;
257
+ const declared = tagged.rawSseDebugBuffer;
258
+ if (declared instanceof RawSseDebugBuffer) return declared;
210
259
 
211
- const existing = fallbackBuffers.get(owner);
260
+ const existing = tagged[kRawSseDebugBuffer];
212
261
  if (existing) return existing;
213
262
 
214
263
  const buffer = new RawSseDebugBuffer();
215
- fallbackBuffers.set(owner, buffer);
216
- if (Object.isExtensible(owner)) {
217
- try {
218
- Object.defineProperty(owner, "rawSseDebugBuffer", {
219
- value: buffer,
220
- configurable: true,
221
- enumerable: false,
222
- writable: true,
223
- });
224
- } catch {
225
- // The WeakMap fallback remains usable if the session object rejects extension.
226
- }
264
+ try {
265
+ tagged[kRawSseDebugBuffer] = buffer;
266
+ } catch {
267
+ // Non-extensible owner: caller gets a fresh buffer on each call.
227
268
  }
228
269
  return buffer;
229
270
  }
@@ -1,5 +1,5 @@
1
- import { sanitizeText } from "@oh-my-pi/pi-natives";
2
1
  import { type Component, matchesKey, padding, replaceTabs, truncateToWidth, visibleWidth } from "@oh-my-pi/pi-tui";
2
+ import { sanitizeText } from "@oh-my-pi/pi-utils";
3
3
  import { theme } from "../modes/theme/theme";
4
4
  import { copyToClipboard } from "../utils/clipboard";
5
5
  import { formatRawSseIsoTime, type RawSseDebugBuffer, rawSseRecordLines } from "./raw-sse-buffer";
@@ -33,13 +33,24 @@ function getUserPathCandidates(ctx: LoadContext, ...segments: string[]): string[
33
33
  return AGENT_DIR_CANDIDATES.map(baseDir => path.join(ctx.home, baseDir, ...segments));
34
34
  }
35
35
 
36
- /** Project-level paths: walk up from cwd to repoRoot, returning .agent/<segments> and .agents/<segments> at each level. */
37
- function getProjectPathCandidates(ctx: LoadContext, ...segments: string[]): string[] {
36
+ /**
37
+ * Project-level paths: walk up from cwd to repoRoot, returning `.agent/<segments>`
38
+ * and `.agents/<segments>` at each ancestor.
39
+ *
40
+ * The user home directory is skipped: `~/.agent[s]/` is by definition
41
+ * user-level config and is already enumerated by {@link getUserPathCandidates}.
42
+ * Without this guard, any cwd under `$HOME` (with no closer git repoRoot) would
43
+ * walk up to home and yield duplicate project+user entries for the same
44
+ * directory — see https://github.com/can1357/oh-my-pi/issues/1116.
45
+ */
46
+ export function getProjectPathCandidates(ctx: LoadContext, ...segments: string[]): string[] {
38
47
  const paths: string[] = [];
39
48
  let current = ctx.cwd;
40
49
  while (true) {
41
- for (const baseDir of AGENT_DIR_CANDIDATES) {
42
- paths.push(path.join(current, baseDir, ...segments));
50
+ if (current !== ctx.home) {
51
+ for (const baseDir of AGENT_DIR_CANDIDATES) {
52
+ paths.push(path.join(current, baseDir, ...segments));
53
+ }
43
54
  }
44
55
  if (current === (ctx.repoRoot ?? ctx.home)) break;
45
56
  const parent = path.dirname(current);
@@ -14,11 +14,7 @@ import { ApplyPatchError } from "../diff";
14
14
  import type { PatchEditEntry } from "./patch";
15
15
 
16
16
  export const applyPatchSchema = z.object({
17
- input: z
18
- .string()
19
- .describe(
20
- "Full Codex apply_patch envelope, including '*** Begin Patch' and '*** End Patch'. Contains any mix of Add/Delete/Update (with optional Move to) file operations.",
21
- ),
17
+ input: z.string().describe("apply_patch envelope"),
22
18
  });
23
19
 
24
20
  export type ApplyPatchParams = z.infer<typeof applyPatchSchema>;