@oh-my-pi/pi-coding-agent 15.1.6 → 15.1.7

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 (50) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/dist/types/config/settings-schema.d.ts +15 -7
  3. package/dist/types/hashline/hash.d.ts +4 -4
  4. package/dist/types/hashline/recovery.d.ts +5 -0
  5. package/dist/types/lsp/edits.d.ts +8 -1
  6. package/dist/types/session/agent-session.d.ts +16 -0
  7. package/dist/types/session/client-bridge.d.ts +1 -0
  8. package/dist/types/tools/find.d.ts +4 -0
  9. package/dist/types/tools/resolve.d.ts +5 -0
  10. package/dist/types/tools/tool-timeouts.d.ts +1 -1
  11. package/package.json +7 -7
  12. package/src/config/settings-schema.ts +22 -7
  13. package/src/dap/session.ts +58 -5
  14. package/src/edit/modes/patch.ts +46 -0
  15. package/src/eval/js/context-manager.ts +11 -7
  16. package/src/eval/js/shared/rewrite-imports.ts +21 -9
  17. package/src/eval/js/shared/runtime.ts +2 -1
  18. package/src/hashline/hash.ts +11 -8
  19. package/src/hashline/parser.ts +23 -6
  20. package/src/hashline/recovery.ts +44 -3
  21. package/src/lsp/edits.ts +92 -38
  22. package/src/lsp/index.ts +110 -7
  23. package/src/lsp/utils.ts +13 -0
  24. package/src/modes/acp/acp-client-bridge.ts +1 -0
  25. package/src/modes/components/status-line/segments.ts +1 -1
  26. package/src/prompts/tools/bash.md +14 -0
  27. package/src/prompts/tools/debug.md +4 -1
  28. package/src/prompts/tools/find.md +10 -0
  29. package/src/prompts/tools/hashline.md +5 -3
  30. package/src/prompts/tools/resolve.md +1 -1
  31. package/src/prompts/tools/search.md +2 -1
  32. package/src/prompts/tools/task.md +4 -0
  33. package/src/prompts/tools/todo-write.md +2 -0
  34. package/src/session/agent-session.ts +116 -8
  35. package/src/session/client-bridge.ts +1 -0
  36. package/src/slash-commands/builtin-registry.ts +1 -1
  37. package/src/task/index.ts +33 -5
  38. package/src/task/render.ts +4 -1
  39. package/src/tools/browser/tab-supervisor.ts +23 -3
  40. package/src/tools/browser/tab-worker.ts +4 -2
  41. package/src/tools/browser.ts +1 -1
  42. package/src/tools/debug.ts +19 -2
  43. package/src/tools/find.ts +80 -24
  44. package/src/tools/read.ts +3 -6
  45. package/src/tools/resolve.ts +54 -22
  46. package/src/tools/search.ts +31 -0
  47. package/src/tools/todo-write.ts +11 -4
  48. package/src/tools/tool-timeouts.ts +1 -1
  49. package/src/utils/tools-manager.ts +29 -22
  50. package/src/web/search/providers/codex.ts +3 -0
@@ -65,11 +65,13 @@ import type {
65
65
  } from "@oh-my-pi/pi-ai";
66
66
  import {
67
67
  calculateRateLimitBackoffMs,
68
+ clearAnthropicFastModeFallback,
68
69
  getSupportedEfforts,
69
70
  isContextOverflow,
70
71
  isUsageLimitError,
71
72
  modelsAreEqual,
72
73
  parseRateLimitReason,
74
+ resolveServiceTier,
73
75
  streamSimple,
74
76
  } from "@oh-my-pi/pi-ai";
75
77
  import { MacOSPowerAssertion } from "@oh-my-pi/pi-natives";
@@ -443,6 +445,44 @@ function todoClearKey(phaseName: string, taskContent: string): string {
443
445
  return `${phaseName}\u0000${taskContent}`;
444
446
  }
445
447
 
448
+ const IRC_REPLY_MAX_BYTES = 4096;
449
+
450
+ /**
451
+ * Collapse degenerate IRC ephemeral replies before they hit the relay.
452
+ * Models occasionally loop on a single line (~16 reports of N-times-repeated
453
+ * replies); compress runs longer than 3 down to one instance + `[…N×]`, then
454
+ * cap at 4 KiB so a runaway reply can't flood the channel.
455
+ */
456
+ function dedupeIrcReply(text: string): string {
457
+ if (!text) return text;
458
+ const lines = text.split("\n");
459
+ const out: string[] = [];
460
+ let i = 0;
461
+ while (i < lines.length) {
462
+ let j = i + 1;
463
+ while (j < lines.length && lines[j] === lines[i]) j++;
464
+ const runLen = j - i;
465
+ if (runLen > 3) {
466
+ out.push(lines[i], `[…${runLen}×]`);
467
+ } else {
468
+ for (let k = 0; k < runLen; k++) out.push(lines[i]);
469
+ }
470
+ i = j;
471
+ }
472
+ let result = out.join("\n");
473
+ if (Buffer.byteLength(result, "utf8") > IRC_REPLY_MAX_BYTES) {
474
+ // Trim by characters until we're under the byte budget — handles multi-byte
475
+ // glyphs at the boundary without splitting them.
476
+ const suffix = "\n[…truncated]";
477
+ const budget = IRC_REPLY_MAX_BYTES - Buffer.byteLength(suffix, "utf8");
478
+ while (Buffer.byteLength(result, "utf8") > budget) {
479
+ result = result.slice(0, -1);
480
+ }
481
+ result += suffix;
482
+ }
483
+ return result;
484
+ }
485
+
446
486
  /**
447
487
  * Build the per-request `metadata` payload for the Anthropic provider, shaped
448
488
  * like real Claude Code's `getAPIMetadata` output (`{ session_id, account_uuid,
@@ -1578,6 +1618,16 @@ export class AgentSession {
1578
1618
  if (event.message.role === "assistant") {
1579
1619
  this.#lastAssistantMessage = event.message;
1580
1620
  const assistantMsg = event.message as AssistantMessage;
1621
+ const currentGrantsAnthropicPriority =
1622
+ this.serviceTier === "priority" || this.serviceTier === "claude-only";
1623
+ if (assistantMsg.disabledFeatures?.includes("priority") && currentGrantsAnthropicPriority) {
1624
+ this.setServiceTier(undefined);
1625
+ this.emitNotice(
1626
+ "warning",
1627
+ "Priority/fast mode rejected for this model; retried without it. Fast mode is now off.",
1628
+ "priority",
1629
+ );
1630
+ }
1581
1631
  // Resolve TTSR resume gate before checking for new deferred injections.
1582
1632
  // Gate on #ttsrAbortPending, not stopReason: a non-TTSR abort (e.g. streaming
1583
1633
  // edit) also produces stopReason === "aborted" but has no continuation coming.
@@ -1713,10 +1763,6 @@ export class AgentSession {
1713
1763
  }
1714
1764
  this.#resolveRetry();
1715
1765
 
1716
- if (msg.stopReason === "aborted" && this.#checkpointState) {
1717
- this.#checkpointState = undefined;
1718
- this.#pendingRewindReport = undefined;
1719
- }
1720
1766
  const compactionTask = this.#checkCompaction(msg);
1721
1767
  this.#trackPostPromptTask(compactionTask);
1722
1768
  await compactionTask;
@@ -3101,6 +3147,13 @@ export class AgentSession {
3101
3147
  if (!permissionIntent) {
3102
3148
  return await target.execute(toolCallId, args as never, signal, onUpdate, ctx);
3103
3149
  }
3150
+ const command =
3151
+ target.name === "bash" && args && typeof args === "object" && !Array.isArray(args)
3152
+ ? getStringProperty(args as Record<string, unknown>, "command")
3153
+ : undefined;
3154
+ const commandContent = command
3155
+ ? [{ type: "content" as const, content: { type: "text" as const, text: `$ ${command}` } }]
3156
+ : undefined;
3104
3157
  // Short-circuit on persisted decisions.
3105
3158
  const persisted = this.#acpPermissionDecisions.get(permissionIntent.cacheKey);
3106
3159
  if (persisted === "allow_always") {
@@ -3125,8 +3178,10 @@ export class AgentSession {
3125
3178
  toolCallId,
3126
3179
  toolName: target.name,
3127
3180
  title: permissionIntent.title,
3181
+ ...(target.name === "bash" ? { kind: "execute" } : {}),
3128
3182
  status: "pending",
3129
3183
  rawInput: args,
3184
+ ...(commandContent ? { content: commandContent } : {}),
3130
3185
  locations: extractPermissionLocations(
3131
3186
  args,
3132
3187
  this.sessionManager.getCwd(),
@@ -4599,7 +4654,12 @@ export class AgentSession {
4599
4654
 
4600
4655
  /** Schedule auto-removal of completed/abandoned tasks after a delay. */
4601
4656
  #scheduleTodoAutoClear(phases: TodoPhase[]): void {
4602
- const delaySec = this.settings.get("tasks.todoClearDelay") ?? 60;
4657
+ // Default bumped from 60s to 30 min: the prior 60s splice mutated canonical
4658
+ // state mid-turn, so the model observed phase totals shrinking ("6 → 5")
4659
+ // between tool calls. Surviving the turn matches user expectations; a
4660
+ // render-time filter in the UI consumer would be cleaner but lives in a
4661
+ // different package and is out of scope for this fix.
4662
+ const delaySec = this.settings.get("tasks.todoClearDelay") ?? 1800;
4603
4663
  if (delaySec < 0) return; // "Never" — no auto-clear
4604
4664
  const delayMs = delaySec * 1000;
4605
4665
  const doneKeys = new Set<string>();
@@ -5110,17 +5170,50 @@ export class AgentSession {
5110
5170
  return nextLevel;
5111
5171
  }
5112
5172
 
5173
+ /**
5174
+ * True when *any* fast-mode-granting service tier is configured, regardless
5175
+ * of whether the active model's provider actually realizes it. Used by the
5176
+ * toggle (`/fast on|off`) so re-toggling a scoped tier (`openai-only`,
5177
+ * `claude-only`) doesn't silently broaden it to unscoped `priority`.
5178
+ *
5179
+ * For "is fast mode actually applied to the next request?" use
5180
+ * {@link isFastModeActive} instead — that one respects the model's provider.
5181
+ */
5113
5182
  isFastModeEnabled(): boolean {
5114
- return this.serviceTier === "priority";
5183
+ return (
5184
+ this.serviceTier === "priority" || this.serviceTier === "claude-only" || this.serviceTier === "openai-only"
5185
+ );
5186
+ }
5187
+
5188
+ /**
5189
+ * True when the configured `serviceTier` resolves to `"priority"` for the
5190
+ * *currently selected model's provider*. Returns false for scoped tiers
5191
+ * that don't match (e.g. `"openai-only"` on an anthropic model) and when
5192
+ * no model is selected.
5193
+ */
5194
+ isFastModeActive(): boolean {
5195
+ return resolveServiceTier(this.serviceTier, this.model?.provider) === "priority";
5115
5196
  }
5116
5197
 
5117
5198
  setServiceTier(serviceTier: ServiceTier | undefined): void {
5118
5199
  if (this.serviceTier === serviceTier) return;
5200
+ // Re-arming priority on Anthropic? Clear the per-session auto-fallback
5201
+ // sticky disable so the next request actually carries `speed: "fast"`
5202
+ // again. Without this, `/fast on` (or user switching to a tier that
5203
+ // grants anthropic priority) after an auto-disable is a silent no-op
5204
+ // and the warning notice fires every turn.
5205
+ if (serviceTier === "priority" || serviceTier === "claude-only") {
5206
+ clearAnthropicFastModeFallback(this.#providerSessionState);
5207
+ }
5119
5208
  this.agent.serviceTier = serviceTier;
5120
5209
  this.sessionManager.appendServiceTierChange(serviceTier ?? null);
5121
5210
  }
5122
5211
 
5123
5212
  setFastMode(enabled: boolean): void {
5213
+ if (enabled && this.isFastModeEnabled()) {
5214
+ // Already on under any scope — keep the user's scoped value.
5215
+ return;
5216
+ }
5124
5217
  this.setServiceTier(enabled ? "priority" : undefined);
5125
5218
  }
5126
5219
 
@@ -7574,6 +7667,11 @@ export class AgentSession {
7574
7667
  const context: Context = {
7575
7668
  systemPrompt: this.systemPrompt,
7576
7669
  messages: llmMessages,
7670
+ // Empty tools array: with toolChoice="none" some encoders still serialize the
7671
+ // recipient's tool catalog and the model leaks raw call markup
7672
+ // (<function_calls>, DSML envelopes) into IRC replies. Stripping tools here
7673
+ // removes the surface entirely.
7674
+ tools: [],
7577
7675
  };
7578
7676
  const options = this.prepareSimpleStreamOptions(
7579
7677
  {
@@ -7609,7 +7707,7 @@ export class AgentSession {
7609
7707
  if (!assistantMessage) {
7610
7708
  throw new Error("Ephemeral turn ended without a final message");
7611
7709
  }
7612
- return { replyText: replyText.trim(), assistantMessage };
7710
+ return { replyText: dedupeIrcReply(replyText.trim()), assistantMessage };
7613
7711
  }
7614
7712
 
7615
7713
  /**
@@ -7622,14 +7720,24 @@ export class AgentSession {
7622
7720
  const messages = [...this.messages];
7623
7721
  const streaming = this.agent.state.streamMessage;
7624
7722
  if (streaming && streaming.role === "assistant") {
7723
+ const preservedBlocks: AssistantMessage["content"] = [];
7724
+ // Preserve thinking blocks: DeepSeek-class encoders replay them as
7725
+ // `reasoning_content` and reject the request (HTTP 400) when the field
7726
+ // goes missing on a turn that previously emitted thinking.
7727
+ for (const c of streaming.content) {
7728
+ if (c.type === "thinking") preservedBlocks.push(c);
7729
+ }
7625
7730
  const streamingText = streaming.content
7626
7731
  .filter((c): c is TextContent => c.type === "text")
7627
7732
  .map(c => c.text)
7628
7733
  .join("");
7629
7734
  if (streamingText) {
7735
+ preservedBlocks.push({ type: "text", text: streamingText });
7736
+ }
7737
+ if (preservedBlocks.length > 0) {
7630
7738
  const normalized: AssistantMessage = {
7631
7739
  ...streaming,
7632
- content: [{ type: "text", text: streamingText }],
7740
+ content: preservedBlocks,
7633
7741
  };
7634
7742
  const lastMessage = messages.at(-1);
7635
7743
  if (lastMessage?.role === "assistant") {
@@ -27,6 +27,7 @@ export interface ClientBridgePermissionToolCall {
27
27
  kind?: string;
28
28
  status?: "pending" | "in_progress" | "completed" | "failed";
29
29
  rawInput?: unknown;
30
+ content?: unknown[];
30
31
  locations?: { path: string; line?: number }[];
31
32
  }
32
33
 
@@ -147,7 +147,7 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<SlashCommandSpec> = [
147
147
  },
148
148
  {
149
149
  name: "fast",
150
- description: "Toggle fast mode (OpenAI service tier priority)",
150
+ description: "Toggle priority service tier (OpenAI service_tier=priority, Anthropic speed=fast)",
151
151
  acpDescription: "Toggle fast mode",
152
152
  acpInputHint: "[on|off|status]",
153
153
  subcommands: [
package/src/task/index.ts CHANGED
@@ -140,11 +140,25 @@ function renderDescription(
140
140
  disabledAgents: string[],
141
141
  simpleMode: TaskSimpleMode,
142
142
  ircEnabled: boolean,
143
+ parentSpawns: string,
143
144
  ): string {
144
- const filteredAgents = disabledAgents.length > 0 ? agents.filter(a => !disabledAgents.includes(a.name)) : agents;
145
+ const spawningDisabled = parentSpawns === "";
146
+ let filteredAgents = disabledAgents.length > 0 ? agents.filter(a => !disabledAgents.includes(a.name)) : agents;
147
+ if (spawningDisabled) {
148
+ filteredAgents = [];
149
+ } else if (parentSpawns !== "*") {
150
+ const allowed = new Set(
151
+ parentSpawns
152
+ .split(",")
153
+ .map(s => s.trim())
154
+ .filter(Boolean),
155
+ );
156
+ filteredAgents = filteredAgents.filter(a => allowed.has(a.name));
157
+ }
145
158
  const { contextEnabled, customSchemaEnabled } = getTaskSimpleModeCapabilities(simpleMode);
146
159
  return prompt.render(taskDescriptionTemplate, {
147
160
  agents: filteredAgents,
161
+ spawningDisabled,
148
162
  MAX_CONCURRENCY: maxConcurrency,
149
163
  isolationEnabled,
150
164
  asyncEnabled,
@@ -230,6 +244,7 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
230
244
  disabledAgents,
231
245
  this.#getTaskSimpleMode(),
232
246
  this.session.settings.get("irc.enabled") === true,
247
+ this.session.getSessionSpawns() ?? "*",
233
248
  );
234
249
  }
235
250
  private constructor(
@@ -493,10 +508,11 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
493
508
  taskIdByItemId.set(taskItems[i].id, uniqueIds[i]);
494
509
  }
495
510
  const startedListing = startedJobs
496
- .map(({ taskId }) => {
511
+ .map(({ taskId, jobId }) => {
497
512
  const id = taskIdByItemId.get(taskId) ?? taskId;
498
513
  const desc = progressByTaskId.get(taskId)?.description;
499
- return desc ? `- \`${id}\` ${desc}` : `- \`${id}\``;
514
+ const prefix = `- \`${id}\` (job \`${jobId}\`)`;
515
+ return desc ? `${prefix} — ${desc}` : prefix;
500
516
  })
501
517
  .join("\n");
502
518
  const coordinationHint = ircEnabled
@@ -1075,6 +1091,7 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
1075
1091
 
1076
1092
  let mergeSummary = "";
1077
1093
  let changesApplied: boolean | null = null;
1094
+ let hadAnyChanges = false;
1078
1095
  let mergedBranchesForNestedPatches: Set<string> | null = null;
1079
1096
  if (isIsolated && repoRoot) {
1080
1097
  try {
@@ -1086,13 +1103,18 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
1086
1103
 
1087
1104
  if (branchEntries.length === 0) {
1088
1105
  changesApplied = true;
1106
+ hadAnyChanges = false;
1107
+ mergeSummary = "\n\nNo changes to apply.";
1089
1108
  } else {
1090
1109
  const mergeResult = await mergeTaskBranches(repoRoot, branchEntries);
1091
1110
  mergedBranchesForNestedPatches = new Set(mergeResult.merged);
1092
1111
  changesApplied = mergeResult.failed.length === 0;
1112
+ hadAnyChanges = changesApplied && mergeResult.merged.length > 0;
1093
1113
 
1094
1114
  if (changesApplied) {
1095
- mergeSummary = `\n\nMerged ${mergeResult.merged.length} branch${mergeResult.merged.length === 1 ? "" : "es"}: ${mergeResult.merged.join(", ")}`;
1115
+ mergeSummary = hadAnyChanges
1116
+ ? `\n\nMerged ${mergeResult.merged.length} branch${mergeResult.merged.length === 1 ? "" : "es"}: ${mergeResult.merged.join(", ")}`
1117
+ : "\n\nNo changes to apply.";
1096
1118
  } else {
1097
1119
  const mergedPart =
1098
1120
  mergeResult.merged.length > 0 ? `Merged: ${mergeResult.merged.join(", ")}.\n` : "";
@@ -1113,6 +1135,7 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
1113
1135
  const missingPatch = results.some(result => !result.patchPath);
1114
1136
  if (missingPatch) {
1115
1137
  changesApplied = false;
1138
+ hadAnyChanges = false;
1116
1139
  } else {
1117
1140
  const patchStats = await Promise.all(
1118
1141
  patchesInOrder.map(async patchPath => ({
@@ -1123,6 +1146,7 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
1123
1146
  const nonEmptyPatches = patchStats.filter(patch => patch.size > 0).map(patch => patch.patchPath);
1124
1147
  if (nonEmptyPatches.length === 0) {
1125
1148
  changesApplied = true;
1149
+ hadAnyChanges = false;
1126
1150
  } else {
1127
1151
  const patchTexts = await Promise.all(
1128
1152
  nonEmptyPatches.map(async patchPath => Bun.file(patchPath).text()),
@@ -1132,13 +1156,16 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
1132
1156
  .join("");
1133
1157
  if (!combinedPatch.trim()) {
1134
1158
  changesApplied = true;
1159
+ hadAnyChanges = false;
1135
1160
  } else {
1136
1161
  changesApplied = await git.patch.canApplyText(repoRoot, combinedPatch);
1137
1162
  if (changesApplied) {
1138
1163
  try {
1139
1164
  await git.patch.applyText(repoRoot, combinedPatch);
1165
+ hadAnyChanges = true;
1140
1166
  } catch {
1141
1167
  changesApplied = false;
1168
+ hadAnyChanges = false;
1142
1169
  }
1143
1170
  }
1144
1171
  }
@@ -1146,7 +1173,7 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
1146
1173
  }
1147
1174
 
1148
1175
  if (changesApplied) {
1149
- mergeSummary = "\n\nApplied patches: yes";
1176
+ mergeSummary = hadAnyChanges ? "\n\nApplied patches: yes" : "\n\nNo changes to apply.";
1150
1177
  } else {
1151
1178
  const notification =
1152
1179
  "<system-notification>Patches were not applied and must be handled manually.</system-notification>";
@@ -1160,6 +1187,7 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
1160
1187
  } catch (mergeErr) {
1161
1188
  const msg = mergeErr instanceof Error ? mergeErr.message : String(mergeErr);
1162
1189
  changesApplied = false;
1190
+ hadAnyChanges = false;
1163
1191
  mergeSummary = `\n\n<system-notification>Merge phase failed: ${msg}\nTask outputs are preserved but changes were not applied.</system-notification>`;
1164
1192
  }
1165
1193
  }
@@ -996,7 +996,10 @@ export function renderResult(
996
996
  if (fallbackText.trim()) {
997
997
  const summaryLines = fallbackText.split("\n");
998
998
  const markerIndex = summaryLines.findIndex(
999
- line => line.includes("<system-notification>") || line.startsWith("Applied patches:"),
999
+ line =>
1000
+ line.includes("<system-notification>") ||
1001
+ line.startsWith("Applied patches:") ||
1002
+ line.startsWith("No changes to apply."),
1000
1003
  );
1001
1004
  if (markerIndex >= 0) {
1002
1005
  const extra = summaryLines.slice(markerIndex);
@@ -120,14 +120,34 @@ export async function acquireTab(
120
120
  }
121
121
 
122
122
  const initPayload = await buildInitPayload(browser, opts);
123
- const worker = await spawnTabWorker();
123
+ let worker = await spawnTabWorker();
124
124
  let info: ReadyInfo;
125
125
  try {
126
126
  info = await initializeTabWorker(worker, initPayload, opts.timeoutMs + GRACE_MS);
127
127
  } catch (error) {
128
+ // `BuildMessage`-class failures arrive asynchronously via the worker's `error` event,
129
+ // after `spawnTabWorker`'s synchronous try/catch has already returned. Fall back to
130
+ // the inline worker here so module-resolution failures don't poison every tab open.
128
131
  await worker.terminate().catch(() => undefined);
129
- if (browser.refCount === 0) await releaseBrowser(browser, { kill: false });
130
- throw error;
132
+ if (worker.mode === "inline") {
133
+ if (browser.refCount === 0) await releaseBrowser(browser, { kill: false });
134
+ throw error;
135
+ }
136
+ logger.warn("Tab worker init failed; retrying with inline tab worker (no sync-loop guard)", {
137
+ error: error instanceof Error ? error.message : String(error),
138
+ });
139
+ worker = await spawnInlineWorker();
140
+ try {
141
+ info = await initializeTabWorker(worker, initPayload, opts.timeoutMs + GRACE_MS);
142
+ } catch (inlineError) {
143
+ await worker.terminate().catch(() => undefined);
144
+ if (browser.refCount === 0) await releaseBrowser(browser, { kill: false });
145
+ const finalError = new ToolError(
146
+ `Failed to start browser tab worker (inline fallback also failed): ${inlineError instanceof Error ? inlineError.message : String(inlineError)}`,
147
+ );
148
+ (finalError as { cause?: unknown }).cause = error;
149
+ throw finalError;
150
+ }
131
151
  }
132
152
 
133
153
  holdBrowser(browser);
@@ -459,7 +459,8 @@ export class WorkerCore {
459
459
  if (payload.dialogs) this.#applyDialogPolicy(payload.dialogs);
460
460
  if (payload.url) {
461
461
  await this.#page.goto(payload.url, {
462
- waitUntil: payload.waitUntil ?? "networkidle2",
462
+ // Default to "load" because dev servers with HMR/WS never reach networkidle.
463
+ waitUntil: payload.waitUntil ?? "load",
463
464
  timeout: payload.timeoutMs,
464
465
  });
465
466
  }
@@ -667,7 +668,8 @@ export class WorkerCore {
667
668
  goto: async (url, opts) => {
668
669
  this.#clearElementCache();
669
670
  await untilAborted(signal, () =>
670
- page.goto(url, { waitUntil: opts?.waitUntil ?? "networkidle2", timeout: timeoutMs }),
671
+ // Default to "load" because dev servers with HMR/WS never reach networkidle.
672
+ page.goto(url, { waitUntil: opts?.waitUntil ?? "load", timeout: timeoutMs }),
671
673
  );
672
674
  },
673
675
  observe: opts => this.#collectObservation({ ...opts, signal }),
@@ -45,7 +45,7 @@ const browserSchema = z.object({
45
45
  .describe("auto-handle dialogs")
46
46
  .optional(),
47
47
  code: z.string().describe("js body to run in tab").optional(),
48
- timeout: z.number().default(30).describe("timeout in seconds").optional(),
48
+ timeout: z.number().default(30).describe("timeout in seconds (default 30, max 300)").optional(),
49
49
  all: z.boolean().describe("close every tab").optional(),
50
50
  kill: z.boolean().describe("also kill spawned-app browsers").optional(),
51
51
  });
@@ -1,3 +1,4 @@
1
+ import * as fs from "node:fs/promises";
1
2
  import type {
2
3
  AgentTool,
3
4
  AgentToolContext,
@@ -6,7 +7,7 @@ import type {
6
7
  RenderResultOptions,
7
8
  } from "@oh-my-pi/pi-agent-core";
8
9
  import { type Component, Text } from "@oh-my-pi/pi-tui";
9
- import { prompt } from "@oh-my-pi/pi-utils";
10
+ import { isEnoent, prompt } from "@oh-my-pi/pi-utils";
10
11
  import * as z from "zod/v4";
11
12
  import {
12
13
  type DapBreakpointRecord,
@@ -37,7 +38,7 @@ import { renderStatusLine } from "../tui";
37
38
  import { CachedOutputBlock } from "../tui/output-block";
38
39
  import type { ToolSession } from ".";
39
40
  import type { OutputMeta } from "./output-meta";
40
- import { resolveToCwd } from "./path-utils";
41
+ import { formatPathRelativeToCwd, resolveToCwd } from "./path-utils";
41
42
  import {
42
43
  formatExpandHint,
43
44
  formatStatusIcon,
@@ -469,6 +470,21 @@ function getConfiguredAdapters(cwd: string): string {
469
470
  const adapters = getAvailableAdapters(cwd).map(adapter => adapter.name);
470
471
  return adapters.length > 0 ? adapters.join(", ") : "none";
471
472
  }
473
+ async function validateLaunchProgram(program: string, cwd: string): Promise<void> {
474
+ let isDirectory: boolean;
475
+ try {
476
+ isDirectory = (await fs.stat(program)).isDirectory();
477
+ } catch (error) {
478
+ if (isEnoent(error)) return;
479
+ throw error;
480
+ }
481
+ if (!isDirectory) return;
482
+
483
+ const displayPath = formatPathRelativeToCwd(program, cwd, { trailingSlash: true });
484
+ throw new ToolError(
485
+ `launch program resolves to a directory: ${displayPath}. Pass an executable file path, or for Python use adapter "debugpy" with program set to the .py file.`,
486
+ );
487
+ }
472
488
 
473
489
  interface DebugRenderArgs extends Partial<DebugParams> {}
474
490
 
@@ -628,6 +644,7 @@ export class DebugTool implements AgentTool<typeof debugSchema, DebugToolDetails
628
644
  }
629
645
  const commandCwd = params.cwd ? resolveToCwd(params.cwd, this.session.cwd) : this.session.cwd;
630
646
  const program = resolveToCwd(params.program, commandCwd);
647
+ await validateLaunchProgram(program, commandCwd);
631
648
  const adapter = selectLaunchAdapter(program, commandCwd, params.adapter);
632
649
  if (!adapter) {
633
650
  throw new ToolError(