@oh-my-pi/pi-coding-agent 17.0.9 → 17.1.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 (285) hide show
  1. package/CHANGELOG.md +54 -0
  2. package/README.md +1 -1
  3. package/dist/cli.js +4659 -4261
  4. package/dist/types/async/job-manager.d.ts +37 -1
  5. package/dist/types/cli/args.d.ts +2 -0
  6. package/dist/types/cli/bench-cli.d.ts +41 -0
  7. package/dist/types/commands/bench.d.ts +15 -3
  8. package/dist/types/commands/launch.d.ts +4 -0
  9. package/dist/types/config/model-registry.d.ts +3 -1
  10. package/dist/types/config/models-config-schema.d.ts +18 -6
  11. package/dist/types/config/models-config.d.ts +14 -5
  12. package/dist/types/config/provider-globals.d.ts +2 -2
  13. package/dist/types/config/service-tier.d.ts +5 -1
  14. package/dist/types/config/settings-schema.d.ts +316 -47
  15. package/dist/types/config/settings.d.ts +7 -0
  16. package/dist/types/dap/client.d.ts +20 -0
  17. package/dist/types/exec/bash-executor.d.ts +38 -0
  18. package/dist/types/exec/direnv.d.ts +33 -0
  19. package/dist/types/export/html/args.d.ts +15 -0
  20. package/dist/types/export/html/index.d.ts +11 -26
  21. package/dist/types/export/html/web-palette.d.ts +55 -116
  22. package/dist/types/extensibility/extensions/loader.d.ts +3 -0
  23. package/dist/types/extensibility/extensions/types.d.ts +17 -2
  24. package/dist/types/extensibility/plugins/legacy-pi-compat.d.ts +13 -0
  25. package/dist/types/live/controller.d.ts +40 -0
  26. package/dist/types/live/protocol.d.ts +85 -0
  27. package/dist/types/live/protocol.test.d.ts +1 -0
  28. package/dist/types/live/transport.d.ts +48 -0
  29. package/dist/types/live/visualizer.d.ts +33 -0
  30. package/dist/types/lsp/client.d.ts +16 -1
  31. package/dist/types/mcp/config-writer.test.d.ts +1 -0
  32. package/dist/types/mcp/oauth-discovery.d.ts +2 -0
  33. package/dist/types/mcp/smithery-auth.d.ts +1 -1
  34. package/dist/types/mcp/smithery-auth.test.d.ts +1 -0
  35. package/dist/types/memory-backend/tool-names.d.ts +2 -0
  36. package/dist/types/modes/components/login-dialog.d.ts +2 -2
  37. package/dist/types/modes/components/plan-review-overlay.d.ts +26 -0
  38. package/dist/types/modes/components/settings-defs.d.ts +7 -1
  39. package/dist/types/modes/components/status-line/component.jj-cache.test.d.ts +1 -0
  40. package/dist/types/modes/components/tool-execution.d.ts +9 -1
  41. package/dist/types/modes/controllers/command-controller.d.ts +1 -1
  42. package/dist/types/modes/controllers/event-controller.d.ts +6 -1
  43. package/dist/types/modes/controllers/live-command-controller.d.ts +14 -0
  44. package/dist/types/modes/interactive-mode.d.ts +5 -0
  45. package/dist/types/modes/theme/theme.d.ts +1 -1
  46. package/dist/types/modes/types.d.ts +3 -1
  47. package/dist/types/modes/utils/context-usage.d.ts +12 -2
  48. package/dist/types/plan-mode/plan-files.d.ts +10 -0
  49. package/dist/types/registry/agent-lifecycle.d.ts +21 -9
  50. package/dist/types/registry/agent-registry.d.ts +11 -4
  51. package/dist/types/sdk.d.ts +15 -1
  52. package/dist/types/secrets/index.d.ts +15 -2
  53. package/dist/types/secrets/obfuscator.d.ts +113 -15
  54. package/dist/types/session/acp-permission-gate.d.ts +19 -0
  55. package/dist/types/session/agent-session-events.d.ts +84 -0
  56. package/dist/types/session/agent-session-types.d.ts +314 -0
  57. package/dist/types/session/agent-session.d.ts +118 -755
  58. package/dist/types/session/async-job-delivery.d.ts +28 -0
  59. package/dist/types/session/bash-runner.d.ts +75 -0
  60. package/dist/types/session/blob-store.d.ts +11 -1
  61. package/dist/types/session/blob-store.test.d.ts +1 -0
  62. package/dist/types/session/checkpoint-entries.d.ts +28 -0
  63. package/dist/types/session/eval-runner.d.ts +49 -0
  64. package/dist/types/session/irc-bridge.d.ts +46 -0
  65. package/dist/types/session/messages.d.ts +22 -0
  66. package/dist/types/session/model-controls.d.ts +180 -0
  67. package/dist/types/session/prewalk.d.ts +46 -0
  68. package/dist/types/session/queued-messages.d.ts +22 -0
  69. package/dist/types/session/redis-session-storage.d.ts +1 -0
  70. package/dist/types/session/retry-fallback-chains.d.ts +74 -0
  71. package/dist/types/session/role-models.d.ts +15 -0
  72. package/dist/types/session/session-advisors.d.ts +228 -0
  73. package/dist/types/session/session-entries.d.ts +8 -0
  74. package/dist/types/session/session-handoff.d.ts +70 -0
  75. package/dist/types/session/session-history-format.d.ts +6 -1
  76. package/dist/types/session/session-maintenance.d.ts +246 -0
  77. package/dist/types/session/session-manager.d.ts +41 -0
  78. package/dist/types/session/session-memory.d.ts +56 -0
  79. package/dist/types/session/session-provider-boundary.d.ts +62 -0
  80. package/dist/types/session/session-stats.d.ts +48 -0
  81. package/dist/types/session/session-tools.d.ts +143 -0
  82. package/dist/types/session/session-workspace.d.ts +32 -0
  83. package/dist/types/session/stream-guards.d.ts +44 -0
  84. package/dist/types/session/todo-tracker.d.ts +56 -0
  85. package/dist/types/session/ttsr-coordinator.d.ts +51 -0
  86. package/dist/types/session/turn-recovery.d.ts +225 -0
  87. package/dist/types/system-prompt.d.ts +2 -0
  88. package/dist/types/task/executor.d.ts +4 -1
  89. package/dist/types/task/output-manager.d.ts +3 -1
  90. package/dist/types/tools/approval.d.ts +1 -0
  91. package/dist/types/tools/browser/launch.d.ts +5 -0
  92. package/dist/types/tools/image-gen.d.ts +4 -4
  93. package/dist/types/tools/image-providers.d.ts +38 -0
  94. package/dist/types/tools/index.d.ts +5 -0
  95. package/dist/types/tools/path-utils.d.ts +8 -0
  96. package/dist/types/tools/todo.d.ts +10 -4
  97. package/dist/types/tools/xdev.d.ts +5 -1
  98. package/dist/types/tts/streaming-player.d.ts +14 -0
  99. package/dist/types/tts/vocalizer.d.ts +5 -0
  100. package/dist/types/utils/jj.d.ts +29 -0
  101. package/dist/types/utils/lang-from-path.d.ts +1 -0
  102. package/dist/types/utils/title-generator.d.ts +30 -0
  103. package/dist/types/vibe/runtime.d.ts +36 -8
  104. package/dist/types/web/search/index.d.ts +1 -1
  105. package/dist/types/web/search/provider.d.ts +15 -5
  106. package/dist/types/web/search/types.d.ts +94 -0
  107. package/package.json +12 -12
  108. package/scripts/generate-share-viewer.ts +4 -6
  109. package/src/advisor/__tests__/advisor.test.ts +643 -0
  110. package/src/advisor/runtime.ts +144 -43
  111. package/src/async/job-manager.ts +90 -2
  112. package/src/cli/args.ts +2 -0
  113. package/src/cli/auth-gateway-cli.ts +18 -3
  114. package/src/cli/bench-cli.ts +395 -29
  115. package/src/cli/flag-tables.ts +3 -0
  116. package/src/cli/gc-cli.ts +2 -2
  117. package/src/cli/usage-cli.ts +10 -1
  118. package/src/collab/host.ts +3 -2
  119. package/src/commands/bench.ts +18 -4
  120. package/src/commands/launch.ts +4 -0
  121. package/src/config/model-registry.ts +10 -2
  122. package/src/config/models-config-schema.ts +15 -4
  123. package/src/config/provider-globals.ts +9 -9
  124. package/src/config/service-tier.ts +26 -1
  125. package/src/config/settings-schema.ts +209 -51
  126. package/src/config/settings.ts +81 -2
  127. package/src/dap/client.ts +37 -4
  128. package/src/edit/index.ts +49 -12
  129. package/src/eval/py/executor.ts +103 -33
  130. package/src/exec/bash-executor.ts +89 -6
  131. package/src/exec/direnv.ts +145 -0
  132. package/src/export/html/args.ts +20 -0
  133. package/src/export/html/index.ts +45 -47
  134. package/src/export/html/template.css +19 -1
  135. package/src/export/html/template.html +6 -0
  136. package/src/export/html/template.js +21 -0
  137. package/src/export/html/tool-views.generated.js +31 -31
  138. package/src/export/html/web-palette.ts +116 -132
  139. package/src/export/share.ts +249 -49
  140. package/src/extensibility/extensions/loader.ts +29 -1
  141. package/src/extensibility/extensions/runner.ts +6 -0
  142. package/src/extensibility/extensions/types.ts +33 -2
  143. package/src/extensibility/plugins/legacy-pi-compat.ts +19 -1
  144. package/src/hindsight/state.ts +64 -6
  145. package/src/internal-urls/local-protocol.ts +2 -1
  146. package/src/internal-urls/memory-protocol.ts +2 -2
  147. package/src/internal-urls/skill-protocol.ts +2 -2
  148. package/src/internal-urls/ssh-protocol.ts +2 -1
  149. package/src/internal-urls/vault-protocol.ts +2 -1
  150. package/src/live/audio-worklet.txt +59 -0
  151. package/src/live/browser-runtime.txt +221 -0
  152. package/src/live/controller.ts +464 -0
  153. package/src/live/prompts/agent-final-message.md +3 -0
  154. package/src/live/prompts/live-instructions.md +23 -0
  155. package/src/live/protocol.test.ts +140 -0
  156. package/src/live/protocol.ts +233 -0
  157. package/src/live/transport.ts +490 -0
  158. package/src/live/visualizer.ts +234 -0
  159. package/src/lsp/client.ts +29 -9
  160. package/src/lsp/index.ts +19 -4
  161. package/src/main.ts +8 -1
  162. package/src/mcp/config-writer.test.ts +43 -0
  163. package/src/mcp/config-writer.ts +109 -82
  164. package/src/mcp/oauth-discovery.ts +10 -2
  165. package/src/mcp/smithery-auth.test.ts +29 -0
  166. package/src/mcp/smithery-auth.ts +3 -2
  167. package/src/memories/index.ts +40 -20
  168. package/src/memory-backend/tool-names.ts +2 -0
  169. package/src/mnemopi/backend.ts +24 -7
  170. package/src/modes/acp/acp-agent.ts +56 -10
  171. package/src/modes/acp/acp-event-mapper.ts +8 -1
  172. package/src/modes/components/agent-hub.ts +1 -1
  173. package/src/modes/components/login-dialog.ts +14 -4
  174. package/src/modes/components/plan-review-overlay.ts +350 -35
  175. package/src/modes/components/settings-defs.ts +23 -1
  176. package/src/modes/components/settings-selector.ts +169 -1
  177. package/src/modes/components/status-line/component.jj-cache.test.ts +229 -0
  178. package/src/modes/components/status-line/component.ts +123 -2
  179. package/src/modes/components/tool-execution.ts +11 -2
  180. package/src/modes/controllers/command-controller.ts +56 -15
  181. package/src/modes/controllers/event-controller.ts +111 -16
  182. package/src/modes/controllers/extension-ui-controller.test.ts +1 -0
  183. package/src/modes/controllers/extension-ui-controller.ts +20 -4
  184. package/src/modes/controllers/live-command-controller.ts +178 -0
  185. package/src/modes/controllers/mcp-command-controller.ts +10 -1
  186. package/src/modes/controllers/omfg-controller.ts +44 -40
  187. package/src/modes/controllers/selector-controller.ts +18 -10
  188. package/src/modes/controllers/session-focus-controller.ts +6 -1
  189. package/src/modes/interactive-mode.ts +157 -38
  190. package/src/modes/runtime-init.ts +2 -0
  191. package/src/modes/setup-wizard/scenes/web-search.ts +17 -9
  192. package/src/modes/theme/theme.ts +1 -1
  193. package/src/modes/types.ts +3 -1
  194. package/src/modes/utils/context-usage.ts +23 -7
  195. package/src/modes/utils/ui-helpers.ts +10 -5
  196. package/src/plan-mode/plan-files.ts +40 -0
  197. package/src/prompts/bench/cache-prefix-chunk.md +1 -0
  198. package/src/prompts/bench/cache-prefix.md +3 -0
  199. package/src/prompts/bench/cache-suffix.md +1 -0
  200. package/src/prompts/system/custom-system-prompt.md +1 -1
  201. package/src/prompts/system/project-prompt.md +8 -1
  202. package/src/prompts/system/subagent-async-pending.md +6 -0
  203. package/src/prompts/system/system-prompt.md +1 -1
  204. package/src/prompts/system/workflow-notice.md +48 -40
  205. package/src/prompts/system/xdev-mount-notice.md +4 -0
  206. package/src/prompts/tools/todo.md +3 -1
  207. package/src/registry/agent-lifecycle.ts +116 -46
  208. package/src/registry/agent-registry.ts +35 -8
  209. package/src/registry/persisted-agents.ts +26 -2
  210. package/src/sdk.ts +210 -136
  211. package/src/secrets/index.ts +127 -4
  212. package/src/secrets/obfuscator.ts +2328 -105
  213. package/src/session/acp-permission-gate.ts +165 -0
  214. package/src/session/agent-session-error-log.test.ts +1 -1
  215. package/src/session/agent-session-events.ts +66 -0
  216. package/src/session/agent-session-types.ts +334 -0
  217. package/src/session/agent-session.ts +1892 -12226
  218. package/src/session/artifacts.ts +6 -5
  219. package/src/session/async-job-delivery.ts +74 -0
  220. package/src/session/bash-runner.ts +326 -0
  221. package/src/session/blob-store.test.ts +56 -0
  222. package/src/session/blob-store.ts +18 -2
  223. package/src/session/checkpoint-entries.ts +81 -0
  224. package/src/session/eval-runner.ts +212 -0
  225. package/src/session/indexed-session-storage.ts +9 -2
  226. package/src/session/irc-bridge.ts +203 -0
  227. package/src/session/messages.ts +263 -1
  228. package/src/session/model-controls.ts +714 -0
  229. package/src/session/prewalk.ts +252 -0
  230. package/src/session/queued-messages.ts +93 -0
  231. package/src/session/redis-session-storage.ts +51 -11
  232. package/src/session/retry-fallback-chains.ts +455 -0
  233. package/src/session/role-models.ts +85 -0
  234. package/src/session/session-advisors.ts +1679 -0
  235. package/src/session/session-context.ts +6 -3
  236. package/src/session/session-entries.ts +8 -0
  237. package/src/session/session-handoff.ts +308 -0
  238. package/src/session/session-history-format.ts +20 -9
  239. package/src/session/session-loader.ts +0 -46
  240. package/src/session/session-maintenance.ts +2983 -0
  241. package/src/session/session-manager.ts +390 -30
  242. package/src/session/session-memory.ts +222 -0
  243. package/src/session/session-provider-boundary.ts +307 -0
  244. package/src/session/session-stats.ts +293 -0
  245. package/src/session/session-tools.ts +943 -0
  246. package/src/session/session-workspace.ts +53 -0
  247. package/src/session/stream-guards.ts +417 -0
  248. package/src/session/todo-tracker.ts +380 -0
  249. package/src/session/ttsr-coordinator.ts +496 -0
  250. package/src/session/turn-recovery.ts +1629 -0
  251. package/src/slash-commands/builtin-registry.ts +92 -11
  252. package/src/slash-commands/helpers/usage-report.ts +22 -3
  253. package/src/system-prompt.ts +41 -17
  254. package/src/task/executor.ts +285 -70
  255. package/src/task/index.ts +98 -105
  256. package/src/task/output-manager.ts +25 -3
  257. package/src/task/persisted-revive.ts +4 -3
  258. package/src/task/structured-subagent.ts +1 -0
  259. package/src/tools/approval.ts +55 -10
  260. package/src/tools/bash-interactive.ts +26 -0
  261. package/src/tools/bash-skill-urls.ts +28 -2
  262. package/src/tools/bash.ts +339 -123
  263. package/src/tools/browser/launch.ts +9 -1
  264. package/src/tools/browser/tab-supervisor.ts +8 -5
  265. package/src/tools/browser.ts +94 -47
  266. package/src/tools/image-gen.ts +20 -28
  267. package/src/tools/image-providers.ts +50 -0
  268. package/src/tools/index.ts +9 -0
  269. package/src/tools/path-utils.ts +49 -2
  270. package/src/tools/read.ts +156 -88
  271. package/src/tools/renderers.ts +7 -1
  272. package/src/tools/todo.ts +101 -11
  273. package/src/tools/vibe.ts +1 -2
  274. package/src/tools/write.ts +51 -1
  275. package/src/tools/xdev.ts +67 -3
  276. package/src/tts/streaming-player.ts +62 -4
  277. package/src/tts/vocalizer.ts +18 -1
  278. package/src/utils/jj.ts +125 -4
  279. package/src/utils/lang-from-path.ts +7 -0
  280. package/src/utils/title-generator.ts +137 -1
  281. package/src/vibe/runtime.ts +852 -72
  282. package/src/web/search/index.ts +3 -6
  283. package/src/web/search/provider.ts +31 -16
  284. package/src/web/search/providers/xai.ts +7 -1
  285. package/src/web/search/types.ts +3 -0
@@ -0,0 +1,53 @@
1
+ import * as os from "node:os";
2
+ import * as path from "node:path";
3
+
4
+ /**
5
+ * Filesystem workspace of a session: one current/default directory plus a
6
+ * non-empty ordered list of workspace directories.
7
+ *
8
+ * `cwd` remains the default directory for relative-path resolution and
9
+ * backward compatibility. `directories` always contains `cwd` first, followed
10
+ * by any additional directories in their supplied order (deduplicated).
11
+ * Directory order is stable but carries no semantic hierarchy.
12
+ *
13
+ * Workspace directories come from the platform (ACP/editor), CLI, or config —
14
+ * never from filesystem walk-up discovery.
15
+ */
16
+ export interface SessionWorkspace {
17
+ /** Current/default directory for compatibility and relative path resolution. */
18
+ cwd: string;
19
+ /** Non-empty ordered list of absolute normalized directories; `cwd` is always first. */
20
+ directories: string[];
21
+ }
22
+
23
+ /** Expand a leading `~`/`~/` and resolve to an absolute path (relative input resolves against `base`). */
24
+ export function normalizeWorkspaceDirectory(directory: string, base?: string): string {
25
+ let expanded = directory;
26
+ if (expanded === "~") {
27
+ expanded = os.homedir();
28
+ } else if (expanded.startsWith("~/") || expanded.startsWith(`~${path.sep}`)) {
29
+ expanded = path.join(os.homedir(), expanded.slice(2));
30
+ }
31
+ return base ? path.resolve(base, expanded) : path.resolve(expanded);
32
+ }
33
+
34
+ /**
35
+ * Build a normalized {@link SessionWorkspace} from a cwd and optional
36
+ * additional directories. Additional entries are normalized (relative entries
37
+ * resolve against the normalized cwd), deduplicated, and appended after `cwd`
38
+ * preserving their supplied order.
39
+ */
40
+ export function normalizeSessionWorkspace(args: { cwd: string; directories?: string[] }): SessionWorkspace {
41
+ const cwd = normalizeWorkspaceDirectory(args.cwd);
42
+ const directories = [cwd];
43
+ for (const directory of args.directories ?? []) {
44
+ const normalized = normalizeWorkspaceDirectory(directory, cwd);
45
+ if (!directories.includes(normalized)) directories.push(normalized);
46
+ }
47
+ return { cwd, directories };
48
+ }
49
+
50
+ /** The workspace directories beyond `cwd`, in order (ACP `additionalDirectories` shape). */
51
+ export function additionalWorkspaceDirectories(workspace: SessionWorkspace): string[] {
52
+ return workspace.directories.filter(directory => directory !== workspace.cwd);
53
+ }
@@ -0,0 +1,417 @@
1
+ import * as fs from "node:fs";
2
+ import type { Agent, AgentEvent, AgentMessage, AgentTurnEndContext } from "@oh-my-pi/pi-agent-core";
3
+ import type { AssistantMessage, AssistantMessageEvent, Model, ToolCall } from "@oh-my-pi/pi-ai";
4
+ import { GeminiHeaderRunDetector, isGeminiThinkingModel } from "@oh-my-pi/pi-ai/utils/thinking-loop";
5
+ import { type RepeatedToolCallDetection, ToolCallLoopGuard } from "@oh-my-pi/pi-ai/utils/tool-call-loop-guard";
6
+ import { isEnoent, logger, prompt } from "@oh-my-pi/pi-utils";
7
+ import type { Settings } from "../config/settings";
8
+ import { normalizeDiff, normalizeToLF, ParseError, previewPatch, stripBom } from "../edit";
9
+ import { type LocalProtocolOptions, resolveLocalUrlToPath } from "../internal-urls";
10
+ import geminiToolReminderTemplate from "../prompts/system/gemini-tool-call-reminder.md" with { type: "text" };
11
+ import toolCallLoopRedirectTemplate from "../prompts/system/tool-call-loop-redirect.md" with { type: "text" };
12
+ import type { SecretObfuscator } from "../secrets/obfuscator";
13
+ import { assertEditableFile } from "../tools/auto-generated-guard";
14
+ import { isInternalUrlPath, normalizeLocalScheme, resolveToCwd } from "../tools/path-utils";
15
+ import { ToolError } from "../tools/tool-errors";
16
+ import type { CustomMessage } from "./messages";
17
+ import type { SessionManager } from "./session-manager";
18
+
19
+ const GEMINI_HEADER_INTERRUPT_REASON = "Interrupted: emit a tool call instead of more planning";
20
+ const GEMINI_TOOL_REMINDER_TYPE = "gemini-tool-call-reminder";
21
+ const TOOL_CALL_LOOP_REDIRECT_TYPE = "tool-call-loop-redirect";
22
+
23
+ /** Capabilities borrowed by the session's streaming and loop guards. */
24
+ export interface StreamGuardsHost {
25
+ agent: Agent;
26
+ settings: Settings;
27
+ sessionManager: SessionManager;
28
+ obfuscator: SecretObfuscator | undefined;
29
+ model(): Model | undefined;
30
+ isDisposed(): boolean;
31
+ promptGeneration(): number;
32
+ localProtocolOptions(): LocalProtocolOptions;
33
+ emitNotice(level: "info" | "warning" | "error", message: string, source?: string): void;
34
+ schedulePostPromptTask(task: (signal: AbortSignal) => Promise<void>): void;
35
+ discardAssistantTurn(message: AssistantMessage): void;
36
+ }
37
+
38
+ /** Guards streamed edit calls against generated files and invalid patch previews. */
39
+ export class StreamingEditGuard {
40
+ readonly #host: StreamGuardsHost;
41
+ #abortTriggered = false;
42
+ #checkedLineCounts = new Map<string, number>();
43
+ #precheckedToolCallIds = new Set<string>();
44
+ #fileCache = new Map<string, string>();
45
+ #lastToolCallId: string | undefined;
46
+
47
+ constructor(host: StreamGuardsHost) {
48
+ this.#host = host;
49
+ }
50
+
51
+ /** Whether the current turn was aborted by streaming edit validation. */
52
+ get abortTriggered(): boolean {
53
+ return this.#abortTriggered;
54
+ }
55
+
56
+ /** Clears all turn-scoped streaming edit state. */
57
+ reset(): void {
58
+ this.#abortTriggered = false;
59
+ this.#checkedLineCounts.clear();
60
+ this.#precheckedToolCallIds.clear();
61
+ this.#fileCache.clear();
62
+ }
63
+
64
+ /** Pre-caches and validates a streamed edit as its arguments arrive. */
65
+ preCache(event: AgentEvent): void {
66
+ if (this.#abortTriggered || event.type !== "message_update") return;
67
+ const assistantEvent = event.assistantMessageEvent;
68
+ if (
69
+ assistantEvent.type !== "toolcall_start" &&
70
+ assistantEvent.type !== "toolcall_delta" &&
71
+ assistantEvent.type !== "toolcall_end"
72
+ ) {
73
+ return;
74
+ }
75
+ const streamingEdit = this.#getToolCall(event);
76
+ if (!streamingEdit) return;
77
+
78
+ // The auto-generated guard runs unconditionally: editing a generated file
79
+ // is never the user's intent, and the cost of a false-positive abort is one
80
+ // wasted turn vs. silently corrupting a regenerated source.
81
+ const shouldCheckAutoGenerated =
82
+ !streamingEdit.toolCall.id || !this.#precheckedToolCallIds.has(streamingEdit.toolCall.id);
83
+ if (shouldCheckAutoGenerated) {
84
+ if (streamingEdit.toolCall.id) this.#precheckedToolCallIds.add(streamingEdit.toolCall.id);
85
+ this.#abortForAutoGeneratedPath(streamingEdit.toolCall, streamingEdit.path, streamingEdit.resolvedPath);
86
+ }
87
+
88
+ // File-cache priming feeds maybeAbort's removed-lines check, which is the
89
+ // optional patch-preview verification gated by edit.streamingAbort.
90
+ if (this.#host.settings.get("edit.streamingAbort")) this.#ensureFileCache(streamingEdit.resolvedPath);
91
+ }
92
+
93
+ /** Invalidates cached source text after an edit tool result lands. */
94
+ invalidate(filePath: string): void {
95
+ const resolvedPath = this.#resolveSessionFsPath(filePath);
96
+ if (resolvedPath !== undefined) this.#fileCache.delete(resolvedPath);
97
+ }
98
+
99
+ /** Aborts a streamed edit whose completed patch preview cannot apply. */
100
+ maybeAbort(event: AgentEvent): void {
101
+ if (!this.#host.settings.get("edit.streamingAbort") || this.#abortTriggered || event.type !== "message_update") {
102
+ return;
103
+ }
104
+ const assistantEvent = event.assistantMessageEvent;
105
+ if (assistantEvent.type !== "toolcall_end" && assistantEvent.type !== "toolcall_delta") return;
106
+ const streamingEdit = this.#getToolCall(event);
107
+ if (!streamingEdit?.toolCall.id) return;
108
+
109
+ const { toolCall, path, resolvedPath, diff, op, rename } = streamingEdit;
110
+ if (!diff || (op && op !== "update") || !diff.includes("\n")) return;
111
+ const lastNewlineIndex = diff.lastIndexOf("\n");
112
+ if (lastNewlineIndex < 0) return;
113
+ const diffForCheck = diff.endsWith("\n") ? diff : diff.slice(0, lastNewlineIndex + 1);
114
+ if (diffForCheck.trim().length === 0) return;
115
+
116
+ let normalizedDiff = normalizeDiff(diffForCheck.replace(/\r/g, ""));
117
+ if (!normalizedDiff) return;
118
+ if (this.#host.obfuscator) normalizedDiff = this.#host.obfuscator.deobfuscate(normalizedDiff);
119
+ if (!normalizedDiff) return;
120
+ const lines = normalizedDiff.split("\n");
121
+ if (!lines.some(line => line.startsWith("+") || line.startsWith("-"))) return;
122
+
123
+ const lineCount = lines.length;
124
+ const lastChecked = this.#checkedLineCounts.get(toolCall.id);
125
+ if (lastChecked !== undefined && lineCount <= lastChecked) return;
126
+ this.#checkedLineCounts.set(toolCall.id, lineCount);
127
+
128
+ const removedLines = lines
129
+ .filter(line => line.startsWith("-") && !line.startsWith("--- "))
130
+ .map(line => line.slice(1));
131
+ if (removedLines.length > 0) {
132
+ let cachedContent = this.#fileCache.get(resolvedPath);
133
+ if (cachedContent === undefined) {
134
+ this.#ensureFileCache(resolvedPath);
135
+ cachedContent = this.#fileCache.get(resolvedPath);
136
+ }
137
+ if (cachedContent !== undefined) {
138
+ const missing = removedLines.find(line => !cachedContent.includes(normalizeToLF(line)));
139
+ if (missing) this.#abortPatch(toolCall.id, path, `Failed to find expected lines in ${path}:\n${missing}`);
140
+ return;
141
+ }
142
+ if (assistantEvent.type === "toolcall_delta") return;
143
+ void this.#checkRemovedLines(toolCall.id, path, resolvedPath, removedLines);
144
+ return;
145
+ }
146
+ if (assistantEvent.type === "toolcall_delta") return;
147
+ void this.#checkPreviewPatch(toolCall.id, path, rename, normalizedDiff);
148
+ }
149
+
150
+ #getToolCall(event: AgentEvent):
151
+ | {
152
+ toolCall: ToolCall;
153
+ path: string;
154
+ resolvedPath: string;
155
+ diff?: string;
156
+ op?: string;
157
+ rename?: string;
158
+ }
159
+ | undefined {
160
+ if (event.type !== "message_update" || event.message.role !== "assistant") return undefined;
161
+ const contentIndex = event.assistantMessageEvent.contentIndex ?? 0;
162
+ const messageContent = event.message.content;
163
+ if (!Array.isArray(messageContent) || contentIndex < 0 || contentIndex >= messageContent.length) return undefined;
164
+ const toolCall = messageContent[contentIndex] as ToolCall;
165
+ if (toolCall.name !== "edit") return undefined;
166
+ const args = toolCall.arguments;
167
+ if (!args || typeof args !== "object" || Array.isArray(args) || "old_text" in args || "new_text" in args) {
168
+ return undefined;
169
+ }
170
+ const filePath = typeof args.path === "string" ? args.path : undefined;
171
+ if (!filePath) return undefined;
172
+ // local:// URLs resolve to artifacts; other internal URLs have no local path.
173
+ const resolvedPath = this.#resolveSessionFsPath(filePath);
174
+ if (resolvedPath === undefined) return undefined;
175
+ return {
176
+ toolCall,
177
+ path: filePath,
178
+ resolvedPath,
179
+ diff: typeof args.diff === "string" ? args.diff : undefined,
180
+ op: typeof args.op === "string" ? args.op : undefined,
181
+ rename: typeof args.rename === "string" ? args.rename : undefined,
182
+ };
183
+ }
184
+
185
+ #abortForAutoGeneratedPath(toolCall: ToolCall, filePath: string, resolvedPath: string): void {
186
+ if (this.#lastToolCallId === toolCall.id) return;
187
+ this.#lastToolCallId = toolCall.id;
188
+ void assertEditableFile(resolvedPath, filePath).catch(error => {
189
+ if (!(error instanceof ToolError) || this.#lastToolCallId !== toolCall.id) return;
190
+ if (!this.#abortTriggered) {
191
+ this.#abortTriggered = true;
192
+ logger.warn("Streaming edit aborted due to auto-generated file guard", {
193
+ toolCallId: toolCall.id,
194
+ path: filePath,
195
+ });
196
+ this.#host.agent.abort();
197
+ }
198
+ });
199
+ }
200
+
201
+ #ensureFileCache(resolvedPath: string): void {
202
+ if (this.#fileCache.has(resolvedPath)) return;
203
+ try {
204
+ const rawText = fs.readFileSync(resolvedPath, "utf-8");
205
+ const { text } = stripBom(rawText);
206
+ this.#fileCache.set(resolvedPath, normalizeToLF(text));
207
+ } catch {
208
+ // Read errors are handled by the edit tool itself.
209
+ }
210
+ }
211
+
212
+ #resolveSessionFsPath(filePath: string): string | undefined {
213
+ const normalized = normalizeLocalScheme(filePath);
214
+ if (normalized.startsWith("local:")) {
215
+ return resolveLocalUrlToPath(normalized, this.#host.localProtocolOptions());
216
+ }
217
+ if (isInternalUrlPath(normalized)) return undefined;
218
+ return resolveToCwd(normalized, this.#host.sessionManager.getCwd());
219
+ }
220
+
221
+ async #checkRemovedLines(
222
+ toolCallId: string,
223
+ filePath: string,
224
+ resolvedPath: string,
225
+ removedLines: string[],
226
+ ): Promise<void> {
227
+ if (this.#abortTriggered) return;
228
+ try {
229
+ const { text } = stripBom(await Bun.file(resolvedPath).text());
230
+ const normalizedContent = normalizeToLF(text);
231
+ const missing = removedLines.find(line => !normalizedContent.includes(normalizeToLF(line)));
232
+ if (missing)
233
+ this.#abortPatch(toolCallId, filePath, `Failed to find expected lines in ${filePath}:\n${missing}`);
234
+ } catch (error) {
235
+ if (!isEnoent(error)) {
236
+ // Unexpected fallback read errors remain non-fatal.
237
+ }
238
+ }
239
+ }
240
+
241
+ async #checkPreviewPatch(
242
+ toolCallId: string,
243
+ filePath: string,
244
+ rename: string | undefined,
245
+ normalizedDiff: string,
246
+ ): Promise<void> {
247
+ if (this.#abortTriggered) return;
248
+ try {
249
+ await previewPatch(
250
+ { path: filePath, op: "update", rename, diff: normalizedDiff },
251
+ {
252
+ cwd: this.#host.sessionManager.getCwd(),
253
+ allowFuzzy: this.#host.settings.get("edit.fuzzyMatch"),
254
+ fuzzyThreshold: this.#host.settings.get("edit.fuzzyThreshold"),
255
+ },
256
+ );
257
+ } catch (error) {
258
+ if (error instanceof ParseError) return;
259
+ this.#abortPatch(toolCallId, filePath, error instanceof Error ? error.message : String(error));
260
+ }
261
+ }
262
+
263
+ #abortPatch(toolCallId: string, filePath: string, error: string): void {
264
+ this.#abortTriggered = true;
265
+ logger.warn("Streaming edit aborted due to patch preview failure", { toolCallId, path: filePath, error });
266
+ this.#host.agent.abort();
267
+ }
268
+ }
269
+
270
+ /** Detects cross-turn tool loops and Gemini reasoning-header runaways. */
271
+ export class LoopGuards {
272
+ readonly #host: StreamGuardsHost;
273
+ #geminiHeaderDetector: GeminiHeaderRunDetector | undefined;
274
+ #toolCallLoopGuard: ToolCallLoopGuard | undefined;
275
+ #toolCallLoopGuardSettingsKey: string | undefined;
276
+
277
+ constructor(host: StreamGuardsHost) {
278
+ this.#host = host;
279
+ }
280
+
281
+ /** Records a completed turn and injects a redirect when calls repeat. */
282
+ recordTurn(messages: AgentMessage[], context: AgentTurnEndContext | undefined): void {
283
+ if (context?.message.role !== "assistant") return;
284
+ const detection = this.#activeToolCallLoopGuard()?.recordTurn({
285
+ message: context.message,
286
+ toolResults: context.toolResults,
287
+ });
288
+ if (detection) this.#injectToolCallLoopRedirect(messages, detection);
289
+ }
290
+
291
+ /** Feeds a streamed assistant event to the Gemini header-runaway detector. */
292
+ onAssistantEvent(message: AssistantMessage, event: AssistantMessageEvent): void {
293
+ if (event.type === "thinking_start") {
294
+ this.#geminiHeaderDetector = this.#geminiHeaderGuardActive() ? new GeminiHeaderRunDetector() : undefined;
295
+ return;
296
+ }
297
+ const detector = this.#geminiHeaderDetector;
298
+ if (!detector) return;
299
+ if (event.type === "thinking_delta") {
300
+ if (detector.push(event.delta)) this.#interruptGeminiHeaderRunaway(detector.count, message.timestamp);
301
+ return;
302
+ }
303
+ if (event.type === "text_start" || event.type === "toolcall_start") detector.reset();
304
+ }
305
+
306
+ #activeToolCallLoopGuard(): ToolCallLoopGuard | undefined {
307
+ if (this.#host.settings.get("model.toolCallLoopGuard.enabled") !== true) {
308
+ this.#toolCallLoopGuard = undefined;
309
+ this.#toolCallLoopGuardSettingsKey = undefined;
310
+ return undefined;
311
+ }
312
+ const threshold = this.#host.settings.get("model.toolCallLoopGuard.threshold");
313
+ const exemptTools = this.#host.settings
314
+ .get("model.toolCallLoopGuard.exemptTools")
315
+ .filter((tool): tool is string => typeof tool === "string" && tool.length > 0);
316
+ const settingsKey = `${threshold}:${JSON.stringify(exemptTools)}`;
317
+ if (!this.#toolCallLoopGuard || this.#toolCallLoopGuardSettingsKey !== settingsKey) {
318
+ this.#toolCallLoopGuard = new ToolCallLoopGuard({ threshold, exemptTools });
319
+ this.#toolCallLoopGuardSettingsKey = settingsKey;
320
+ }
321
+ return this.#toolCallLoopGuard;
322
+ }
323
+
324
+ #injectToolCallLoopRedirect(messages: AgentMessage[], detection: RepeatedToolCallDetection): void {
325
+ const content = prompt.render(toolCallLoopRedirectTemplate, {
326
+ tool_name: detection.toolName,
327
+ count: detection.count,
328
+ arguments_summary: detection.argumentsSummary,
329
+ result_summary: detection.resultSummary || "(no text result)",
330
+ });
331
+ const details = {
332
+ toolName: detection.toolName,
333
+ count: detection.count,
334
+ argumentsSummary: detection.argumentsSummary,
335
+ resultSummary: detection.resultSummary,
336
+ };
337
+ logger.warn("cross-turn tool-call loop detected", { toolName: detection.toolName, count: detection.count });
338
+ const redirectMessage: CustomMessage = {
339
+ role: "custom",
340
+ customType: TOOL_CALL_LOOP_REDIRECT_TYPE,
341
+ content,
342
+ display: false,
343
+ details,
344
+ attribution: "agent",
345
+ timestamp: Date.now(),
346
+ };
347
+ messages.push(redirectMessage);
348
+ if (this.#host.agent.state.messages !== messages) this.#host.agent.appendMessage(redirectMessage);
349
+ this.#host.sessionManager.appendCustomMessageEntry(
350
+ TOOL_CALL_LOOP_REDIRECT_TYPE,
351
+ content,
352
+ false,
353
+ details,
354
+ "agent",
355
+ );
356
+ }
357
+
358
+ #geminiHeaderGuardActive(): boolean {
359
+ const model = this.#host.model();
360
+ return (
361
+ process.env.PI_NO_THINKING_LOOP_GUARD !== "1" &&
362
+ this.#host.settings.get("model.loopGuard.enabled") === true &&
363
+ this.#host.settings.get("model.loopGuard.toolCallReminder") === true &&
364
+ model !== undefined &&
365
+ isGeminiThinkingModel(model)
366
+ );
367
+ }
368
+
369
+ #interruptGeminiHeaderRunaway(headerCount: number, targetTimestamp: number): void {
370
+ const model = this.#host.model();
371
+ logger.warn("Gemini reasoning-header runaway; interrupting to require a tool call", {
372
+ model: model?.id,
373
+ provider: model?.provider,
374
+ headers: headerCount,
375
+ });
376
+ this.#host.emitNotice(
377
+ "warning",
378
+ `Interrupted ${headerCount} planning headers with no tool call; reminded the model to issue one.`,
379
+ "loop-guard",
380
+ );
381
+ this.#host.agent.abort(GEMINI_HEADER_INTERRUPT_REASON);
382
+ const generation = this.#host.promptGeneration();
383
+ this.#host.schedulePostPromptTask(async signal => {
384
+ if (signal.aborted || this.#host.isDisposed() || this.#host.promptGeneration() !== generation) return;
385
+ await this.#host.agent.waitForIdle();
386
+ if (signal.aborted || this.#host.isDisposed() || this.#host.promptGeneration() !== generation) return;
387
+ const aborted = this.#host.agent.state.messages.findLast(
388
+ (message): message is AssistantMessage =>
389
+ message.role === "assistant" && message.timestamp === targetTimestamp,
390
+ );
391
+ if (aborted) this.#host.discardAssistantTurn(aborted);
392
+ const content = prompt.render(geminiToolReminderTemplate, { count: headerCount });
393
+ const details = { headers: headerCount };
394
+ this.#host.agent.appendMessage({
395
+ role: "custom",
396
+ customType: GEMINI_TOOL_REMINDER_TYPE,
397
+ content,
398
+ display: false,
399
+ details,
400
+ attribution: "agent",
401
+ timestamp: Date.now(),
402
+ });
403
+ this.#host.sessionManager.appendCustomMessageEntry(
404
+ GEMINI_TOOL_REMINDER_TYPE,
405
+ content,
406
+ false,
407
+ details,
408
+ "agent",
409
+ );
410
+ try {
411
+ await this.#host.agent.continue();
412
+ } catch (error) {
413
+ logger.warn("gemini tool-call reminder continue failed", { error: String(error) });
414
+ }
415
+ });
416
+ }
417
+ }