@oh-my-pi/pi-coding-agent 17.0.7 → 17.0.9

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 (153) hide show
  1. package/CHANGELOG.md +91 -0
  2. package/dist/cli.js +5025 -4930
  3. package/dist/types/config/__tests__/model-registry.test.d.ts +1 -0
  4. package/dist/types/config/model-registry.d.ts +28 -1
  5. package/dist/types/config/model-resolver.d.ts +5 -1
  6. package/dist/types/config/settings-schema.d.ts +50 -3
  7. package/dist/types/dap/client.d.ts +19 -0
  8. package/dist/types/extensibility/extensions/runner.d.ts +4 -2
  9. package/dist/types/extensibility/extensions/types.d.ts +3 -0
  10. package/dist/types/extensibility/legacy-pi-coding-agent-shim.d.ts +1 -0
  11. package/dist/types/extensibility/legacy-pi-tui-shim.d.ts +10 -0
  12. package/dist/types/extensibility/plugins/legacy-pi-compat.d.ts +1 -1
  13. package/dist/types/extensibility/typebox.d.ts +4 -0
  14. package/dist/types/hindsight/client.d.ts +10 -0
  15. package/dist/types/hindsight/config.d.ts +8 -0
  16. package/dist/types/internal-urls/registry-helpers.d.ts +11 -0
  17. package/dist/types/lsp/client.d.ts +2 -0
  18. package/dist/types/mcp/manager.d.ts +6 -2
  19. package/dist/types/mcp/render.d.ts +2 -2
  20. package/dist/types/mcp/tool-bridge.d.ts +16 -3
  21. package/dist/types/mcp/types.d.ts +6 -0
  22. package/dist/types/mnemopi/state.d.ts +2 -0
  23. package/dist/types/modes/components/oauth-selector.d.ts +8 -0
  24. package/dist/types/modes/components/settings-defs.d.ts +1 -0
  25. package/dist/types/modes/components/user-message.d.ts +25 -1
  26. package/dist/types/modes/controllers/extension-ui-controller.d.ts +8 -0
  27. package/dist/types/modes/controllers/mcp-command-controller.d.ts +3 -0
  28. package/dist/types/modes/interactive-mode.d.ts +7 -1
  29. package/dist/types/modes/rpc/rpc-client.d.ts +10 -1
  30. package/dist/types/modes/rpc/rpc-frame.d.ts +16 -0
  31. package/dist/types/modes/rpc/rpc-messages.d.ts +26 -0
  32. package/dist/types/modes/rpc/rpc-types.d.ts +40 -0
  33. package/dist/types/modes/setup-wizard/scenes/sign-in.d.ts +1 -1
  34. package/dist/types/modes/setup-wizard/scenes/types.d.ts +9 -1
  35. package/dist/types/modes/setup-wizard/scenes/web-search.d.ts +1 -1
  36. package/dist/types/modes/types.d.ts +14 -1
  37. package/dist/types/modes/utils/ui-helpers.d.ts +12 -8
  38. package/dist/types/sdk.d.ts +1 -0
  39. package/dist/types/session/agent-session-error-log.test.d.ts +1 -0
  40. package/dist/types/session/agent-session.d.ts +86 -3
  41. package/dist/types/session/session-loader.d.ts +6 -4
  42. package/dist/types/task/executor.d.ts +10 -0
  43. package/dist/types/task/index.d.ts +1 -0
  44. package/dist/types/task/prewalk.d.ts +3 -0
  45. package/dist/types/task/types.d.ts +14 -0
  46. package/dist/types/tools/ask.d.ts +10 -0
  47. package/dist/types/tools/hub/jobs.d.ts +1 -1
  48. package/dist/types/tools/index.d.ts +3 -0
  49. package/dist/types/tools/report-tool-issue.d.ts +24 -9
  50. package/dist/types/tools/tool-timeouts.d.ts +8 -2
  51. package/dist/types/utils/changelog.d.ts +4 -6
  52. package/dist/types/web/search/providers/firecrawl.d.ts +9 -0
  53. package/dist/types/web/search/types.d.ts +1 -1
  54. package/package.json +13 -13
  55. package/scripts/legacy-pi-virtual-module.ts +1 -1
  56. package/src/cli/grievances-cli.ts +2 -2
  57. package/src/cli/models-cli.ts +37 -17
  58. package/src/cli/update-cli.ts +14 -1
  59. package/src/cli/usage-cli.ts +1 -1
  60. package/src/config/__tests__/model-registry.test.ts +147 -0
  61. package/src/config/model-registry.ts +87 -24
  62. package/src/config/model-resolver.ts +56 -32
  63. package/src/config/settings-schema.ts +46 -3
  64. package/src/config/settings.ts +68 -5
  65. package/src/dap/client.ts +86 -7
  66. package/src/edit/diff.ts +4 -4
  67. package/src/eval/py/prelude.py +5 -5
  68. package/src/export/html/template.js +23 -9
  69. package/src/extensibility/extensions/runner.ts +9 -4
  70. package/src/extensibility/extensions/types.ts +3 -0
  71. package/src/extensibility/legacy-pi-coding-agent-shim.ts +1 -0
  72. package/src/extensibility/legacy-pi-tui-shim.ts +10 -0
  73. package/src/extensibility/plugins/legacy-pi-compat.ts +851 -310
  74. package/src/extensibility/typebox.ts +22 -9
  75. package/src/hindsight/client.test.ts +42 -0
  76. package/src/hindsight/client.ts +40 -3
  77. package/src/hindsight/config.ts +18 -0
  78. package/src/internal-urls/registry-helpers.ts +40 -0
  79. package/src/launch/broker.ts +31 -5
  80. package/src/lsp/client.ts +23 -0
  81. package/src/lsp/clients/biome-client.ts +3 -4
  82. package/src/lsp/index.ts +26 -8
  83. package/src/main.ts +16 -5
  84. package/src/mcp/manager.ts +39 -8
  85. package/src/mcp/render.ts +94 -35
  86. package/src/mcp/tool-bridge.ts +116 -11
  87. package/src/mcp/types.ts +7 -0
  88. package/src/mnemopi/state.ts +70 -3
  89. package/src/modes/components/agent-dashboard.ts +6 -2
  90. package/src/modes/components/agent-hub.ts +26 -9
  91. package/src/modes/components/chat-transcript-builder.ts +13 -2
  92. package/src/modes/components/diff.ts +2 -2
  93. package/src/modes/components/oauth-selector.ts +24 -7
  94. package/src/modes/components/plan-review-overlay.ts +20 -4
  95. package/src/modes/components/settings-defs.ts +5 -2
  96. package/src/modes/components/settings-selector.ts +9 -5
  97. package/src/modes/components/tool-execution.test.ts +63 -2
  98. package/src/modes/components/user-message.ts +100 -1
  99. package/src/modes/controllers/command-controller.ts +36 -12
  100. package/src/modes/controllers/event-controller.ts +4 -1
  101. package/src/modes/controllers/extension-ui-controller.ts +18 -0
  102. package/src/modes/controllers/input-controller.ts +47 -12
  103. package/src/modes/controllers/mcp-command-controller.ts +70 -40
  104. package/src/modes/controllers/selector-controller.ts +82 -6
  105. package/src/modes/interactive-mode.ts +39 -4
  106. package/src/modes/rpc/rpc-client.ts +94 -3
  107. package/src/modes/rpc/rpc-frame.ts +164 -4
  108. package/src/modes/rpc/rpc-messages.ts +127 -0
  109. package/src/modes/rpc/rpc-mode.ts +79 -7
  110. package/src/modes/rpc/rpc-types.ts +34 -2
  111. package/src/modes/setup-wizard/scenes/model.ts +5 -7
  112. package/src/modes/setup-wizard/scenes/providers.ts +3 -2
  113. package/src/modes/setup-wizard/scenes/sign-in.ts +8 -2
  114. package/src/modes/setup-wizard/scenes/theme.ts +13 -3
  115. package/src/modes/setup-wizard/scenes/types.ts +9 -1
  116. package/src/modes/setup-wizard/scenes/web-search.ts +6 -1
  117. package/src/modes/setup-wizard/wizard-overlay.ts +1 -1
  118. package/src/modes/types.ts +16 -3
  119. package/src/modes/utils/ui-helpers.ts +40 -20
  120. package/src/prompts/goals/guided-goal-system.md +24 -3
  121. package/src/prompts/system/workflow-notice.md +1 -1
  122. package/src/prompts/tools/task.md +12 -2
  123. package/src/sdk.ts +179 -51
  124. package/src/session/agent-session-error-log.test.ts +59 -0
  125. package/src/session/agent-session.ts +351 -46
  126. package/src/session/session-context.test.ts +224 -1
  127. package/src/session/session-context.ts +41 -2
  128. package/src/session/session-loader.ts +10 -5
  129. package/src/slash-commands/helpers/usage-report.ts +3 -1
  130. package/src/system-prompt.ts +1 -2
  131. package/src/task/executor.ts +44 -27
  132. package/src/task/index.ts +63 -8
  133. package/src/task/prewalk.ts +6 -0
  134. package/src/task/structured-subagent.ts +3 -1
  135. package/src/task/types.ts +16 -0
  136. package/src/task/worktree.ts +25 -11
  137. package/src/tools/ask.ts +15 -0
  138. package/src/tools/bash.ts +14 -6
  139. package/src/tools/browser.ts +1 -1
  140. package/src/tools/debug.ts +1 -1
  141. package/src/tools/eval.ts +4 -5
  142. package/src/tools/fetch.ts +1 -1
  143. package/src/tools/hub/index.ts +1 -1
  144. package/src/tools/hub/jobs.ts +67 -8
  145. package/src/tools/hub/launch.ts +7 -0
  146. package/src/tools/index.ts +3 -0
  147. package/src/tools/report-tool-issue.ts +79 -28
  148. package/src/tools/tool-timeouts.ts +10 -3
  149. package/src/tools/write.ts +31 -0
  150. package/src/utils/changelog.ts +54 -58
  151. package/src/utils/git.ts +57 -49
  152. package/src/web/search/providers/firecrawl.ts +46 -13
  153. package/src/web/search/types.ts +5 -1
@@ -782,7 +782,6 @@ function parseModelPatternWithContext(
782
782
  options?: { allowInvalidThinkingSelectorFallback?: boolean },
783
783
  ): ParsedModelResult {
784
784
  // Exact match on the full pattern first (no fuzzy): a literal id that
785
- // contains a colon (`coding-router:max`) wins over any suffix split.
786
785
  const exactMatch = matchModel(pattern, availableModels, context, { exactOnly: true });
787
786
  if (exactMatch) {
788
787
  return { model: exactMatch, thinkingLevel: undefined, warning: undefined, explicitThinkingLevel: false };
@@ -1598,11 +1597,33 @@ export function filterAvailableModelsByEnabledPatterns(
1598
1597
 
1599
1598
  return includeSyntheticAllowedModels(available, allowedModels);
1600
1599
  }
1600
+ function findExactCliModel(
1601
+ selector: string,
1602
+ allModels: Model<Api>[],
1603
+ availableModels: Model<Api>[],
1604
+ ): Model<Api> | undefined {
1605
+ // Explicit provider/id references stay authoritative against the full catalog.
1606
+ const referenced = findExactModelReferenceMatch(selector, allModels);
1607
+ if (referenced) return referenced;
1608
+
1609
+ // Flat-id (or full-selector-string) matches prefer authenticated providers,
1610
+ // then fall back to catalog order. This covers aggregator-style flat ids
1611
+ // that merely look provider-qualified (e.g. "openai/gpt-oss-120b" hosted on
1612
+ // OpenRouter), where the provider/id decomposition above found nothing.
1613
+ const lower = selector.toLowerCase();
1614
+ const isFlatMatch = (model: Model<Api>) =>
1615
+ model.id.toLowerCase() === lower || formatModelString(model).toLowerCase() === lower;
1616
+ const preferred = availableModels.find(isFlatMatch);
1617
+ if (preferred) return preferred;
1618
+ return availableModels === allModels ? undefined : allModels.find(isFlatMatch);
1619
+ }
1601
1620
 
1602
1621
  export interface ResolveCliModelResult {
1603
1622
  model: Model<Api> | undefined;
1604
- /** configuredPatterns is the full configured fallback chain when the selector resolves through a role. */
1623
+ /** configuredPatterns contains the role's ordered primary candidates. */
1605
1624
  configuredPatterns?: string[];
1625
+ /** configuredRole identifies the role expanded into configuredPatterns. */
1626
+ configuredRole?: string;
1606
1627
  /** configuredPatternIndex identifies the configured role pattern that matched an available model. */
1607
1628
  configuredPatternIndex?: number;
1608
1629
  selector?: string;
@@ -1620,17 +1641,19 @@ export function resolveCliModel(options: {
1620
1641
  cliProvider?: string;
1621
1642
  cliModel?: string;
1622
1643
  modelRegistry: CliModelRegistry;
1644
+ /** Authenticated models to prefer for unqualified selectors; omit to preserve catalog-order behavior. */
1645
+ availableModels?: Model<Api>[];
1623
1646
  settings?: Settings;
1624
1647
  preferences?: ModelMatchPreferences;
1625
1648
  }): ResolveCliModelResult {
1626
- const { cliProvider, cliModel, modelRegistry, settings, preferences } = options;
1649
+ const { cliProvider, cliModel, modelRegistry, settings, preferences, availableModels: preferredModels } = options;
1627
1650
 
1628
1651
  if (!cliModel) {
1629
1652
  return { model: undefined, selector: undefined, warning: undefined, error: undefined };
1630
1653
  }
1631
1654
 
1632
- const availableModels = modelRegistry.getAll();
1633
- if (availableModels.length === 0) {
1655
+ const allModels = modelRegistry.getAll();
1656
+ if (allModels.length === 0) {
1634
1657
  return {
1635
1658
  model: undefined,
1636
1659
  selector: undefined,
@@ -1639,8 +1662,9 @@ export function resolveCliModel(options: {
1639
1662
  };
1640
1663
  }
1641
1664
 
1665
+ const availableModels = preferredModels ?? allModels;
1642
1666
  const providerMap = new Map<string, string>();
1643
- for (const model of availableModels) {
1667
+ for (const model of allModels) {
1644
1668
  providerMap.set(model.provider.toLowerCase(), model.provider);
1645
1669
  }
1646
1670
 
@@ -1656,19 +1680,7 @@ export function resolveCliModel(options: {
1656
1680
 
1657
1681
  const trimmedModel = cliModel.trim();
1658
1682
  if (!provider) {
1659
- const lower = trimmedModel.toLowerCase();
1660
- // When input has provider/id format (e.g. "zai/glm-5"), prefer decomposed
1661
- // provider+id match over flat id match. Without this, a model with id
1662
- // "zai/glm-5" on provider "vercel-ai-gateway" wins over provider "zai"
1663
- // with id "glm-5", because Array.find returns the first catalog hit.
1664
- let exact = findExactModelReferenceMatch(trimmedModel, availableModels);
1665
- if (!exact) {
1666
- // Flat exact id (or full selector) by catalog order: CLI resolution
1667
- // stays deterministic across runs regardless of usage-based ranking.
1668
- exact = availableModels.find(
1669
- model => model.id.toLowerCase() === lower || `${model.provider}/${model.id}`.toLowerCase() === lower,
1670
- );
1671
- }
1683
+ const exact = findExactCliModel(trimmedModel, allModels, availableModels);
1672
1684
  if (exact) {
1673
1685
  return {
1674
1686
  model: exact,
@@ -1684,15 +1696,7 @@ export function resolveCliModel(options: {
1684
1696
  MAX_THINKING_SUFFIX_OPTIONS,
1685
1697
  );
1686
1698
  if (exactThinkingLevel) {
1687
- let exactSuffixed = findExactModelReferenceMatch(exactBase, availableModels);
1688
- if (!exactSuffixed) {
1689
- const lowerExactBase = exactBase.toLowerCase();
1690
- exactSuffixed = availableModels.find(
1691
- model =>
1692
- model.id.toLowerCase() === lowerExactBase ||
1693
- `${model.provider}/${model.id}`.toLowerCase() === lowerExactBase,
1694
- );
1695
- }
1699
+ const exactSuffixed = findExactCliModel(exactBase, allModels, availableModels);
1696
1700
  if (exactSuffixed) {
1697
1701
  return {
1698
1702
  model: exactSuffixed,
@@ -1718,15 +1722,28 @@ export function resolveCliModel(options: {
1718
1722
  ? `${formatModelRoleAlias(bareRoleName)}${bareRoleThinkingLevel ? `:${bareRoleThinkingLevel}` : ""}`
1719
1723
  : undefined;
1720
1724
  if (roleSelector) {
1725
+ const { base: roleAlias } = splitThinkingSuffix(
1726
+ roleSelector,
1727
+ modelRoleAliasPrefixLength(roleSelector) ?? -1,
1728
+ MAX_THINKING_SUFFIX_OPTIONS,
1729
+ );
1730
+ const configuredRole = getModelRoleAlias(roleAlias, settings);
1721
1731
  configuredPatterns = resolveConfiguredModelPatterns([roleSelector], settings);
1722
- const resolved = resolveModelRoleValue(roleSelector, availableModels, {
1732
+ const availableResolved = resolveModelRoleValue(roleSelector, availableModels, {
1723
1733
  settings,
1724
1734
  matchPreferences: preferences,
1725
1735
  });
1736
+ const resolved = availableResolved.model
1737
+ ? availableResolved
1738
+ : resolveModelRoleValue(roleSelector, allModels, {
1739
+ settings,
1740
+ matchPreferences: preferences,
1741
+ });
1726
1742
  if (resolved.model) {
1727
1743
  return {
1728
1744
  model: resolved.model,
1729
1745
  selector: formatModelString(resolved.model),
1746
+ configuredRole,
1730
1747
  configuredPatterns,
1731
1748
  configuredPatternIndex: resolved.matchedPatternIndex,
1732
1749
  thinkingLevel: resolved.thinkingLevel,
@@ -1738,6 +1755,7 @@ export function resolveCliModel(options: {
1738
1755
  return {
1739
1756
  model: undefined,
1740
1757
  configuredPatterns,
1758
+ configuredRole,
1741
1759
  selector: undefined,
1742
1760
  thinkingLevel: undefined,
1743
1761
  warning: resolved.warning,
@@ -1767,7 +1785,7 @@ export function resolveCliModel(options: {
1767
1785
  }
1768
1786
 
1769
1787
  if (provider) {
1770
- const exactProviderMatch = resolveProviderModelReference(provider, pattern, availableModels);
1788
+ const exactProviderMatch = resolveProviderModelReference(provider, pattern, allModels);
1771
1789
  if (exactProviderMatch) {
1772
1790
  return {
1773
1791
  model: exactProviderMatch,
@@ -1779,10 +1797,16 @@ export function resolveCliModel(options: {
1779
1797
  }
1780
1798
  }
1781
1799
 
1782
- const candidates = provider ? availableModels.filter(model => model.provider === provider) : availableModels;
1783
- const { model, thinkingLevel, warning, upstream } = parseModelPattern(pattern, candidates, preferences, {
1800
+ const candidates = provider ? allModels.filter(model => model.provider === provider) : availableModels;
1801
+ let parsed = parseModelPattern(pattern, candidates, preferences, {
1784
1802
  allowInvalidThinkingSelectorFallback: false,
1785
1803
  });
1804
+ if (!parsed.model && !provider) {
1805
+ parsed = parseModelPattern(pattern, allModels, preferences, {
1806
+ allowInvalidThinkingSelectorFallback: false,
1807
+ });
1808
+ }
1809
+ const { model, thinkingLevel, warning, upstream } = parsed;
1786
1810
 
1787
1811
  if (!model) {
1788
1812
  const display = provider ? `${provider}/${pattern}` : cliModel;
@@ -207,6 +207,8 @@ interface UiNumber extends UiBase {
207
207
  }
208
208
 
209
209
  interface UiString extends UiBase {
210
+ /** Mask the value in both the settings row and text editor. */
211
+ secret?: boolean;
210
212
  /**
211
213
  * Submenu options.
212
214
  * - Array → submenu with these choices.
@@ -219,6 +221,7 @@ interface UiString extends UiBase {
219
221
  /** Wide ui shape exposed to consumers that walk the schema generically. */
220
222
  export type AnyUiMetadata = UiBase & {
221
223
  options?: ReadonlyArray<SubmenuOption> | "runtime";
224
+ secret?: boolean;
222
225
  };
223
226
 
224
227
  interface BooleanDef {
@@ -2753,7 +2756,18 @@ export const SETTINGS_SCHEMA = {
2753
2756
  },
2754
2757
  },
2755
2758
 
2756
- "hindsight.apiToken": { type: "string", default: undefined },
2759
+ "hindsight.apiToken": {
2760
+ type: "string",
2761
+ default: undefined,
2762
+ ui: {
2763
+ tab: "memory",
2764
+ group: "Hindsight",
2765
+ label: "Hindsight API Token",
2766
+ description: "Bearer token for authenticated Hindsight servers",
2767
+ condition: "hindsightActive",
2768
+ secret: true,
2769
+ },
2770
+ },
2757
2771
 
2758
2772
  "hindsight.bankId": {
2759
2773
  type: "string",
@@ -2861,6 +2875,11 @@ export const SETTINGS_SCHEMA = {
2861
2875
 
2862
2876
  "hindsight.debug": { type: "boolean", default: false },
2863
2877
 
2878
+ "hindsight.requestTimeoutMs": { type: "number", default: 30_000 },
2879
+ "hindsight.reflectTimeoutMs": { type: "number", default: 120_000 },
2880
+ "hindsight.recallTimeoutMs": { type: "number", default: 30_000 },
2881
+ "hindsight.retainTimeoutMs": { type: "number", default: 60_000 },
2882
+
2864
2883
  "hindsight.mentalModelsEnabled": {
2865
2884
  type: "boolean",
2866
2885
  default: true,
@@ -3940,6 +3959,17 @@ export const SETTINGS_SCHEMA = {
3940
3959
  },
3941
3960
  },
3942
3961
 
3962
+ "mcp.renderMarkdownResults": {
3963
+ type: "boolean",
3964
+ default: true,
3965
+ ui: {
3966
+ tab: "tools",
3967
+ group: "Discovery & MCP",
3968
+ label: "MCP Markdown Results",
3969
+ description: "Render non-JSON MCP text results as Markdown in the transcript",
3970
+ },
3971
+ },
3972
+
3943
3973
  "mcp.notifications": {
3944
3974
  type: "boolean",
3945
3975
  default: false,
@@ -4084,6 +4114,18 @@ export const SETTINGS_SCHEMA = {
4084
4114
  },
4085
4115
  },
4086
4116
 
4117
+ "task.isolation.apply": {
4118
+ type: "boolean",
4119
+ default: true,
4120
+ ui: {
4121
+ tab: "tasks",
4122
+ group: "Isolation",
4123
+ label: "Apply Isolated Changes",
4124
+ description:
4125
+ "Automatically apply successful isolated task changes to the parent checkout; disable to retain patch or branch artifacts",
4126
+ },
4127
+ },
4128
+
4087
4129
  "task.isolation.merge": {
4088
4130
  type: "enum",
4089
4131
  values: ["patch", "branch"] as const,
@@ -5027,12 +5069,13 @@ export const SETTINGS_SCHEMA = {
5027
5069
 
5028
5070
  "dev.autoqa": {
5029
5071
  type: "boolean",
5030
- default: false,
5072
+ default: true,
5031
5073
  ui: {
5032
5074
  tab: "tools",
5033
5075
  group: "Developer",
5034
5076
  label: "Auto QA",
5035
- description: "Enable automated tool issue reporting (report_tool_issue) for all agents",
5077
+ description:
5078
+ "Automated tool issue reporting (xd://report_issue). On by default; the first report asks for consent, and denying it disables reporting until re-enabled explicitly",
5036
5079
  },
5037
5080
  },
5038
5081
 
@@ -330,6 +330,8 @@ export class Settings {
330
330
  #modified = new Set<string>();
331
331
  /** Individual project model roles modified during this session */
332
332
  #modifiedProjectModelRoles = new Set<string>();
333
+ /** Individual global model roles modified during this session (for partial save) */
334
+ #modifiedGlobalModelRoles = new Set<string>();
333
335
  /**
334
336
  * Original process-wide model-role overrides captured before a project edit
335
337
  * temporarily replaced them via `#updateRuntimeModelRoleOverride`. Restored
@@ -551,7 +553,7 @@ export class Settings {
551
553
  if (this.#projectSavePromise) {
552
554
  await this.#projectSavePromise;
553
555
  }
554
- if (this.#modified.size > 0) {
556
+ if (this.#modified.size > 0 || this.#modifiedGlobalModelRoles.size > 0) {
555
557
  await this.#saveNow();
556
558
  }
557
559
  if (this.#modifiedProjectModelRoles.size > 0) {
@@ -833,13 +835,22 @@ export class Settings {
833
835
  * stale skip in place.
834
836
  */
835
837
  setModelRole(role: ModelRole | string, modelId: string | undefined): void {
838
+ const prev = this.get("modelRoles");
836
839
  const current = this.#modelRolesFromLayer(this.#global);
837
840
  if (modelId === undefined) {
838
841
  delete current[role];
839
842
  } else {
840
843
  current[role] = modelId;
841
844
  }
842
- this.set("modelRoles", current);
845
+ // Persist per-role rather than marking the whole `modelRoles` path
846
+ // modified: #saveNow merges only the changed role into the re-read
847
+ // file, so a concurrent external edit to a sibling role is not
848
+ // clobbered by this process's stale in-memory snapshot.
849
+ setByPath(this.#global, ["modelRoles"], current);
850
+ this.#modifiedGlobalModelRoles.add(role);
851
+ this.#rebuildMerged();
852
+ this.#queueSave();
853
+ this.#fireEffectiveSettingChanged("modelRoles", this.get("modelRoles"), prev);
843
854
  if (this.isProjectModelRoleRuntimeOverrideActive(role)) {
844
855
  return;
845
856
  }
@@ -1619,7 +1630,8 @@ export class Settings {
1619
1630
  clearTimeout(this.#saveTimer);
1620
1631
  this.#saveTimer = setTimeout(() => {
1621
1632
  this.#saveTimer = undefined;
1622
- const savePromise = this.#saveNow();
1633
+ const previousSave = this.#savePromise;
1634
+ const savePromise = previousSave ? previousSave.then(() => this.#saveNow()) : this.#saveNow();
1623
1635
  this.#savePromise = savePromise;
1624
1636
  savePromise
1625
1637
  .catch(err => {
@@ -1634,27 +1646,75 @@ export class Settings {
1634
1646
  }
1635
1647
 
1636
1648
  async #saveNow(): Promise<void> {
1637
- if (!this.#persist || !this.#configPath || this.#modified.size === 0) return;
1649
+ if (!this.#persist || !this.#configPath) return;
1650
+ if (this.#modified.size === 0 && this.#modifiedGlobalModelRoles.size === 0) return;
1638
1651
 
1639
1652
  const configPath = this.#configPath;
1640
1653
  const modifiedPaths = [...this.#modified];
1654
+ const modifiedModelRoles = [...this.#modifiedGlobalModelRoles];
1655
+ const globalRolesAtStart = this.#modelRolesFromLayer(this.#global);
1641
1656
  this.#modified.clear();
1657
+ this.#modifiedGlobalModelRoles.clear();
1642
1658
 
1643
1659
  try {
1644
1660
  await withFileLock(configPath, async () => {
1645
1661
  // Re-read to preserve external changes
1646
1662
  const current = await this.#loadYaml(configPath);
1647
1663
 
1648
- // Apply only our modified paths
1664
+ // Apply only our modified whole-value paths
1649
1665
  for (const modPath of modifiedPaths) {
1650
1666
  const segments = modPath.split(".");
1651
1667
  const value = getByPath(this.#global, segments);
1652
1668
  setByPath(current, segments, value);
1653
1669
  }
1654
1670
 
1671
+ // Merge only the model roles captured by this save. Then retain
1672
+ // any role changed while the async read/lock was pending before
1673
+ // replacing #global, so the follow-up save still sees its value.
1674
+ const latestGlobalRoles = this.#modelRolesFromLayer(this.#global);
1675
+ const rolesToPreserve = new Set(this.#modifiedGlobalModelRoles);
1676
+ for (const role in globalRolesAtStart) {
1677
+ if (globalRolesAtStart[role] !== latestGlobalRoles[role]) {
1678
+ rolesToPreserve.add(role);
1679
+ }
1680
+ }
1681
+ for (const role in latestGlobalRoles) {
1682
+ if (globalRolesAtStart[role] !== latestGlobalRoles[role]) {
1683
+ rolesToPreserve.add(role);
1684
+ }
1685
+ }
1686
+ if (modifiedModelRoles.length > 0 || rolesToPreserve.size > 0) {
1687
+ const currentRoles = getByPath(current, ["modelRoles"]);
1688
+ const mergedRoles: Record<string, unknown> = isRecord(currentRoles) ? { ...currentRoles } : {};
1689
+ for (const role of modifiedModelRoles) {
1690
+ if (Object.hasOwn(globalRolesAtStart, role)) {
1691
+ mergedRoles[role] = globalRolesAtStart[role];
1692
+ } else {
1693
+ delete mergedRoles[role];
1694
+ }
1695
+ }
1696
+ for (const role of rolesToPreserve) {
1697
+ if (Object.hasOwn(latestGlobalRoles, role)) {
1698
+ mergedRoles[role] = latestGlobalRoles[role];
1699
+ } else {
1700
+ delete mergedRoles[role];
1701
+ }
1702
+ }
1703
+ setByPath(current, ["modelRoles"], mergedRoles);
1704
+ }
1705
+
1655
1706
  // Update our global with any external changes we preserved
1656
1707
  this.#global = current;
1657
1708
  await Bun.write(configPath, YAML.stringify(this.#global, null, 2));
1709
+ // These pending roles were included in this write. Remove each
1710
+ // only if no newer local change arrived while Bun.write was in
1711
+ // flight; a newer value still needs the queued follow-up save.
1712
+ const globalRolesAfterWrite = this.#modelRolesFromLayer(this.#global);
1713
+ for (const role of rolesToPreserve) {
1714
+ if (latestGlobalRoles[role] === globalRolesAfterWrite[role]) {
1715
+ this.#modifiedGlobalModelRoles.delete(role);
1716
+ }
1717
+ }
1658
1718
  });
1659
1719
  } catch (error) {
1660
1720
  logger.warn("Settings: save failed", { error: String(error) });
@@ -1662,6 +1722,9 @@ export class Settings {
1662
1722
  for (const p of modifiedPaths) {
1663
1723
  this.#modified.add(p);
1664
1724
  }
1725
+ for (const role of modifiedModelRoles) {
1726
+ this.#modifiedGlobalModelRoles.add(role);
1727
+ }
1665
1728
  }
1666
1729
 
1667
1730
  this.#rebuildMerged();
package/src/dap/client.ts CHANGED
@@ -75,6 +75,10 @@ export class DapClient {
75
75
  #reverseRequestHandlers = new Map<string, DapReverseRequestHandler>();
76
76
  #adapterExited = false;
77
77
  #pendingWriteExitRejectors = new Set<() => void>();
78
+ /** Rejectors for in-flight {@link waitForEvent} calls, woken when the
79
+ * transport closes so an event that can never arrive fails fast instead of
80
+ * waiting out its own timeout. */
81
+ #eventWaiterRejectors = new Set<(error: Error) => void>();
78
82
 
79
83
  constructor(
80
84
  adapter: DapResolvedAdapter,
@@ -188,12 +192,17 @@ export class DapClient {
188
192
  });
189
193
 
190
194
  try {
191
- const { readable, writeSink, socket } = await waitForTcpTransport(
192
- host,
193
- port,
194
- socketReadyTimeoutMs ?? SOCKET_READY_TIMEOUT_MS,
195
- proc,
196
- );
195
+ // Wait for the adapter to announce it is listening on `port` before
196
+ // connecting. Without this gate the first connect can land in the
197
+ // window where a just-released reservation port still accepts
198
+ // connections (WSL2 mirrored networking, issue #6055): the transport
199
+ // then binds to a ghost of the reservation listener instead of the
200
+ // adapter. Draining stdout here also avoids a pipe-buffer deadlock —
201
+ // in tcp mode the DAP protocol flows over the socket, so nothing else
202
+ // consumes the adapter's stdout.
203
+ const readyTimeoutMs = socketReadyTimeoutMs ?? SOCKET_READY_TIMEOUT_MS;
204
+ await waitForTcpServerListening(proc, port, readyTimeoutMs);
205
+ const { readable, writeSink, socket } = await waitForTcpTransport(host, port, readyTimeoutMs, proc);
197
206
  const client = new DapClient(adapter, cwd, proc, { readable, writeSink, socket, port });
198
207
  proc.exited.then(() => client.#handleProcessExit());
199
208
  void client.#startMessageReader();
@@ -389,6 +398,7 @@ export class DapClient {
389
398
  let timeout: NodeJS.Timeout | undefined;
390
399
  const cleanup = () => {
391
400
  unsubscribe();
401
+ this.#eventWaiterRejectors.delete(closeHandler);
392
402
  if (timeout) clearTimeout(timeout);
393
403
  if (signal) {
394
404
  signal.removeEventListener("abort", abortHandler);
@@ -398,6 +408,10 @@ export class DapClient {
398
408
  cleanup();
399
409
  reject(signal?.reason instanceof Error ? signal.reason : new ToolAbortError());
400
410
  };
411
+ const closeHandler = (error: Error) => {
412
+ cleanup();
413
+ reject(error);
414
+ };
401
415
  const unsubscribe = this.onEvent(event, body => {
402
416
  const typedBody = body as TBody;
403
417
  if (predicate && !predicate(typedBody)) {
@@ -406,6 +420,7 @@ export class DapClient {
406
420
  cleanup();
407
421
  resolve(typedBody);
408
422
  });
423
+ this.#eventWaiterRejectors.add(closeHandler);
409
424
  if (signal) {
410
425
  signal.addEventListener("abort", abortHandler, { once: true });
411
426
  }
@@ -580,6 +595,7 @@ export class DapClient {
580
595
 
581
596
  const framer = new MessageFramer(this.#messageBuffer);
582
597
 
598
+ let closeError: Error | undefined;
583
599
  try {
584
600
  while (true) {
585
601
  const { done, value } = await reader.read();
@@ -619,13 +635,19 @@ export class DapClient {
619
635
  }
620
636
  }
621
637
  } catch (error) {
622
- this.#rejectPendingRequests(new Error(`DAP connection closed: ${toErrorMessage(error)}`));
638
+ closeError = new Error(`DAP connection closed: ${toErrorMessage(error)}`);
623
639
  } finally {
624
640
  // Persist any unparsed remainder so a restarted reader resumes mid-message.
625
641
  this.#messageBuffer = framer.remainder();
626
642
  reader.releaseLock();
627
643
  this.#isReading = false;
628
644
  }
645
+ // The transport is gone once the reader loop exits — on a thrown error
646
+ // or a clean stream end (a socket the peer dropped after we wrote, e.g.
647
+ // the WSL2-mirrored ghost-accept race in issue #6055). Fail every
648
+ // in-flight request and event waiter so callers see an immediate error
649
+ // instead of waiting out their own timeout.
650
+ this.#failConnection(closeError ?? new Error(`DAP connection closed: ${this.adapter.name} transport ended`));
629
651
  }
630
652
 
631
653
  #handleResponse(message: DapResponseMessage): void {
@@ -712,7 +734,19 @@ export class DapClient {
712
734
  ? `DAP adapter exited (code ${exitCode}): ${stderr}`
713
735
  : `DAP adapter exited unexpectedly (code ${exitCode})`,
714
736
  );
737
+ this.#failConnection(error);
738
+ }
739
+
740
+ /** Reject every in-flight request and wake every event waiter with `error`.
741
+ * Called when the transport dies (reader end, socket close, adapter exit)
742
+ * so nothing sits pending until its own timeout. */
743
+ #failConnection(error: Error): void {
715
744
  this.#rejectPendingRequests(error);
745
+ const waiters = Array.from(this.#eventWaiterRejectors);
746
+ this.#eventWaiterRejectors.clear();
747
+ for (const reject of waiters) {
748
+ reject(error);
749
+ }
716
750
  }
717
751
 
718
752
  #rejectPendingRequests(error: Error): void {
@@ -826,6 +860,51 @@ async function waitForTcpTransport(
826
860
  throw new Error(`TCP port ${host}:${port} was not ready after ${timeoutMs}ms`);
827
861
  }
828
862
 
863
+ /**
864
+ * Give the adapter a chance to announce it is listening on `port` before the
865
+ * first connect. vscode-js-debug prints `Debug server listening at HOST:PORT`
866
+ * to stdout from inside its `listen()` callback; waiting for the port to appear
867
+ * there means we only connect once the child genuinely owns the reserved port,
868
+ * which closes the WSL2-mirrored ghost-accept window (issue #6055) at its root.
869
+ *
870
+ * Best-effort: resolves on the banner, on process exit, or on timeout — the
871
+ * subsequent connect loop and `proc.exitCode` checks surface real failures, so
872
+ * an adapter that never prints a banner still proceeds (just without the gate).
873
+ * Also drains stdout for the wait's duration: in tcp mode the DAP protocol
874
+ * flows over the socket, so nothing else consumes the adapter's stdout.
875
+ *
876
+ * Exported so tests can drive the gate deterministically with a synthetic stdout.
877
+ */
878
+ export async function waitForTcpServerListening(
879
+ proc: { stdout: ReadableStream<Uint8Array>; exitCode: number | null },
880
+ port: number,
881
+ timeoutMs: number,
882
+ ): Promise<void> {
883
+ const ready = Promise.withResolvers<void>();
884
+ const portText = String(port);
885
+ void (async () => {
886
+ try {
887
+ const decoder = new TextDecoder();
888
+ let buffered = "";
889
+ for await (const chunk of proc.stdout) {
890
+ buffered += decoder.decode(chunk, { stream: true });
891
+ if (buffered.includes(portText)) {
892
+ ready.resolve();
893
+ }
894
+ // Keep only the tail relevant for banner matching so a chatty
895
+ // adapter cannot grow this buffer without bound.
896
+ if (buffered.length > 4096) {
897
+ buffered = buffered.slice(-1024);
898
+ }
899
+ }
900
+ } catch {
901
+ /* stdout errored — the connect loop surfaces the real failure */
902
+ }
903
+ ready.resolve();
904
+ })();
905
+ await Promise.race([ready.promise, Bun.sleep(timeoutMs)]);
906
+ }
907
+
829
908
  interface SocketTransport {
830
909
  readable: ReadableStream<Uint8Array>;
831
910
  writeSink: DapWriteSink;
package/src/edit/diff.ts CHANGED
@@ -4,7 +4,7 @@
4
4
  * Provides diff string generation and the replace-mode edit logic
5
5
  * used when not in patch mode.
6
6
  */
7
- import * as Diff from "diff";
7
+ import { diffLines, structuredPatchHunks } from "@oh-my-pi/pi-natives";
8
8
  import { resolveToCwd } from "../tools/path-utils";
9
9
  import { type BlockContextSource, findBlockContextLines } from "../utils/block-context";
10
10
  import { DEFAULT_FUZZY_THRESHOLD, EditMatchError, findMatch } from "./modes/replace";
@@ -235,7 +235,7 @@ export function generateDiffString(
235
235
  contextLines = 2,
236
236
  source: BlockContextSource = {},
237
237
  ): DiffResult {
238
- const parts = Diff.diffLines(oldContent, newContent);
238
+ const parts = diffLines(oldContent, newContent);
239
239
  const output: string[] = [];
240
240
 
241
241
  let oldLineNum = 1;
@@ -373,10 +373,10 @@ export function generateUnifiedDiffString(
373
373
  contextLines = 3,
374
374
  source: BlockContextSource = {},
375
375
  ): DiffResult {
376
- const patch = Diff.structuredPatch("", "", oldContent, newContent, "", "", { context: contextLines });
376
+ const hunks = structuredPatchHunks(oldContent, newContent, contextLines);
377
377
  const output: string[] = [];
378
378
  let firstChangedLine: number | undefined;
379
- for (const hunk of patch.hunks) {
379
+ for (const hunk of hunks) {
380
380
  output.push(`@@ -${hunk.oldStart},${hunk.oldLines} +${hunk.newStart},${hunk.newLines} @@`);
381
381
  let oldLine = hunk.oldStart;
382
382
  let newLine = hunk.newStart;
@@ -153,7 +153,7 @@ if "__omp_prelude_loaded__" not in globals():
153
153
  """Read task/agent output by ID. Returns text or JSON depending on format.
154
154
 
155
155
  Args:
156
- *ids: Output IDs to read (e.g., 'explore_0', 'reviewer_1')
156
+ *ids: Output IDs to read (e.g., 'scout_0', 'reviewer_1')
157
157
  format: 'raw' (default), 'json' (dict with metadata), 'stripped' (no ANSI)
158
158
  query: jq-like query for JSON outputs (e.g., '.endpoints[0].file')
159
159
  offset: Line number to start reading from (1-indexed)
@@ -164,11 +164,11 @@ if "__omp_prelude_loaded__" not in globals():
164
164
  Multiple IDs: list of dict with 'id' and 'content'/'data' keys
165
165
 
166
166
  Examples:
167
- output('explore_0') # Read as raw text
167
+ output('scout_0') # Read as raw text
168
168
  output('reviewer_0', format='json') # Read with metadata
169
- output('explore_0', query='.files[0]') # Extract JSON field
170
- output('explore_0', offset=10, limit=20) # Lines 10-29
171
- output('explore_0', 'reviewer_1') # Read multiple outputs
169
+ output('scout_0', query='.files[0]') # Extract JSON field
170
+ output('scout_0', offset=10, limit=20) # Lines 10-29
171
+ output('scout_0', 'reviewer_1') # Read multiple outputs
172
172
  """
173
173
  # Prefer PI_ARTIFACTS_DIR so subagents resolve through the parent's
174
174
  # shared artifacts dir; fall back to deriving from PI_SESSION_FILE