@oh-my-pi/pi-coding-agent 14.0.3 → 14.0.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/CHANGELOG.md +63 -1
  2. package/package.json +11 -8
  3. package/src/config/model-registry.ts +3 -2
  4. package/src/config/model-resolver.ts +33 -25
  5. package/src/config/settings.ts +9 -2
  6. package/src/dap/session.ts +31 -39
  7. package/src/debug/log-formatting.ts +2 -2
  8. package/src/edit/index.ts +2 -0
  9. package/src/edit/modes/chunk.ts +45 -16
  10. package/src/edit/modes/hashline.ts +2 -2
  11. package/src/ipy/executor.ts +3 -7
  12. package/src/ipy/kernel.ts +3 -3
  13. package/src/lsp/client.ts +4 -2
  14. package/src/lsp/index.ts +4 -9
  15. package/src/lsp/lspmux.ts +2 -2
  16. package/src/lsp/utils.ts +27 -143
  17. package/src/modes/components/diff.ts +1 -1
  18. package/src/modes/controllers/event-controller.ts +438 -426
  19. package/src/modes/theme/mermaid-cache.ts +5 -7
  20. package/src/modes/theme/theme.ts +2 -161
  21. package/src/priority.json +8 -0
  22. package/src/prompts/agents/designer.md +1 -2
  23. package/src/prompts/system/system-prompt.md +40 -2
  24. package/src/prompts/tools/chunk-edit.md +66 -38
  25. package/src/prompts/tools/read-chunk.md +10 -1
  26. package/src/sdk.ts +2 -1
  27. package/src/session/agent-session.ts +10 -0
  28. package/src/session/compaction/compaction.ts +1 -1
  29. package/src/tools/ast-edit.ts +2 -2
  30. package/src/tools/browser.ts +84 -21
  31. package/src/tools/fetch.ts +1 -1
  32. package/src/tools/find.ts +40 -94
  33. package/src/tools/gemini-image.ts +1 -0
  34. package/src/tools/index.ts +2 -3
  35. package/src/tools/read.ts +2 -0
  36. package/src/tools/render-utils.ts +1 -1
  37. package/src/tools/report-tool-issue.ts +2 -2
  38. package/src/utils/edit-mode.ts +2 -2
  39. package/src/utils/image-resize.ts +73 -37
  40. package/src/utils/lang-from-path.ts +239 -0
  41. package/src/utils/sixel.ts +2 -2
  42. package/src/web/scrapers/types.ts +50 -32
  43. package/src/web/search/providers/codex.ts +21 -2
package/CHANGELOG.md CHANGED
@@ -2,6 +2,68 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [14.0.5] - 2026-04-11
6
+ ### Added
7
+
8
+ - Added `designer` model role for UI/UX design tasks with Gemini 3.1 Pro as default model
9
+ - Added support for model role fallback lists — roles can now resolve to multiple model patterns with automatic fallback to next available model
10
+ - Added `extractReadableFromHtml` utility function to extract readable content from HTML with Readability article extraction and CSS selector fallback
11
+ - Added support for GFM (GitHub Flavored Markdown) features including tables, strikethrough, and task lists in HTML-to-markdown conversion
12
+ - Added `resolveDiagnosticTargets` utility function to handle glob pattern resolution with fallback to literal file paths for bracket-style paths
13
+
14
+ ### Changed
15
+
16
+ - Clarified fenced code block editing behavior in markdown — the tool now preserves literal indentation inside fenced blocks, with content written verbatim as supplied
17
+ - Updated guidance for inserting content after markdown section headings to use `after` on the heading chunk rather than `before`/`prepend` on the section itself
18
+ - Reduced default image resize limits to 1568px (from 2000px) and 500KB (from 4.5MB) to match Anthropic's internal downscaling threshold and reduce payload sizes in tool calls
19
+ - Adjusted screenshot compression to use 1024px max dimensions and 150KB budget for more aggressive optimization of browser screenshots in LLM requests
20
+ - Updated JPEG quality defaults from 80 to 75 and refined quality ladder steps (70, 60, 50, 40) for tighter byte budgets
21
+ - Improved image resize fast-path to skip re-encoding when images are already within dimensions and at ≤25% of byte budget, avoiding unnecessary processing of small icons and diagrams
22
+ - Clarified that chunk names are truncated and must be copied from `read` or `?` output rather than constructed from source identifiers
23
+ - Enhanced guidance for editing fenced code blocks in markdown to preserve exact whitespace using `raw` reads, as the tool normalizes tabs to spaces which can damage indentation-sensitive content
24
+ - Updated designer agent to use `pi/designer` role alias instead of explicit model list
25
+ - Refactored model role resolution to support multiple fallback patterns per role, improving model availability handling
26
+ - Replaced regex-based HTML-to-markdown conversion with Turndown library and GFM plugin for more accurate formatting of complex HTML structures
27
+ - Simplified no-changes response to omit redundant response text when chunk content already matches
28
+ - Clarified region suffix behavior on leaf and compound statement chunks — `~` and `^` now fall back to whole-chunk replacement with explicit guidance to supply complete structural content
29
+ - Updated CRC refresh guidance to direct users to use CRCs from edit responses or run `read(path="file", sel="?")`
30
+ - Added clarification that region suffixes fall back to whole-chunk replacement for prose and data formats (markdown, YAML, JSON, fenced code blocks, frontmatter)
31
+ - Documented `L20` shorthand syntax for single-line reads extending to end-of-file, with `L20-L20` for one-line windows
32
+ - Refactored diagnostic target resolution to use new `resolveDiagnosticTargets` function, consolidating glob pattern detection and file matching logic
33
+ - Updated chunk selector syntax from `@region` format to `~` (body) and `^` (head) suffixes for more concise region targeting
34
+ - Simplified chunk edit documentation to use new `~` and `^` region syntax instead of `@head`, `@body`, `@tail`, `@decl` keywords
35
+ - Replaced internal `raceAbort` function with imported `raceWithAbort` utility from pi-utils
36
+ - Refactored cleanup timer to use async iterator pattern with `timers.setInterval` instead of `setInterval`
37
+ - Made `#cleanupIdleSessions` synchronous and moved async cleanup loop logic to new `#runCleanupLoop` method
38
+ - Replaced regex-based `htmlToBasicMarkdown` with a Turndown + GFM plugin pipeline (tables, strikethrough, task lists, nested lists now convert correctly). Added direct `turndown` and `turndown-plugin-gfm` dependencies
39
+
40
+ ### Fixed
41
+
42
+ - Fixed chunk edit tool to report file-not-found error distinctly when attempting to use chunk selectors on non-existent files, with guidance to use write tool or verify the path
43
+ - Fixed stale child selector reuse to correctly match chunks by checksum when multiple sibling chunks with the same name exist under the same parent
44
+ - Fixed stale diagnostics being reused after unrelated file publishes by clearing cached diagnostics before refreshing file state
45
+ - Fixed Codex search to use streamed answer text when final answer is an image placeholder or empty
46
+
47
+ ## [14.0.4] - 2026-04-10
48
+ ### Added
49
+
50
+ - Added `PI_CHUNK_AUTOINDENT` environment variable to control whether chunk read/edit tools normalize indentation to canonical tabs or preserve literal file whitespace
51
+ - Added dynamic chunk tool prompts that automatically adjust guidance based on `PI_CHUNK_AUTOINDENT` setting without exposing a tool parameter
52
+ - Added `<instruction-priority>`, `<output-contract>`, `<default-follow-through>`, `<tool-persistence>`, and `<completeness-contract>` sections to system prompt for improved long-horizon agent workflows
53
+
54
+ ### Changed
55
+
56
+ - Updated chunk edit tool to apply `normalizeIndent` setting during edit operations, enabling literal whitespace preservation when `PI_CHUNK_AUTOINDENT=0`
57
+ - Refactored environment variable parsing to use `$flag()` and `$envpos()` utilities from pi-utils for consistent boolean and integer handling across codebase
58
+ - Updated system prompt communication guidelines to emphasize conciseness and information density, and added guidance on avoiding repetition of user requests
59
+ - Enhanced system prompt with explicit rules for design integrity, verification before yielding, and handling of missing context via tool-based retrieval
60
+ - Added `PI_CHUNK_AUTOINDENT` to control whether chunk read/edit tools normalize indentation, and updated chunk prompts to switch guidance automatically based on that setting
61
+ - Refined the default system prompt with explicit instruction-priority, output-contract, tool-persistence, completeness, and verification rules for long-horizon GPT-5.4-style agent workflows
62
+
63
+ ### Fixed
64
+
65
+ - Fixed typo in system prompt: 'backwards compatibiltity' → 'backwards compatibility'
66
+
5
67
  ## [14.0.3] - 2026-04-09
6
68
 
7
69
  ### Fixed
@@ -6900,4 +6962,4 @@ Initial public release.
6900
6962
  - Git branch display in footer
6901
6963
  - Message queueing during streaming responses
6902
6964
  - OAuth integration for Gmail and Google Calendar access
6903
- - HTML export with syntax highlighting and collapsible sections
6965
+ - HTML export with syntax highlighting and collapsible sections
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/pi-coding-agent",
4
- "version": "14.0.3",
4
+ "version": "14.0.5",
5
5
  "description": "Coding agent CLI with read, bash, edit, write tools and session management",
6
6
  "homepage": "https://github.com/can1357/oh-my-pi",
7
7
  "author": "Can Boluk",
@@ -46,12 +46,12 @@
46
46
  "dependencies": {
47
47
  "@agentclientprotocol/sdk": "0.16.1",
48
48
  "@mozilla/readability": "^0.6",
49
- "@oh-my-pi/omp-stats": "14.0.3",
50
- "@oh-my-pi/pi-agent-core": "14.0.3",
51
- "@oh-my-pi/pi-ai": "14.0.3",
52
- "@oh-my-pi/pi-natives": "14.0.3",
53
- "@oh-my-pi/pi-tui": "14.0.3",
54
- "@oh-my-pi/pi-utils": "14.0.3",
49
+ "@oh-my-pi/omp-stats": "14.0.5",
50
+ "@oh-my-pi/pi-agent-core": "14.0.5",
51
+ "@oh-my-pi/pi-ai": "14.0.5",
52
+ "@oh-my-pi/pi-natives": "14.0.5",
53
+ "@oh-my-pi/pi-tui": "14.0.5",
54
+ "@oh-my-pi/pi-utils": "14.0.5",
55
55
  "@sinclair/typebox": "^0.34",
56
56
  "@xterm/headless": "^6.0",
57
57
  "ajv": "^8.18",
@@ -63,10 +63,13 @@
63
63
  "lru-cache": "11.3.1",
64
64
  "markit-ai": "0.5.0",
65
65
  "puppeteer": "^24.37",
66
+ "turndown": "7.2.4",
67
+ "turndown-plugin-gfm": "1.0.2",
66
68
  "zod": "4.3.6"
67
69
  },
68
70
  "devDependencies": {
69
- "@types/bun": "^1.3"
71
+ "@types/bun": "^1.3",
72
+ "@types/turndown": "5.0.6"
70
73
  },
71
74
  "engines": {
72
75
  "bun": ">=1.3.7"
@@ -39,7 +39,7 @@ export function isAuthenticated(apiKey: string | undefined | null): apiKey is st
39
39
  return Boolean(apiKey) && apiKey !== kNoAuth;
40
40
  }
41
41
 
42
- export type ModelRole = "default" | "smol" | "slow" | "vision" | "plan" | "commit" | "task";
42
+ export type ModelRole = "default" | "smol" | "slow" | "vision" | "plan" | "designer" | "commit" | "task";
43
43
 
44
44
  export interface ModelRoleInfo {
45
45
  tag?: string;
@@ -53,11 +53,12 @@ export const MODEL_ROLES: Record<ModelRole, ModelRoleInfo> = {
53
53
  slow: { tag: "SLOW", name: "Thinking", color: "accent" },
54
54
  vision: { tag: "VISION", name: "Vision", color: "error" },
55
55
  plan: { tag: "PLAN", name: "Architect", color: "muted" },
56
+ designer: { tag: "DESIGNER", name: "Designer", color: "muted" },
56
57
  commit: { tag: "COMMIT", name: "Commit", color: "dim" },
57
58
  task: { tag: "TASK", name: "Subtask", color: "muted" },
58
59
  };
59
60
 
60
- export const MODEL_ROLE_IDS: ModelRole[] = ["default", "smol", "slow", "vision", "plan", "commit", "task"];
61
+ export const MODEL_ROLE_IDS: ModelRole[] = ["default", "smol", "slow", "vision", "plan", "designer", "commit", "task"];
61
62
 
62
63
  /** Alias for ModelRoleInfo - used for both built-in and custom roles */
63
64
  export type RoleInfo = ModelRoleInfo;
@@ -387,7 +387,7 @@ function isSessionInheritedAgentPattern(value: string): boolean {
387
387
  return value === DEFAULT_MODEL_ROLE || value === `${PREFIX_MODEL_ROLE}${DEFAULT_MODEL_ROLE}` || value === "pi/task";
388
388
  }
389
389
 
390
- function resolveConfiguredRolePattern(value: string, settings?: Settings): string | undefined {
390
+ function resolveConfiguredRolePattern(value: string, settings?: Settings): string[] | undefined {
391
391
  const normalized = value.trim();
392
392
  if (!normalized) return undefined;
393
393
 
@@ -396,11 +396,16 @@ function resolveConfiguredRolePattern(value: string, settings?: Settings): strin
396
396
  lastColonIndex > PREFIX_MODEL_ROLE.length ? parseThinkingLevel(normalized.slice(lastColonIndex + 1)) : undefined;
397
397
  const aliasCandidate = thinkingLevel ? normalized.slice(0, lastColonIndex) : normalized;
398
398
  const role = getModelRoleAlias(aliasCandidate);
399
- if (!role) return normalized;
399
+ if (!role) return [normalized];
400
400
 
401
401
  const configured = settings?.getModelRole(role)?.trim();
402
- if (!configured) return undefined;
403
- return thinkingLevel ? `${configured}:${thinkingLevel}` : configured;
402
+ const roleDefaults = normalizeModelPatternList(MODEL_PRIO[role as keyof typeof MODEL_PRIO]);
403
+ const resolved = configured ? normalizeModelPatternList(configured) : roleDefaults;
404
+ if (!resolved || resolved.length === 0) {
405
+ return undefined;
406
+ }
407
+
408
+ return thinkingLevel ? resolved.map(pattern => `${pattern}:${thinkingLevel}`) : resolved;
404
409
  }
405
410
 
406
411
  /**
@@ -412,7 +417,7 @@ export function expandRoleAlias(value: string, settings?: Settings): string {
412
417
  return settings?.getModelRole("default") ?? value;
413
418
  }
414
419
 
415
- const resolved = resolveConfiguredRolePattern(value, settings);
420
+ const resolved = resolveConfiguredRolePattern(value, settings)?.[0];
416
421
  return resolved ?? value;
417
422
  }
418
423
 
@@ -420,10 +425,9 @@ export function resolveConfiguredModelPatterns(value: string | string[] | undefi
420
425
  const patterns = normalizeModelPatternList(value);
421
426
  return patterns.flatMap(pattern => {
422
427
  const resolved = resolveConfiguredRolePattern(pattern, settings);
423
- return resolved ? [resolved] : [];
428
+ return resolved ?? [];
424
429
  });
425
430
  }
426
-
427
431
  export interface AgentModelPatternResolutionOptions {
428
432
  settingsOverride?: string | string[];
429
433
  agentModel?: string | string[];
@@ -477,28 +481,32 @@ export function resolveModelRoleValue(
477
481
  }
478
482
 
479
483
  const lastColonIndex = normalized.lastIndexOf(":");
480
- const thinkingSelector =
484
+ const _thinkingSelector =
481
485
  lastColonIndex > PREFIX_MODEL_ROLE.length ? parseThinkingLevel(normalized.slice(lastColonIndex + 1)) : undefined;
482
- const aliasCandidate = thinkingSelector ? normalized.slice(0, lastColonIndex) : normalized;
483
- const effectivePattern = resolveConfiguredRolePattern(aliasCandidate, options?.settings);
484
- if (!effectivePattern) {
486
+ const effectivePatterns = resolveConfiguredRolePattern(normalized, options?.settings);
487
+ if (!effectivePatterns || effectivePatterns.length === 0) {
485
488
  return { model: undefined, thinkingLevel: undefined, explicitThinkingLevel: false, warning: undefined };
486
489
  }
487
- const patternWithSuffix = thinkingSelector ? `${effectivePattern}:${thinkingSelector}` : effectivePattern;
488
- const { model, thinkingLevel, warning, explicitThinkingLevel } = parseModelPattern(
489
- patternWithSuffix,
490
- availableModels,
491
- options?.matchPreferences,
492
- );
493
490
 
494
- return {
495
- model,
496
- thinkingLevel: explicitThinkingLevel
497
- ? (resolveThinkingLevelForModel(model, thinkingLevel) ?? thinkingLevel)
498
- : thinkingLevel,
499
- explicitThinkingLevel,
500
- warning,
501
- };
491
+ let warning: string | undefined;
492
+ for (const effectivePattern of effectivePatterns) {
493
+ const resolved = parseModelPattern(effectivePattern, availableModels, options?.matchPreferences);
494
+ if (resolved.model) {
495
+ return {
496
+ model: resolved.model,
497
+ thinkingLevel: resolved.explicitThinkingLevel
498
+ ? (resolveThinkingLevelForModel(resolved.model, resolved.thinkingLevel) ?? resolved.thinkingLevel)
499
+ : resolved.thinkingLevel,
500
+ explicitThinkingLevel: resolved.explicitThinkingLevel,
501
+ warning: resolved.warning,
502
+ };
503
+ }
504
+ if (!warning && resolved.warning) {
505
+ warning = resolved.warning;
506
+ }
507
+ }
508
+
509
+ return { model: undefined, thinkingLevel: undefined, explicitThinkingLevel: false, warning };
502
510
  }
503
511
 
504
512
  export function extractExplicitThinkingSelector(
@@ -13,8 +13,15 @@
13
13
 
14
14
  import * as fs from "node:fs";
15
15
  import * as path from "node:path";
16
- import { setDefaultTabWidth } from "@oh-my-pi/pi-natives";
17
- import { getAgentDbPath, getAgentDir, getProjectDir, isEnoent, logger, procmgr } from "@oh-my-pi/pi-utils";
16
+ import {
17
+ getAgentDbPath,
18
+ getAgentDir,
19
+ getProjectDir,
20
+ isEnoent,
21
+ logger,
22
+ procmgr,
23
+ setDefaultTabWidth,
24
+ } from "@oh-my-pi/pi-utils";
18
25
  import { YAML } from "bun";
19
26
  import { type Settings as SettingsCapabilityItem, settingsCapability } from "../capability/settings";
20
27
  import type { ModelRole } from "../config/model-registry";
@@ -1,5 +1,6 @@
1
1
  import * as path from "node:path";
2
- import { logger, ptree } from "@oh-my-pi/pi-utils";
2
+ import * as timers from "node:timers/promises";
3
+ import { logger, ptree, untilAborted } from "@oh-my-pi/pi-utils";
3
4
  import { NON_INTERACTIVE_ENV } from "../exec/non-interactive-env";
4
5
  import { DapClient } from "./client";
5
6
  import type {
@@ -154,27 +155,10 @@ function buildSummary(session: DapSession): DapSessionSummary {
154
155
  };
155
156
  }
156
157
 
157
- async function raceAbort<T>(promise: Promise<T>, signal?: AbortSignal): Promise<T> {
158
- if (!signal) return promise;
159
- if (signal.aborted) {
160
- throw signal.reason instanceof Error ? signal.reason : new Error("Operation aborted");
161
- }
162
- const { promise: abortPromise, reject } = Promise.withResolvers<never>();
163
- const onAbort = () => {
164
- reject(signal.reason instanceof Error ? signal.reason : new Error("Operation aborted"));
165
- };
166
- signal.addEventListener("abort", onAbort, { once: true });
167
- try {
168
- return await Promise.race([promise, abortPromise]);
169
- } finally {
170
- signal.removeEventListener("abort", onAbort);
171
- }
172
- }
173
-
174
158
  export class DapSessionManager {
175
159
  #sessions = new Map<string, DapSession>();
176
160
  #activeSessionId: string | null = null;
177
- #cleanupTimer?: NodeJS.Timeout;
161
+ #cleanupLoopPromise?: Promise<void>;
178
162
  #nextId = 0;
179
163
 
180
164
  constructor() {
@@ -235,7 +219,7 @@ export class DapSessionManager {
235
219
  // Try to capture initial stopped state (e.g. stopOnEntry).
236
220
  // Timeout is acceptable — the program may simply be running.
237
221
  try {
238
- await raceAbort(initialStopPromise, signal);
222
+ await untilAborted(signal, initialStopPromise);
239
223
  if (session.status === "stopped") {
240
224
  await this.#fetchTopFrame(session, signal, Math.min(timeoutMs, STOP_CAPTURE_TIMEOUT_MS));
241
225
  }
@@ -283,7 +267,7 @@ export class DapSessionManager {
283
267
  await this.#completeConfigurationHandshake(session, signal, timeoutMs);
284
268
  await attachPromise;
285
269
  try {
286
- await raceAbort(initialStopPromise, signal);
270
+ await untilAborted(signal, initialStopPromise);
287
271
  if (session.status === "stopped") {
288
272
  await this.#fetchTopFrame(session, signal, Math.min(timeoutMs, STOP_CAPTURE_TIMEOUT_MS));
289
273
  }
@@ -696,9 +680,9 @@ export class DapSessionManager {
696
680
  // between the request and here. Wait for it, but tolerate timeout if the
697
681
  // session already transitioned.
698
682
  try {
699
- await raceAbort(
700
- session.client.waitForEvent<DapStoppedEventBody>("stopped", undefined, signal, timeoutMs),
683
+ await untilAborted(
701
684
  signal,
685
+ session.client.waitForEvent<DapStoppedEventBody>("stopped", undefined, signal, timeoutMs),
702
686
  );
703
687
  } catch {
704
688
  // Timeout or abort — report current state regardless
@@ -833,16 +817,16 @@ export class DapSessionManager {
833
817
  session.lastUsedAt = Date.now();
834
818
  if (session.status !== "terminated") {
835
819
  if (session.capabilities?.supportsTerminateRequest) {
836
- await raceAbort(
837
- session.client.sendRequest("terminate", undefined, signal, timeoutMs).catch(() => undefined),
820
+ await untilAborted(
838
821
  signal,
822
+ session.client.sendRequest("terminate", undefined, signal, timeoutMs).catch(() => undefined),
839
823
  );
840
824
  }
841
- await raceAbort(
825
+ await untilAborted(
826
+ signal,
842
827
  session.client
843
828
  .sendRequest("disconnect", { terminateDebuggee: true }, signal, timeoutMs)
844
829
  .catch(() => undefined),
845
- signal,
846
830
  );
847
831
  }
848
832
  session.status = "terminated";
@@ -852,22 +836,30 @@ export class DapSessionManager {
852
836
  }
853
837
 
854
838
  #startCleanupTimer(): void {
855
- if (this.#cleanupTimer) return;
856
- this.#cleanupTimer = setInterval(() => {
857
- void this.#cleanupIdleSessions();
858
- }, CLEANUP_INTERVAL_MS);
859
- this.#cleanupTimer.unref?.();
839
+ if (this.#cleanupLoopPromise) return;
840
+ this.#cleanupLoopPromise = this.#runCleanupLoop();
841
+ }
842
+
843
+ async #runCleanupLoop(): Promise<void> {
844
+ for await (const _ of timers.setInterval(CLEANUP_INTERVAL_MS, null, { ref: false })) {
845
+ try {
846
+ this.#cleanupIdleSessions();
847
+ } catch (error) {
848
+ logger.error("DAP idle session cleanup failed", { error: toErrorMessage(error) });
849
+ }
850
+ }
860
851
  }
861
852
 
862
- async #cleanupIdleSessions(): Promise<void> {
853
+ #cleanupIdleSessions(): void {
854
+ if (this.#sessions.size === 0) return;
863
855
  const now = Date.now();
864
- for (const session of Array.from(this.#sessions.values())) {
856
+ for (const session of this.#sessions.values()) {
865
857
  if (
866
858
  session.status === "terminated" ||
867
859
  now - session.lastUsedAt > IDLE_TIMEOUT_MS ||
868
860
  !session.client.isAlive()
869
861
  ) {
870
- await this.#disposeSession(session);
862
+ this.#disposeSession(session);
871
863
  }
872
864
  }
873
865
  }
@@ -1006,7 +998,7 @@ export class DapSessionManager {
1006
998
  // Wait for the initialized event if we haven't seen it yet.
1007
999
  if (!session.initializedSeen) {
1008
1000
  try {
1009
- await raceAbort(session.client.waitForEvent("initialized", undefined, signal, timeoutMs), signal);
1001
+ await untilAborted(signal, session.client.waitForEvent("initialized", undefined, signal, timeoutMs));
1010
1002
  } catch {
1011
1003
  // Adapter may not send initialized (e.g. it already terminated).
1012
1004
  // Proceed anyway — the launch/attach response will surface any real error.
@@ -1100,7 +1092,7 @@ export class DapSessionManager {
1100
1092
  timeoutMs: number = 30_000,
1101
1093
  ): Promise<DapContinueOutcome> {
1102
1094
  try {
1103
- await raceAbort(outcomePromise, signal);
1095
+ await untilAborted(signal, outcomePromise);
1104
1096
  if (session.status === "stopped") {
1105
1097
  await this.#fetchTopFrame(session, signal, Math.min(timeoutMs, 5_000));
1106
1098
  }
@@ -1243,12 +1235,12 @@ export class DapSessionManager {
1243
1235
  return session;
1244
1236
  }
1245
1237
 
1246
- async #disposeSession(session: DapSession): Promise<void> {
1238
+ #disposeSession(session: DapSession) {
1247
1239
  if (this.#activeSessionId === session.id) {
1248
1240
  this.#activeSessionId = null;
1249
1241
  }
1250
1242
  this.#sessions.delete(session.id);
1251
- await session.client.dispose().catch(() => {});
1243
+ void session.client.dispose().catch(() => {});
1252
1244
  }
1253
1245
  }
1254
1246
 
@@ -1,5 +1,5 @@
1
- import { sanitizeText, wrapTextWithAnsi } from "@oh-my-pi/pi-natives";
2
- import { replaceTabs, truncateToWidth } from "../tools/render-utils";
1
+ import { sanitizeText } from "@oh-my-pi/pi-natives";
2
+ import { replaceTabs, truncateToWidth, wrapTextWithAnsi } from "../tools/render-utils";
3
3
 
4
4
  export function formatDebugLogLine(line: string, maxWidth: number): string {
5
5
  const sanitized = sanitizeText(line);
package/src/edit/index.ts CHANGED
@@ -19,6 +19,7 @@ import {
19
19
  executeChunkMode,
20
20
  isChunkParams,
21
21
  resolveAnchorStyle,
22
+ resolveChunkAutoIndent,
22
23
  } from "./modes/chunk";
23
24
  import { executeHashlineMode, type HashlineParams, hashlineEditParamsSchema, isHashlineParams } from "./modes/hashline";
24
25
  import { executePatchMode, isPatchParams, type PatchParams, patchEditSchema } from "./modes/patch";
@@ -197,6 +198,7 @@ export class EditTool implements AgentTool<TInput> {
197
198
  description: (session: ToolSession) =>
198
199
  prompt.render(chunkEditDescription, {
199
200
  anchorStyle: resolveAnchorStyle(session.settings),
201
+ chunkAutoIndent: resolveChunkAutoIndent(),
200
202
  }),
201
203
  parameters: chunkEditParamsSchema,
202
204
  invalidParamsMessage: "Invalid edit parameters for chunk mode.",
@@ -11,6 +11,7 @@ import {
11
11
  ChunkState,
12
12
  type EditOperation as NativeEditOperation,
13
13
  } from "@oh-my-pi/pi-natives";
14
+ import { $envpos } from "@oh-my-pi/pi-utils";
14
15
  import { type Static, Type } from "@sinclair/typebox";
15
16
  import type { BunFile } from "bun";
16
17
  import { LRUCache } from "lru-cache";
@@ -63,6 +64,34 @@ const validAnchorStyles: Record<string, ChunkAnchorStyle> = {
63
64
  bare: ChunkAnchorStyle.Bare,
64
65
  };
65
66
 
67
+ export function resolveChunkAutoIndent(rawValue = Bun.env.PI_CHUNK_AUTOINDENT): boolean {
68
+ if (!rawValue) return true;
69
+ const normalized = rawValue.trim().toLowerCase();
70
+ switch (normalized) {
71
+ case "1":
72
+ case "true":
73
+ case "yes":
74
+ case "on":
75
+ return true;
76
+ case "0":
77
+ case "false":
78
+ case "no":
79
+ case "off":
80
+ return false;
81
+ default:
82
+ throw new Error(`Invalid PI_CHUNK_AUTOINDENT: ${rawValue}`);
83
+ }
84
+ }
85
+
86
+ function getChunkRenderIndentOptions(): {
87
+ normalizeIndent: boolean;
88
+ tabReplacement: string;
89
+ } {
90
+ return resolveChunkAutoIndent()
91
+ ? { normalizeIndent: true, tabReplacement: " " }
92
+ : { normalizeIndent: false, tabReplacement: "\t" };
93
+ }
94
+
66
95
  export function resolveAnchorStyle(settings?: Settings): ChunkAnchorStyle {
67
96
  const envStyle = Bun.env.PI_ANCHOR_STYLE;
68
97
  return (
@@ -72,16 +101,8 @@ export function resolveAnchorStyle(settings?: Settings): ChunkAnchorStyle {
72
101
  );
73
102
  }
74
103
 
75
- const readEnvInt = (name: string, defaultValue: number): number => {
76
- const value = Bun.env[name];
77
- if (!value) return defaultValue;
78
- const parsed = Number.parseInt(value, 10);
79
- if (Number.isNaN(parsed) || parsed <= 0) return defaultValue;
80
- return parsed;
81
- };
82
-
83
104
  const chunkStateCache = new LRUCache<string, ChunkCacheEntry>({
84
- max: readEnvInt("PI_CHUNK_CACHE_MAX_ENTRIES", 200),
105
+ max: $envpos("PI_CHUNK_CACHE_MAX_ENTRIES", 200),
85
106
  });
86
107
 
87
108
  export function invalidateChunkCache(filePath: string): void {
@@ -215,6 +236,7 @@ export async function formatChunkedRead(params: {
215
236
  const normalizedLanguage = normalizeLanguage(language);
216
237
  const { state } = await loadChunkStateForFile(filePath, normalizedLanguage);
217
238
  const displayPath = displayPathForFile(filePath, cwd);
239
+ const renderIndentOptions = getChunkRenderIndentOptions();
218
240
  const result = state.renderRead({
219
241
  readPath,
220
242
  displayPath,
@@ -224,8 +246,8 @@ export async function formatChunkedRead(params: {
224
246
  absoluteLineRange: absoluteLineRange
225
247
  ? { startLine: absoluteLineRange.startLine, endLine: absoluteLineRange.endLine ?? absoluteLineRange.startLine }
226
248
  : undefined,
227
- tabReplacement: " ",
228
- normalizeIndent: true,
249
+ tabReplacement: renderIndentOptions.tabReplacement,
250
+ normalizeIndent: renderIndentOptions.normalizeIndent,
229
251
  });
230
252
  return { text: result.text, resolvedPath: filePath, chunk: result.chunk };
231
253
  }
@@ -280,6 +302,7 @@ export function applyChunkEdits(params: {
280
302
  const state = ChunkState.parse(normalizedSource, normalizeLanguage(params.language));
281
303
  const result = state.applyEdits({
282
304
  operations: nativeOperations,
305
+ normalizeIndent: resolveChunkAutoIndent(),
283
306
  defaultSelector: params.defaultSelector,
284
307
  defaultCrc: params.defaultCrc,
285
308
  anchorStyle: params.anchorStyle,
@@ -309,10 +332,11 @@ export const chunkToolEditSchema = Type.Object({
309
332
  op: StringEnum(CHUNK_OP_VALUES),
310
333
  sel: Type.String({
311
334
  description:
312
- "Chunk selector. Format: 'path@region' for insertions, 'path#CRC@region' for replace. Omit @region to target the full chunk. Valid regions: head, body, tail, decl.",
335
+ "Chunk selector. Use 'path~' or 'path^' for insertions, 'path#CRC~' or 'path#CRC^' for replace, or omit the suffix to target the full chunk.",
313
336
  }),
314
337
  content: Type.String({
315
- description: "New content. Use \\t for indentation. Do NOT include the chunk's base padding.",
338
+ description:
339
+ "New content. Write indentation relative to the targeted region as described in the tool prompt. Do NOT include the chunk's base padding.",
316
340
  }),
317
341
  });
318
342
  export const chunkEditParamsSchema = Type.Object(
@@ -387,7 +411,7 @@ async function writeChunkResult(params: {
387
411
  invalidateFsScanAfterWrite(resolvedPath);
388
412
 
389
413
  const diffResult = generateUnifiedDiffString(result.diffSourceBefore, result.diffSourceAfter);
390
- const warningsBlock = result.warnings.length > 0 ? `\n\n${result.warnings.join("\n")}` : "";
414
+ const warningsBlock = result.warnings.length > 0 ? `\n\nWarnings:\n${result.warnings.join("\n")}` : "";
391
415
  const meta = outputMeta()
392
416
  .diagnostics(diagnostics?.summary ?? "", diagnostics?.messages ?? [])
393
417
  .get();
@@ -419,6 +443,12 @@ export async function executeChunkMode(
419
443
  }
420
444
  const normalizedOperations = normalizeChunkEditOperations(edits);
421
445
 
446
+ if (!sourceExists && normalizedOperations.some(op => op.sel)) {
447
+ throw new Error(
448
+ `File does not exist: ${path}. Cannot resolve chunk selectors on a non-existent file. Use the write tool to create a new file, or check the path for typos.`,
449
+ );
450
+ }
451
+
422
452
  const chunkResult = applyChunkEdits({
423
453
  source: rawContent,
424
454
  language: chunkLanguage,
@@ -429,9 +459,8 @@ export async function executeChunkMode(
429
459
  });
430
460
 
431
461
  if (!chunkResult.changed) {
432
- const responseText = `[No changes needed — content already matches.]\n\n${chunkResult.responseText}`;
433
462
  return {
434
- content: [{ type: "text", text: responseText }],
463
+ content: [{ type: "text", text: "[No changes needed \u2014 content already matches.]" }],
435
464
  details: {
436
465
  diff: "",
437
466
  op: sourceExists ? "update" : "create",
@@ -156,8 +156,8 @@ interface ExecuteHashlineModeOptions {
156
156
  beginDeferredDiagnosticsForPath: (path: string) => WritethroughDeferredHandle;
157
157
  }
158
158
 
159
- export function hashlineParseText(edit: string[] | string | null): string[] {
160
- if (edit === null) return [];
159
+ export function hashlineParseText(edit: string[] | string | null | undefined): string[] {
160
+ if (edit == null) return [];
161
161
  if (typeof edit === "string") {
162
162
  const normalizedEdit = edit.endsWith("\n") ? edit.slice(0, -1) : edit;
163
163
  edit = normalizedEdit.replaceAll("\r", "").split("\n");
@@ -1,5 +1,5 @@
1
1
  import * as path from "node:path";
2
- import { getAgentDir, getProjectDir, isEnoent, logger } from "@oh-my-pi/pi-utils";
2
+ import { getAgentDir, getProjectDir, isBunTestRuntime, isEnoent, logger } from "@oh-my-pi/pi-utils";
3
3
  import { OutputSink } from "../session/streaming-output";
4
4
  import { shutdownSharedGateway } from "./gateway-coordinator";
5
5
  import {
@@ -299,10 +299,6 @@ async function writePreludeCache(state: PreludeCacheState, helpers: PreludeHelpe
299
299
  }
300
300
  }
301
301
 
302
- function isPythonTestEnvironment(): boolean {
303
- return Bun.env.BUN_ENV === "test" || Bun.env.NODE_ENV === "test";
304
- }
305
-
306
302
  function getPreludeIntrospectionOptions(
307
303
  options: KernelSessionExecutionOptions = {},
308
304
  ): Pick<KernelExecuteOptions, "signal" | "timeoutMs"> {
@@ -318,7 +314,7 @@ async function cachePreludeDocs(
318
314
  cacheState?: PreludeCacheState | null,
319
315
  ): Promise<PreludeHelper[]> {
320
316
  cachedPreludeDocs = docs;
321
- if (!isPythonTestEnvironment() && docs.length > 0) {
317
+ if (!isBunTestRuntime() && docs.length > 0) {
322
318
  const state = cacheState ?? (await buildPreludeCacheState(cwd));
323
319
  await writePreludeCache(state, docs);
324
320
  }
@@ -416,7 +412,7 @@ export async function warmPythonEnvironment(
416
412
  cachedPreludeDocs = [];
417
413
  return { ok: false, reason, docs: [] };
418
414
  }
419
- if (!isPythonTestEnvironment()) {
415
+ if (!isBunTestRuntime()) {
420
416
  try {
421
417
  cacheState = await buildPreludeCacheState(cwd);
422
418
  const cached = await readPreludeCache(cacheState);
package/src/ipy/kernel.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { $env, logger, Snowflake } from "@oh-my-pi/pi-utils";
1
+ import { $env, $flag, isBunTestRuntime, logger, Snowflake } from "@oh-my-pi/pi-utils";
2
2
  import { $ } from "bun";
3
3
  import { Settings } from "../config/settings";
4
4
  import { htmlToBasicMarkdown } from "../web/scrapers/types";
@@ -10,7 +10,7 @@ import { filterEnv, resolvePythonRuntime } from "./runtime";
10
10
 
11
11
  const TEXT_ENCODER = new TextEncoder();
12
12
  const TEXT_DECODER = new TextDecoder();
13
- const TRACE_IPC = $env.PI_PYTHON_IPC_TRACE === "1";
13
+ const TRACE_IPC = $flag("PI_PYTHON_IPC_TRACE");
14
14
  const PRELUDE_INTROSPECTION_SNIPPET = "import json\nprint(json.dumps(__omp_prelude_docs__()))";
15
15
 
16
16
  class SharedGatewayCreateError extends Error {
@@ -195,7 +195,7 @@ export interface PythonKernelAvailability {
195
195
  }
196
196
 
197
197
  export async function checkPythonKernelAvailability(cwd: string): Promise<PythonKernelAvailability> {
198
- if (Bun.env.BUN_ENV === "test" || Bun.env.NODE_ENV === "test" || $env.PI_PYTHON_SKIP_CHECK === "1") {
198
+ if (isBunTestRuntime() || $flag("PI_PYTHON_SKIP_CHECK")) {
199
199
  return { ok: true };
200
200
  }
201
201