@oh-my-pi/pi-coding-agent 16.0.7 → 16.0.8

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 (85) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/dist/cli.js +4752 -12462
  3. package/dist/types/cli/update-cli.d.ts +11 -0
  4. package/dist/types/debug/remote-debugger.d.ts +45 -0
  5. package/dist/types/internal-urls/docs-index.d.ts +19 -0
  6. package/dist/types/markit/converters/docx.d.ts +6 -0
  7. package/dist/types/markit/converters/epub.d.ts +15 -0
  8. package/dist/types/markit/converters/pdf/columns.d.ts +35 -0
  9. package/dist/types/markit/converters/pdf/extract.d.ts +10 -0
  10. package/dist/types/markit/converters/pdf/grid.d.ts +25 -0
  11. package/dist/types/markit/converters/pdf/headers.d.ts +24 -0
  12. package/dist/types/markit/converters/pdf/index.d.ts +6 -0
  13. package/dist/types/markit/converters/pdf/render.d.ts +24 -0
  14. package/dist/types/markit/converters/pdf/types.d.ts +75 -0
  15. package/dist/types/markit/converters/pptx.d.ts +57 -0
  16. package/dist/types/markit/converters/xlsx.d.ts +25 -0
  17. package/dist/types/markit/index.d.ts +2 -0
  18. package/dist/types/markit/registry.d.ts +16 -0
  19. package/dist/types/markit/types.d.ts +30 -0
  20. package/dist/types/session/agent-session.d.ts +7 -8
  21. package/dist/types/session/auth-storage.d.ts +3 -2
  22. package/dist/types/session/yield-queue.d.ts +3 -1
  23. package/dist/types/tools/browser/attach.d.ts +1 -1
  24. package/dist/types/utils/markit.d.ts +0 -8
  25. package/dist/types/utils/mupdf-wasm-embed.d.ts +1 -0
  26. package/dist/types/utils/turndown.d.ts +15 -0
  27. package/dist/types/utils/zip.d.ts +119 -0
  28. package/package.json +20 -18
  29. package/scripts/build-binary.ts +7 -3
  30. package/scripts/bundle-dist.ts +28 -12
  31. package/scripts/embed-mupdf-wasm.ts +67 -0
  32. package/scripts/generate-docs-index.ts +48 -32
  33. package/scripts/omp +1 -1
  34. package/src/advisor/__tests__/advisor.test.ts +83 -0
  35. package/src/advisor/runtime.ts +16 -1
  36. package/src/cli/auth-broker-cli.ts +1 -3
  37. package/src/cli/auth-gateway-cli.ts +2 -5
  38. package/src/cli/update-cli.ts +63 -3
  39. package/src/config/model-discovery.ts +20 -8
  40. package/src/config/models-config-schema.ts +8 -1
  41. package/src/debug/index.ts +44 -0
  42. package/src/debug/remote-debugger.ts +151 -0
  43. package/src/debug/report-bundle.ts +2 -1
  44. package/src/internal-urls/docs-index.generated.txt +2 -0
  45. package/src/internal-urls/docs-index.ts +102 -0
  46. package/src/internal-urls/omp-protocol.ts +10 -9
  47. package/src/markit/NOTICE +32 -0
  48. package/src/markit/converters/docx.ts +56 -0
  49. package/src/markit/converters/epub.ts +136 -0
  50. package/src/markit/converters/mammoth.d.ts +24 -0
  51. package/src/markit/converters/pdf/columns.ts +103 -0
  52. package/src/markit/converters/pdf/extract.ts +574 -0
  53. package/src/markit/converters/pdf/grid.ts +780 -0
  54. package/src/markit/converters/pdf/headers.ts +106 -0
  55. package/src/markit/converters/pdf/index.ts +146 -0
  56. package/src/markit/converters/pdf/render.ts +501 -0
  57. package/src/markit/converters/pdf/types.ts +84 -0
  58. package/src/markit/converters/pptx.ts +325 -0
  59. package/src/markit/converters/xlsx.ts +173 -0
  60. package/src/markit/index.ts +2 -0
  61. package/src/markit/registry.ts +59 -0
  62. package/src/markit/types.ts +35 -0
  63. package/src/modes/components/snapcompact-shape-preview-doc.md +14 -7
  64. package/src/modes/components/snapcompact-shape-preview.ts +2 -2
  65. package/src/modes/controllers/input-controller.ts +29 -8
  66. package/src/modes/interactive-mode.ts +26 -9
  67. package/src/prompts/advisor/system.md +1 -0
  68. package/src/sdk.ts +5 -9
  69. package/src/session/agent-session.ts +62 -40
  70. package/src/session/auth-storage.ts +2 -11
  71. package/src/session/yield-queue.ts +7 -1
  72. package/src/tools/browser/attach.ts +2 -2
  73. package/src/tools/fetch.ts +25 -60
  74. package/src/tools/read.ts +1 -1
  75. package/src/tools/search.ts +1 -6
  76. package/src/tools/write.ts +25 -65
  77. package/src/utils/markit.ts +25 -9
  78. package/src/utils/mupdf-wasm-embed.ts +12 -0
  79. package/src/utils/tools-manager.ts +2 -11
  80. package/src/utils/turndown.ts +83 -0
  81. package/src/{tools/archive-reader.ts → utils/zip.ts} +453 -83
  82. package/src/web/scrapers/types.ts +3 -46
  83. package/dist/types/internal-urls/docs-index.generated.d.ts +0 -2
  84. package/dist/types/tools/archive-reader.d.ts +0 -49
  85. package/src/internal-urls/docs-index.generated.ts +0 -120
@@ -44,6 +44,26 @@ function hasPasteText(value: unknown): value is PasteTarget {
44
44
  return typeof value === "object" && value !== null && typeof (value as PasteTarget).pasteText === "function";
45
45
  }
46
46
 
47
+ function pythonCommandPrefixLength(trimmedText: string): 0 | 1 | 2 {
48
+ if (trimmedText.charCodeAt(0) !== 36 /* $ */) return 0;
49
+ if (trimmedText.charCodeAt(1) === 123 /* { */) return 0;
50
+
51
+ const prefixLength = trimmedText.charCodeAt(1) === 36 /* $ */ ? 2 : 1;
52
+ const next = trimmedText.charCodeAt(prefixLength);
53
+ if (Number.isNaN(next)) return prefixLength;
54
+ return next === 32 || next === 9 || next === 10 || next === 13 ? prefixLength : 0;
55
+ }
56
+
57
+ function parsePythonCommandInput(text: string): { code: string; isExcluded: boolean } | undefined {
58
+ const trimmed = text.trimStart();
59
+ const prefixLength = pythonCommandPrefixLength(trimmed);
60
+ if (prefixLength === 0) return undefined;
61
+ return {
62
+ code: trimmed.slice(prefixLength).trim(),
63
+ isExcluded: prefixLength === 2,
64
+ };
65
+ }
66
+
47
67
  /** Wrap pasted text in `<attachment>` tags so the model treats it as one quoted block. */
48
68
  function wrapPasteInAttachmentBlock(content: string): string {
49
69
  return `<attachment>\n${content}\n</attachment>`;
@@ -381,8 +401,8 @@ export class InputController {
381
401
  const wasBashMode = this.ctx.isBashMode;
382
402
  const wasPythonMode = this.ctx.isPythonMode;
383
403
  const trimmed = text.trimStart();
384
- this.ctx.isBashMode = text.trimStart().startsWith("!");
385
- this.ctx.isPythonMode = trimmed.startsWith("$") && !trimmed.startsWith("${");
404
+ this.ctx.isBashMode = trimmed.startsWith("!");
405
+ this.ctx.isPythonMode = pythonCommandPrefixLength(trimmed) > 0;
386
406
  if (wasBashMode !== this.ctx.isBashMode || wasPythonMode !== this.ctx.isPythonMode) {
387
407
  this.ctx.updateEditorBorderColor();
388
408
  }
@@ -550,7 +570,7 @@ export class InputController {
550
570
  this.ctx.editor.setText("");
551
571
  return;
552
572
  }
553
- if (text.startsWith("!") || text.startsWith("$")) {
573
+ if (text.startsWith("!") || parsePythonCommandInput(text)) {
554
574
  this.ctx.showStatus("Local execution is host-only during a collab session");
555
575
  this.ctx.editor.setText("");
556
576
  return;
@@ -598,10 +618,11 @@ export class InputController {
598
618
  }
599
619
  }
600
620
 
601
- // Handle python command ($ for normal, $$ for excluded from context)
602
- if (text.startsWith("$")) {
603
- const isExcluded = text.startsWith("$$");
604
- const code = isExcluded ? text.slice(2).trim() : text.slice(1).trim();
621
+ // Handle python command (`$ <code>` for normal, `$$ <code>` for excluded from context).
622
+ // Shell-style variables such as `$HOME` are normal prose unless a space follows the sigil.
623
+ const pythonCommand = parsePythonCommandInput(text);
624
+ if (pythonCommand) {
625
+ const { code, isExcluded } = pythonCommand;
605
626
  if (code) {
606
627
  if (this.ctx.session.isEvalRunning) {
607
628
  this.ctx.showWarning("A Python execution is already running. Press Esc to cancel it first.");
@@ -768,7 +789,7 @@ export class InputController {
768
789
  }
769
790
  return;
770
791
  }
771
- if (text.startsWith("/") || text.startsWith("!") || text.startsWith("$")) {
792
+ if (text.startsWith("/") || text.startsWith("!") || parsePythonCommandInput(text)) {
772
793
  this.ctx.showStatus("Commands run in the main session — press ←← to return first");
773
794
  return; // editor text not cleared: Editor does not auto-clear on submit
774
795
  }
@@ -2309,7 +2309,7 @@ export class InteractiveMode implements InteractiveModeContext {
2309
2309
  // Branchless mark+clear when !compactBeforeExecute: mark is gated; clear
2310
2310
  // is unconditional and idempotent.
2311
2311
  if (options.compactBeforeExecute) {
2312
- this.session.markPlanCompactAbortPending();
2312
+ this.session.markPlanInternalAbortPending();
2313
2313
  }
2314
2314
  let compactOutcome: CompactionOutcome | undefined;
2315
2315
  try {
@@ -2355,7 +2355,7 @@ export class InteractiveMode implements InteractiveModeContext {
2355
2355
  // (i.e., the !compactBeforeExecute branch), and a no-op when the flag
2356
2356
  // was already consumed by AgentSession.#handleAgentEvent's aborted
2357
2357
  // message_end stamping. Guarantees the flag is dead at every exit.
2358
- this.session.clearPlanCompactAbortPending();
2358
+ this.session.clearPlanInternalAbortPending();
2359
2359
  }
2360
2360
 
2361
2361
  // Tool restoration runs on every path — the plan mode tools must be
@@ -2421,10 +2421,18 @@ export class InteractiveMode implements InteractiveModeContext {
2421
2421
  // in-flight turn first — abort() bumps the prompt generation and cancels pending
2422
2422
  // continuations, so nothing re-streams in the synchronous gap before prompt().
2423
2423
  if (this.session.isStreaming) {
2424
- await this.session.abort();
2424
+ await this.#abortPlanApprovalTurnSilently();
2425
2425
  }
2426
2426
  await this.session.prompt(planModePrompt, { synthetic: true });
2427
2427
  }
2428
+ async #abortPlanApprovalTurnSilently(): Promise<void> {
2429
+ this.session.markPlanInternalAbortPending();
2430
+ try {
2431
+ await this.session.abort();
2432
+ } finally {
2433
+ this.session.clearPlanInternalAbortPending();
2434
+ }
2435
+ }
2428
2436
 
2429
2437
  async handlePlanModeCommand(initialPrompt?: string): Promise<void> {
2430
2438
  if (this.goalModeEnabled || this.goalModePaused) {
@@ -2807,7 +2815,8 @@ export class InteractiveMode implements InteractiveModeContext {
2807
2815
  // plan) while the popup is showing. The event listener fires asynchronously
2808
2816
  // (agent's #emit is fire-and-forget), so without this the model sees
2809
2817
  // "Plan ready for approval." and immediately re-invokes `resolve` in a loop.
2810
- await this.session.abort();
2818
+ // This abort is an internal UI transition, not operator cancellation.
2819
+ await this.#abortPlanApprovalTurnSilently();
2811
2820
 
2812
2821
  const planFilePath = details.planFilePath || this.planModePlanFilePath || (await this.#getPlanFilePath());
2813
2822
  this.planModePlanFilePath = planFilePath;
@@ -2912,11 +2921,19 @@ export class InteractiveMode implements InteractiveModeContext {
2912
2921
  }
2913
2922
 
2914
2923
  if (choice === "Refine plan") {
2915
- // Section annotations entered in the overlay become a refinement prompt
2916
- // re-submitted to the model. With no annotations, fall back to today's
2917
- // behavior: close the overlay and let the operator type their own.
2918
- if (feedback.trim() && this.onInputCallback) {
2919
- this.onInputCallback(this.startPendingSubmission({ text: feedback }));
2924
+ const refinement = feedback.trim();
2925
+ try {
2926
+ if (refinement) {
2927
+ if (this.onInputCallback) {
2928
+ this.onInputCallback(this.startPendingSubmission({ text: feedback }));
2929
+ } else {
2930
+ await this.session.prompt(feedback);
2931
+ }
2932
+ } else {
2933
+ this.showStatus("Refine plan: enter a follow-up prompt.");
2934
+ }
2935
+ } catch (error) {
2936
+ this.showError(`Failed to refine plan: ${error instanceof Error ? error.message : String(error)}`);
2920
2937
  }
2921
2938
  return;
2922
2939
  }
@@ -15,6 +15,7 @@ Keep exploration lean — 2–3 calls per advise unless you've spotted a critica
15
15
 
16
16
  <communication>
17
17
  At most one `advise` per update. Prefer silence when the agent is on track. Address the agent directly. Offer alternatives, not lectures. Never restate what they know; never explain how to use the advisor.
18
+ Do not comment merely to add insight, context, or a second opinion. NEVER restate information the agent already has, including tool or CLI errors returned directly to it. NEVER flag a problem that will surface on its own — type errors, LSP diagnostics, failed builds, failing tests, lint — the agent's own tooling catches those. NEVER repeat advice you already gave.
18
19
  </communication>
19
20
 
20
21
  <critical>
package/src/sdk.ts CHANGED
@@ -56,6 +56,10 @@ import { loadPromptTemplates as loadPromptTemplatesInternal, type PromptTemplate
56
56
  import { Settings, type SkillsSettings } from "./config/settings";
57
57
  import { CursorExecHandlers } from "./cursor";
58
58
  import "./discovery";
59
+ import { AuthBrokerClient } from "@oh-my-pi/pi-ai/auth-broker/client";
60
+ import { RemoteAuthCredentialStore } from "@oh-my-pi/pi-ai/auth-broker/remote-store";
61
+ import { readAuthBrokerSnapshotCache, writeAuthBrokerSnapshotCache } from "@oh-my-pi/pi-ai/auth-broker/snapshot-cache";
62
+ import { DEFAULT_SNAPSHOT_CACHE_TTL_MS, type SnapshotResponse } from "@oh-my-pi/pi-ai/auth-broker/types";
59
63
  import { resolveConfigValue } from "./config/resolve-config-value";
60
64
  import { initializeWithSettings } from "./discovery";
61
65
  import { disposeAllKernelSessions, disposeKernelSessionsByOwner } from "./eval/py/executor";
@@ -116,15 +120,7 @@ import {
116
120
  } from "./secrets";
117
121
  import { AgentSession } from "./session/agent-session";
118
122
  import { resolveAuthBrokerConfig } from "./session/auth-broker-config";
119
- import {
120
- AuthBrokerClient,
121
- AuthStorage,
122
- DEFAULT_SNAPSHOT_CACHE_TTL_MS,
123
- RemoteAuthCredentialStore,
124
- readAuthBrokerSnapshotCache,
125
- type SnapshotResponse,
126
- writeAuthBrokerSnapshotCache,
127
- } from "./session/auth-storage";
123
+ import { AuthStorage } from "./session/auth-storage";
128
124
  import {
129
125
  type CustomMessage,
130
126
  convertToLlm,
@@ -34,7 +34,6 @@ import {
34
34
  type CompactionSummaryMessage,
35
35
  countTokens,
36
36
  resolveTelemetry,
37
- STREAM_INTERRUPTED_AFTER_CONTENT_STOP_DETAIL,
38
37
  ThinkingLevel,
39
38
  } from "@oh-my-pi/pi-agent-core";
40
39
  import {
@@ -1235,13 +1234,12 @@ export class AgentSession {
1235
1234
  #ttsrResumePromise: Promise<void> | undefined = undefined;
1236
1235
  #ttsrResumeResolve: (() => void) | undefined = undefined;
1237
1236
 
1238
- /** One-shot flag set in InteractiveMode.#approvePlan(compactBeforeExecute=true)
1239
- * before the plan-mode compaction transition. Consumed inside
1240
- * #handleAgentEvent for the matching `message_end` + `stopReason: "aborted"`;
1241
- * cleared unconditionally by the caller's `finally` so it cannot leak into
1242
- * later unrelated aborts (e.g. when compaction returns cancelled/failed
1243
- * without producing an aborted message_end). */
1244
- #planCompactAbortPending = false;
1237
+ /** One-shot flag for expected internal plan-mode aborts. Approval actions may
1238
+ * abort the post-`resolve` continuation before compaction, execution, or
1239
+ * manual refinement. Consumed inside `#handleAgentEvent` for the matching
1240
+ * `message_end` + `stopReason: "aborted"`; callers clear it in `finally` so
1241
+ * it cannot leak into later unrelated aborts. */
1242
+ #planInternalAbortPending = false;
1245
1243
 
1246
1244
  #postPromptTasks = new Set<Promise<unknown>>();
1247
1245
  #postPromptTasksPromise: Promise<void> | undefined = undefined;
@@ -1677,6 +1675,33 @@ export class AgentSession {
1677
1675
  this.#advisorInterruptImmuneTurnStart = this.#advisorPrimaryTurnsCompleted + 1;
1678
1676
  }
1679
1677
 
1678
+ /**
1679
+ * Re-prime the advisor across a conversation boundary: `/new`, `/branch`,
1680
+ * `/btw`, `/tree`, and session switch/resume. Beyond {@link AdvisorRuntime.reset}
1681
+ * (which only re-primes the advisor's transcript view and is also fired by
1682
+ * within-conversation rewrites like compaction/shake/rewind), this clears the
1683
+ * session-level interrupt latches so the prior conversation's cooldown cannot
1684
+ * leak into the new one: the post-interrupt immune-turn window
1685
+ * (`#advisorPrimaryTurnsCompleted`, `#advisorInterruptImmuneTurnStart`) and the
1686
+ * user-interrupt auto-resume suppression flag. It also drops advisor deliveries
1687
+ * still queued against the prior conversation — pending asides in the yield
1688
+ * queue (advisor entries use `skipIdleFlush`, so they linger until the next
1689
+ * `drainLazy` rather than self-flushing), interrupting cards parked in the
1690
+ * agent steer/follow-up queues, and preserved cards deferred to the next turn —
1691
+ * so none of them inject into the new conversation.
1692
+ */
1693
+ #resetAdvisorSessionState(): void {
1694
+ this.#advisorRuntime?.reset();
1695
+ this.#advisorPrimaryTurnsCompleted = 0;
1696
+ this.#advisorInterruptImmuneTurnStart = undefined;
1697
+ this.#advisorAutoResumeSuppressed = false;
1698
+ this.yieldQueue.clear("advisor");
1699
+ this.#extractQueuedAdvisorCards();
1700
+ if (this.#pendingNextTurnMessages.some(isAdvisorCard)) {
1701
+ this.#pendingNextTurnMessages = this.#pendingNextTurnMessages.filter(m => !isAdvisorCard(m));
1702
+ }
1703
+ }
1704
+
1680
1705
  #buildAdvisorRuntime(seedToCurrent = false): boolean {
1681
1706
  if (this.#isDisposed) return false;
1682
1707
  if (this.#advisorRuntime) return true;
@@ -2115,25 +2140,24 @@ export class AgentSession {
2115
2140
  return this.#ttsrAbortPending;
2116
2141
  }
2117
2142
 
2118
- /** Whether the plan-mode compaction transition's expected internal abort is
2119
- * pending. Consumed by `#handleAgentEvent` to stamp `SILENT_ABORT_MARKER`
2120
- * on the next aborted assistant message_end; cleared unconditionally by
2121
- * `InteractiveMode.#approvePlan`'s `finally` block. */
2122
- get isPlanCompactAbortPending(): boolean {
2123
- return this.#planCompactAbortPending;
2143
+ /** Whether an expected internal plan-mode abort is pending. Consumed by
2144
+ * `#handleAgentEvent` to stamp `SILENT_ABORT_MARKER` on the next aborted
2145
+ * assistant message_end; callers clear it in `finally`. */
2146
+ get isPlanInternalAbortPending(): boolean {
2147
+ return this.#planInternalAbortPending;
2124
2148
  }
2125
2149
 
2126
2150
  /** Arm the silent-abort marker for the next aborted assistant message_end.
2127
- * Caller MUST clear via `clearPlanCompactAbortPending()` in a `finally`
2151
+ * Caller MUST clear via `clearPlanInternalAbortPending()` in a `finally`
2128
2152
  * to guarantee no leak. */
2129
- markPlanCompactAbortPending(): void {
2130
- this.#planCompactAbortPending = true;
2153
+ markPlanInternalAbortPending(): void {
2154
+ this.#planInternalAbortPending = true;
2131
2155
  }
2132
2156
 
2133
2157
  /** Unconditionally clear the silent-abort flag. Idempotent: safe when the
2134
2158
  * flag was never set OR was already consumed by `#handleAgentEvent`. */
2135
- clearPlanCompactAbortPending(): void {
2136
- this.#planCompactAbortPending = false;
2159
+ clearPlanInternalAbortPending(): void {
2160
+ this.#planInternalAbortPending = false;
2137
2161
  }
2138
2162
 
2139
2163
  getAsyncJobSnapshot(options?: { recentLimit?: number }): AsyncJobSnapshot | null {
@@ -2283,7 +2307,7 @@ export class AgentSession {
2283
2307
  };
2284
2308
 
2285
2309
  #processAgentEvent = async (event: AgentEvent): Promise<void> => {
2286
- // Plan-mode compaction transition: stamp `SILENT_ABORT_MARKER` on the
2310
+ // Plan-mode internal transition: stamp `SILENT_ABORT_MARKER` on the
2287
2311
  // persisted message BEFORE the obfuscator's display-side copy below.
2288
2312
  // Invariant (must hold across refactors): this branch precedes the
2289
2313
  // `let displayEvent = event; ... displayEvent = { ...event, message: { ...message, content: deobfuscated } }`
@@ -2291,18 +2315,16 @@ export class AgentSession {
2291
2315
  // and `event.message` (in-place mutation, used by SessionManager
2292
2316
  // persistence) carry the marker, guaranteeing streaming render and
2293
2317
  // history replay branch identically. The one-shot flag is consumed
2294
- // here, scoped strictly to this aborted message_end; the caller's
2295
- // `finally` (in `InteractiveMode.#approvePlan`) clears it again on
2296
- // every terminal compaction outcome (`ok` / `cancelled` / `failed` /
2297
- // throw) so a leaked flag cannot silence a later unrelated abort.
2318
+ // here, scoped strictly to this aborted message_end; callers still clear it
2319
+ // in `finally` so a leaked flag cannot silence a later unrelated abort.
2298
2320
  if (
2299
2321
  event.type === "message_end" &&
2300
2322
  event.message.role === "assistant" &&
2301
2323
  event.message.stopReason === "aborted" &&
2302
- this.#planCompactAbortPending
2324
+ this.#planInternalAbortPending
2303
2325
  ) {
2304
2326
  (event.message as AssistantMessage).errorMessage = SILENT_ABORT_MARKER;
2305
- this.#planCompactAbortPending = false;
2327
+ this.#planInternalAbortPending = false;
2306
2328
  }
2307
2329
 
2308
2330
  // Deobfuscate assistant message content for display emission — the LLM echoes back
@@ -6552,7 +6574,7 @@ export class AgentSession {
6552
6574
  this.#todoReminderAwaitingProgress = false;
6553
6575
  this.#planReferenceSent = false;
6554
6576
  this.#planReferencePath = "local://PLAN.md";
6555
- this.#advisorRuntime?.reset();
6577
+ this.#resetAdvisorSessionState();
6556
6578
  this.#reconnectToAgent();
6557
6579
 
6558
6580
  // Emit session_switch event with reason "new" to hooks
@@ -9691,21 +9713,20 @@ export class AgentSession {
9691
9713
  if (this.#isClassifierRefusal(message)) return true;
9692
9714
  if (this.#isProviderErrorFinishReasonBeforeToolUse(message)) return true;
9693
9715
  if (this.#isMalformedFunctionCallError(message)) return true;
9694
- if (this.#streamInterruptedAfterObservableOutput(message)) return false;
9716
+ if (this.#hasReplayUnsafeToolOutput(message)) return false;
9695
9717
  if (this.#isStaleOpenAIResponsesReplayError(message)) return true;
9696
9718
 
9697
9719
  const err = message.errorMessage;
9698
9720
  return this.#isTransientErrorMessage(err) || isUsageLimitError(err);
9699
9721
  }
9700
- #streamInterruptedAfterObservableOutput(message: AssistantMessage): boolean {
9701
- if (message.stopDetails?.type === STREAM_INTERRUPTED_AFTER_CONTENT_STOP_DETAIL) return true;
9702
- for (const block of message.content) {
9703
- if (block.type === "toolCall") return true;
9704
- if (block.type === "text" && block.text.length > 0) return true;
9705
- if (block.type === "thinking" && block.thinking.length > 0) return true;
9706
- if (block.type === "redactedThinking" && block.data.length > 0) return true;
9707
- }
9708
- return false;
9722
+ /**
9723
+ * Retried turns remove the failed assistant message from active context.
9724
+ * Text/thinking-only partials are safe to discard and replay. Retained
9725
+ * tool calls are not: a completed tool call may already have emitted its
9726
+ * tool result after this assistant message, so replaying can duplicate work.
9727
+ */
9728
+ #hasReplayUnsafeToolOutput(message: AssistantMessage): boolean {
9729
+ return message.content.some(block => block.type === "toolCall");
9709
9730
  }
9710
9731
 
9711
9732
  #isStaleOpenAIResponsesReplayError(message: AssistantMessage): boolean {
@@ -10997,6 +11018,7 @@ export class AgentSession {
10997
11018
  }
10998
11019
 
10999
11020
  this.agent.replaceMessages(sessionContext.messages);
11021
+ this.#resetAdvisorSessionState();
11000
11022
  this.#syncTodoPhasesFromBranch();
11001
11023
  if (switchingToDifferentSession) {
11002
11024
  this.#closeAllProviderSessions("session switch");
@@ -11202,7 +11224,7 @@ export class AgentSession {
11202
11224
 
11203
11225
  if (!skipConversationRestore) {
11204
11226
  this.agent.replaceMessages(sessionContext.messages);
11205
- this.#advisorRuntime?.reset();
11227
+ this.#resetAdvisorSessionState();
11206
11228
  this.#closeCodexProviderSessionsForHistoryRewrite();
11207
11229
  }
11208
11230
 
@@ -11291,7 +11313,7 @@ export class AgentSession {
11291
11313
  }
11292
11314
 
11293
11315
  this.agent.replaceMessages(sessionContext.messages);
11294
- this.#advisorRuntime?.reset();
11316
+ this.#resetAdvisorSessionState();
11295
11317
  this.#closeCodexProviderSessionsForHistoryRewrite();
11296
11318
 
11297
11319
  return { cancelled: false, sessionFile: this.sessionFile };
@@ -11457,7 +11479,7 @@ export class AgentSession {
11457
11479
  const displayContext = deobfuscateSessionContext(stateContext, this.#obfuscator);
11458
11480
  await this.#restoreMCPSelectionsForSessionContext(displayContext);
11459
11481
  this.agent.replaceMessages(displayContext.messages);
11460
- this.#advisorRuntime?.reset();
11482
+ this.#resetAdvisorSessionState();
11461
11483
  this.#syncTodoPhasesFromBranch();
11462
11484
  this.#closeCodexProviderSessionsForHistoryRewrite();
11463
11485
 
@@ -18,16 +18,7 @@ export type {
18
18
  ResetCreditRedeemOutcome,
19
19
  ResetCreditTarget,
20
20
  SerializedAuthStorage,
21
- SnapshotResponse,
22
21
  StoredAuthCredential,
23
22
  } from "@oh-my-pi/pi-ai";
24
- export {
25
- AuthBrokerClient,
26
- AuthStorage,
27
- DEFAULT_SNAPSHOT_CACHE_TTL_MS,
28
- REMOTE_REFRESH_SENTINEL,
29
- RemoteAuthCredentialStore,
30
- readAuthBrokerSnapshotCache,
31
- SqliteAuthCredentialStore,
32
- writeAuthBrokerSnapshotCache,
33
- } from "@oh-my-pi/pi-ai";
23
+ export { AuthStorage, REMOTE_REFRESH_SENTINEL, SqliteAuthCredentialStore } from "@oh-my-pi/pi-ai";
24
+ export type { SnapshotResponse } from "@oh-my-pi/pi-ai/auth-broker/types";
@@ -124,7 +124,13 @@ export class YieldQueue {
124
124
  return thunks;
125
125
  }
126
126
 
127
- clear(): void {
127
+ /** Drop queued entries. With `kind`, drop only that kind's entries (leaving
128
+ * any pending idle-flush for other kinds intact); otherwise drop everything. */
129
+ clear(kind?: string): void {
130
+ if (kind !== undefined) {
131
+ this.#entries.delete(kind);
132
+ return;
133
+ }
128
134
  this.#entries.clear();
129
135
  this.#idleFlushPending = false;
130
136
  }
@@ -1,6 +1,6 @@
1
1
  import * as net from "node:net";
2
2
  import { Process, ProcessStatus } from "@oh-my-pi/pi-natives";
3
- import { type Browser, type Page, TargetType } from "puppeteer-core";
3
+ import type { Browser, Page } from "puppeteer-core";
4
4
  import { ToolError, throwIfAborted } from "../tool-errors";
5
5
 
6
6
  const ATTACH_TARGET_SKIP_PATTERN =
@@ -126,7 +126,7 @@ export async function findReusableCdp(
126
126
  export async function pickElectronTarget(browser: Browser, matcher?: string): Promise<Page> {
127
127
  const discoveredPages = await Promise.all(
128
128
  browser.targets().map(async target => {
129
- if (target.type() !== TargetType.PAGE) return null;
129
+ if (String(target.type()) !== "page") return null;
130
130
  return await target.page().catch(() => null);
131
131
  }),
132
132
  );
@@ -20,12 +20,11 @@ import { CachedOutputBlock, markFramedBlockComponent } from "../tui/output-block
20
20
  import { webpExclusionForModel } from "../utils/image-loading";
21
21
  import { formatDimensionNote, resizeImage } from "../utils/image-resize";
22
22
  import { ensureTool } from "../utils/tools-manager";
23
+ import { type ArchiveFormat, listArchiveRoot, sniffArchiveFormat } from "../utils/zip";
23
24
  import { extractWithParallel, findParallelApiKey, getParallelExtractContent } from "../web/parallel";
24
- import { specialHandlers } from "../web/scrapers";
25
- import type { RenderResult } from "../web/scrapers/types";
25
+ import type { RenderResult, SpecialHandler } from "../web/scrapers/types";
26
26
  import { finalizeOutput, loadPage, looksLikeHtml, MAX_BYTES, MAX_OUTPUT_CHARS } from "../web/scrapers/types";
27
27
  import { convertWithMarkit, fetchBinary } from "../web/scrapers/utils";
28
- import { type ArchiveFormat, listArchiveRoot, sniffArchiveFormat } from "./archive-reader";
29
28
  import { applyListLimit } from "./list-limit";
30
29
  import { formatStyledArtifactReference, type OutputMeta } from "./output-meta";
31
30
  import { type LineRange, parseLineRanges } from "./path-utils";
@@ -51,34 +50,9 @@ const CONVERTIBLE_MIMES = new Set([
51
50
  "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
52
51
  "application/rtf",
53
52
  "application/epub+zip",
54
- "image/png",
55
- "image/jpeg",
56
- "image/gif",
57
- "image/webp",
58
- "audio/mpeg",
59
- "audio/wav",
60
- "audio/ogg",
61
53
  ]);
62
54
 
63
- const CONVERTIBLE_EXTENSIONS = new Set([
64
- ".pdf",
65
- ".doc",
66
- ".docx",
67
- ".ppt",
68
- ".pptx",
69
- ".xls",
70
- ".xlsx",
71
- ".rtf",
72
- ".epub",
73
- ".png",
74
- ".jpg",
75
- ".jpeg",
76
- ".gif",
77
- ".webp",
78
- ".mp3",
79
- ".wav",
80
- ".ogg",
81
- ]);
55
+ const CONVERTIBLE_EXTENSIONS = new Set([".pdf", ".doc", ".docx", ".ppt", ".pptx", ".xls", ".xlsx", ".rtf", ".epub"]);
82
56
 
83
57
  const NOTEBOOK_MIMES = new Set(["application/x-ipynb+json"]);
84
58
  const NOTEBOOK_EXTENSIONS = new Set([".ipynb"]);
@@ -1044,6 +1018,18 @@ async function tryRenderBinaryPayload(
1044
1018
  // Unified Special Handler Dispatch
1045
1019
  // =============================================================================
1046
1020
 
1021
+ let specialHandlersPromise: Promise<SpecialHandler[]> | undefined;
1022
+
1023
+ /**
1024
+ * Lazily load the site-specific scraper handlers. The scrapers barrel eagerly
1025
+ * imports ~80 site modules, none of which are needed until the first fetch that
1026
+ * requires a special handler, so we keep them out of the cold-startup graph.
1027
+ */
1028
+ function loadSpecialHandlers(): Promise<SpecialHandler[]> {
1029
+ specialHandlersPromise ??= import("../web/scrapers").then(m => m.specialHandlers);
1030
+ return specialHandlersPromise;
1031
+ }
1032
+
1047
1033
  /**
1048
1034
  * Try all special handlers
1049
1035
  */
@@ -1053,6 +1039,7 @@ async function handleSpecialUrls(
1053
1039
  signal: AbortSignal | undefined,
1054
1040
  storage: AgentStorage | null,
1055
1041
  ): Promise<FetchRenderResult | null> {
1042
+ const specialHandlers = await loadSpecialHandlers();
1056
1043
  for (const handler of specialHandlers) {
1057
1044
  if (signal?.aborted) {
1058
1045
  throw new ToolAbortError();
@@ -1144,45 +1131,25 @@ async function renderUrl(
1144
1131
  notes.push(
1145
1132
  `Image MIME type ${imageMimeType} is unsupported for inline model serialization; returning text metadata only`,
1146
1133
  );
1147
- const shouldTryConvertibleFallback = isConvertible(mime, extHint);
1148
- if (shouldTryConvertibleFallback) {
1149
- notes.push("Attempting binary conversion fallback for unsupported image MIME type");
1150
- } else {
1151
- notes.push("Falling back to textual rendering from initial response");
1152
- }
1153
- skipConvertibleBinaryRetry = !shouldTryConvertibleFallback;
1134
+ notes.push("Falling back to textual rendering from initial response");
1135
+ skipConvertibleBinaryRetry = true;
1154
1136
  } else {
1155
1137
  const binary = await fetchBinary(finalUrl, timeout, signal);
1156
1138
  if (binary.ok) {
1157
1139
  notes.push("Fetched image binary");
1158
- const conversionExtension = getExtensionHint(finalUrl, binary.contentDisposition) || extHint;
1159
- let convertedText: string | null = null;
1160
- const converted = await convertWithMarkit(binary.buffer, conversionExtension, timeout, signal);
1161
- if (converted.ok) {
1162
- if (converted.content.trim().length > 50) {
1163
- notes.push("Converted with markit");
1164
- convertedText = converted.content;
1165
- } else {
1166
- notes.push("markit conversion produced no usable output");
1167
- }
1168
- } else if (converted.error) {
1169
- notes.push(`markit conversion failed: ${converted.error}`);
1170
- } else {
1171
- notes.push("markit conversion failed");
1172
- }
1173
1140
 
1174
1141
  if (binary.buffer.byteLength > MAX_INLINE_IMAGE_SOURCE_BYTES) {
1175
1142
  notes.push(
1176
1143
  `Image exceeds inline source limit (${binary.buffer.byteLength} bytes > ${MAX_INLINE_IMAGE_SOURCE_BYTES} bytes)`,
1177
1144
  );
1178
1145
  const output = finalizeOutput(
1179
- convertedText ?? `Fetched image content (${imageMimeType}), but it is too large to inline render.`,
1146
+ `Fetched image content (${imageMimeType}), but it is too large to inline render.`,
1180
1147
  );
1181
1148
  return {
1182
1149
  url,
1183
1150
  finalUrl,
1184
1151
  contentType: imageMimeType,
1185
- method: convertedText ? "markit" : "image-too-large",
1152
+ method: "image-too-large",
1186
1153
  content: output.content,
1187
1154
  fetchedAt,
1188
1155
  truncated: output.truncated,
@@ -1199,15 +1166,13 @@ async function renderUrl(
1199
1166
  if (!isDecodedImage) {
1200
1167
  notes.push(`Fetched payload could not be decoded as ${imageMimeType}; returning text metadata only`);
1201
1168
  const output = finalizeOutput(
1202
- convertedText ??
1203
- rawContent ??
1204
- `Fetched payload was labeled ${imageMimeType}, but bytes were not a valid image.`,
1169
+ rawContent ?? `Fetched payload was labeled ${imageMimeType}, but bytes were not a valid image.`,
1205
1170
  );
1206
1171
  return {
1207
1172
  url,
1208
1173
  finalUrl,
1209
1174
  contentType: imageMimeType,
1210
- method: convertedText ? "markit" : "image-invalid",
1175
+ method: "image-invalid",
1211
1176
  content: output.content,
1212
1177
  fetchedAt,
1213
1178
  truncated: output.truncated,
@@ -1219,13 +1184,13 @@ async function renderUrl(
1219
1184
  `Image exceeds inline output limit after resize (${resized.buffer.length} bytes > ${MAX_INLINE_IMAGE_OUTPUT_BYTES} bytes)`,
1220
1185
  );
1221
1186
  const output = finalizeOutput(
1222
- convertedText ?? `Fetched image content (${imageMimeType}), but it is too large to inline render.`,
1187
+ `Fetched image content (${imageMimeType}), but it is too large to inline render.`,
1223
1188
  );
1224
1189
  return {
1225
1190
  url,
1226
1191
  finalUrl,
1227
1192
  contentType: imageMimeType,
1228
- method: convertedText ? "markit" : "image-too-large",
1193
+ method: "image-too-large",
1229
1194
  content: output.content,
1230
1195
  fetchedAt,
1231
1196
  truncated: output.truncated,
@@ -1234,7 +1199,7 @@ async function renderUrl(
1234
1199
  }
1235
1200
 
1236
1201
  const dimensionNote = formatDimensionNote(resized);
1237
- let imageSummary = convertedText ?? `Fetched image content (${resized.mimeType}).`;
1202
+ let imageSummary = `Fetched image content (${resized.mimeType}).`;
1238
1203
  if (dimensionNote) {
1239
1204
  imageSummary += `\n${dimensionNote}`;
1240
1205
  }
package/src/tools/read.ts CHANGED
@@ -48,8 +48,8 @@ import {
48
48
  webpExclusionForModel,
49
49
  } from "../utils/image-loading";
50
50
  import { convertFileWithMarkit } from "../utils/markit";
51
+ import { type ArchiveReader, formatArchiveEntryLines, openArchive, parseArchivePathCandidates } from "../utils/zip";
51
52
  import { buildDirectoryTree, type DirectoryTree } from "../workspace-tree";
52
- import { type ArchiveReader, formatArchiveEntryLines, openArchive, parseArchivePathCandidates } from "./archive-reader";
53
53
  import {
54
54
  type ConflictEntry,
55
55
  type ConflictScope,
@@ -28,13 +28,8 @@ import {
28
28
  uriHyperlink,
29
29
  } from "../tui";
30
30
  import { resolveFileDisplayMode } from "../utils/file-display-mode";
31
+ import { type ArchiveReader, type ExtractedArchiveFile, openArchive, parseArchivePathCandidates } from "../utils/zip";
31
32
  import type { ToolSession } from ".";
32
- import {
33
- type ArchiveReader,
34
- type ExtractedArchiveFile,
35
- openArchive,
36
- parseArchivePathCandidates,
37
- } from "./archive-reader";
38
33
  import { createFileRecorder, formatResultPath } from "./file-recorder";
39
34
  import { classifyGroupedLines, formatGroupedFiles, groupLineIndicesByBlank } from "./grouped-file-output";
40
35
  import { formatMatchLine } from "./match-line-format";