@oh-my-pi/pi-coding-agent 6.7.67 → 6.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (114) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/package.json +6 -7
  3. package/src/cli/session-picker.ts +27 -28
  4. package/src/cli/setup-cli.ts +7 -16
  5. package/src/cli/update-cli.ts +1 -1
  6. package/src/config.ts +1 -1
  7. package/src/core/agent-session.ts +202 -37
  8. package/src/core/agent-storage.ts +1 -1
  9. package/src/core/auth-storage.ts +15 -25
  10. package/src/core/bash-executor.ts +63 -105
  11. package/src/core/custom-commands/loader.ts +1 -1
  12. package/src/core/custom-tools/loader.ts +1 -1
  13. package/src/core/custom-tools/types.ts +1 -2
  14. package/src/core/exec.ts +16 -100
  15. package/src/core/extensions/index.ts +1 -7
  16. package/src/core/extensions/loader.ts +1 -1
  17. package/src/core/extensions/runner.ts +1 -1
  18. package/src/core/extensions/types.ts +2 -2
  19. package/src/core/extensions/wrapper.ts +15 -20
  20. package/src/core/frontmatter.ts +1 -1
  21. package/src/core/history-storage.ts +3 -6
  22. package/src/core/hooks/index.ts +2 -2
  23. package/src/core/hooks/loader.ts +1 -1
  24. package/src/core/hooks/tool-wrapper.ts +14 -26
  25. package/src/core/hooks/types.ts +1 -2
  26. package/src/core/keybindings.ts +1 -1
  27. package/src/core/mcp/client.ts +13 -13
  28. package/src/core/mcp/json-rpc.ts +1 -1
  29. package/src/core/mcp/loader.ts +1 -1
  30. package/src/core/mcp/manager.ts +2 -2
  31. package/src/core/mcp/tool-cache.ts +1 -1
  32. package/src/core/mcp/transports/http.ts +32 -70
  33. package/src/core/model-registry.ts +1 -1
  34. package/src/core/plugins/installer.ts +13 -11
  35. package/src/core/prompt-templates.ts +4 -9
  36. package/src/core/python-executor.ts +23 -18
  37. package/src/core/python-gateway-coordinator.ts +29 -28
  38. package/src/core/python-kernel.ts +230 -211
  39. package/src/core/sdk.ts +10 -13
  40. package/src/core/session-manager.ts +1 -1
  41. package/src/core/settings-manager.ts +22 -9
  42. package/src/core/skills.ts +1 -1
  43. package/src/core/ssh/connection-manager.ts +19 -33
  44. package/src/core/ssh/ssh-executor.ts +39 -35
  45. package/src/core/ssh/sshfs-mount.ts +14 -33
  46. package/src/core/storage-migration.ts +1 -1
  47. package/src/core/streaming-output.ts +183 -127
  48. package/src/core/system-prompt.ts +119 -79
  49. package/src/core/title-generator.ts +1 -1
  50. package/src/core/tools/ask.ts +2 -2
  51. package/src/core/tools/bash.ts +3 -3
  52. package/src/core/tools/calculator.ts +1 -1
  53. package/src/core/tools/exa/mcp-client.ts +1 -1
  54. package/src/core/tools/exa/render.ts +1 -1
  55. package/src/core/tools/find.ts +39 -71
  56. package/src/core/tools/gemini-image.ts +1 -1
  57. package/src/core/tools/grep.ts +88 -100
  58. package/src/core/tools/index.ts +1 -1
  59. package/src/core/tools/ls.ts +1 -1
  60. package/src/core/tools/lsp/client.ts +50 -50
  61. package/src/core/tools/lsp/clients/lsp-linter-client.ts +1 -1
  62. package/src/core/tools/lsp/config.ts +1 -1
  63. package/src/core/tools/lsp/index.ts +2 -4
  64. package/src/core/tools/lsp/lspmux.ts +1 -1
  65. package/src/core/tools/lsp/rust-analyzer.ts +2 -2
  66. package/src/core/tools/lsp/utils.ts +0 -14
  67. package/src/core/tools/notebook.ts +1 -1
  68. package/src/core/tools/patch/shared.ts +3 -4
  69. package/src/core/tools/python.ts +3 -3
  70. package/src/core/tools/read.ts +29 -68
  71. package/src/core/tools/render-utils.ts +0 -5
  72. package/src/core/tools/ssh.ts +3 -3
  73. package/src/core/tools/task/model-resolver.ts +7 -9
  74. package/src/core/tools/task/worker.ts +144 -139
  75. package/src/core/tools/todo-write.ts +1 -1
  76. package/src/core/tools/truncate.ts +2 -2
  77. package/src/core/tools/web-fetch.ts +13 -15
  78. package/src/core/tools/web-scrapers/types.ts +1 -3
  79. package/src/core/tools/web-scrapers/utils.ts +14 -13
  80. package/src/core/tools/web-scrapers/youtube.ts +39 -12
  81. package/src/core/tools/web-search/auth.ts +9 -45
  82. package/src/core/tools/write.ts +1 -1
  83. package/src/core/ttsr.ts +1 -1
  84. package/src/core/utils.ts +1 -187
  85. package/src/core/voice-controller.ts +1 -1
  86. package/src/core/voice-supervisor.ts +11 -38
  87. package/src/core/voice.ts +1 -8
  88. package/src/discovery/codex.ts +1 -1
  89. package/src/index.ts +4 -4
  90. package/src/main.ts +5 -10
  91. package/src/migrations.ts +1 -1
  92. package/src/modes/index.ts +7 -40
  93. package/src/modes/interactive/components/extensions/state-manager.ts +1 -1
  94. package/src/modes/interactive/components/hook-editor.ts +12 -9
  95. package/src/modes/interactive/components/login-dialog.ts +24 -11
  96. package/src/modes/interactive/components/settings-defs.ts +9 -0
  97. package/src/modes/interactive/components/status-line.ts +36 -35
  98. package/src/modes/interactive/components/todo-display.ts +1 -1
  99. package/src/modes/interactive/components/tool-execution.ts +1 -1
  100. package/src/modes/interactive/controllers/command-controller.ts +50 -84
  101. package/src/modes/interactive/controllers/extension-ui-controller.ts +76 -76
  102. package/src/modes/interactive/controllers/input-controller.ts +12 -11
  103. package/src/modes/interactive/interactive-mode.ts +10 -11
  104. package/src/modes/interactive/theme/theme.ts +1 -1
  105. package/src/modes/interactive/types.ts +1 -1
  106. package/src/modes/rpc/rpc-client.ts +91 -121
  107. package/src/modes/rpc/rpc-mode.ts +71 -79
  108. package/src/prompts/system/ttsr-interrupt.md +7 -0
  109. package/src/utils/clipboard.ts +57 -141
  110. package/src/utils/shell-snapshot.ts +12 -60
  111. package/src/utils/shell.ts +35 -56
  112. package/src/utils/tools-manager.ts +42 -71
  113. package/src/core/logger.ts +0 -111
  114. package/src/modes/cleanup.ts +0 -23
package/CHANGELOG.md CHANGED
@@ -2,6 +2,34 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [6.8.0] - 2026-01-20
6
+
7
+ ### Added
8
+
9
+ - Added streaming abort setting to control edit tool behavior when patch preview fails
10
+
11
+ ### Changed
12
+
13
+ - Replaced internal logger with @oh-my-pi/pi-utils logger across all modules
14
+ - Updated process spawning to use cspawn and ptree utilities from pi-utils
15
+ - Migrated file operations to use async fs/promises and Bun file APIs
16
+ - Refactored promise handling to use Promise.withResolvers and utility functions
17
+ - Updated timeout and abort handling to use standardized utility functions
18
+ - Refactored authentication login method to use OAuthController interface instead of individual callbacks
19
+
20
+ ### Fixed
21
+
22
+ - Fixed Python package installation to handle async operations properly
23
+ - Fixed streaming output truncation to use consistent column limits
24
+ - Fixed shell command execution to properly handle process cleanup and timeouts
25
+ - Fixed SSH connection management to properly await async operations
26
+ - Fixed voice supervisor process cleanup to use proper async handling
27
+ - Added automatic regex pattern validation in grep tool to handle invalid patterns by switching to literal mode
28
+
29
+ ### Security
30
+
31
+ - Updated temporary file cleanup to use secure async removal methods
32
+
5
33
  ## [6.7.67] - 2026-01-19
6
34
  ### Added
7
35
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oh-my-pi/pi-coding-agent",
3
- "version": "6.7.67",
3
+ "version": "6.8.0",
4
4
  "description": "Coding agent CLI with read, bash, edit, write tools and session management",
5
5
  "type": "module",
6
6
  "ompConfig": {
@@ -40,10 +40,11 @@
40
40
  "prepublishOnly": "bun run generate-template && bun run clean && bun run build"
41
41
  },
42
42
  "dependencies": {
43
- "@oh-my-pi/pi-agent-core": "6.7.67",
44
- "@oh-my-pi/pi-ai": "6.7.67",
45
- "@oh-my-pi/pi-git-tool": "6.7.67",
46
- "@oh-my-pi/pi-tui": "6.7.67",
43
+ "@oh-my-pi/pi-agent-core": "6.8.0",
44
+ "@oh-my-pi/pi-ai": "6.8.0",
45
+ "@oh-my-pi/pi-git-tool": "6.8.0",
46
+ "@oh-my-pi/pi-tui": "6.8.0",
47
+ "@oh-my-pi/pi-utils": "6.8.0",
47
48
  "@openai/agents": "^0.3.7",
48
49
  "@sinclair/typebox": "^0.34.46",
49
50
  "ajv": "^8.17.1",
@@ -61,8 +62,6 @@
61
62
  "node-html-parser": "^6.1.13",
62
63
  "smol-toml": "^1.6.0",
63
64
  "strip-ansi": "^7.1.2",
64
- "winston": "^3.17.0",
65
- "winston-daily-rotate-file": "^5.0.0",
66
65
  "zod": "^4.3.5"
67
66
  },
68
67
  "devDependencies": {
@@ -8,34 +8,33 @@ import { SessionSelectorComponent } from "../modes/interactive/components/sessio
8
8
 
9
9
  /** Show TUI session selector and return selected session path or null if cancelled */
10
10
  export async function selectSession(sessions: SessionInfo[]): Promise<string | null> {
11
- return new Promise((resolve) => {
12
- const ui = new TUI(new ProcessTerminal());
13
- let resolved = false;
14
-
15
- const selector = new SessionSelectorComponent(
16
- sessions,
17
- (path: string) => {
18
- if (!resolved) {
19
- resolved = true;
20
- ui.stop();
21
- resolve(path);
22
- }
23
- },
24
- () => {
25
- if (!resolved) {
26
- resolved = true;
27
- ui.stop();
28
- resolve(null);
29
- }
30
- },
31
- () => {
11
+ const { promise, resolve } = Promise.withResolvers<string | null>();
12
+ const ui = new TUI(new ProcessTerminal());
13
+ let resolved = false;
14
+ const selector = new SessionSelectorComponent(
15
+ sessions,
16
+ (path: string) => {
17
+ if (!resolved) {
18
+ resolved = true;
19
+ ui.stop();
20
+ resolve(path);
21
+ }
22
+ },
23
+ () => {
24
+ if (!resolved) {
25
+ resolved = true;
32
26
  ui.stop();
33
- process.exit(0);
34
- },
35
- );
27
+ resolve(null);
28
+ }
29
+ },
30
+ () => {
31
+ ui.stop();
32
+ process.exit(0);
33
+ },
34
+ );
36
35
 
37
- ui.addChild(selector);
38
- ui.setFocus(selector.getSessionList());
39
- ui.start();
40
- });
36
+ ui.addChild(selector);
37
+ ui.setFocus(selector.getSessionList());
38
+ ui.start();
39
+ return promise;
41
40
  }
@@ -4,6 +4,7 @@
4
4
  * Handles `omp setup <component>` to install dependencies for optional features.
5
5
  */
6
6
 
7
+ import { $ } from "bun";
7
8
  import chalk from "chalk";
8
9
  import { APP_NAME } from "../config";
9
10
  import { theme } from "../modes/interactive/theme/theme";
@@ -89,10 +90,8 @@ async function checkPythonSetup(): Promise<PythonCheckResult> {
89
90
 
90
91
  for (const pkg of PYTHON_PACKAGES) {
91
92
  const moduleName = pkg === "jupyter_kernel_gateway" ? "kernel_gateway" : pkg;
92
- const check = Bun.spawnSync(
93
- [pythonPath, "-c", `import importlib.util; exit(0 if importlib.util.find_spec('${moduleName}') else 1)`],
94
- { stdin: "ignore", stdout: "pipe", stderr: "pipe" },
95
- );
93
+ const script = `import importlib.util; raise SystemExit(0 if importlib.util.find_spec('${moduleName}') else 1)`;
94
+ const check = await $`${pythonPath} -c ${script}`.quiet().nothrow();
96
95
  if (check.exitCode === 0) {
97
96
  result.installedPackages.push(pkg);
98
97
  } else {
@@ -107,24 +106,16 @@ async function checkPythonSetup(): Promise<PythonCheckResult> {
107
106
  /**
108
107
  * Install Python packages using uv (preferred) or pip.
109
108
  */
110
- function installPythonPackages(packages: string[], uvPath?: string, pipPath?: string): boolean {
109
+ async function installPythonPackages(packages: string[], uvPath?: string, pipPath?: string): Promise<boolean> {
111
110
  if (uvPath) {
112
111
  console.log(chalk.dim(`Installing via uv: ${packages.join(" ")}`));
113
- const result = Bun.spawnSync([uvPath, "pip", "install", ...packages], {
114
- stdin: "ignore",
115
- stdout: "inherit",
116
- stderr: "inherit",
117
- });
112
+ const result = await $`${uvPath} pip install ${packages}`.nothrow();
118
113
  return result.exitCode === 0;
119
114
  }
120
115
 
121
116
  if (pipPath) {
122
117
  console.log(chalk.dim(`Installing via pip: ${packages.join(" ")}`));
123
- const result = Bun.spawnSync([pipPath, "install", ...packages], {
124
- stdin: "ignore",
125
- stdout: "inherit",
126
- stderr: "inherit",
127
- });
118
+ const result = await $`${pipPath} install ${packages}`.nothrow();
128
119
  return result.exitCode === 0;
129
120
  }
130
121
 
@@ -188,7 +179,7 @@ async function handlePythonSetup(flags: { json?: boolean; check?: boolean }): Pr
188
179
  }
189
180
 
190
181
  console.log("");
191
- const success = installPythonPackages(check.missingPackages, check.uvPath, check.pipPath);
182
+ const success = await installPythonPackages(check.missingPackages, check.uvPath, check.pipPath);
192
183
 
193
184
  if (!success) {
194
185
  console.error(chalk.red(`\n${theme.status.error} Installation failed`));
@@ -142,7 +142,7 @@ async function updateViaBun(): Promise<void> {
142
142
  console.log(chalk.dim("Updating via bun..."));
143
143
 
144
144
  try {
145
- execSync(`bun update -g ${PACKAGE}`, { stdio: "inherit" });
145
+ execSync(`bun update --latest -g ${PACKAGE}`, { stdio: "inherit" });
146
146
  console.log(chalk.green(`\n${theme.status.success} Update complete`));
147
147
  } catch (error) {
148
148
  throw new Error("bun update failed", { cause: error });
package/src/config.ts CHANGED
@@ -1,9 +1,9 @@
1
1
  import { existsSync, readFileSync, statSync } from "node:fs";
2
2
  import { homedir } from "node:os";
3
3
  import { dirname, join, resolve } from "node:path";
4
+ import { logger } from "@oh-my-pi/pi-utils";
4
5
  // Embed package.json at build time for config
5
6
  import packageJson from "../package.json" with { type: "json" };
6
- import { logger } from "./core/logger";
7
7
 
8
8
  // =============================================================================
9
9
  // App Config (from embedded package.json)
@@ -13,12 +13,15 @@
13
13
  * Modes use this class and add their own I/O layer on top.
14
14
  */
15
15
 
16
+ import { existsSync, readFileSync } from "node:fs";
16
17
  import type { Agent, AgentEvent, AgentMessage, AgentState, AgentTool, ThinkingLevel } from "@oh-my-pi/pi-agent-core";
17
- import type { AssistantMessage, ImageContent, Message, Model, TextContent, Usage } from "@oh-my-pi/pi-ai";
18
+ import type { AssistantMessage, ImageContent, Message, Model, TextContent, ToolCall, Usage } from "@oh-my-pi/pi-ai";
18
19
  import { isContextOverflow, modelsAreEqual, supportsXhigh } from "@oh-my-pi/pi-ai";
20
+ import { abortableSleep, logger } from "@oh-my-pi/pi-utils";
19
21
  import type { Rule } from "../capability/rule";
20
22
  import { getAgentDbPath } from "../config";
21
23
  import { theme } from "../modes/interactive/theme/theme";
24
+ import ttsrInterruptTemplate from "../prompts/system/ttsr-interrupt.md" with { type: "text" };
22
25
  import { type BashResult, executeBash as executeBashCommand, executeBashWithOperations } from "./bash-executor";
23
26
  import {
24
27
  type CompactionResult,
@@ -47,11 +50,10 @@ import type {
47
50
  import type { CompactOptions, ContextUsage } from "./extensions/types";
48
51
  import { extractFileMentions, generateFileMentionMessages } from "./file-mentions";
49
52
  import type { HookCommandContext } from "./hooks/types";
50
- import { logger } from "./logger";
51
53
  import type { BashExecutionMessage, CustomMessage } from "./messages";
52
54
  import type { ModelRegistry } from "./model-registry";
53
55
  import { parseModelString } from "./model-resolver";
54
- import { expandPromptTemplate, type PromptTemplate, parseCommandArgs } from "./prompt-templates";
56
+ import { expandPromptTemplate, type PromptTemplate, parseCommandArgs, renderPromptTemplate } from "./prompt-templates";
55
57
  import type { BranchSummaryEntry, CompactionEntry, NewSessionOptions, SessionManager } from "./session-manager";
56
58
  import type { SettingsManager, SkillsSettings } from "./settings-manager";
57
59
  import type { Skill, SkillWarning } from "./skills";
@@ -59,6 +61,8 @@ import { expandSlashCommand, type FileSlashCommand } from "./slash-commands";
59
61
  import { closeAllConnections } from "./ssh/connection-manager";
60
62
  import { unmountAll } from "./ssh/sshfs-mount";
61
63
  import type { BashOperations } from "./tools/bash";
64
+ import { normalizeDiff, normalizeToLF, ParseError, previewPatch, stripBom } from "./tools/patch";
65
+ import { resolveToCwd } from "./tools/path-utils";
62
66
  import { getArtifactsDir } from "./tools/task/artifacts";
63
67
  import type { TodoItem } from "./tools/todo-write";
64
68
  import type { TtsrManager } from "./ttsr";
@@ -271,6 +275,10 @@ export class AgentSession {
271
275
  private _pendingTtsrInjections: Rule[] = [];
272
276
  private _ttsrAbortPending = false;
273
277
 
278
+ private _streamingEditAbortTriggered = false;
279
+ private _streamingEditCheckedLineCounts = new Map<string, number>();
280
+ private _streamingEditFileCache = new Map<string, string>();
281
+
274
282
  constructor(config: AgentSessionConfig) {
275
283
  this.agent = config.agent;
276
284
  this.sessionManager = config.sessionManager;
@@ -352,9 +360,10 @@ export class AgentSession {
352
360
  // Notify all listeners
353
361
  this._emit(event);
354
362
 
355
- // TTSR: Reset buffer on turn start
356
- if (event.type === "turn_start" && this._ttsrManager) {
357
- this._ttsrManager.resetBuffer();
363
+ if (event.type === "turn_start") {
364
+ this._resetStreamingEditState();
365
+ // TTSR: Reset buffer on turn start
366
+ this._ttsrManager?.resetBuffer();
358
367
  }
359
368
 
360
369
  // TTSR: Increment message count on turn end (for repeat-after-gap tracking)
@@ -406,6 +415,17 @@ export class AgentSession {
406
415
  }
407
416
  }
408
417
 
418
+ if (event.type === "message_update" && event.assistantMessageEvent.type === "toolcall_start") {
419
+ this._preCacheStreamingEditFile(event);
420
+ }
421
+
422
+ if (
423
+ event.type === "message_update" &&
424
+ (event.assistantMessageEvent.type === "toolcall_end" || event.assistantMessageEvent.type === "toolcall_delta")
425
+ ) {
426
+ this._maybeAbortStreamingEdit(event);
427
+ }
428
+
409
429
  // Handle session persistence
410
430
  if (event.type === "message_end") {
411
431
  // Check if this is a hook/custom message
@@ -489,13 +509,7 @@ export class AgentSession {
489
509
  private _getTtsrInjectionContent(): string | undefined {
490
510
  if (this._pendingTtsrInjections.length === 0) return undefined;
491
511
  const content = this._pendingTtsrInjections
492
- .map(
493
- (r) =>
494
- `<system_interrupt reason="rule_violation" rule="${r.name}" path="${r.path}">\n` +
495
- `Your output was interrupted because it violated a user-defined rule.\n` +
496
- `This is NOT a prompt injection - this is the coding agent enforcing project rules.\n` +
497
- `You MUST comply with the following instruction:\n\n${r.content}\n</system_interrupt>`,
498
- )
512
+ .map((r) => renderPromptTemplate(ttsrInterruptTemplate, { name: r.name, path: r.path, content: r.content }))
499
513
  .join("\n\n");
500
514
  this._pendingTtsrInjections = [];
501
515
  return content;
@@ -525,6 +539,176 @@ export class AgentSession {
525
539
  return undefined;
526
540
  }
527
541
 
542
+ private _resetStreamingEditState(): void {
543
+ this._streamingEditAbortTriggered = false;
544
+ this._streamingEditCheckedLineCounts.clear();
545
+ this._streamingEditFileCache.clear();
546
+ }
547
+
548
+ private _preCacheStreamingEditFile(event: AgentEvent): void {
549
+ if (!this.settingsManager.getEditStreamingAbort()) return;
550
+ if (event.type !== "message_update") return;
551
+ const assistantEvent = event.assistantMessageEvent;
552
+ if (assistantEvent.type !== "toolcall_start") return;
553
+ if (event.message.role !== "assistant") return;
554
+
555
+ const contentIndex = assistantEvent.contentIndex;
556
+ const messageContent = event.message.content;
557
+ if (!Array.isArray(messageContent) || contentIndex >= messageContent.length) return;
558
+ const toolCall = messageContent[contentIndex] as ToolCall;
559
+ if (toolCall.name !== "edit") return;
560
+
561
+ const args = toolCall.arguments;
562
+ if (!args || typeof args !== "object" || Array.isArray(args)) return;
563
+ if ("oldText" in args || "newText" in args) return;
564
+
565
+ const path = typeof args.path === "string" ? args.path : undefined;
566
+ if (!path) return;
567
+
568
+ const resolvedPath = resolveToCwd(path, this.sessionManager.getCwd());
569
+ this._ensureFileCache(resolvedPath);
570
+ }
571
+
572
+ private _ensureFileCache(resolvedPath: string): void {
573
+ if (this._streamingEditFileCache.has(resolvedPath)) return;
574
+
575
+ try {
576
+ if (existsSync(resolvedPath)) {
577
+ const rawText = readFileSync(resolvedPath, "utf8");
578
+ const { text } = stripBom(rawText);
579
+ this._streamingEditFileCache.set(resolvedPath, normalizeToLF(text));
580
+ }
581
+ } catch {
582
+ // Ignore errors - mark as empty string so we don't retry
583
+ this._streamingEditFileCache.set(resolvedPath, "");
584
+ }
585
+ }
586
+
587
+ private _maybeAbortStreamingEdit(event: AgentEvent): void {
588
+ if (!this.settingsManager.getEditStreamingAbort()) return;
589
+ if (this._streamingEditAbortTriggered) return;
590
+ if (event.type !== "message_update") return;
591
+ const assistantEvent = event.assistantMessageEvent;
592
+ if (assistantEvent.type !== "toolcall_end" && assistantEvent.type !== "toolcall_delta") return;
593
+ if (event.message.role !== "assistant") return;
594
+
595
+ const contentIndex = assistantEvent.contentIndex;
596
+ const messageContent = event.message.content;
597
+ if (!Array.isArray(messageContent) || contentIndex >= messageContent.length) return;
598
+ const toolCall = messageContent[contentIndex] as ToolCall;
599
+ if (toolCall.name !== "edit" || !toolCall.id) return;
600
+
601
+ const args = toolCall.arguments;
602
+ if (!args || typeof args !== "object" || Array.isArray(args)) return;
603
+ if ("oldText" in args || "newText" in args) return;
604
+
605
+ const path = typeof args.path === "string" ? args.path : undefined;
606
+ const diff = typeof args.diff === "string" ? args.diff : undefined;
607
+ const op = typeof args.op === "string" ? args.op : undefined;
608
+ if (!path || !diff) return;
609
+ if (op && op !== "update") return;
610
+
611
+ if (!diff.includes("\n")) return;
612
+ const lastNewlineIndex = diff.lastIndexOf("\n");
613
+ if (lastNewlineIndex < 0) return;
614
+ const diffForCheck = diff.endsWith("\n") ? diff : diff.slice(0, lastNewlineIndex + 1);
615
+ if (diffForCheck.trim().length === 0) return;
616
+
617
+ const normalizedDiff = normalizeDiff(diffForCheck.replace(/\r/g, ""));
618
+ if (!normalizedDiff) return;
619
+ const lines = normalizedDiff.split("\n");
620
+ const hasChangeLine = lines.some((line) => line.startsWith("+") || line.startsWith("-"));
621
+ if (!hasChangeLine) return;
622
+
623
+ const lineCount = lines.length;
624
+ const lastChecked = this._streamingEditCheckedLineCounts.get(toolCall.id);
625
+ if (lastChecked !== undefined && lineCount <= lastChecked) return;
626
+ this._streamingEditCheckedLineCounts.set(toolCall.id, lineCount);
627
+
628
+ const rename = typeof args.rename === "string" ? args.rename : undefined;
629
+
630
+ const removedLines = lines
631
+ .filter((line) => line.startsWith("-") && !line.startsWith("--- "))
632
+ .map((line) => line.slice(1));
633
+ if (removedLines.length > 0) {
634
+ const resolvedPath = resolveToCwd(path, this.sessionManager.getCwd());
635
+ const cachedContent = this._streamingEditFileCache.get(resolvedPath);
636
+ if (cachedContent !== undefined) {
637
+ const missing = removedLines.find((line) => !cachedContent.includes(normalizeToLF(line)));
638
+ if (missing) {
639
+ this._streamingEditAbortTriggered = true;
640
+ logger.warn("Streaming edit aborted due to patch preview failure", {
641
+ toolCallId: toolCall.id,
642
+ path,
643
+ error: `Failed to find expected lines in ${path}:\n${missing}`,
644
+ });
645
+ this.agent.abort();
646
+ }
647
+ return;
648
+ }
649
+ if (assistantEvent.type === "toolcall_delta") return;
650
+ void this._checkRemovedLinesAsync(toolCall.id, path, resolvedPath, removedLines);
651
+ return;
652
+ }
653
+
654
+ if (assistantEvent.type === "toolcall_delta") return;
655
+ void this._checkPreviewPatchAsync(toolCall.id, path, rename, normalizedDiff);
656
+ }
657
+
658
+ private async _checkRemovedLinesAsync(
659
+ toolCallId: string,
660
+ path: string,
661
+ resolvedPath: string,
662
+ removedLines: string[],
663
+ ): Promise<void> {
664
+ if (this._streamingEditAbortTriggered) return;
665
+ try {
666
+ if (!(await Bun.file(resolvedPath).exists())) return;
667
+ const { text } = stripBom(await Bun.file(resolvedPath).text());
668
+ const normalizedContent = normalizeToLF(text);
669
+ const missing = removedLines.find((line) => !normalizedContent.includes(normalizeToLF(line)));
670
+ if (missing) {
671
+ this._streamingEditAbortTriggered = true;
672
+ logger.warn("Streaming edit aborted due to patch preview failure", {
673
+ toolCallId,
674
+ path,
675
+ error: `Failed to find expected lines in ${path}:\n${missing}`,
676
+ });
677
+ this.agent.abort();
678
+ }
679
+ } catch {
680
+ // Ignore errors during async fallback
681
+ }
682
+ }
683
+
684
+ private async _checkPreviewPatchAsync(
685
+ toolCallId: string,
686
+ path: string,
687
+ rename: string | undefined,
688
+ normalizedDiff: string,
689
+ ): Promise<void> {
690
+ if (this._streamingEditAbortTriggered) return;
691
+ try {
692
+ await previewPatch(
693
+ { path, op: "update", rename, diff: normalizedDiff },
694
+ {
695
+ cwd: this.sessionManager.getCwd(),
696
+ allowFuzzy: this.settingsManager.getEditFuzzyMatch(),
697
+ fuzzyThreshold: this.settingsManager.getEditFuzzyThreshold(),
698
+ },
699
+ );
700
+ } catch (error) {
701
+ if (error instanceof ParseError) return;
702
+ this._streamingEditAbortTriggered = true;
703
+ logger.warn("Streaming edit aborted due to patch preview failure", {
704
+ toolCallId,
705
+ path,
706
+ error: error instanceof Error ? error.message : String(error),
707
+ });
708
+ this.agent.abort();
709
+ }
710
+ }
711
+
528
712
  /** Rewrite tool call arguments in agent state and persisted session history. */
529
713
  private async _rewriteToolCallArgs(toolCallId: string, args: Record<string, unknown>): Promise<void> {
530
714
  let updated = false;
@@ -2042,7 +2226,7 @@ export class AgentSession {
2042
2226
  error: message,
2043
2227
  model: `${candidate.provider}/${candidate.id}`,
2044
2228
  });
2045
- await new Promise((resolve) => setTimeout(resolve, delayMs));
2229
+ await Bun.sleep(delayMs);
2046
2230
  }
2047
2231
  }
2048
2232
 
@@ -2223,9 +2407,9 @@ export class AgentSession {
2223
2407
  // Create retry promise on first attempt so waitForRetry() can await it
2224
2408
  // Ensure only one promise exists (avoid orphaned promises from concurrent calls)
2225
2409
  if (!this._retryPromise) {
2226
- this._retryPromise = new Promise((resolve) => {
2227
- this._retryResolve = resolve;
2228
- });
2410
+ const { promise, resolve } = Promise.withResolvers<void>();
2411
+ this._retryPromise = promise;
2412
+ this._retryResolve = resolve;
2229
2413
  }
2230
2414
 
2231
2415
  if (this._retryAttempt > settings.maxRetries) {
@@ -2280,7 +2464,7 @@ export class AgentSession {
2280
2464
  }
2281
2465
  this._retryAbortController = new AbortController();
2282
2466
  try {
2283
- await this._sleep(delayMs, this._retryAbortController.signal);
2467
+ await abortableSleep(delayMs, this._retryAbortController.signal);
2284
2468
  } catch {
2285
2469
  // Aborted during sleep - emit end event so UI can clean up
2286
2470
  const attempt = this._retryAttempt;
@@ -2307,25 +2491,6 @@ export class AgentSession {
2307
2491
  return true;
2308
2492
  }
2309
2493
 
2310
- /**
2311
- * Sleep helper that respects abort signal.
2312
- */
2313
- private _sleep(ms: number, signal?: AbortSignal): Promise<void> {
2314
- return new Promise((resolve, reject) => {
2315
- if (signal?.aborted) {
2316
- reject(new Error("Aborted"));
2317
- return;
2318
- }
2319
-
2320
- const timeout = setTimeout(resolve, ms);
2321
-
2322
- signal?.addEventListener("abort", () => {
2323
- clearTimeout(timeout);
2324
- reject(new Error("Aborted"));
2325
- });
2326
- });
2327
- }
2328
-
2329
2494
  /**
2330
2495
  * Cancel in-progress retry.
2331
2496
  */
@@ -1,9 +1,9 @@
1
1
  import { Database } from "bun:sqlite";
2
2
  import { chmodSync, existsSync, mkdirSync } from "node:fs";
3
3
  import { dirname } from "node:path";
4
+ import { logger } from "@oh-my-pi/pi-utils";
4
5
  import { getAgentDbPath } from "../config";
5
6
  import type { AuthCredential } from "./auth-storage";
6
- import { logger } from "./logger";
7
7
  import type { Settings } from "./settings-manager";
8
8
 
9
9
  /** Prepared SQLite statement type from bun:sqlite */
@@ -13,12 +13,13 @@ import {
13
13
  loginGeminiCli,
14
14
  loginGitHubCopilot,
15
15
  loginOpenAICodex,
16
+ type OAuthController,
16
17
  type OAuthCredentials,
17
18
  type OAuthProvider,
18
19
  } from "@oh-my-pi/pi-ai";
20
+ import { logger } from "@oh-my-pi/pi-utils";
19
21
  import { getAgentDbPath, getAuthPath } from "../config";
20
22
  import { AgentStorage } from "./agent-storage";
21
- import { logger } from "./logger";
22
23
  import { migrateJsonStorage } from "./storage-migration";
23
24
 
24
25
  export type ApiKeyCredential = {
@@ -545,51 +546,40 @@ export class AuthStorage {
545
546
  */
546
547
  async login(
547
548
  provider: OAuthProvider,
548
- callbacks: {
549
+ ctrl: OAuthController & {
550
+ /** onAuth is required by auth-storage but optional in OAuthController */
549
551
  onAuth: (info: { url: string; instructions?: string }) => void;
552
+ /** onPrompt is required for some providers (github-copilot, openai-codex) */
550
553
  onPrompt: (prompt: { message: string; placeholder?: string }) => Promise<string>;
551
- onProgress?: (message: string) => void;
552
- /** For providers with local callback servers (e.g., openai-codex), races with browser callback */
553
- onManualCodeInput?: () => Promise<string>;
554
- /** For cancellation support (e.g., github-copilot polling) */
555
- signal?: AbortSignal;
556
554
  },
557
555
  ): Promise<void> {
558
556
  let credentials: OAuthCredentials;
559
557
 
560
558
  switch (provider) {
561
559
  case "anthropic":
562
- credentials = await loginAnthropic(
563
- (url) => callbacks.onAuth({ url }),
564
- () => callbacks.onPrompt({ message: "Paste the authorization code:" }),
565
- );
560
+ credentials = await loginAnthropic(ctrl);
566
561
  break;
567
562
  case "github-copilot":
568
563
  credentials = await loginGitHubCopilot({
569
- onAuth: (url, instructions) => callbacks.onAuth({ url, instructions }),
570
- onPrompt: callbacks.onPrompt,
571
- onProgress: callbacks.onProgress,
572
- signal: callbacks.signal,
564
+ onAuth: (url, instructions) => ctrl.onAuth({ url, instructions }),
565
+ onPrompt: ctrl.onPrompt,
566
+ onProgress: ctrl.onProgress,
567
+ signal: ctrl.signal,
573
568
  });
574
569
  break;
575
570
  case "google-gemini-cli":
576
- credentials = await loginGeminiCli(callbacks.onAuth, callbacks.onProgress, callbacks.onManualCodeInput);
571
+ credentials = await loginGeminiCli(ctrl);
577
572
  break;
578
573
  case "google-antigravity":
579
- credentials = await loginAntigravity(callbacks.onAuth, callbacks.onProgress, callbacks.onManualCodeInput);
574
+ credentials = await loginAntigravity(ctrl);
580
575
  break;
581
576
  case "openai-codex":
582
- credentials = await loginOpenAICodex({
583
- onAuth: callbacks.onAuth,
584
- onPrompt: callbacks.onPrompt,
585
- onProgress: callbacks.onProgress,
586
- onManualCodeInput: callbacks.onManualCodeInput,
587
- });
577
+ credentials = await loginOpenAICodex(ctrl);
588
578
  break;
589
579
  case "cursor":
590
580
  credentials = await loginCursor(
591
- (url) => callbacks.onAuth({ url }),
592
- callbacks.onProgress ? () => callbacks.onProgress?.("Waiting for browser authentication...") : undefined,
581
+ (url) => ctrl.onAuth({ url }),
582
+ ctrl.onProgress ? () => ctrl.onProgress?.("Waiting for browser authentication...") : undefined,
593
583
  );
594
584
  break;
595
585
  default: