@oh-my-pi/pi-coding-agent 17.0.4 → 17.0.5

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 (112) hide show
  1. package/CHANGELOG.md +86 -43
  2. package/dist/cli.js +3540 -3521
  3. package/dist/types/advisor/advise-tool.d.ts +3 -0
  4. package/dist/types/advisor/runtime.d.ts +7 -1
  5. package/dist/types/async/job-manager.d.ts +2 -0
  6. package/dist/types/config/model-registry.d.ts +7 -0
  7. package/dist/types/config/model-resolver.d.ts +8 -0
  8. package/dist/types/config/models-config-schema.d.ts +10 -0
  9. package/dist/types/config/models-config.d.ts +6 -0
  10. package/dist/types/dap/client.d.ts +10 -0
  11. package/dist/types/dap/types.d.ts +6 -5
  12. package/dist/types/extensibility/extensions/runner.d.ts +4 -2
  13. package/dist/types/extensibility/legacy-pi-coding-agent-shim.d.ts +16 -0
  14. package/dist/types/launch/spawn-options.d.ts +10 -0
  15. package/dist/types/launch/spawn-options.test.d.ts +1 -0
  16. package/dist/types/modes/components/status-line/component.d.ts +2 -3
  17. package/dist/types/modes/components/status-line/types.d.ts +3 -1
  18. package/dist/types/modes/components/tool-execution.d.ts +2 -0
  19. package/dist/types/modes/components/transcript-container.d.ts +4 -3
  20. package/dist/types/modes/components/tree-selector.d.ts +6 -2
  21. package/dist/types/modes/interactive-mode.d.ts +2 -0
  22. package/dist/types/modes/print-mode.d.ts +4 -0
  23. package/dist/types/modes/rpc/rpc-input.d.ts +5 -0
  24. package/dist/types/modes/rpc/rpc-mode.d.ts +1 -1
  25. package/dist/types/modes/types.d.ts +2 -0
  26. package/dist/types/sdk.d.ts +2 -0
  27. package/dist/types/session/agent-session.d.ts +19 -1
  28. package/dist/types/session/messages.d.ts +6 -0
  29. package/dist/types/tools/browser/aria/aria-snapshot.d.ts +17 -7
  30. package/dist/types/tools/browser/cmux/socket-client.d.ts +2 -0
  31. package/dist/types/tools/browser/tab-protocol.d.ts +2 -0
  32. package/dist/types/tools/browser/tab-worker.d.ts +2 -0
  33. package/dist/types/tools/gh.d.ts +5 -3
  34. package/dist/types/tools/hub/index.d.ts +1 -1
  35. package/dist/types/tools/hub/jobs.d.ts +1 -0
  36. package/dist/types/tools/hub/types.d.ts +2 -0
  37. package/dist/types/tools/tool-timeouts.d.ts +1 -1
  38. package/dist/types/utils/git.d.ts +33 -0
  39. package/dist/types/web/search/providers/codex.d.ts +1 -1
  40. package/package.json +12 -12
  41. package/src/advisor/__tests__/advisor.test.ts +150 -0
  42. package/src/advisor/advise-tool.ts +4 -0
  43. package/src/advisor/runtime.ts +38 -14
  44. package/src/async/job-manager.ts +3 -0
  45. package/src/cli/bench-cli.ts +8 -2
  46. package/src/cli/dry-balance-cli.ts +1 -0
  47. package/src/config/model-registry.ts +89 -8
  48. package/src/config/model-resolver.ts +78 -14
  49. package/src/config/models-config-schema.ts +1 -0
  50. package/src/config/settings.ts +3 -1
  51. package/src/dap/client.ts +168 -1
  52. package/src/dap/config.ts +51 -1
  53. package/src/dap/session.ts +575 -234
  54. package/src/dap/types.ts +6 -5
  55. package/src/discovery/agents.ts +2 -2
  56. package/src/extensibility/extensions/runner.ts +6 -4
  57. package/src/extensibility/legacy-pi-coding-agent-shim.ts +26 -0
  58. package/src/launch/broker.ts +34 -31
  59. package/src/launch/client.ts +7 -1
  60. package/src/launch/spawn-options.test.ts +31 -0
  61. package/src/launch/spawn-options.ts +17 -0
  62. package/src/lsp/types.ts +5 -1
  63. package/src/main.ts +17 -4
  64. package/src/mcp/transports/stdio.test.ts +9 -1
  65. package/src/modes/components/ask-dialog.ts +137 -73
  66. package/src/modes/components/status-line/component.ts +2 -2
  67. package/src/modes/components/status-line/segments.ts +20 -3
  68. package/src/modes/components/status-line/types.ts +3 -1
  69. package/src/modes/components/tool-execution.ts +5 -0
  70. package/src/modes/components/transcript-container.ts +23 -122
  71. package/src/modes/components/tree-selector.ts +10 -4
  72. package/src/modes/controllers/event-controller.ts +1 -2
  73. package/src/modes/controllers/input-controller.ts +1 -1
  74. package/src/modes/controllers/selector-controller.ts +29 -8
  75. package/src/modes/interactive-mode.ts +40 -8
  76. package/src/modes/noninteractive-dispose.test.ts +12 -1
  77. package/src/modes/print-mode.ts +21 -9
  78. package/src/modes/rpc/rpc-input.ts +38 -0
  79. package/src/modes/rpc/rpc-mode.ts +7 -2
  80. package/src/modes/setup-wizard/scenes/sign-in.ts +3 -1
  81. package/src/modes/types.ts +2 -0
  82. package/src/modes/utils/ui-helpers.ts +3 -2
  83. package/src/prompts/tools/browser.md +3 -2
  84. package/src/prompts/tools/debug.md +2 -7
  85. package/src/prompts/tools/github.md +6 -1
  86. package/src/prompts/tools/hub.md +4 -2
  87. package/src/prompts/tools/task-async-contract.md +1 -0
  88. package/src/prompts/tools/task.md +9 -2
  89. package/src/sdk.ts +38 -6
  90. package/src/session/agent-session.ts +395 -162
  91. package/src/session/messages.test.ts +91 -0
  92. package/src/session/messages.ts +248 -110
  93. package/src/slash-commands/builtin-registry.ts +1 -0
  94. package/src/task/executor.ts +59 -33
  95. package/src/task/index.ts +46 -9
  96. package/src/task/worktree.ts +10 -0
  97. package/src/tools/browser/aria/aria-snapshot.ts +36 -8
  98. package/src/tools/browser/cmux/cmux-tab.ts +2 -1
  99. package/src/tools/browser/cmux/socket-client.ts +139 -3
  100. package/src/tools/browser/run-cancellation.ts +4 -0
  101. package/src/tools/browser/tab-protocol.ts +2 -0
  102. package/src/tools/browser/tab-supervisor.ts +21 -11
  103. package/src/tools/browser/tab-worker.ts +199 -33
  104. package/src/tools/debug.ts +3 -0
  105. package/src/tools/gh.ts +40 -2
  106. package/src/tools/hub/index.ts +4 -1
  107. package/src/tools/hub/jobs.ts +42 -1
  108. package/src/tools/hub/types.ts +2 -0
  109. package/src/tools/tool-timeouts.ts +1 -1
  110. package/src/utils/git.ts +237 -0
  111. package/src/web/search/index.ts +9 -5
  112. package/src/web/search/providers/codex.ts +195 -99
@@ -857,6 +857,18 @@ export class ModelRegistry {
857
857
  }
858
858
  }
859
859
  await this.#refreshRuntimeDiscoveries(strategy, new Set([providerId]));
860
+ // #reloadStaticModels above may have rebuilt #models from static sources,
861
+ // dropping models previously discovered by OTHER runtime providers (their
862
+ // fetchDynamicModels results live only in #models + the SQLite cache, not
863
+ // in #loadModels' static inputs). Restore them from cache with the default
864
+ // online-if-uncached strategy: no network while their cached row is
865
+ // fresh, so the scoped refresh above stays the only forced fetch.
866
+ const otherRuntimeProviderIds = new Set(
867
+ [...this.#runtimeModelManagers.keys()].filter(runtimeId => runtimeId !== providerId),
868
+ );
869
+ if (otherRuntimeProviderIds.size > 0) {
870
+ await this.#refreshRuntimeDiscoveries("online-if-uncached", otherRuntimeProviderIds);
871
+ }
860
872
  }
861
873
 
862
874
  /**
@@ -1105,9 +1117,33 @@ export class ModelRegistry {
1105
1117
  if (cache.fresh && cache.authoritative) {
1106
1118
  authoritativeFreshProviders.add(providerId);
1107
1119
  }
1108
- const models = cache.models.map(model =>
1109
- model.provider === providerId ? model : { ...model, provider: providerId },
1110
- );
1120
+ // The v10 model cache never persists request headers (#5780): restore
1121
+ // them from the bundled static catalog, and drop cached rows whose
1122
+ // headers cannot be rebuilt so the bundled fallback (which still
1123
+ // carries its headers) wins the startup merge instead of a cached
1124
+ // model with required transport headers missing.
1125
+ const omittedHeaderIds = new Set(cache.headerOmittedModelIds);
1126
+ const unrestorableHeaderIds = new Set(cache.unrestorableHeaderModelIds);
1127
+ const bundledById =
1128
+ omittedHeaderIds.size > 0
1129
+ ? new Map(
1130
+ (getBundledModels(providerId as Parameters<typeof getBundledModels>[0]) as Model<Api>[]).map(
1131
+ bundledModel => [bundledModel.id, bundledModel],
1132
+ ),
1133
+ )
1134
+ : undefined;
1135
+ const models: ModelSpec<Api>[] = [];
1136
+ for (const cachedModel of cache.models) {
1137
+ const spec = cachedModel.provider === providerId ? cachedModel : { ...cachedModel, provider: providerId };
1138
+ if (!omittedHeaderIds.has(spec.id)) {
1139
+ models.push(spec);
1140
+ continue;
1141
+ }
1142
+ if (unrestorableHeaderIds.has(spec.id)) continue;
1143
+ const bundledHeaders = bundledById?.get(spec.id)?.headers;
1144
+ if (!bundledHeaders) continue;
1145
+ models.push({ ...spec, headers: bundledHeaders });
1146
+ }
1111
1147
  const providerOverride = this.#providerOverrides.get(providerId);
1112
1148
  const withTransport = providerOverride
1113
1149
  ? models.map(model => this.#applyProviderTransportOverride(model, providerOverride))
@@ -1145,13 +1181,20 @@ export class ModelRegistry {
1145
1181
  continue;
1146
1182
  }
1147
1183
  const configStale = this.#isDiscoveryCacheOlderThanModelsConfig(cache.updatedAt);
1184
+ // Cached rows never persist headers (#5780); models that had live
1185
+ // headers cannot be rebuilt here, so exclude them and mark the
1186
+ // discovery stale to force a refetch instead of returning models
1187
+ // missing required transport headers.
1188
+ const omittedHeaderIds = new Set(cache.headerOmittedModelIds);
1189
+ const usableCacheModels =
1190
+ omittedHeaderIds.size > 0 ? cache.models.filter(model => !omittedHeaderIds.has(model.id)) : cache.models;
1148
1191
  const models = this.#applyProviderModelOverrides(
1149
1192
  providerConfig.provider,
1150
1193
  this.#normalizeDiscoverableModels(
1151
1194
  providerConfig,
1152
1195
  this.#applyProviderCompat(
1153
1196
  providerConfig.compat,
1154
- cache.models.map(model => buildModel(model)),
1197
+ usableCacheModels.map(model => buildModel(model)),
1155
1198
  ),
1156
1199
  ),
1157
1200
  );
@@ -1160,7 +1203,12 @@ export class ModelRegistry {
1160
1203
  provider: providerConfig.provider,
1161
1204
  status: "cached",
1162
1205
  optional: providerConfig.optional ?? false,
1163
- stale: providerConfig.discovery.type === "llama.cpp" || !cache.fresh || !cache.authoritative || configStale,
1206
+ stale:
1207
+ providerConfig.discovery.type === "llama.cpp" ||
1208
+ !cache.fresh ||
1209
+ !cache.authoritative ||
1210
+ configStale ||
1211
+ omittedHeaderIds.size > 0,
1164
1212
  fetchedAt: cache.updatedAt,
1165
1213
  models: models.map(model => model.id),
1166
1214
  });
@@ -1593,6 +1641,7 @@ export class ModelRegistry {
1593
1641
  providerId: string,
1594
1642
  strategy: ModelRefreshStrategy,
1595
1643
  cacheProviderId: string,
1644
+ authoritative: boolean,
1596
1645
  ): Promise<string | undefined> {
1597
1646
  const peekedKey = await this.#peekApiKeyForProvider(providerId);
1598
1647
  if (isAuthenticated(peekedKey) || strategy === "offline") {
@@ -1602,7 +1651,13 @@ export class ModelRegistry {
1602
1651
  if (oauthCredentials.length === 0) {
1603
1652
  return peekedKey;
1604
1653
  }
1605
- if (strategy === "online-if-uncached") {
1654
+ // Authoritative providers prune bundled models only when their manager is
1655
+ // actually constructed, which needs an authenticated key. A fresh cache does
1656
+ // not let us skip the refresh here: with an expired OAuth token peekedKey is
1657
+ // undefined, the manager is never added, and stale bundled models survive the
1658
+ // full cache TTL. So only take the no-refresh shortcut for non-authoritative
1659
+ // providers, whose bundled models stay visible regardless.
1660
+ if (strategy === "online-if-uncached" && !authoritative) {
1606
1661
  // Mirror shouldFetchRemoteSources: built-in managers use the catalog's
1607
1662
  // default TTL, so only refresh when the manager will actually fetch.
1608
1663
  const cache = readModelCache<Api>(
@@ -1634,11 +1689,13 @@ export class ModelRegistry {
1634
1689
  ): Promise<ModelManagerOptions<Api>[]> {
1635
1690
  const specialProviderDescriptors: Array<{
1636
1691
  providerId: string;
1692
+ authoritative: boolean;
1637
1693
  resolveKey: (value: string | undefined) => string | undefined;
1638
1694
  createOptions: (key: string) => ModelManagerOptions<Api>;
1639
1695
  }> = [
1640
1696
  {
1641
1697
  providerId: "google-antigravity",
1698
+ authoritative: false,
1642
1699
  resolveKey: extractGoogleOAuthToken,
1643
1700
  createOptions: oauthToken =>
1644
1701
  googleAntigravityModelManagerOptions({
@@ -1649,6 +1706,7 @@ export class ModelRegistry {
1649
1706
  },
1650
1707
  {
1651
1708
  providerId: "google-gemini-cli",
1709
+ authoritative: false,
1652
1710
  resolveKey: extractGoogleOAuthToken,
1653
1711
  createOptions: oauthToken =>
1654
1712
  googleGeminiCliModelManagerOptions({
@@ -1659,12 +1717,14 @@ export class ModelRegistry {
1659
1717
  },
1660
1718
  {
1661
1719
  providerId: "openai-codex",
1720
+ authoritative: true,
1662
1721
  resolveKey: value => value,
1663
1722
  createOptions: accessToken => {
1664
1723
  const accountId = resolveOAuthAccountIdForAccessToken(this.authStorage, "openai-codex", accessToken);
1665
1724
  return openaiCodexModelManagerOptions({
1666
1725
  accessToken,
1667
1726
  accountId,
1727
+ fetch: this.#fetch,
1668
1728
  });
1669
1729
  },
1670
1730
  },
@@ -1689,12 +1749,22 @@ export class ModelRegistry {
1689
1749
  const cacheProviderId =
1690
1750
  descriptor.createModelManagerOptions({ baseUrl: discoveryBaseUrl, fetch: this.#fetch })
1691
1751
  .cacheProviderId ?? descriptor.providerId;
1692
- return this.#resolveBuiltInDiscoveryApiKey(descriptor.providerId, strategy, cacheProviderId);
1752
+ return this.#resolveBuiltInDiscoveryApiKey(
1753
+ descriptor.providerId,
1754
+ strategy,
1755
+ cacheProviderId,
1756
+ descriptor.dynamicModelsAuthoritative ?? false,
1757
+ );
1693
1758
  }),
1694
1759
  );
1695
1760
  const specialKeys = await Promise.all(
1696
1761
  enabledSpecialProviderDescriptors.map(descriptor =>
1697
- this.#resolveBuiltInDiscoveryApiKey(descriptor.providerId, strategy, descriptor.providerId),
1762
+ this.#resolveBuiltInDiscoveryApiKey(
1763
+ descriptor.providerId,
1764
+ strategy,
1765
+ descriptor.providerId,
1766
+ descriptor.authoritative,
1767
+ ),
1698
1768
  ),
1699
1769
  );
1700
1770
  const options: ModelManagerOptions<Api>[] = [];
@@ -1986,6 +2056,17 @@ export class ModelRegistry {
1986
2056
  );
1987
2057
  }
1988
2058
 
2059
+ /**
2060
+ * Whether the provider's configured API key is resolved from a command.
2061
+ *
2062
+ * Callers use this to distinguish the registry's command-first resolver
2063
+ * path from lower-priority credentials in {@link authStorage}.
2064
+ */
2065
+ hasCommandBackedApiKey(provider: string): boolean {
2066
+ const keyConfig = this.#customProviderApiKeys.get(provider);
2067
+ return isCommandConfigValue(keyConfig);
2068
+ }
2069
+
1989
2070
  getDiscoverableProviders(): string[] {
1990
2071
  const disabledProviders = getDisabledProviderIdsFromSettings();
1991
2072
  return this.#discoverableProviders
@@ -1138,6 +1138,8 @@ export function resolveAgentPrewalkPattern(options: AgentPrewalkResolutionOption
1138
1138
  export interface ResolvedModelRoleValue {
1139
1139
  model: Model<Api> | undefined;
1140
1140
  thinkingLevel?: ConfiguredThinkingLevel;
1141
+ /** matchedPatternIndex identifies the first configured pattern that matched an available model. */
1142
+ matchedPatternIndex?: number;
1141
1143
  explicitThinkingLevel: boolean;
1142
1144
  warning: string | undefined;
1143
1145
  }
@@ -1167,11 +1169,12 @@ export function resolveModelRoleValue(
1167
1169
  // models) once and reuse it across every fallback pattern instead of
1168
1170
  // rebuilding it per pattern inside parseModelPattern.
1169
1171
  const preferenceContext = buildPreferenceContext(availableModels, matchPreferences);
1170
- for (const effectivePattern of effectivePatterns) {
1172
+ for (const [patternIndex, effectivePattern] of effectivePatterns.entries()) {
1171
1173
  const resolved = matchPatternWithContext(effectivePattern, availableModels, preferenceContext);
1172
1174
  if (resolved.model) {
1173
1175
  return {
1174
1176
  model: resolved.model,
1177
+ matchedPatternIndex: patternIndex,
1175
1178
  thinkingLevel: resolved.explicitThinkingLevel
1176
1179
  ? resolved.thinkingLevel === AUTO_THINKING
1177
1180
  ? AUTO_THINKING
@@ -1598,6 +1601,10 @@ export function filterAvailableModelsByEnabledPatterns(
1598
1601
 
1599
1602
  export interface ResolveCliModelResult {
1600
1603
  model: Model<Api> | undefined;
1604
+ /** configuredPatterns is the full configured fallback chain when the selector resolves through a role. */
1605
+ configuredPatterns?: string[];
1606
+ /** configuredPatternIndex identifies the configured role pattern that matched an available model. */
1607
+ configuredPatternIndex?: number;
1601
1608
  selector?: string;
1602
1609
  thinkingLevel?: ConfiguredThinkingLevel;
1603
1610
  warning: string | undefined;
@@ -1606,6 +1613,8 @@ export interface ResolveCliModelResult {
1606
1613
 
1607
1614
  /**
1608
1615
  * Resolve a single model from CLI flags.
1616
+ *
1617
+ * Exact model names take precedence over configured role names.
1609
1618
  */
1610
1619
  export function resolveCliModel(options: {
1611
1620
  cliProvider?: string;
@@ -1630,19 +1639,6 @@ export function resolveCliModel(options: {
1630
1639
  };
1631
1640
  }
1632
1641
 
1633
- if (!cliProvider && modelRoleAliasPrefixLength(cliModel) !== undefined) {
1634
- const resolved = resolveModelRoleValue(cliModel, availableModels, { settings, matchPreferences: preferences });
1635
- if (resolved.model) {
1636
- return {
1637
- model: resolved.model,
1638
- selector: formatModelString(resolved.model),
1639
- thinkingLevel: resolved.thinkingLevel,
1640
- warning: resolved.warning,
1641
- error: undefined,
1642
- };
1643
- }
1644
- }
1645
-
1646
1642
  const providerMap = new Map<string, string>();
1647
1643
  for (const model of availableModels) {
1648
1644
  providerMap.set(model.provider.toLowerCase(), model.provider);
@@ -1682,6 +1678,73 @@ export function resolveCliModel(options: {
1682
1678
  error: undefined,
1683
1679
  };
1684
1680
  }
1681
+ const { base: exactBase, level: exactThinkingLevel } = splitThinkingSuffix(
1682
+ trimmedModel,
1683
+ -1,
1684
+ MAX_THINKING_SUFFIX_OPTIONS,
1685
+ );
1686
+ 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
+ }
1696
+ if (exactSuffixed) {
1697
+ return {
1698
+ model: exactSuffixed,
1699
+ selector: formatModelString(exactSuffixed),
1700
+ warning: undefined,
1701
+ thinkingLevel: exactThinkingLevel,
1702
+ error: undefined,
1703
+ };
1704
+ }
1705
+ }
1706
+ }
1707
+ let configuredPatterns: string[] | undefined;
1708
+ if (!cliProvider) {
1709
+ const { base: bareRoleName, level: bareRoleThinkingLevel } = splitThinkingSuffix(
1710
+ trimmedModel,
1711
+ -1,
1712
+ MAX_THINKING_SUFFIX_OPTIONS,
1713
+ );
1714
+ const roleSelector =
1715
+ modelRoleAliasPrefixLength(trimmedModel) !== undefined
1716
+ ? trimmedModel
1717
+ : settings?.getModelRole(bareRoleName) !== undefined
1718
+ ? `${formatModelRoleAlias(bareRoleName)}${bareRoleThinkingLevel ? `:${bareRoleThinkingLevel}` : ""}`
1719
+ : undefined;
1720
+ if (roleSelector) {
1721
+ configuredPatterns = resolveConfiguredModelPatterns([roleSelector], settings);
1722
+ const resolved = resolveModelRoleValue(roleSelector, availableModels, {
1723
+ settings,
1724
+ matchPreferences: preferences,
1725
+ });
1726
+ if (resolved.model) {
1727
+ return {
1728
+ model: resolved.model,
1729
+ selector: formatModelString(resolved.model),
1730
+ configuredPatterns,
1731
+ configuredPatternIndex: resolved.matchedPatternIndex,
1732
+ thinkingLevel: resolved.thinkingLevel,
1733
+ warning: resolved.warning,
1734
+ error: undefined,
1735
+ };
1736
+ }
1737
+ if (configuredPatterns && configuredPatterns.length > 0) {
1738
+ return {
1739
+ model: undefined,
1740
+ configuredPatterns,
1741
+ selector: undefined,
1742
+ thinkingLevel: undefined,
1743
+ warning: resolved.warning,
1744
+ error: `Model "${trimmedModel}" not found. Run "omp models" to see available models.`,
1745
+ };
1746
+ }
1747
+ }
1685
1748
  }
1686
1749
 
1687
1750
  let pattern = trimmedModel;
@@ -1725,6 +1788,7 @@ export function resolveCliModel(options: {
1725
1788
  const display = provider ? `${provider}/${pattern}` : cliModel;
1726
1789
  return {
1727
1790
  model: undefined,
1791
+ configuredPatterns,
1728
1792
  selector: undefined,
1729
1793
  thinkingLevel: undefined,
1730
1794
  warning,
@@ -61,6 +61,7 @@ const OpenAICompatFields = {
61
61
  "supportsImageDetailOriginal?": "boolean",
62
62
  // anthropic-messages compat flags (same `compat` slot, per-api interpretation)
63
63
  "supportsEagerToolInputStreaming?": "boolean",
64
+ "allowAnthropicHeaderOverrides?": "boolean",
64
65
  "requiresToolResultId?": "boolean",
65
66
  "replayUnsignedThinking?": "boolean",
66
67
  } as const;
@@ -355,7 +355,9 @@ export class Settings {
355
355
  this.#cwd = path.normalize(options.cwd ?? getProjectDir());
356
356
  this.#agentDir = path.normalize(options.agentDir ?? getAgentDir());
357
357
  this.#configPath = options.inMemory ? null : path.join(this.#agentDir, MAIN_CONFIG_FILENAMES[0]);
358
- this.#configFiles = options.configFiles?.map(file => path.resolve(this.#cwd, expandTilde(file))) ?? [];
358
+ const configFiles = process.env.PI_CONFIG_FILES?.split(path.delimiter).filter(Boolean) ?? [];
359
+ if (options.configFiles) configFiles.push(...options.configFiles);
360
+ this.#configFiles = configFiles.map(file => path.resolve(this.#cwd, expandTilde(file)));
359
361
  this.#persist = !options.inMemory && options.readOnly !== true;
360
362
 
361
363
  if (options.overrides) {
package/src/dap/client.ts CHANGED
@@ -55,6 +55,8 @@ export class DapClient {
55
55
  readonly adapter: DapResolvedAdapter;
56
56
  readonly cwd: string;
57
57
  readonly proc: DapClientState["proc"];
58
+ /** TCP server port reused by child DAP sessions. */
59
+ readonly port?: number;
58
60
  /** ReadableStream of DAP bytes — from proc.stdout (stdio) or a socket (socket mode). */
59
61
  readonly #readable: ReadableStream<Uint8Array>;
60
62
  /** Write sink — proc.stdin (stdio) or a socket (socket mode). */
@@ -78,7 +80,12 @@ export class DapClient {
78
80
  adapter: DapResolvedAdapter,
79
81
  cwd: string,
80
82
  proc: DapClientState["proc"],
81
- options?: { readable?: ReadableStream<Uint8Array>; writeSink?: DapWriteSink; socket?: { end(): void } },
83
+ options?: {
84
+ readable?: ReadableStream<Uint8Array>;
85
+ writeSink?: DapWriteSink;
86
+ socket?: { end(): void };
87
+ port?: number;
88
+ },
82
89
  ) {
83
90
  this.adapter = adapter;
84
91
  this.cwd = cwd;
@@ -86,6 +93,7 @@ export class DapClient {
86
93
  this.#readable = options?.readable ?? (proc.stdout as ReadableStream<Uint8Array>);
87
94
  this.#writeSink = options?.writeSink ?? proc.stdin;
88
95
  this.#socket = options?.socket;
96
+ this.port = options?.port;
89
97
  this.proc.exited.then(
90
98
  () => this.#rejectPendingWritesForExit(),
91
99
  () => this.#rejectPendingWritesForExit(),
@@ -96,6 +104,9 @@ export class DapClient {
96
104
  if (adapter.connectMode === "socket") {
97
105
  return DapClient.#spawnSocket({ adapter, cwd, socketReadyTimeoutMs });
98
106
  }
107
+ if (adapter.connectMode === "tcp") {
108
+ return DapClient.#spawnTcp({ adapter, cwd, socketReadyTimeoutMs });
109
+ }
99
110
  // Merge non-interactive env and start in a new session (detached → setsid)
100
111
  // so the adapter process tree has no controlling terminal. Without this,
101
112
  // debuggee children can reach /dev/tty and trigger SIGTTIN, suspending
@@ -118,6 +129,85 @@ export class DapClient {
118
129
  return client;
119
130
  }
120
131
 
132
+ /** Connect to another session on an existing TCP DAP server. */
133
+ static async connect({
134
+ adapter,
135
+ cwd,
136
+ host,
137
+ port,
138
+ }: {
139
+ adapter: DapResolvedAdapter;
140
+ cwd: string;
141
+ host: string;
142
+ port: number;
143
+ }): Promise<DapClient> {
144
+ const exited = Promise.withResolvers<void>();
145
+ const { readable, writeSink, socket } = await connectTcpSocket(host, port, () => exited.resolve());
146
+ const proc = {
147
+ exited: exited.promise,
148
+ exitCode: null,
149
+ stdin: { write: () => 0, flush: () => undefined },
150
+ stdout: new ReadableStream<Uint8Array>(),
151
+ stderr: new ReadableStream<Uint8Array>(),
152
+ peekStderr: () => "",
153
+ kill: () => {
154
+ exited.resolve();
155
+ return true;
156
+ },
157
+ } as unknown as DapClientState["proc"];
158
+ const client = new DapClient(adapter, cwd, proc, { readable, writeSink, socket, port });
159
+ exited.promise.then(() => client.#handleProcessExit());
160
+ void client.#startMessageReader();
161
+ return client;
162
+ }
163
+
164
+ /** Spawn an adapter that listens on a caller-selected TCP port. */
165
+ static async #spawnTcp({ adapter, cwd, socketReadyTimeoutMs }: DapSpawnOptions): Promise<DapClient> {
166
+ const host = "127.0.0.1";
167
+ const reservation = Bun.listen({
168
+ hostname: host,
169
+ port: 0,
170
+ socket: {
171
+ open() {},
172
+ data() {},
173
+ close() {},
174
+ error() {},
175
+ },
176
+ });
177
+ const port = reservation.port;
178
+ reservation.stop(true);
179
+ const args = adapter.args.map(arg => arg.replaceAll("$" + "{port}", String(port)));
180
+ const proc = ptree.spawn([adapter.resolvedCommand, ...args], {
181
+ cwd,
182
+ stdin: "pipe",
183
+ env: {
184
+ ...Bun.env,
185
+ ...NON_INTERACTIVE_ENV,
186
+ },
187
+ detached: true,
188
+ });
189
+
190
+ try {
191
+ const { readable, writeSink, socket } = await waitForTcpTransport(
192
+ host,
193
+ port,
194
+ socketReadyTimeoutMs ?? SOCKET_READY_TIMEOUT_MS,
195
+ proc,
196
+ );
197
+ const client = new DapClient(adapter, cwd, proc, { readable, writeSink, socket, port });
198
+ proc.exited.then(() => client.#handleProcessExit());
199
+ void client.#startMessageReader();
200
+ return client;
201
+ } catch (error) {
202
+ try {
203
+ proc.kill();
204
+ } catch {
205
+ /* proc may already be dead */
206
+ }
207
+ throw error;
208
+ }
209
+ }
210
+
121
211
  /**
122
212
  * Spawn a socket-mode adapter (e.g. dlv).
123
213
  * Linux: connect to a unix domain socket via --listen=unix:<path>
@@ -659,6 +749,83 @@ async function waitForCondition(
659
749
  throw new Error(`Socket not ready after ${timeoutMs}ms`);
660
750
  }
661
751
 
752
+ /** Connect once to a TCP DAP server. */
753
+ async function connectTcpSocket(host: string, port: number, onClose?: () => void): Promise<SocketTransport> {
754
+ const { promise, resolve, reject } = Promise.withResolvers<SocketTransport>();
755
+ let streamController: ReadableStreamDefaultController<Uint8Array>;
756
+ let opened = false;
757
+ const readable = new ReadableStream<Uint8Array>({
758
+ start(controller) {
759
+ streamController = controller;
760
+ },
761
+ });
762
+
763
+ void Bun.connect({
764
+ hostname: host,
765
+ port,
766
+ socket: {
767
+ open(socket) {
768
+ opened = true;
769
+ resolve({
770
+ readable,
771
+ writeSink: socketToSink(socket),
772
+ socket,
773
+ });
774
+ },
775
+ data(_socket, data) {
776
+ streamController.enqueue(new Uint8Array(data));
777
+ },
778
+ close() {
779
+ onClose?.();
780
+ if (!opened) {
781
+ reject(new Error(`Connection to TCP port ${host}:${port} closed before opening`));
782
+ }
783
+ try {
784
+ streamController.close();
785
+ } catch {
786
+ /* already closed */
787
+ }
788
+ },
789
+ error(_socket, error) {
790
+ onClose?.();
791
+ if (!opened) {
792
+ reject(error);
793
+ }
794
+ try {
795
+ streamController.error(error);
796
+ } catch {
797
+ /* already closed */
798
+ }
799
+ },
800
+ },
801
+ }).catch(error => {
802
+ onClose?.();
803
+ reject(error);
804
+ });
805
+ return promise;
806
+ }
807
+
808
+ /** Wait for a TCP DAP server and retain the first successful connection. */
809
+ async function waitForTcpTransport(
810
+ host: string,
811
+ port: number,
812
+ timeoutMs: number,
813
+ proc: { exitCode: number | null },
814
+ ): Promise<SocketTransport> {
815
+ const deadline = Date.now() + timeoutMs;
816
+ while (Date.now() < deadline) {
817
+ if (proc.exitCode !== null) {
818
+ throw new Error(`Adapter process exited before TCP port ${host}:${port} was ready`);
819
+ }
820
+ try {
821
+ return await connectTcpSocket(host, port);
822
+ } catch {
823
+ await Bun.sleep(50);
824
+ }
825
+ }
826
+ throw new Error(`TCP port ${host}:${port} was not ready after ${timeoutMs}ms`);
827
+ }
828
+
662
829
  interface SocketTransport {
663
830
  readable: ReadableStream<Uint8Array>;
664
831
  writeSink: DapWriteSink;
package/src/dap/config.ts CHANGED
@@ -10,6 +10,8 @@ import DEFAULTS from "./defaults.json" with { type: "json" };
10
10
  import type { DapAdapterConfig, DapResolvedAdapter } from "./types";
11
11
 
12
12
  const EXTENSIONLESS_DEBUGGER_ORDER: readonly string[] = ["gdb", "lldb-dap"];
13
+ const JS_DEBUG_SERVER_ENV = "JS_DEBUG_DAP_SERVER";
14
+ const DAP_PORT_ARGUMENT = "$" + "{port}";
13
15
 
14
16
  interface NormalizedConfig {
15
17
  adapters: Record<string, unknown>;
@@ -45,7 +47,7 @@ function normalizeObject(value: unknown): Record<string, unknown> {
45
47
  function normalizeAdapterConfig(config: unknown): DapAdapterConfig | null {
46
48
  if (!isRecord(config)) return null;
47
49
  if (typeof config.command !== "string" || config.command.length === 0) return null;
48
- const connectMode = config.connectMode === "socket" ? ("socket" as const) : undefined;
50
+ const connectMode = config.connectMode === "socket" || config.connectMode === "tcp" ? config.connectMode : undefined;
49
51
  return {
50
52
  command: config.command,
51
53
  args: normalizeStringArray(config.args),
@@ -184,6 +186,52 @@ function normalizeCommandForCwd(command: string, cwd: string): string {
184
186
  return command;
185
187
  }
186
188
 
189
+ function resolveJsDebugServerPath(cwd: string): string | null {
190
+ const configured = process.env[JS_DEBUG_SERVER_ENV];
191
+ const dataHome = process.env.XDG_DATA_HOME ?? path.join(os.homedir(), ".local", "share");
192
+ const candidates = [
193
+ ...(configured ? [path.resolve(cwd, configured)] : []),
194
+ path.join(dataHome, "nvim", "mason", "packages", "js-debug-adapter", "js-debug", "src", "dapDebugServer.js"),
195
+ path.join(os.homedir(), ".local", "opt", "js-debug", "src", "dapDebugServer.js"),
196
+ ];
197
+ for (const candidate of candidates) {
198
+ if (fs.existsSync(candidate)) return candidate;
199
+ }
200
+ return null;
201
+ }
202
+
203
+ function resolveDefaultJsDebugAdapter(
204
+ adapterName: string,
205
+ config: DapAdapterConfig,
206
+ cwd: string,
207
+ localRoots?: readonly string[],
208
+ ): DapResolvedAdapter | null | undefined {
209
+ if (adapterName !== "js-debug-adapter" || config.command !== "js-debug-adapter") {
210
+ return undefined;
211
+ }
212
+ const serverPath = resolveJsDebugServerPath(cwd);
213
+ if (!serverPath) return null;
214
+ const nodeCommand = resolveCommand("node", cwd, {
215
+ cache: WhichCachePolicy.Fresh,
216
+ PATH: process.env.PATH,
217
+ localRoots,
218
+ });
219
+ const resolvedCommand = nodeCommand ?? process.execPath;
220
+ return {
221
+ name: adapterName,
222
+ command: nodeCommand ? "node" : "bun",
223
+ args: [serverPath, DAP_PORT_ARGUMENT, "127.0.0.1"],
224
+ resolvedCommand,
225
+ languages: config.languages ?? [],
226
+ fileTypes: config.fileTypes ?? [],
227
+ rootMarkers: config.rootMarkers ?? [],
228
+ launchDefaults: config.launchDefaults ?? {},
229
+ attachDefaults: config.attachDefaults ?? {},
230
+ connectMode: "tcp",
231
+ acceptsDirectoryProgram: config.acceptsDirectoryProgram === true,
232
+ };
233
+ }
234
+
187
235
  function resolveAdapterFromConfig(
188
236
  adapterName: string,
189
237
  configs: Record<string, DapAdapterConfig>,
@@ -192,6 +240,8 @@ function resolveAdapterFromConfig(
192
240
  ): DapResolvedAdapter | null {
193
241
  const config = configs[adapterName];
194
242
  if (!config) return null;
243
+ const jsDebugAdapter = resolveDefaultJsDebugAdapter(adapterName, config, cwd, localRoots);
244
+ if (jsDebugAdapter !== undefined) return jsDebugAdapter;
195
245
  const normalizedCommand = normalizeCommandForCwd(config.command, cwd);
196
246
  const commandIsBare =
197
247
  !path.isAbsolute(config.command) && !config.command.includes("/") && !config.command.includes("\\");