@oh-my-pi/pi-coding-agent 16.3.14 → 16.4.0

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 (76) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/dist/cli.js +3053 -3158
  3. package/dist/types/advisor/config.d.ts +7 -0
  4. package/dist/types/cli/args.d.ts +1 -0
  5. package/dist/types/config/model-resolver.d.ts +1 -1
  6. package/dist/types/config/models-config-schema.d.ts +28 -15
  7. package/dist/types/config/models-config.d.ts +6 -0
  8. package/dist/types/config/settings-schema.d.ts +7 -2
  9. package/dist/types/dap/config.d.ts +13 -1
  10. package/dist/types/extensibility/extensions/types.d.ts +6 -0
  11. package/dist/types/lsp/config.d.ts +7 -1
  12. package/dist/types/main.d.ts +2 -0
  13. package/dist/types/mcp/transports/stdio.d.ts +8 -3
  14. package/dist/types/modes/components/hook-selector.d.ts +2 -0
  15. package/dist/types/modes/theme/defaults/index.d.ts +2 -0
  16. package/dist/types/modes/theme/theme.d.ts +3 -2
  17. package/dist/types/sdk.d.ts +2 -0
  18. package/dist/types/session/agent-session.d.ts +5 -3
  19. package/dist/types/session/session-context.test.d.ts +1 -0
  20. package/dist/types/session/session-entries.d.ts +4 -0
  21. package/dist/types/thinking.d.ts +6 -3
  22. package/dist/types/utils/file-display-mode.d.ts +1 -1
  23. package/package.json +12 -12
  24. package/src/advisor/__tests__/config.test.ts +84 -0
  25. package/src/advisor/config.ts +28 -0
  26. package/src/cli/args.ts +1 -0
  27. package/src/cli/flag-tables.ts +3 -0
  28. package/src/config/model-registry.ts +1 -1
  29. package/src/config/model-resolver.ts +14 -12
  30. package/src/config/models-config-schema.ts +3 -2
  31. package/src/config/settings-schema.ts +5 -2
  32. package/src/dap/config.ts +136 -39
  33. package/src/dap/defaults.json +1 -1
  34. package/src/eval/js/shared/runtime.ts +20 -4
  35. package/src/eval/js/worker-core.ts +9 -2
  36. package/src/exec/bash-executor.ts +8 -1
  37. package/src/extensibility/extensions/types.ts +6 -0
  38. package/src/internal-urls/docs-index.generated.txt +1 -1
  39. package/src/lsp/config.ts +27 -13
  40. package/src/lsp/index.ts +114 -29
  41. package/src/main.ts +21 -1
  42. package/src/mcp/transports/stdio.test.ts +12 -0
  43. package/src/mcp/transports/stdio.ts +14 -8
  44. package/src/modes/components/hook-selector.ts +9 -2
  45. package/src/modes/controllers/extension-ui-controller.ts +3 -0
  46. package/src/modes/controllers/input-controller.ts +4 -4
  47. package/src/modes/controllers/selector-controller.ts +1 -1
  48. package/src/modes/theme/defaults/dark-poimandres.json +6 -5
  49. package/src/modes/theme/defaults/light-poimandres.json +6 -5
  50. package/src/modes/theme/theme-schema.json +6 -2
  51. package/src/modes/theme/theme.ts +24 -18
  52. package/src/prompts/agents/init.md +1 -1
  53. package/src/prompts/agents/plan.md +2 -2
  54. package/src/prompts/agents/reviewer.md +1 -1
  55. package/src/prompts/agents/{explore.md → scout.md} +3 -2
  56. package/src/prompts/system/plan-mode-active.md +2 -2
  57. package/src/prompts/system/system-prompt.md +5 -3
  58. package/src/prompts/tools/ast-grep.md +1 -1
  59. package/src/prompts/tools/debug.md +4 -4
  60. package/src/prompts/tools/grep.md +1 -1
  61. package/src/prompts/tools/task.md +2 -2
  62. package/src/sdk.ts +68 -45
  63. package/src/session/agent-session.ts +215 -64
  64. package/src/session/session-context.test.ts +83 -0
  65. package/src/session/session-context.ts +1 -0
  66. package/src/session/session-entries.ts +4 -0
  67. package/src/session/session-manager.ts +9 -1
  68. package/src/system-prompt.test.ts +20 -11
  69. package/src/task/agents.ts +2 -5
  70. package/src/task/executor.ts +111 -74
  71. package/src/thinking.ts +16 -7
  72. package/src/tools/ask.ts +51 -13
  73. package/src/tools/browser/cmux/cmux-tab.ts +21 -12
  74. package/src/tools/debug.ts +41 -11
  75. package/src/utils/file-display-mode.ts +1 -1
  76. package/src/prompts/agents/tester.md +0 -111
@@ -5,6 +5,7 @@ import * as path from "node:path";
5
5
  import {
6
6
  advisorConfigFilePath,
7
7
  discoverAdvisorConfigs,
8
+ getOrCreateAdvisorProviderSessionId,
8
9
  loadWatchdogConfigFile,
9
10
  resolveAdvisorConfigEditPath,
10
11
  saveWatchdogConfigFile,
@@ -86,6 +87,89 @@ describe("slugifyAdvisorName", () => {
86
87
  });
87
88
  });
88
89
 
90
+ describe("getOrCreateAdvisorProviderSessionId", () => {
91
+ const primarySessionA = "018f8f5d-75b0-7cc6-8a6f-2f1c0b8e4c9d";
92
+ const primarySessionB = "018f8f5d-75b1-7cc6-8a6f-2f1c0b8e4c9d";
93
+
94
+ it("returns the generated UUIDv7 instead of a local advisor label", () => {
95
+ const generated = "0193c8f2-7b1a-7c4d-9e2f-123456789abc";
96
+
97
+ const providerSessionId = getOrCreateAdvisorProviderSessionId(
98
+ new Map<string, string>(),
99
+ primarySessionA,
100
+ "security-advisor",
101
+ () => generated,
102
+ );
103
+
104
+ expect(providerSessionId).toBe(generated);
105
+ expect(providerSessionId).not.toContain("-advisor");
106
+ });
107
+
108
+ it("reuses the same generated UUIDv7 for repeated calls with the same primary session and slug", () => {
109
+ const generatedIds = ["0193c8f2-7b1a-7c4d-9e2f-123456789abc", "0193c8f2-7b1b-7c4d-9e2f-123456789abc"];
110
+ let nextGeneratedIdIndex = 0;
111
+ const ids = new Map<string, string>();
112
+
113
+ const first = getOrCreateAdvisorProviderSessionId(ids, primarySessionA, "architecture", () => {
114
+ const generated = generatedIds[nextGeneratedIdIndex];
115
+ if (!generated) throw new Error("unexpected generator call");
116
+ nextGeneratedIdIndex += 1;
117
+ return generated;
118
+ });
119
+ const second = getOrCreateAdvisorProviderSessionId(ids, primarySessionA, "architecture", () => {
120
+ const generated = generatedIds[nextGeneratedIdIndex];
121
+ if (!generated) throw new Error("unexpected generator call");
122
+ nextGeneratedIdIndex += 1;
123
+ return generated;
124
+ });
125
+
126
+ expect(first).toBe(generatedIds[0]);
127
+ expect(second).toBe(generatedIds[0]);
128
+ expect(nextGeneratedIdIndex).toBe(1);
129
+ });
130
+
131
+ it("creates distinct UUIDv7 values for different advisor slugs or primary sessions", () => {
132
+ const generatedIds = [
133
+ "0193c8f2-7b1a-7c4d-9e2f-123456789abc",
134
+ "0193c8f2-7b1b-7c4d-9e2f-123456789abc",
135
+ "0193c8f2-7b1c-7c4d-9e2f-123456789abc",
136
+ ];
137
+ let nextGeneratedIdIndex = 0;
138
+ const ids = new Map<string, string>();
139
+ const nextGeneratedId = () => {
140
+ const generated = generatedIds[nextGeneratedIdIndex];
141
+ if (!generated) throw new Error("unexpected generator call");
142
+ nextGeneratedIdIndex += 1;
143
+ return generated;
144
+ };
145
+
146
+ const architecture = getOrCreateAdvisorProviderSessionId(ids, primarySessionA, "architecture", nextGeneratedId);
147
+ const security = getOrCreateAdvisorProviderSessionId(ids, primarySessionA, "security", nextGeneratedId);
148
+ const architectureForOtherSession = getOrCreateAdvisorProviderSessionId(
149
+ ids,
150
+ primarySessionB,
151
+ "architecture",
152
+ nextGeneratedId,
153
+ );
154
+
155
+ expect(architecture).toBe(generatedIds[0]);
156
+ expect(security).toBe(generatedIds[1]);
157
+ expect(architectureForOtherSession).toBe(generatedIds[2]);
158
+ expect(new Set([architecture, security, architectureForOtherSession]).size).toBe(3);
159
+ });
160
+
161
+ it("rejects generated values that are not UUIDv7", () => {
162
+ expect(() =>
163
+ getOrCreateAdvisorProviderSessionId(
164
+ new Map<string, string>(),
165
+ primarySessionA,
166
+ "architecture",
167
+ () => "550e8400-e29b-41d4-a716-446655440000",
168
+ ),
169
+ ).toThrow("non-UUIDv7");
170
+ });
171
+ });
172
+
89
173
  describe("WATCHDOG.yml file round-trip", () => {
90
174
  let tmp: string;
91
175
  beforeEach(async () => {
@@ -59,6 +59,34 @@ export function slugifyAdvisorName(name: string): string {
59
59
  return slug || "advisor";
60
60
  }
61
61
 
62
+ const UUID_V7_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
63
+ const ADVISOR_PROVIDER_SESSION_KEY_SEPARATOR = "\u0000";
64
+
65
+ /**
66
+ * Returns a stable provider-facing UUIDv7 for one advisor within one primary session.
67
+ *
68
+ * Codex treats `session_id`/`conversation_id` as a UUID-shaped routing identity,
69
+ * so advisor labels such as `-advisor` stay local-only.
70
+ */
71
+ export function getOrCreateAdvisorProviderSessionId(
72
+ ids: Map<string, string>,
73
+ primarySessionId: string | undefined,
74
+ slug: string,
75
+ randomSessionId: () => string = () => Bun.randomUUIDv7(),
76
+ ): string | undefined {
77
+ if (!primarySessionId) return undefined;
78
+ const key = `${primarySessionId}${ADVISOR_PROVIDER_SESSION_KEY_SEPARATOR}${slug}`;
79
+ const existing = ids.get(key);
80
+ if (existing) return existing;
81
+
82
+ const next = randomSessionId();
83
+ if (!UUID_V7_PATTERN.test(next)) {
84
+ throw new Error("Advisor provider session id generator returned a non-UUIDv7 value");
85
+ }
86
+ ids.set(key, next);
87
+ return next;
88
+ }
89
+
62
90
  /** Built tool names, for validating an advisor's `tools` list. */
63
91
  const KNOWN_TOOL_NAMES = new Set<string>(BUILTIN_TOOL_NAMES);
64
92
 
package/src/cli/args.ts CHANGED
@@ -42,6 +42,7 @@ export interface Args {
42
42
  noSession?: boolean;
43
43
  sessionDir?: string;
44
44
  providerSessionId?: string;
45
+ providerPromptCacheKey?: string;
45
46
  fork?: string;
46
47
  /** Collab link to join at startup (set by the `join` subcommand; no CLI flag). */
47
48
  join?: string;
@@ -141,6 +141,9 @@ export const STRING_SETTERS: Record<string, StringSetter> = {
141
141
  "--provider-session-id": (result, value) => {
142
142
  result.providerSessionId = value;
143
143
  },
144
+ "--prompt-cache-key": (result, value) => {
145
+ result.providerPromptCacheKey = value;
146
+ },
144
147
  "--session-dir": (result, value) => {
145
148
  result.sessionDir = value;
146
149
  },
@@ -690,7 +690,7 @@ function normalizeSuppressedSelector(
690
690
  const trimmed = selector.trim();
691
691
  if (!trimmed) return trimmed;
692
692
  const parsed = parseModelString(trimmed, {
693
- allowMaxAlias: true,
693
+ allowMaxSuffix: true,
694
694
  allowAutoAlias: true,
695
695
  isLiteralModelId: (provider, id) => hasLiveModel?.(provider, id) === true,
696
696
  });
@@ -79,23 +79,24 @@ export interface ScopedModel {
79
79
  }
80
80
 
81
81
  interface ThinkingSuffixOptions {
82
- allowMaxAlias?: boolean;
82
+ allowMaxSuffix?: boolean;
83
83
  allowAutoAlias?: boolean;
84
84
  }
85
85
 
86
86
  interface ModelStringParseOptions extends ThinkingSuffixOptions {
87
87
  isLiteralModelId?: (provider: string, id: string) => boolean;
88
88
  }
89
- // Alias-suffix recognition for the model-pattern parser: `:max` maps to xhigh
90
- // and `:auto` maps to the auto sentinel. Both are gated behind the alias flags
91
- // (and the literal-id / exact-match guards on the callers) so a real model id
92
- // ending in `:max` / `:auto` isn't silently reinterpreted as a thinking suffix.
93
- const MAX_THINKING_SUFFIX_OPTIONS: ThinkingSuffixOptions = { allowMaxAlias: true, allowAutoAlias: true };
89
+ // Suffix recognition for the model-pattern parser: `:max` is a real thinking
90
+ // level and `:auto` maps to the auto sentinel. Both are gated behind flags
91
+ // (and the literal-id / exact-match guards on the callers) because real model
92
+ // ids end in `:max` (e.g. `glm-4.7:max`) an ungated split would silently
93
+ // reinterpret them as a thinking suffix.
94
+ const MAX_THINKING_SUFFIX_OPTIONS: ThinkingSuffixOptions = { allowMaxSuffix: true, allowAutoAlias: true };
94
95
 
95
96
  function parseThinkingSuffix(value: string, options?: ThinkingSuffixOptions): ConfiguredThinkingLevel | undefined {
96
97
  const level = parseThinkingLevel(value);
98
+ if (level === ThinkingLevel.Max) return options?.allowMaxSuffix === true ? level : undefined;
97
99
  if (level !== undefined) return level;
98
- if (options?.allowMaxAlias === true && value === "max") return ThinkingLevel.XHigh;
99
100
  if (options?.allowAutoAlias === true && value === AUTO_THINKING) return AUTO_THINKING;
100
101
  return undefined;
101
102
  }
@@ -104,8 +105,9 @@ function parseThinkingSuffix(value: string, options?: ThinkingSuffixOptions): Co
104
105
  * Split a trailing `:<level>` thinking selector off a model pattern.
105
106
  *
106
107
  * `level` is set when the suffix parses as a concrete thinking level (or, when
107
- * the caller opts in via `allowMaxAlias`/`allowAutoAlias`, the `:max` / `:auto`
108
- * aliases); `base` then has the suffix stripped. Otherwise `base` is the input.
108
+ * the caller opts in via `allowMaxSuffix`/`allowAutoAlias`, the guarded `:max`
109
+ * level / `:auto` sentinel); `base` then has the suffix stripped. Otherwise
110
+ * `base` is the input.
109
111
  * `minColonIndex` requires the colon to appear strictly after that index —
110
112
  * role-alias callers pass `PREFIX_MODEL_ROLE.length` so the base is at least
111
113
  * as long as the `pi/` prefix.
@@ -183,7 +185,7 @@ export function parseModelString(
183
185
  // Strip strict thinking level suffixes first (e.g. "claude-sonnet-4-6:high" -> id "claude-sonnet-4-6", thinkingLevel "high").
184
186
  const strict = splitThinkingSuffix(id);
185
187
  if (strict.level) return { provider, id: strict.base, thinkingLevel: strict.level };
186
- // `max` is a provider-facing alias for xhigh, but real model IDs can end in
188
+ // `max` is a real thinking level, but real model IDs can also end in
187
189
  // `:max`. Context-aware callers pass a literal lookup so those models win.
188
190
  const maxAlias = splitThinkingSuffix(id, -1, options);
189
191
  if (maxAlias.level) {
@@ -239,7 +241,7 @@ function getOpenRouterRouteSuffix(modelId: string): { baseId: string; suffix: st
239
241
  }
240
242
 
241
243
  const suffix = modelId.slice(colonIdx + 1).trim();
242
- // `max` is a thinking-level alias (xhigh), never an OpenRouter route suffix, so
244
+ // `max` is a thinking-level suffix, never an OpenRouter route suffix, so
243
245
  // `openrouter/<id>:max` falls through to the max-aware selector split instead of
244
246
  // being cloned into a literal `<id>:max` model id with the reasoning level lost.
245
247
  if (!suffix || parseThinkingSuffix(suffix, MAX_THINKING_SUFFIX_OPTIONS)) {
@@ -766,7 +768,7 @@ function parseModelPatternWithContext(
766
768
 
767
769
  // No match - try stripping a valid thinking suffix and recursing.
768
770
  // `max` is accepted only after the full pattern failed, so literal model IDs
769
- // ending in `:max` keep winning over the alias.
771
+ // ending in `:max` keep winning over the thinking suffix.
770
772
  const { base, level } = splitThinkingSuffix(pattern, -1, MAX_THINKING_SUFFIX_OPTIONS);
771
773
  if (level) {
772
774
  const result = parseModelPatternWithContext(base, availableModels, context, options);
@@ -23,6 +23,7 @@ const ReasoningEffortMapSchema = type({
23
23
  "medium?": "string",
24
24
  "high?": "string",
25
25
  "xhigh?": "string",
26
+ "max?": "string",
26
27
  });
27
28
 
28
29
  const OpenAICompatFields = {
@@ -74,13 +75,13 @@ const ApiSchema = type(
74
75
  '"openai-completions" | "openai-responses" | "openai-codex-responses" | "azure-openai-responses" | "anthropic-messages" | "google-generative-ai" | "google-gemini-cli" | "google-vertex"',
75
76
  );
76
77
 
77
- const EffortSchema = type('"minimal" | "low" | "medium" | "high" | "xhigh"');
78
+ const EffortSchema = type('"minimal" | "low" | "medium" | "high" | "xhigh" | "max"');
78
79
 
79
80
  const ThinkingControlModeSchema = type(
80
81
  '"effort" | "budget" | "google-level" | "anthropic-adaptive" | "anthropic-budget-effort"',
81
82
  );
82
83
 
83
- const EFFORT_ORDER = ["minimal", "low", "medium", "high", "xhigh"] as const;
84
+ const EFFORT_ORDER = ["minimal", "low", "medium", "high", "xhigh", "max"] as const;
84
85
 
85
86
  /**
86
87
  * Accepts the canonical `efforts` vocabulary plus the legacy
@@ -936,7 +936,7 @@ export const SETTINGS_SCHEMA = {
936
936
  // Reasoning and prompts
937
937
  defaultThinkingLevel: {
938
938
  type: "enum",
939
- values: [...THINKING_EFFORTS, AUTO_THINKING, "max"],
939
+ values: [...THINKING_EFFORTS, AUTO_THINKING],
940
940
  default: "high",
941
941
  ui: {
942
942
  tab: "model",
@@ -4148,7 +4148,7 @@ export const SETTINGS_SCHEMA = {
4148
4148
  group: "Subagents",
4149
4149
  label: "Soft Subagent Request Budget",
4150
4150
  description:
4151
- "Soft per-subagent request budget (assistant requests per run). Crossing it can inject a steering notice when task.softRequestBudgetNotice is enabled; at 1.5x the budget the run is aborted gracefully, salvaging partial output. 0 disables the guard. Bundled explore/sonic agents use a lower built-in budget.",
4151
+ "Soft per-subagent request budget (assistant requests per run). Crossing it can inject a steering notice when task.softRequestBudgetNotice is enabled; at 1.5x the budget the run is aborted gracefully, salvaging partial output. 0 disables the guard. Bundled scout/sonic agents use a lower built-in budget.",
4152
4152
  options: [
4153
4153
  { value: "0", label: "Disabled" },
4154
4154
  { value: "40", label: "40 requests" },
@@ -4964,6 +4964,8 @@ export const SETTINGS_SCHEMA = {
4964
4964
  "thinkingBudgets.high": { type: "number", default: 16384 },
4965
4965
 
4966
4966
  "thinkingBudgets.xhigh": { type: "number", default: 32768 },
4967
+
4968
+ "thinkingBudgets.max": { type: "number", default: 32768 },
4967
4969
  } as const;
4968
4970
 
4969
4971
  // ═══════════════════════════════════════════════════════════════════════════
@@ -5180,6 +5182,7 @@ export interface ThinkingBudgetsSettings {
5180
5182
  medium: number;
5181
5183
  high: number;
5182
5184
  xhigh: number;
5185
+ max: number;
5183
5186
  }
5184
5187
 
5185
5188
  export interface SttSettings {
package/src/dap/config.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as os from "node:os";
3
3
  import * as path from "node:path";
4
- import { isRecord, logger } from "@oh-my-pi/pi-utils";
4
+ import { isRecord, logger, WhichCachePolicy } from "@oh-my-pi/pi-utils";
5
5
  import { YAML } from "bun";
6
6
  import { getConfigDirPaths } from "../config";
7
7
  import { getPreloadedPluginRoots } from "../discovery/helpers";
@@ -9,7 +9,7 @@ import { hasRootMarkers, resolveCommand } from "../lsp/config";
9
9
  import DEFAULTS from "./defaults.json" with { type: "json" };
10
10
  import type { DapAdapterConfig, DapResolvedAdapter } from "./types";
11
11
 
12
- const EXTENSIONLESS_DEBUGGER_ORDER = ["gdb", "lldb-dap"] as const;
12
+ const EXTENSIONLESS_DEBUGGER_ORDER: readonly string[] = ["gdb", "lldb-dap"];
13
13
 
14
14
  interface NormalizedConfig {
15
15
  adapters: Record<string, unknown>;
@@ -188,10 +188,18 @@ function resolveAdapterFromConfig(
188
188
  adapterName: string,
189
189
  configs: Record<string, DapAdapterConfig>,
190
190
  cwd: string,
191
+ localRoots?: readonly string[],
191
192
  ): DapResolvedAdapter | null {
192
193
  const config = configs[adapterName];
193
194
  if (!config) return null;
194
- const resolvedCommand = resolveCommand(normalizeCommandForCwd(config.command, cwd), cwd);
195
+ const normalizedCommand = normalizeCommandForCwd(config.command, cwd);
196
+ const commandIsBare =
197
+ !path.isAbsolute(config.command) && !config.command.includes("/") && !config.command.includes("\\");
198
+ const resolvedCommand = resolveCommand(normalizedCommand, cwd, {
199
+ cache: WhichCachePolicy.Fresh,
200
+ PATH: process.env.PATH,
201
+ localRoots: commandIsBare ? localRoots : undefined,
202
+ });
195
203
  if (!resolvedCommand) return null;
196
204
  return {
197
205
  name: adapterName,
@@ -219,33 +227,125 @@ export function getAvailableAdapters(cwd: string): DapResolvedAdapter[] {
219
227
  .filter((adapter): adapter is DapResolvedAdapter => adapter !== null);
220
228
  }
221
229
 
222
- function getMatchingAdapters(program: string, cwd: string): DapResolvedAdapter[] {
230
+ /** Launch adapter selection, including a configured adapter whose command is unavailable. */
231
+ export type LaunchAdapterSelection =
232
+ | { kind: "adapter"; adapter: DapResolvedAdapter }
233
+ | { kind: "unavailable"; adapterName: string; command: string }
234
+ | { kind: "none" };
235
+
236
+ interface LaunchAdapterCandidate {
237
+ name: string;
238
+ rootDir: string | null;
239
+ }
240
+
241
+ function findRootMarkerInLaunchAncestry(
242
+ program: string,
243
+ cwd: string,
244
+ markers: string[],
245
+ programKind: LaunchProgramKind,
246
+ ): string | null {
247
+ if (markers.length === 0) return null;
248
+ let dir = programKind === "directory" ? path.resolve(cwd, program) : path.dirname(path.resolve(cwd, program));
249
+ while (true) {
250
+ if (hasRootMarkers(dir, markers)) return dir;
251
+ const parent = path.dirname(dir);
252
+ if (parent === dir) return null;
253
+ dir = parent;
254
+ }
255
+ }
256
+
257
+ function resolveAdapterForLaunch(
258
+ adapterName: string,
259
+ configs: Record<string, DapAdapterConfig>,
260
+ cwd: string,
261
+ rootDir: string | null,
262
+ ): DapResolvedAdapter | null {
263
+ const localRoots = rootDir && rootDir !== cwd ? [rootDir, cwd] : undefined;
264
+ return resolveAdapterFromConfig(adapterName, configs, cwd, localRoots);
265
+ }
266
+
267
+ function unavailableAdapter(
268
+ candidate: LaunchAdapterCandidate,
269
+ configs: Record<string, DapAdapterConfig>,
270
+ ): LaunchAdapterSelection {
271
+ const config = configs[candidate.name];
272
+ if (!config) return { kind: "none" };
273
+ return { kind: "unavailable", adapterName: candidate.name, command: config.command };
274
+ }
275
+
276
+ function selectAutomaticLaunchAdapter(
277
+ program: string,
278
+ cwd: string,
279
+ programKind: LaunchProgramKind,
280
+ configs: Record<string, DapAdapterConfig>,
281
+ ): LaunchAdapterSelection {
223
282
  const extension = path.extname(program).toLowerCase();
224
- const available = getAvailableAdapters(cwd);
225
- if (!extension) {
226
- // For extensionless binaries, only consider native debuggers (gdb, lldb-dap)
227
- // or adapters that match by root markers. Don't silently fall back to
228
- // unrelated adapters like debugpy for a C binary.
229
- const nativeDebuggers: ReadonlySet<string> = new Set(EXTENSIONLESS_DEBUGGER_ORDER);
230
- return available.filter(
231
- adapter =>
232
- nativeDebuggers.has(adapter.name) ||
233
- (adapter.rootMarkers.length > 0 && hasRootMarkers(cwd, adapter.rootMarkers)),
234
- );
283
+ if (extension) {
284
+ const configured: LaunchAdapterCandidate[] = [];
285
+ const available: DapResolvedAdapter[] = [];
286
+ for (const name in configs) {
287
+ const config = configs[name];
288
+ if (!config || !(config.fileTypes ?? []).includes(extension)) continue;
289
+ const rootDir = findRootMarkerInLaunchAncestry(program, cwd, config.rootMarkers ?? [], programKind);
290
+ configured.push({ name, rootDir });
291
+ const adapter = resolveAdapterForLaunch(name, configs, cwd, rootDir);
292
+ if (adapter) available.push(adapter);
293
+ }
294
+ const selected = sortAdaptersForLaunch(program, cwd, programKind, available)[0];
295
+ if (selected) return { kind: "adapter", adapter: selected };
296
+ const rootMatch = configured.find(candidate => candidate.rootDir !== null);
297
+ const unavailable = rootMatch ?? configured[0];
298
+ if (unavailable) return unavailableAdapter(unavailable, configs);
235
299
  }
236
- const exactMatches = available.filter(adapter => adapter.fileTypes.includes(extension));
237
- if (exactMatches.length > 0) {
238
- return exactMatches;
300
+
301
+ const available: DapResolvedAdapter[] = [];
302
+ const rootMatches: LaunchAdapterCandidate[] = [];
303
+ const directoryMatches: LaunchAdapterCandidate[] = [];
304
+ for (const name in configs) {
305
+ const config = configs[name];
306
+ if (!config) continue;
307
+ const rootDir = findRootMarkerInLaunchAncestry(program, cwd, config.rootMarkers ?? [], programKind);
308
+ const candidate = { name, rootDir };
309
+ if (rootDir) {
310
+ rootMatches.push(candidate);
311
+ if (config.acceptsDirectoryProgram === true) directoryMatches.push(candidate);
312
+ }
313
+ if (!EXTENSIONLESS_DEBUGGER_ORDER.includes(name) && !rootDir) continue;
314
+ const adapter = resolveAdapterForLaunch(name, configs, cwd, rootDir);
315
+ if (adapter) available.push(adapter);
316
+ }
317
+
318
+ if (programKind === "directory" && directoryMatches.length > 0) {
319
+ const matchingNames = new Set(directoryMatches.map(candidate => candidate.name));
320
+ const directoryAdapters = available.filter(
321
+ adapter => adapter.acceptsDirectoryProgram && matchingNames.has(adapter.name),
322
+ );
323
+ const selected = sortAdaptersForLaunch(program, cwd, programKind, directoryAdapters)[0];
324
+ if (selected) return { kind: "adapter", adapter: selected };
325
+ const unavailable = directoryMatches[0];
326
+ return unavailable ? unavailableAdapter(unavailable, configs) : { kind: "none" };
239
327
  }
240
- return available;
328
+
329
+ const directoryAdapters =
330
+ programKind === "directory" ? available.filter(adapter => adapter.acceptsDirectoryProgram) : available;
331
+ const candidates = directoryAdapters.length > 0 ? directoryAdapters : available;
332
+ const selected = sortAdaptersForLaunch(program, cwd, programKind, candidates)[0];
333
+ if (selected) return { kind: "adapter", adapter: selected };
334
+ const unavailable = rootMatches[0];
335
+ return unavailable ? unavailableAdapter(unavailable, configs) : { kind: "none" };
241
336
  }
242
337
 
243
- function sortAdaptersForLaunch(program: string, cwd: string, adapters: DapResolvedAdapter[]): DapResolvedAdapter[] {
338
+ function sortAdaptersForLaunch(
339
+ program: string,
340
+ cwd: string,
341
+ programKind: LaunchProgramKind,
342
+ adapters: DapResolvedAdapter[],
343
+ ): DapResolvedAdapter[] {
244
344
  const extension = path.extname(program).toLowerCase();
245
345
  const rootAware = adapters.map(adapter => ({
246
346
  adapter,
247
347
  hasExtensionMatch: extension.length > 0 && adapter.fileTypes.includes(extension),
248
- hasRootMatch: adapter.rootMarkers.length > 0 && hasRootMarkers(cwd, adapter.rootMarkers),
348
+ hasRootMatch: findRootMarkerInLaunchAncestry(program, cwd, adapter.rootMarkers, programKind) !== null,
249
349
  }));
250
350
  rootAware.sort((left, right) => {
251
351
  if (left.hasExtensionMatch !== right.hasExtensionMatch) {
@@ -254,36 +354,33 @@ function sortAdaptersForLaunch(program: string, cwd: string, adapters: DapResolv
254
354
  if (left.hasRootMatch !== right.hasRootMatch) {
255
355
  return left.hasRootMatch ? -1 : 1;
256
356
  }
257
- const leftDebuggerRank = EXTENSIONLESS_DEBUGGER_ORDER.indexOf(
258
- left.adapter.name as (typeof EXTENSIONLESS_DEBUGGER_ORDER)[number],
259
- );
260
- const rightDebuggerRank = EXTENSIONLESS_DEBUGGER_ORDER.indexOf(
261
- right.adapter.name as (typeof EXTENSIONLESS_DEBUGGER_ORDER)[number],
262
- );
263
- const normalizedLeftRank = leftDebuggerRank === -1 ? Number.MAX_SAFE_INTEGER : leftDebuggerRank;
264
- const normalizedRightRank = rightDebuggerRank === -1 ? Number.MAX_SAFE_INTEGER : rightDebuggerRank;
265
- if (normalizedLeftRank !== normalizedRightRank) {
266
- return normalizedLeftRank - normalizedRightRank;
267
- }
357
+ const leftRank = EXTENSIONLESS_DEBUGGER_ORDER.indexOf(left.adapter.name);
358
+ const rightRank = EXTENSIONLESS_DEBUGGER_ORDER.indexOf(right.adapter.name);
359
+ const normalizedLeftRank = leftRank === -1 ? Number.MAX_SAFE_INTEGER : leftRank;
360
+ const normalizedRightRank = rightRank === -1 ? Number.MAX_SAFE_INTEGER : rightRank;
361
+ const rankDelta = normalizedLeftRank - normalizedRightRank;
362
+ if (rankDelta !== 0) return rankDelta;
268
363
  return left.adapter.name.localeCompare(right.adapter.name);
269
364
  });
270
365
  return rootAware.map(entry => entry.adapter);
271
366
  }
272
367
 
368
+ /** Selects a launch adapter or reports why matching configuration cannot run. */
273
369
  export function selectLaunchAdapter(
274
370
  program: string,
275
371
  cwd: string,
276
372
  adapterName?: string,
277
373
  programKind: LaunchProgramKind = "file",
278
- ): DapResolvedAdapter | null {
374
+ ): LaunchAdapterSelection {
375
+ const configs = getAdapterConfigs(cwd);
279
376
  if (adapterName) {
280
- return resolveAdapter(adapterName, cwd);
377
+ const config = configs[adapterName];
378
+ if (!config) return { kind: "none" };
379
+ const rootDir = findRootMarkerInLaunchAncestry(program, cwd, config.rootMarkers ?? [], programKind);
380
+ const adapter = resolveAdapterForLaunch(adapterName, configs, cwd, rootDir);
381
+ return adapter ? { kind: "adapter", adapter } : { kind: "unavailable", adapterName, command: config.command };
281
382
  }
282
- const matches = getMatchingAdapters(program, cwd);
283
- const candidates =
284
- programKind === "directory" ? matches.filter(adapter => adapter.acceptsDirectoryProgram) : matches;
285
- const sorted = sortAdaptersForLaunch(program, cwd, candidates.length > 0 ? candidates : matches);
286
- return sorted[0] ?? null;
383
+ return selectAutomaticLaunchAdapter(program, cwd, programKind, configs);
287
384
  }
288
385
 
289
386
  export function selectAttachAdapter(cwd: string, adapterName?: string, port?: number): DapResolvedAdapter | null {
@@ -64,7 +64,7 @@
64
64
  "connectMode": "socket",
65
65
  "languages": ["go"],
66
66
  "fileTypes": [".go"],
67
- "rootMarkers": ["go.mod", "go.sum"],
67
+ "rootMarkers": ["go.mod", "go.sum", "go.work"],
68
68
  "acceptsDirectoryProgram": true,
69
69
  "launchDefaults": {
70
70
  "request": "launch",
@@ -163,6 +163,7 @@ export class JsRuntime {
163
163
 
164
164
  readonly helpers: HelperBundle;
165
165
  #cwd: string;
166
+ #session: { cwd: string; sessionId: string };
166
167
  readonly sessionId: string;
167
168
  #env: Map<string, string>;
168
169
  #als = new AsyncLocalStorage<RunContext>();
@@ -171,6 +172,7 @@ export class JsRuntime {
171
172
 
172
173
  constructor(opts: RuntimeOptions) {
173
174
  this.#cwd = opts.initialCwd;
175
+ this.#session = { cwd: opts.initialCwd, sessionId: opts.sessionId };
174
176
  this.sessionId = opts.sessionId;
175
177
  this.#env = new Map();
176
178
  this.#moduleLoader = new LocalModuleLoader(this.sessionId);
@@ -189,10 +191,19 @@ export class JsRuntime {
189
191
  }
190
192
 
191
193
  setCwd(cwd: string): void {
192
- this.#activateGlobals("set cwd");
194
+ if (this.#disposed) throw new Error("Cannot set cwd on a disposed JS runtime");
195
+ // Always stamp the runtime and session state: WorkerCore/browser/cmux call
196
+ // setCwd from init and pre-run paths that may race another same-realm
197
+ // runtime, and a throw here used to escape the inline-worker microtask
198
+ // path as a fatal unhandledRejection that killed the whole session.
199
+ // #session is the same object saved in this owner's global stack entry,
200
+ // so the new cwd survives deferred activation and is visible to this
201
+ // runtime's next run; run()/setRunScope still assert exclusive ownership.
193
202
  this.#cwd = cwd;
194
- const session = (globalThis as { __omp_session__?: { cwd?: string } }).__omp_session__;
195
- if (session) session.cwd = cwd;
203
+ this.#session.cwd = cwd;
204
+ if (activeGlobalRunOwner === null || activeGlobalRunOwner === this.#globalOwner) {
205
+ this.#activateGlobals("set cwd");
206
+ }
196
207
  }
197
208
 
198
209
  /**
@@ -318,8 +329,13 @@ export class JsRuntime {
318
329
  }
319
330
 
320
331
  #install(extraGlobals: Record<string, unknown> | undefined): void {
332
+ // Constructing a runtime while another same-realm runtime is mid-run would
333
+ // silently replace the live runtime's globals (Object.assign + prelude eval
334
+ // below). Fail before any global/stack mutation; WorkerCore reports it as
335
+ // init-failed instead of corrupting the active run.
336
+ assertCanUseGlobalOwner(this.#globalOwner, "initialize a JS runtime");
321
337
  const injected: Record<string, unknown> = {
322
- __omp_session__: { cwd: this.#cwd, sessionId: this.sessionId },
338
+ __omp_session__: this.#session,
323
339
  __omp_helpers__: this.helpers,
324
340
  __omp_call_tool__: async (name: string, args: unknown) => {
325
341
  const hooks = this.#activeHooks("tool");
@@ -184,8 +184,15 @@ export class WorkerCore {
184
184
  #handle(msg: WorkerInbound): void {
185
185
  switch (msg.type) {
186
186
  case "init":
187
- this.#ensureRuntime(msg.snapshot);
188
- this.#transport.send({ type: "ready" });
187
+ try {
188
+ this.#ensureRuntime(msg.snapshot);
189
+ this.#transport.send({ type: "ready" });
190
+ } catch (error) {
191
+ // Inline fallback delivers messages on a microtask. A sync throw
192
+ // from ensureRuntime/setCwd would otherwise become a process-fatal
193
+ // unhandledRejection on the main thread.
194
+ this.#transport.send({ type: "init-failed", error: errorPayload(error) });
195
+ }
189
196
  return;
190
197
  case "run":
191
198
  void this.#runOne(msg.runId, msg.code, msg.filename, msg.snapshot);
@@ -300,9 +300,16 @@ export async function executeBash(command: string, options?: BashExecutorOptions
300
300
  const requestedTimeoutMs = options?.timeout;
301
301
  const deadlineTimeoutMs = requestedTimeoutMs === 0 ? undefined : Math.max(1_000, requestedTimeoutMs ?? 300_000);
302
302
  const nativeTimeoutMs = requestedTimeoutMs !== undefined && requestedTimeoutMs > 0 ? requestedTimeoutMs : undefined;
303
+ const nativeOwnsTimeout = nativeTimeoutMs !== undefined;
303
304
  if (deadlineTimeoutMs !== undefined) {
304
305
  timeoutTimer = setTimeout(() => {
305
- abortCurrentExecution();
306
+ // Explicit timeouts are already enforced inside pi-natives via
307
+ // `timeoutMs`. Do not also abort the JS AbortSignal here: on Windows,
308
+ // aborting that signal while a piped command is still forwarding output
309
+ // can terminate the Bun host before the native timeout result resolves.
310
+ if (!nativeOwnsTimeout) {
311
+ abortCurrentExecution();
312
+ }
306
313
  timeoutDeferred.resolve("timeout");
307
314
  }, deadlineTimeoutMs);
308
315
  }
@@ -125,6 +125,10 @@ export interface ExtensionUIDialogOptions {
125
125
  timeout?: number;
126
126
  /** Invoked when the UI times out while waiting for a selection/input */
127
127
  onTimeout?: () => void;
128
+ /** Invoked when the UI-managed timeout countdown starts */
129
+ onTimeoutStart?: () => void;
130
+ /** Invoked when user input resets a UI-managed timeout countdown */
131
+ onTimeoutReset?: () => void;
128
132
  /** Initial cursor position for select dialogs (0-indexed) */
129
133
  initialIndex?: number;
130
134
  /** Render an outlined list for select dialogs */
@@ -176,6 +180,8 @@ export type AutocompleteProviderFactory = (current: AutocompleteProvider) => Aut
176
180
  // and may be invoked from event handlers that have already taken the agent
177
181
  // loop's lock — hooks intentionally cannot.
178
182
  export interface ExtensionUIContext {
183
+ /** True when selector timeouts start only after the dialog is presented. */
184
+ timeoutStartsOnPresentation?: boolean;
179
185
  /** Show a selector and return the selected label, even when an option also includes a description. */
180
186
  select(
181
187
  title: string,