@oh-my-pi/pi-coding-agent 17.0.0 → 17.0.1

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 (101) hide show
  1. package/CHANGELOG.md +56 -0
  2. package/dist/cli.js +3305 -3302
  3. package/dist/types/advisor/advise-tool.d.ts +5 -2
  4. package/dist/types/cli-commands.d.ts +14 -1
  5. package/dist/types/config/model-registry.d.ts +4 -0
  6. package/dist/types/config/models-config-schema.d.ts +10 -0
  7. package/dist/types/config/models-config.d.ts +6 -0
  8. package/dist/types/config/settings-schema.d.ts +1 -1
  9. package/dist/types/discovery/substitute-plugin-root.d.ts +20 -7
  10. package/dist/types/internal-urls/agent-protocol.d.ts +1 -0
  11. package/dist/types/lib/xai-http.d.ts +11 -0
  12. package/dist/types/modes/acp/acp-agent.d.ts +3 -0
  13. package/dist/types/modes/acp/acp-mode.d.ts +6 -2
  14. package/dist/types/modes/components/assistant-message.d.ts +2 -0
  15. package/dist/types/modes/components/custom-editor-plugin-ctor.test.d.ts +1 -0
  16. package/dist/types/modes/components/custom-editor.d.ts +23 -1
  17. package/dist/types/modes/components/hook-editor.d.ts +9 -2
  18. package/dist/types/modes/components/login-dialog.test.d.ts +1 -0
  19. package/dist/types/modes/components/session-selector.d.ts +2 -0
  20. package/dist/types/modes/components/tool-execution.d.ts +8 -1
  21. package/dist/types/modes/components/tool-execution.test.d.ts +1 -0
  22. package/dist/types/modes/interactive-mode.d.ts +6 -0
  23. package/dist/types/modes/noninteractive-dispose.test.d.ts +1 -0
  24. package/dist/types/modes/types.d.ts +10 -0
  25. package/dist/types/modes/utils/transcript-render-helpers.d.ts +2 -2
  26. package/dist/types/session/agent-session.d.ts +9 -1
  27. package/dist/types/session/streaming-output.d.ts +2 -0
  28. package/dist/types/slash-commands/types.d.ts +4 -4
  29. package/dist/types/system-prompt.d.ts +1 -1
  30. package/dist/types/tools/browser/registry.d.ts +7 -3
  31. package/dist/types/tools/browser/tab-supervisor.d.ts +2 -0
  32. package/dist/types/tools/index.d.ts +3 -1
  33. package/dist/types/tools/manage-skill.d.ts +2 -0
  34. package/dist/types/web/search/index.d.ts +2 -0
  35. package/dist/types/web/search/providers/base.d.ts +3 -0
  36. package/package.json +12 -12
  37. package/src/advisor/__tests__/advisor.test.ts +22 -12
  38. package/src/advisor/advise-tool.ts +7 -3
  39. package/src/cli/bench-cli.ts +4 -1
  40. package/src/cli/grep-cli.ts +2 -1
  41. package/src/cli-commands.ts +57 -23
  42. package/src/config/model-registry.ts +9 -0
  43. package/src/config/model-resolver.ts +43 -5
  44. package/src/config/models-config-schema.ts +1 -0
  45. package/src/config/settings-schema.ts +2 -1
  46. package/src/discovery/codex.ts +14 -4
  47. package/src/discovery/substitute-plugin-root.ts +23 -7
  48. package/src/export/html/template.js +2 -0
  49. package/src/internal-urls/__tests__/agent-protocol-nested.test.ts +73 -0
  50. package/src/internal-urls/agent-protocol.ts +73 -39
  51. package/src/lib/xai-http.ts +28 -2
  52. package/src/modes/acp/acp-agent.ts +28 -9
  53. package/src/modes/acp/acp-event-mapper.ts +8 -0
  54. package/src/modes/acp/acp-mode.ts +18 -4
  55. package/src/modes/components/assistant-message.ts +35 -14
  56. package/src/modes/components/chat-transcript-builder.ts +2 -0
  57. package/src/modes/components/custom-editor-plugin-ctor.test.ts +36 -0
  58. package/src/modes/components/custom-editor.ts +62 -1
  59. package/src/modes/components/hook-editor.ts +17 -2
  60. package/src/modes/components/login-dialog.test.ts +56 -0
  61. package/src/modes/components/login-dialog.ts +7 -3
  62. package/src/modes/components/plan-review-overlay.ts +51 -10
  63. package/src/modes/components/session-selector.ts +6 -0
  64. package/src/modes/components/status-line/component.ts +2 -4
  65. package/src/modes/components/tool-execution.test.ts +101 -0
  66. package/src/modes/components/tool-execution.ts +117 -9
  67. package/src/modes/components/transcript-container.ts +30 -2
  68. package/src/modes/controllers/command-controller-shared.ts +1 -1
  69. package/src/modes/controllers/command-controller.ts +1 -1
  70. package/src/modes/controllers/event-controller.ts +1 -0
  71. package/src/modes/controllers/selector-controller.ts +19 -17
  72. package/src/modes/interactive-mode.ts +66 -11
  73. package/src/modes/noninteractive-dispose.test.ts +60 -0
  74. package/src/modes/print-mode.ts +8 -3
  75. package/src/modes/rpc/rpc-mode.ts +12 -3
  76. package/src/modes/theme/theme.ts +2 -2
  77. package/src/modes/types.ts +10 -0
  78. package/src/modes/utils/interactive-context-helpers.ts +3 -1
  79. package/src/modes/utils/transcript-render-helpers.ts +3 -2
  80. package/src/prompts/system/system-prompt.md +1 -1
  81. package/src/sdk.ts +6 -2
  82. package/src/session/agent-session.ts +79 -34
  83. package/src/session/session-listing.ts +6 -5
  84. package/src/session/session-loader.ts +9 -0
  85. package/src/session/session-persistence.ts +11 -0
  86. package/src/session/streaming-output.ts +3 -1
  87. package/src/slash-commands/builtin-registry.ts +2 -0
  88. package/src/slash-commands/types.ts +4 -4
  89. package/src/system-prompt.ts +2 -2
  90. package/src/tools/browser/registry.ts +9 -2
  91. package/src/tools/browser/tab-supervisor.ts +65 -10
  92. package/src/tools/browser.ts +4 -3
  93. package/src/tools/eval.ts +3 -0
  94. package/src/tools/image-gen.ts +561 -512
  95. package/src/tools/index.ts +3 -1
  96. package/src/tools/manage-skill.ts +5 -3
  97. package/src/tools/output-meta.ts +7 -0
  98. package/src/tools/path-utils.ts +8 -5
  99. package/src/web/search/index.ts +21 -6
  100. package/src/web/search/providers/base.ts +3 -0
  101. package/src/web/search/providers/xai.ts +40 -9
@@ -170,6 +170,7 @@ import {
170
170
  } from "../advisor";
171
171
  import { type AsyncJob, type AsyncJobDeliveryState, AsyncJobManager } from "../async";
172
172
  import { classifyDifficulty } from "../auto-thinking/classifier";
173
+ import { reset as resetCapabilities } from "../capability";
173
174
  import type { Rule } from "../capability/rule";
174
175
  import { shouldEnableAppendOnlyContext } from "../config/append-only-context-mode";
175
176
  import type { ModelRegistry } from "../config/model-registry";
@@ -237,7 +238,7 @@ import type { CompactOptions, ContextUsage } from "../extensibility/extensions/t
237
238
  import { ExtensionToolWrapper } from "../extensibility/extensions/wrapper";
238
239
  import type { HookCommandContext } from "../extensibility/hooks/types";
239
240
  import type { RecoveredRetryError } from "../extensibility/shared-events";
240
- import type { Skill, SkillWarning } from "../extensibility/skills";
241
+ import { loadSkills, type Skill, type SkillWarning, setActiveSkills } from "../extensibility/skills";
241
242
  import { expandSlashCommand, type FileSlashCommand } from "../extensibility/slash-commands";
242
243
  import { GoalRuntime } from "../goals/runtime";
243
244
  import type { Goal, GoalModeState } from "../goals/state";
@@ -314,7 +315,7 @@ import { releaseTabsForOwner } from "../tools/browser/tab-supervisor";
314
315
  import { isMCPToolName, normalizeToolNames } from "../tools/builtin-names";
315
316
  import type { CheckpointState, CompletedRewindState } from "../tools/checkpoint";
316
317
  import { outputMeta, wrapToolWithMetaNotice } from "../tools/output-meta";
317
- import { normalizeLocalScheme, resolveToCwd } from "../tools/path-utils";
318
+ import { isInternalUrlPath, normalizeLocalScheme, resolveToCwd } from "../tools/path-utils";
318
319
  import {
319
320
  buildResolveReminderMessage,
320
321
  isPreviewResolutionToolCall,
@@ -827,6 +828,8 @@ export interface AgentSessionConfig {
827
828
  skills?: Skill[];
828
829
  /** Skill loading warnings (already captured by SDK) */
829
830
  skillWarnings?: SkillWarning[];
831
+ /** Whether runtime reloads may rediscover disk-backed skills for this session. */
832
+ skillsReloadable?: boolean;
830
833
  /** Custom commands (TypeScript slash commands) */
831
834
  customCommands?: LoadedCustomCommand[];
832
835
  skillsSettings?: SkillsSettings;
@@ -1536,15 +1539,20 @@ function queuedTextContent(message: AgentMessage): string | undefined {
1536
1539
  if (!("content" in message)) return undefined;
1537
1540
  const content = message.content;
1538
1541
  if (typeof content === "string") return content;
1539
- return content.find((part): part is TextContent => part.type === "text")?.text;
1542
+ for (const part of content) {
1543
+ if (part.type === "text") return part.text;
1544
+ }
1545
+ return undefined;
1540
1546
  }
1541
1547
 
1542
1548
  function queuedImageContent(message: AgentMessage): ImageContent[] | undefined {
1543
1549
  if (!("content" in message) || typeof message.content === "string") return undefined;
1544
- const images = message.content.filter(
1545
- (part): part is ImageContent =>
1546
- part.type === "image" && typeof part.data === "string" && typeof part.mimeType === "string",
1547
- );
1550
+ const images: ImageContent[] = [];
1551
+ for (const part of message.content) {
1552
+ if (part.type === "image" && typeof part.data === "string" && typeof part.mimeType === "string") {
1553
+ images.push(part);
1554
+ }
1555
+ }
1548
1556
  return images.length > 0 ? images : undefined;
1549
1557
  }
1550
1558
 
@@ -1907,6 +1915,7 @@ export class AgentSession {
1907
1915
  #mcpPromptCommands: LoadedCustomCommand[] = [];
1908
1916
 
1909
1917
  #skillsSettings: SkillsSettings | undefined;
1918
+ #skillsReloadable: boolean;
1910
1919
 
1911
1920
  // Model registry for API key resolution
1912
1921
  #modelRegistry: ModelRegistry;
@@ -2516,6 +2525,7 @@ export class AgentSession {
2516
2525
  this.#skills = config.skills ?? [];
2517
2526
  this.#skillWarnings = config.skillWarnings ?? [];
2518
2527
  this.#customCommands = config.customCommands ?? [];
2528
+ this.#skillsReloadable = config.skillsReloadable ?? true;
2519
2529
  this.#skillsSettings = config.skillsSettings;
2520
2530
  this.#modelRegistry = config.modelRegistry;
2521
2531
  // Resolve the wire service-tier per request so the Fireworks Priority
@@ -3110,11 +3120,11 @@ export class AgentSession {
3110
3120
  /**
3111
3121
  * Route one accepted advice note from `advisor` to the primary. Concern and
3112
3122
  * blocker interrupt the running agent through the steering channel; once the
3113
- * loop has yielded, `triggerTurn` resumes it. If the loop already ended with a
3114
- * terminal text answer and no queued work remains, the note is preserved as an
3115
- * advisor card instead of waking a duplicate completion turn. After a deliberate
3116
- * user interrupt auto-resume is suppressed while idle/unwinding (the note
3117
- * becomes a preserved card re-entering on resume); a live-streaming turn is
3123
+ * loop has yielded, `triggerTurn` resumes it. After a terminal text answer with
3124
+ * no queued work, a concern is preserved as a visible advisor card, while a
3125
+ * blocker wakes the primary to acknowledge work it handed off incorrectly.
3126
+ * After a deliberate user interrupt auto-resume is suppressed while idle/unwinding
3127
+ * (the note becomes a preserved card re-entering on resume); a live-streaming turn is
3118
3128
  * steered in directly. A plain nit always rides the non-interrupting YieldQueue
3119
3129
  * aside. Suppression by the per-advisor emission guard drops the note silently —
3120
3130
  * the model still saw `Recorded.`, so it isn't tempted to rephrase the same note
@@ -3171,10 +3181,19 @@ export class AgentSession {
3171
3181
  });
3172
3182
  return;
3173
3183
  }
3174
- this.#recordAdvisorInterruptDelivered();
3175
- if (this.#planModeState?.enabled) {
3176
- // Plan mode: record advice visibly in context but never wake an
3177
- // autonomous turn only user-driven turns converge on ask/resolve.
3184
+ // A steered interrupting note only continues the run when the session can
3185
+ // actually start (or is already running) a turn. Two idle cases cannot, so
3186
+ // `sendCustomMessage({ triggerTurn: true })` would silently bury the card in
3187
+ // `#pendingNextTurnMessages` until the next user prompt strictly worse than
3188
+ // the visible preserved card. Preserve instead:
3189
+ // - Plan mode: only user-driven turns converge on ask/resolve.
3190
+ // - ACP bridges with `deferAgentInitiatedTurns`: the client cannot show an
3191
+ // agent-initiated turn as busy, so idle triggers are refused (#5628 review).
3192
+ const cannotAutoTrigger =
3193
+ !this.agent.state.isStreaming &&
3194
+ this.#clientBridge?.deferAgentInitiatedTurns === true &&
3195
+ !this.#allowAcpAgentInitiatedTurns;
3196
+ if (this.#planModeState?.enabled || cannotAutoTrigger) {
3178
3197
  this.#preserveAdvisorCard({
3179
3198
  role: "custom",
3180
3199
  customType: "advisor",
@@ -3186,6 +3205,11 @@ export class AgentSession {
3186
3205
  });
3187
3206
  return;
3188
3207
  }
3208
+ // Arm the post-interrupt immune window only now that a turn is actually
3209
+ // being steered/triggered. A merely preserved card never interrupts, so
3210
+ // arming earlier would downgrade the next `advisor.immuneTurns` worth of
3211
+ // real concerns/blockers to skip-idle-flush asides (#5628 review).
3212
+ this.#recordAdvisorInterruptDelivered();
3189
3213
  void this.sendCustomMessage(
3190
3214
  { customType: "advisor", content, display: true, attribution: "agent", details },
3191
3215
  { deliverAs: "steer", triggerTurn: true },
@@ -5585,10 +5609,9 @@ export class AgentSession {
5585
5609
 
5586
5610
  // `local://` URLs (e.g. local://PLAN.md for plan-mode) resolve to a real
5587
5611
  // on-disk artifacts path; pre-caching works as long as we ask the
5588
- // local-protocol handler. Other internal-scheme URLs (agent://, skill://,
5589
- // rule://, mcp://, artifact://) have no stable filesystem representation;
5590
- // skip pre-cache entirely for those the edit tool itself will reject
5591
- // them through its normal dispatch path.
5612
+ // local-protocol handler. Other internal-scheme URLs have no local
5613
+ // filesystem representation; skip pre-cache entirely for those — the
5614
+ // edit tool itself will reject them through its normal dispatch path.
5592
5615
  const resolvedPath = this.#resolveSessionFsPath(path);
5593
5616
  if (resolvedPath === undefined) return undefined;
5594
5617
 
@@ -5688,9 +5711,8 @@ export class AgentSession {
5688
5711
  * - `local://` URLs route through the local-protocol handler so they map
5689
5712
  * onto the session's on-disk artifacts directory; pre-caching, ENOENT
5690
5713
  * handling, and post-edit invalidation all work normally.
5691
- * - Other internal-scheme URLs (agent://, skill://, rule://, mcp://,
5692
- * artifact://) have no stable filesystem path; this returns `undefined`
5693
- * so callers skip filesystem-only operations.
5714
+ * - Other internal-scheme URLs have no local filesystem path; this returns
5715
+ * `undefined` so callers skip filesystem-only operations.
5694
5716
  * - Cwd-relative and absolute paths resolve via `resolveToCwd`.
5695
5717
  */
5696
5718
  #resolveSessionFsPath(filePath: string): string | undefined {
@@ -5698,15 +5720,7 @@ export class AgentSession {
5698
5720
  if (normalized.startsWith("local:")) {
5699
5721
  return resolveLocalUrlToPath(normalized, this.#localProtocolOptions());
5700
5722
  }
5701
- if (
5702
- normalized.startsWith("agent://") ||
5703
- normalized.startsWith("skill://") ||
5704
- normalized.startsWith("rule://") ||
5705
- normalized.startsWith("mcp://") ||
5706
- normalized.startsWith("artifact://")
5707
- ) {
5708
- return undefined;
5709
- }
5723
+ if (isInternalUrlPath(normalized)) return undefined;
5710
5724
  return resolveToCwd(normalized, this.sessionManager.getCwd());
5711
5725
  }
5712
5726
 
@@ -6817,6 +6831,34 @@ export class AgentSession {
6817
6831
  this.emitNotice("info", `xd://: ${parts.join("; ")}`, "xdev");
6818
6832
  }
6819
6833
 
6834
+ /**
6835
+ * Rediscover disk-backed skills and rebuild prompt-facing state without
6836
+ * recreating the session. Explicit skill snapshots (`--no-skills`,
6837
+ * SDK-provided `skills`) remain fixed for the lifetime of the session.
6838
+ */
6839
+ async refreshSkills(): Promise<void> {
6840
+ if (!this.#skillsReloadable) {
6841
+ return;
6842
+ }
6843
+
6844
+ resetCapabilities();
6845
+ const skillsSettings = this.settings.getGroup("skills");
6846
+ const discovered = await loadSkills({
6847
+ ...skillsSettings,
6848
+ cwd: this.sessionManager.getCwd(),
6849
+ disabledExtensions: this.settings.get("disabledExtensions") ?? [],
6850
+ });
6851
+ this.#skills = discovered.skills;
6852
+ this.#skillWarnings = discovered.warnings;
6853
+ this.#skillsSettings = skillsSettings;
6854
+
6855
+ if (this.#agentKind === "main") {
6856
+ setActiveSkills(this.#skills);
6857
+ }
6858
+ await this.refreshBaseSystemPrompt();
6859
+ this.#notifyCommandMetadataChanged();
6860
+ }
6861
+
6820
6862
  /**
6821
6863
  * Set active tools by name.
6822
6864
  * Only tools in the registry can be enabled. Unknown tool names are ignored.
@@ -8264,8 +8306,11 @@ export class AgentSession {
8264
8306
  },
8265
8307
  hasPendingMessages: () => this.queuedMessageCount > 0,
8266
8308
  shutdown: () => {
8267
- void this.dispose();
8268
- process.exit(0);
8309
+ // Await the idempotent dispose() before exiting so the browser
8310
+ // reaper and other bounded teardown complete — a fire-and-forget
8311
+ // `void this.dispose()` raced process.exit() and could leave an
8312
+ // OMP-owned Chromium alive (#5643).
8313
+ void this.dispose().finally(() => process.exit(0));
8269
8314
  },
8270
8315
  getContextUsage: () => this.getContextUsage(),
8271
8316
  waitForIdle: () => this.waitForIdle(),
@@ -1,6 +1,6 @@
1
1
  import * as os from "node:os";
2
2
  import * as path from "node:path";
3
- import type { Message, TextContent } from "@oh-my-pi/pi-ai";
3
+ import type { Message } from "@oh-my-pi/pi-ai";
4
4
  import { getAgentDir as getDefaultAgentDir, logger, parseJsonlLenient, toError } from "@oh-my-pi/pi-utils";
5
5
  import { computeDefaultSessionDir } from "./session-paths";
6
6
  import { FileSessionStorage, type SessionStorage } from "./session-storage";
@@ -108,10 +108,11 @@ function sessionDisplayName(info: SessionInfo): string {
108
108
 
109
109
  function extractTextFromContent(content: Message["content"]): string {
110
110
  if (typeof content === "string") return content;
111
- return content
112
- .filter((block): block is TextContent => block.type === "text")
113
- .map(block => block.text)
114
- .join(" ");
111
+ const text: string[] = [];
112
+ for (const block of content) {
113
+ if (block.type === "text") text.push(block.text);
114
+ }
115
+ return text.join(" ");
115
116
  }
116
117
 
117
118
  /**
@@ -252,6 +252,15 @@ async function resolvePersistedBlobRefs(value: unknown, blobStore: BlobStore, ke
252
252
  }
253
253
 
254
254
  if (typeof value !== "object" || value === null) return;
255
+ if (
256
+ "type" in value &&
257
+ value.type === "image_generation_call" &&
258
+ "result" in value &&
259
+ typeof value.result === "string" &&
260
+ isBlobRef(value.result)
261
+ ) {
262
+ value.result = await resolveImageData(blobStore, value.result);
263
+ }
255
264
 
256
265
  if (hasImageUrl(value) && isBlobRef(value.image_url)) {
257
266
  value.image_url = await resolveImageDataUrl(blobStore, value.image_url);
@@ -79,6 +79,17 @@ function isNonEmptyString(value: unknown): value is string {
79
79
  */
80
80
  function truncateForPersistence(obj: unknown, blobStore: BlobStore, key?: string): unknown {
81
81
  if (obj === null || obj === undefined) return obj;
82
+ if (
83
+ typeof obj === "object" &&
84
+ "type" in obj &&
85
+ obj.type === "image_generation_call" &&
86
+ "result" in obj &&
87
+ typeof obj.result === "string" &&
88
+ !isBlobRef(obj.result) &&
89
+ obj.result.length >= BLOB_EXTERNALIZE_THRESHOLD
90
+ ) {
91
+ return { ...obj, result: externalizeImageDataSync(blobStore, obj.result) };
92
+ }
82
93
  if (shouldExternalizeImagePayload(obj, key)) {
83
94
  return { ...obj, data: externalizeImageDataSync(blobStore, obj.data, obj.mimeType) };
84
95
  }
@@ -43,6 +43,8 @@ export interface OutputSummary {
43
43
  columnDroppedBytes?: number;
44
44
  /** Number of distinct lines that hit the per-line column cap. */
45
45
  columnTruncatedLines?: number;
46
+ /** Configured per-line column cap in effect (chars), when > 0. */
47
+ columnMax?: number;
46
48
  /** Artifact ID for internal URL access (artifact://<id>) when truncated */
47
49
  artifactId?: string;
48
50
  }
@@ -836,7 +838,6 @@ export class OutputSink {
836
838
  const capped = this.#maxColumns > 0 ? this.#applyColumnCap(chunk) : chunk;
837
839
  const cappedBytes = capped === chunk ? rawBytes : Buffer.byteLength(capped, "utf-8");
838
840
  const cappedThisChunk = cappedBytes < rawBytes;
839
- if (cappedThisChunk) this.#truncated = true;
840
841
 
841
842
  // Mirror RAW chunk to the artifact file so the on-disk record is the full
842
843
  // uncapped stream. Mirror triggers on: in-memory overflow OR this chunk's
@@ -1276,6 +1277,7 @@ export class OutputSink {
1276
1277
  elidedLines,
1277
1278
  columnDroppedBytes: this.#columnDroppedBytes > 0 ? this.#columnDroppedBytes : undefined,
1278
1279
  columnTruncatedLines: this.#columnTruncatedLines > 0 ? this.#columnTruncatedLines : undefined,
1280
+ columnMax: this.#columnTruncatedLines > 0 ? this.#maxColumns : undefined,
1279
1281
  artifactId: this.#file?.artifactId,
1280
1282
  };
1281
1283
  }
@@ -2254,6 +2254,7 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<SlashCommandSpec> = [
2254
2254
  // listClaudePluginRoots re-reads from disk on next access.
2255
2255
  const projectPath = await resolveActiveProjectRegistryPath(runtime.ctx.sessionManager.getCwd());
2256
2256
  clearPluginRootsAndCaches(projectPath ? [projectPath] : undefined);
2257
+ await runtime.ctx.refreshSkillState();
2257
2258
  await runtime.ctx.refreshSlashCommandState();
2258
2259
  resetCapabilities();
2259
2260
  runtime.ctx.showStatus("Plugins reloaded.");
@@ -2601,6 +2602,7 @@ export async function executeBuiltinSlashCommand(
2601
2602
  reloadPlugins: async () => {
2602
2603
  const projectPath = await resolveActiveProjectRegistryPath(ctx.sessionManager.getCwd());
2603
2604
  clearPluginRootsAndCaches(projectPath ? [projectPath] : undefined);
2605
+ await ctx.refreshSkillState();
2604
2606
  await ctx.refreshSlashCommandState();
2605
2607
  resetCapabilities();
2606
2608
  },
@@ -64,10 +64,10 @@ export interface SlashCommandRuntime {
64
64
  /** Re-advertise the available command list (no-op outside ACP). */
65
65
  refreshCommands: () => Promise<void> | void;
66
66
  /**
67
- * Reload plugin state (caches, slash command registry, project registries)
68
- * and re-emit available commands. Used by `/reload-plugins`, `/move`, and
69
- * `/marketplace`/`/plugins` mutations so the session sees a consistent view
70
- * after plugin or project-scope changes.
67
+ * Reload plugin state (caches, skills, slash command registry, project
68
+ * registries) and re-emit available commands. Used by `/reload-plugins`,
69
+ * `/move`, and `/marketplace`/`/plugins` mutations so the session sees a
70
+ * consistent view after plugin or project-scope changes.
71
71
  */
72
72
  reloadPlugins: () => Promise<void>;
73
73
  notifyTitleChanged?: () => Promise<void> | void;
@@ -470,7 +470,7 @@ export interface BuildSystemPromptOptions {
470
470
  /** Pre-loaded context files (skips discovery if provided). */
471
471
  contextFiles?: Array<{ path: string; content: string; depth?: number }>;
472
472
  /** Skills provided directly to system prompt construction. */
473
- skills?: Skill[];
473
+ skills?: readonly Skill[];
474
474
  /** Pre-loaded rulebook rules (descriptions, excluding TTSR and always-apply). */
475
475
  rules?: Array<{ name: string; description?: string; path: string; globs?: string[] }>;
476
476
  /** Intent field name injected into every tool schema. If set, explains the field in the prompt. */
@@ -637,7 +637,7 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}):
637
637
  totalLines: 0,
638
638
  agentsMdFiles: [],
639
639
  });
640
- const skillsPromise: Promise<Skill[]> =
640
+ const skillsPromise: Promise<readonly Skill[]> =
641
641
  providedSkills !== undefined
642
642
  ? Promise.resolve(providedSkills)
643
643
  : skillsSettings?.enabled !== false
@@ -47,6 +47,13 @@ export interface CmuxBrowserHandle extends BrowserHandleCommon {
47
47
 
48
48
  export type BrowserHandle = PuppeteerBrowserHandle | CmuxBrowserHandle;
49
49
 
50
+ /** Controls bounded browser-handle teardown and identifies the owning resource in timeout diagnostics. */
51
+ export interface ReleaseBrowserOptions {
52
+ kill: boolean;
53
+ timeoutMs?: number;
54
+ resource?: string;
55
+ }
56
+
50
57
  const browsers = new Map<string, BrowserHandle>();
51
58
 
52
59
  function browserKey(kind: BrowserKind): string {
@@ -221,7 +228,7 @@ export function holdBrowser(handle: BrowserHandle): void {
221
228
  handle.refCount++;
222
229
  }
223
230
 
224
- export async function releaseBrowser(handle: BrowserHandle, opts: { kill: boolean }): Promise<void> {
231
+ export async function releaseBrowser(handle: BrowserHandle, opts: ReleaseBrowserOptions): Promise<void> {
225
232
  handle.refCount = Math.max(0, handle.refCount - 1);
226
233
  if (handle.refCount === 0) {
227
234
  // Only evict if the registry still points at THIS handle. After a disconnect,
@@ -232,7 +239,7 @@ export async function releaseBrowser(handle: BrowserHandle, opts: { kill: boolea
232
239
  }
233
240
  }
234
241
 
235
- async function disposeBrowserHandle(handle: BrowserHandle, opts: { kill: boolean }): Promise<void> {
242
+ async function disposeBrowserHandle(handle: BrowserHandle, opts: ReleaseBrowserOptions): Promise<void> {
236
243
  if ("client" in handle) {
237
244
  handle.client.close();
238
245
  return;
@@ -1,4 +1,4 @@
1
- import { getPuppeteerDir, logger, postmortem, Snowflake, workerHostEntry } from "@oh-my-pi/pi-utils";
1
+ import { getPuppeteerDir, logger, postmortem, Snowflake, withTimeout, workerHostEntry } from "@oh-my-pi/pi-utils";
2
2
  import type { Page, Target } from "puppeteer-core";
3
3
  import { callSessionTool } from "../../eval/js/tool-bridge";
4
4
  import { webpExclusionForModel } from "../../utils/image-loading";
@@ -123,6 +123,8 @@ export interface RunInTabOptions {
123
123
 
124
124
  export interface ReleaseTabOptions {
125
125
  kill?: boolean;
126
+ /** Maximum time for each asynchronous cleanup resource before close fails with diagnostics. */
127
+ timeoutMs?: number;
126
128
  }
127
129
 
128
130
  const tabs = new Map<string, TabSession>();
@@ -135,6 +137,22 @@ const GRACE_MS = 750;
135
137
  // mapped to the kill reason. Lets the next `run` on that name explain WHY the tab
136
138
  // vanished instead of a bare "not alive". Cleared when the name is opened again.
137
139
  const killedTabs = new Map<string, string>();
140
+ const DEFAULT_TAB_CLOSE_TIMEOUT_MS = 5_000;
141
+
142
+ async function waitForTabCleanup<T>(
143
+ tab: TabSession,
144
+ timeoutMs: number,
145
+ pendingResource: string,
146
+ promise: Promise<T>,
147
+ ): Promise<T> {
148
+ const message = `Timed out after ${timeoutMs}ms closing ${tab.kindTag} browser tab ${JSON.stringify(tab.name)}; pending resource: ${pendingResource}`;
149
+ try {
150
+ return await withTimeout(promise, timeoutMs, message);
151
+ } catch (error) {
152
+ if (error instanceof Error && error.message === message) throw new ToolError(message);
153
+ throw error;
154
+ }
155
+ }
138
156
 
139
157
  export function getTab(name: string): TabSession | undefined {
140
158
  return tabs.get(name);
@@ -519,26 +537,42 @@ export async function releaseTab(name: string, opts: ReleaseTabOptions = {}): Pr
519
537
  pending.reject(closeError);
520
538
  }
521
539
  tab.pending.clear();
540
+ const timeoutMs = opts.timeoutMs ?? DEFAULT_TAB_CLOSE_TIMEOUT_MS;
522
541
  if (tab.backend === "cmux") {
523
- let nonLastCloseError: unknown;
542
+ let closeError: unknown;
524
543
  if (wasAlive && tab.cmuxOwnsSurface) {
525
544
  try {
526
- await tab.browser.client.request("surface.close", { surface_id: tab.targetId });
545
+ await waitForTabCleanup(
546
+ tab,
547
+ timeoutMs,
548
+ `cmux surface ${JSON.stringify(tab.targetId)} (surface.close)`,
549
+ tab.browser.client.request("surface.close", { surface_id: tab.targetId }, { timeoutMs }),
550
+ );
527
551
  } catch (err) {
528
552
  if (isLastSurfaceCloseError(err)) {
529
553
  logger.debug("Leaving cmux browser surface open because it is the last surface in the workspace", {
530
554
  error: err instanceof Error ? err.message : String(err),
531
555
  });
532
556
  } else {
533
- nonLastCloseError = err;
557
+ closeError = err;
534
558
  }
535
559
  }
536
560
  }
537
- await releaseBrowser(tab.browser, { kill: opts.kill ?? false });
538
- tabs.delete(name);
539
- if (nonLastCloseError) throw nonLastCloseError;
561
+ try {
562
+ await releaseBrowser(tab.browser, {
563
+ kill: opts.kill ?? false,
564
+ timeoutMs,
565
+ resource: `tab ${JSON.stringify(name)}`,
566
+ });
567
+ } catch (error) {
568
+ closeError ??= error;
569
+ } finally {
570
+ tabs.delete(name);
571
+ }
572
+ if (closeError) throw closeError;
540
573
  return true;
541
574
  }
575
+ let cleanupError: unknown;
542
576
  let forced = false;
543
577
  if (wasAlive) {
544
578
  try {
@@ -549,9 +583,30 @@ export async function releaseTab(name: string, opts: ReleaseTabOptions = {}): Pr
549
583
  }
550
584
  }
551
585
  await tab.worker.terminate().catch(() => undefined);
552
- if (forced && tab.kindTag === "headless") await closeOrphanTarget(tab);
553
- await releaseBrowser(tab.browser, { kill: opts.kill ?? false });
554
- tabs.delete(name);
586
+ if (forced && tab.kindTag === "headless") {
587
+ try {
588
+ await waitForTabCleanup(
589
+ tab,
590
+ timeoutMs,
591
+ `orphan CDP target ${JSON.stringify(tab.targetId)} (Page.close)`,
592
+ closeOrphanTarget(tab),
593
+ );
594
+ } catch (error) {
595
+ cleanupError = error;
596
+ }
597
+ }
598
+ try {
599
+ await releaseBrowser(tab.browser, {
600
+ kill: opts.kill ?? false,
601
+ timeoutMs,
602
+ resource: `tab ${JSON.stringify(name)}`,
603
+ });
604
+ } catch (error) {
605
+ cleanupError ??= error;
606
+ } finally {
607
+ tabs.delete(name);
608
+ }
609
+ if (cleanupError) throw cleanupError;
555
610
  return true;
556
611
  }
557
612
 
@@ -199,7 +199,7 @@ export class BrowserTool implements AgentTool<typeof browserSchema, BrowserToolD
199
199
  case "open":
200
200
  return await this.#open(name, params, details, timeoutMs, signal);
201
201
  case "close":
202
- return await this.#close(name, params, details, signal);
202
+ return await this.#close(name, params, details, timeoutMs, signal);
203
203
  case "run":
204
204
  return await this.#run(name, params, details, timeoutMs, signal);
205
205
  default:
@@ -284,15 +284,16 @@ export class BrowserTool implements AgentTool<typeof browserSchema, BrowserToolD
284
284
  name: string,
285
285
  params: BrowserParams,
286
286
  details: BrowserToolDetails,
287
+ timeoutMs: number,
287
288
  signal?: AbortSignal,
288
289
  ): Promise<AgentToolResult<BrowserToolDetails>> {
289
290
  const kill = !!params.kill;
290
291
  if (params.all) {
291
- const count = await untilAborted(signal, () => releaseAllTabs({ kill }));
292
+ const count = await untilAborted(signal, () => releaseAllTabs({ kill, timeoutMs }));
292
293
  details.result = `Closed ${count} tab(s)`;
293
294
  return toolResult(details).text(details.result).done();
294
295
  }
295
- const closed = await untilAborted(signal, () => releaseTab(name, { kill }));
296
+ const closed = await untilAborted(signal, () => releaseTab(name, { kill, timeoutMs }));
296
297
  details.result = closed ? `Closed tab ${JSON.stringify(name)}` : `No tab named ${JSON.stringify(name)}`;
297
298
  return toolResult(details).text(details.result).done();
298
299
  }
package/src/tools/eval.ts CHANGED
@@ -768,5 +768,8 @@ async function summarizeFinal(
768
768
  outputLines,
769
769
  outputBytes,
770
770
  artifactId: rawSummary.artifactId,
771
+ columnDroppedBytes: rawSummary.columnDroppedBytes,
772
+ columnTruncatedLines: rawSummary.columnTruncatedLines,
773
+ columnMax: rawSummary.columnMax,
771
774
  };
772
775
  }