@oh-my-pi/pi-coding-agent 15.5.7 → 15.5.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/CHANGELOG.md +53 -1
  2. package/dist/types/cli/auth-gateway-cli.d.ts +8 -0
  3. package/dist/types/commands/auth-gateway.d.ts +3 -0
  4. package/dist/types/config/settings-schema.d.ts +10 -10
  5. package/dist/types/edit/file-snapshot-store.d.ts +9 -6
  6. package/dist/types/edit/hashline/diff.d.ts +4 -5
  7. package/dist/types/edit/streaming.d.ts +2 -1
  8. package/dist/types/eval/py/index.d.ts +1 -0
  9. package/dist/types/extensibility/custom-tools/types.d.ts +1 -1
  10. package/dist/types/extensibility/shared-events.d.ts +1 -1
  11. package/dist/types/internal-urls/index.d.ts +1 -0
  12. package/dist/types/internal-urls/vault-protocol.d.ts +93 -0
  13. package/dist/types/mcp/transports/http.d.ts +9 -0
  14. package/dist/types/modes/components/tool-execution.d.ts +2 -1
  15. package/dist/types/session/agent-session.d.ts +3 -1
  16. package/dist/types/tools/match-line-format.d.ts +2 -2
  17. package/dist/types/tools/render-utils.d.ts +3 -1
  18. package/dist/types/tools/write.d.ts +2 -0
  19. package/dist/types/utils/file-mentions.d.ts +2 -0
  20. package/package.json +8 -8
  21. package/src/cli/args.ts +2 -0
  22. package/src/cli/auth-broker-cli.ts +2 -1
  23. package/src/cli/auth-gateway-cli.ts +210 -9
  24. package/src/commands/auth-gateway.ts +7 -1
  25. package/src/config/settings-schema.ts +12 -11
  26. package/src/edit/file-snapshot-store.ts +9 -6
  27. package/src/edit/hashline/diff.ts +26 -13
  28. package/src/edit/hashline/execute.ts +13 -9
  29. package/src/edit/renderer.ts +9 -9
  30. package/src/edit/streaming.ts +4 -6
  31. package/src/eval/py/index.ts +1 -1
  32. package/src/extensibility/custom-tools/types.ts +1 -1
  33. package/src/extensibility/shared-events.ts +1 -1
  34. package/src/internal-urls/docs-index.generated.ts +7 -7
  35. package/src/internal-urls/index.ts +1 -0
  36. package/src/internal-urls/router.ts +2 -0
  37. package/src/internal-urls/vault-protocol.ts +936 -0
  38. package/src/main.ts +1 -2
  39. package/src/mcp/transports/http.ts +29 -2
  40. package/src/modes/components/tool-execution.ts +6 -4
  41. package/src/modes/controllers/event-controller.ts +10 -3
  42. package/src/modes/interactive-mode.ts +10 -2
  43. package/src/modes/utils/ui-helpers.ts +2 -1
  44. package/src/prompts/system/system-prompt.md +3 -0
  45. package/src/prompts/tools/ast-edit.md +1 -1
  46. package/src/prompts/tools/ast-grep.md +1 -1
  47. package/src/prompts/tools/read.md +3 -3
  48. package/src/prompts/tools/search.md +1 -1
  49. package/src/sdk.ts +26 -1
  50. package/src/session/agent-session.ts +82 -11
  51. package/src/system-prompt.ts +2 -0
  52. package/src/tools/ast-edit.ts +10 -7
  53. package/src/tools/ast-grep.ts +12 -11
  54. package/src/tools/eval.ts +28 -3
  55. package/src/tools/match-line-format.ts +2 -2
  56. package/src/tools/path-utils.ts +2 -0
  57. package/src/tools/plan-mode-guard.ts +6 -1
  58. package/src/tools/read.ts +70 -55
  59. package/src/tools/render-utils.ts +15 -0
  60. package/src/tools/search.ts +12 -12
  61. package/src/tools/write.ts +61 -6
  62. package/src/utils/file-mentions.ts +11 -5
  63. package/src/web/search/providers/codex.ts +2 -1
package/src/main.ts CHANGED
@@ -9,7 +9,6 @@ import * as fs from "node:fs/promises";
9
9
  import * as os from "node:os";
10
10
  import * as path from "node:path";
11
11
  import { createInterface } from "node:readline/promises";
12
- import { keepaliveWhile } from "@oh-my-pi/pi-agent-core";
13
12
  import type { ImageContent } from "@oh-my-pi/pi-ai";
14
13
  import {
15
14
  $env,
@@ -316,7 +315,7 @@ async function runInteractiveMode(
316
315
  }
317
316
 
318
317
  while (true) {
319
- const input = await keepaliveWhile(mode.getUserInput());
318
+ const input = await mode.getUserInput();
320
319
  await submitInteractiveInput(mode, session, input);
321
320
  }
322
321
  }
@@ -16,8 +16,23 @@ import type {
16
16
  MCPTransport,
17
17
  } from "../../mcp/types";
18
18
  import { toJsonRpcError } from "../../mcp/types";
19
- import { createMCPTimeout, getNeverAbortSignal, resolveMCPTimeoutMs } from "../timeout";
19
+ import { createMCPTimeout, getNeverAbortSignal, isMCPTimeoutEnabled, resolveMCPTimeoutMs } from "../timeout";
20
20
 
21
+ const HTTP_SSE_CONNECT_TIMEOUT_MS = 1_000;
22
+ /**
23
+ * Best-effort startup deadline for the optional Streamable HTTP GET SSE listener.
24
+ *
25
+ * Returns `0` (disabled) when the operator has explicitly disabled MCP client-side
26
+ * timeouts via `timeout: 0` or `OMP_MCP_TIMEOUT_MS=0`, mirroring the rest of the
27
+ * MCP timeout surface. Otherwise caps the wait at one second and scales below
28
+ * short request timeouts so connect-time never exceeds the request budget.
29
+ */
30
+ export function resolveSSEConnectTimeoutMs(configTimeout?: number): number {
31
+ const requestTimeout = resolveMCPTimeoutMs(configTimeout);
32
+ if (!isMCPTimeoutEnabled(requestTimeout)) return 0;
33
+ const boundedTimeout = Math.min(HTTP_SSE_CONNECT_TIMEOUT_MS, Math.floor(requestTimeout / 4));
34
+ return Math.max(1, boundedTimeout);
35
+ }
21
36
  /**
22
37
  * HTTP transport for MCP servers.
23
38
  * Uses POST for requests, supports SSE responses.
@@ -73,6 +88,15 @@ export class HttpTransport implements MCPTransport {
73
88
  }
74
89
 
75
90
  let response: Response;
91
+ let timedOut = false;
92
+ const startupTimeoutMs = resolveSSEConnectTimeoutMs(this.config.timeout);
93
+ const timeoutId =
94
+ startupTimeoutMs > 0
95
+ ? setTimeout(() => {
96
+ timedOut = true;
97
+ this.#sseConnection?.abort();
98
+ }, startupTimeoutMs)
99
+ : null;
76
100
  try {
77
101
  response = await fetch(this.config.url, {
78
102
  method: "GET",
@@ -81,13 +105,16 @@ export class HttpTransport implements MCPTransport {
81
105
  });
82
106
  } catch (error) {
83
107
  this.#sseConnection = null;
84
- if (error instanceof Error && error.name !== "AbortError") {
108
+ if (error instanceof Error && error.name !== "AbortError" && !timedOut) {
85
109
  this.onError?.(error);
86
110
  }
87
111
  return;
112
+ } finally {
113
+ if (timeoutId !== null) clearTimeout(timeoutId);
88
114
  }
89
115
 
90
116
  if (response.status === 405 || !response.ok || !response.body) {
117
+ await response.body?.cancel();
91
118
  this.#sseConnection = null;
92
119
  return;
93
120
  }
@@ -1,3 +1,4 @@
1
+ import type { SnapshotStore } from "@oh-my-pi/hashline";
1
2
  import type { AgentTool } from "@oh-my-pi/pi-agent-core";
2
3
  import {
3
4
  Box,
@@ -105,10 +106,10 @@ function resolveEditModeForTool(toolName: string, tool: AgentTool | undefined):
105
106
  }
106
107
 
107
108
  export interface ToolExecutionOptions {
109
+ snapshots?: SnapshotStore;
108
110
  showImages?: boolean; // default: true (only used if terminal supports images)
109
111
  editFuzzyThreshold?: number;
110
112
  editAllowFuzzy?: boolean;
111
- hashlineAutoDropPureInsertDuplicates?: boolean;
112
113
  }
113
114
 
114
115
  export interface ToolExecutionHandle {
@@ -142,7 +143,7 @@ export class ToolExecutionComponent extends Container {
142
143
  #showImages: boolean;
143
144
  #editFuzzyThreshold: number | undefined;
144
145
  #editAllowFuzzy: boolean | undefined;
145
- #hashlineAutoDropPureInsertDuplicates: boolean | undefined;
146
+ #snapshots?: SnapshotStore;
146
147
  #isPartial = true;
147
148
  #tool?: AgentTool;
148
149
  #ui: TUI;
@@ -189,7 +190,7 @@ export class ToolExecutionComponent extends Container {
189
190
  this.#showImages = options.showImages ?? true;
190
191
  this.#editFuzzyThreshold = options.editFuzzyThreshold;
191
192
  this.#editAllowFuzzy = options.editAllowFuzzy;
192
- this.#hashlineAutoDropPureInsertDuplicates = options.hashlineAutoDropPureInsertDuplicates;
193
+ this.#snapshots = options.snapshots;
193
194
  this.#tool = tool;
194
195
  this.#ui = ui;
195
196
  this.#cwd = cwd;
@@ -266,12 +267,13 @@ export class ToolExecutionComponent extends Container {
266
267
 
267
268
  try {
268
269
  const isStreaming = !this.#argsComplete;
270
+ if (editMode === "hashline" && !this.#snapshots) return;
269
271
  const previews = await strategy.computeDiffPreview(effectiveArgs, {
270
272
  cwd: this.#cwd,
271
273
  signal: controller.signal,
274
+ snapshots: this.#snapshots!,
272
275
  fuzzyThreshold: this.#editFuzzyThreshold,
273
276
  allowFuzzy: this.#editAllowFuzzy,
274
- hashlineAutoDropPureInsertDuplicates: this.#hashlineAutoDropPureInsertDuplicates,
275
277
  isStreaming,
276
278
  });
277
279
  if (controller.signal.aborted) return;
@@ -3,6 +3,7 @@ import { calculatePromptTokens } from "@oh-my-pi/pi-agent-core/compaction/compac
3
3
  import type { AssistantMessage, ImageContent } from "@oh-my-pi/pi-ai";
4
4
  import { type Component, Loader, TERMINAL, Text } from "@oh-my-pi/pi-tui";
5
5
  import { settings } from "../../config/settings";
6
+ import { getFileSnapshotStore } from "../../edit/file-snapshot-store";
6
7
  import { AssistantMessageComponent } from "../../modes/components/assistant-message";
7
8
  import {
8
9
  ReadToolGroupComponent,
@@ -329,10 +330,10 @@ export class EventController {
329
330
  content.name,
330
331
  renderArgs,
331
332
  {
333
+ snapshots: getFileSnapshotStore(this.ctx.session),
332
334
  showImages: settings.get("terminal.showImages"),
333
335
  editFuzzyThreshold: settings.get("edit.fuzzyThreshold"),
334
336
  editAllowFuzzy: settings.get("edit.fuzzyMatch"),
335
- hashlineAutoDropPureInsertDuplicates: settings.get("edit.hashlineAutoDropPureInsertDuplicates"),
336
337
  },
337
338
  tool,
338
339
  this.ctx.ui,
@@ -444,10 +445,10 @@ export class EventController {
444
445
  event.toolName,
445
446
  event.args,
446
447
  {
448
+ snapshots: getFileSnapshotStore(this.ctx.session),
447
449
  showImages: settings.get("terminal.showImages"),
448
450
  editFuzzyThreshold: settings.get("edit.fuzzyThreshold"),
449
451
  editAllowFuzzy: settings.get("edit.fuzzyMatch"),
450
- hashlineAutoDropPureInsertDuplicates: settings.get("edit.hashlineAutoDropPureInsertDuplicates"),
451
452
  },
452
453
  tool,
453
454
  this.ctx.ui,
@@ -598,7 +599,13 @@ export class EventController {
598
599
  };
599
600
  this.ctx.statusContainer.clear();
600
601
  const reasonText =
601
- event.reason === "overflow" ? "Context overflow detected, " : event.reason === "idle" ? "Idle " : "";
602
+ event.reason === "overflow"
603
+ ? "Context overflow detected, "
604
+ : event.reason === "incomplete"
605
+ ? "Response incomplete, "
606
+ : event.reason === "idle"
607
+ ? "Idle "
608
+ : "";
602
609
  const actionLabel = event.action === "handoff" ? "Auto-handoff" : "Auto context-full maintenance";
603
610
  this.ctx.autoCompactionLoader = new Loader(
604
611
  this.ctx.ui,
@@ -4,7 +4,13 @@
4
4
  */
5
5
  import * as fs from "node:fs/promises";
6
6
  import * as path from "node:path";
7
- import { type Agent, type AgentMessage, type AgentToolResult, ThinkingLevel } from "@oh-my-pi/pi-agent-core";
7
+ import {
8
+ type Agent,
9
+ type AgentMessage,
10
+ type AgentToolResult,
11
+ EventLoopKeepalive,
12
+ ThinkingLevel,
13
+ } from "@oh-my-pi/pi-agent-core";
8
14
  import type { CompactionOutcome } from "@oh-my-pi/pi-agent-core/compaction";
9
15
  import {
10
16
  type AssistantMessage,
@@ -619,7 +625,9 @@ export class InteractiveMode implements InteractiveModeContext {
619
625
  };
620
626
  this.#scheduleLoopAutoSubmit();
621
627
  this.#scheduleGoalContinuation();
622
- return promise;
628
+
629
+ using _ = new EventLoopKeepalive();
630
+ return await promise;
623
631
  }
624
632
 
625
633
  #scheduleLoopAutoSubmit(): void {
@@ -2,6 +2,7 @@ import type { AgentMessage } from "@oh-my-pi/pi-agent-core";
2
2
  import type { AssistantMessage, ImageContent, Message } from "@oh-my-pi/pi-ai";
3
3
  import { type Component, Spacer, Text, TruncatedText } from "@oh-my-pi/pi-tui";
4
4
  import { settings } from "../../config/settings";
5
+ import { getFileSnapshotStore } from "../../edit/file-snapshot-store";
5
6
  import { AssistantMessageComponent } from "../../modes/components/assistant-message";
6
7
  import { BashExecutionComponent } from "../../modes/components/bash-execution";
7
8
  import { BranchSummaryMessageComponent } from "../../modes/components/branch-summary-message";
@@ -377,10 +378,10 @@ export class UiHelpers {
377
378
  content.name,
378
379
  renderArgs,
379
380
  {
381
+ snapshots: getFileSnapshotStore(this.ctx.session),
380
382
  showImages: settings.get("terminal.showImages"),
381
383
  editFuzzyThreshold: settings.get("edit.fuzzyThreshold"),
382
384
  editAllowFuzzy: settings.get("edit.fuzzyMatch"),
383
- hashlineAutoDropPureInsertDuplicates: settings.get("edit.hashlineAutoDropPureInsertDuplicates"),
384
385
  },
385
386
  tool,
386
387
  this.ctx.ui,
@@ -59,6 +59,9 @@ With most FS/bash-like tools, static references to them will automatically resol
59
59
  - `/<path>`: JSON field extraction
60
60
  - `artifact://<id>`: Artifact content
61
61
  - `local://<name>.md`: Plan artifacts and shared content with subagents
62
+ {{#if hasObsidian}}
63
+ - `vault://<vault>/<path>`: Obsidian vault content (read/edit). `vault://` lists vaults; `vault://_/…` targets the active vault. File-scoped `?op=outline|backlinks|links|tags|properties|tasks|base|…`; vault-scoped `?op=search&q=…|daily|tasks|orphans|unresolved|bases|…`.
64
+ {{/if}}
62
65
  - `mcp://<uri>`: MCP resource
63
66
  - `issue://<N>` (or `issue://<owner>/<repo>/<N>`): GitHub issue view; cached on disk so re-reads are free. Bare `issue://` (or `issue://<owner>/<repo>`) lists recent issues; supports `?state=open|closed|all&limit=&author=&label=`.
64
67
  - `pr://<N>` (or `pr://<owner>/<repo>/<N>`): GitHub PR view; same cache. Append `?comments=0` to drop the comments section. Bare `pr://` (or `pr://<owner>/<repo>`) lists recent PRs; supports `?state=open|closed|merged|all&limit=&author=&label=`.
@@ -14,7 +14,7 @@ Performs structural AST-aware rewrites via native ast-grep.
14
14
  </instruction>
15
15
 
16
16
  <output>
17
- - Replacement summary, per-file replacement counts, and change diffs as `¶src/foo.ts#1a2b`, `-12:before`, `+12:after` lines in hashline mode
17
+ - Replacement summary, per-file replacement counts, and change diffs as `¶src/foo.ts#0a`, `-12:before`, `+12:after` lines in hashline mode
18
18
  - Parse issues when files cannot be processed
19
19
  </output>
20
20
 
@@ -18,7 +18,7 @@ Performs structural code search using AST matching via native ast-grep.
18
18
 
19
19
  <output>
20
20
  - Grouped matches with file path, byte range, line/column ranges, metavariable captures
21
- - Match lines are numbered under a file-hash header in hashline mode: `¶src/foo.ts#1a2b`, `*42:content` for the matched line, ` 43:content` for context
21
+ - Match lines are numbered under a file snapshot tag header in hashline mode: `¶src/foo.ts#0a`, `*42:content` for the matched line, ` 43:content` for context
22
22
  - Summary counts (`totalMatches`, `filesWithMatches`, `filesSearched`) and parse issues when present
23
23
  </output>
24
24
 
@@ -8,7 +8,7 @@ Read files, directories, archives, SQLite databases, images, documents, internal
8
8
 
9
9
  ## Parameters
10
10
 
11
- - `path` — required. Local path, internal URI (`skill://`, `agent://`, `artifact://`, `memory://`, `rule://`, `local://`, `mcp://`), or URL. Append `:<sel>` for line ranges, raw mode, or special modes (e.g. `src/foo.ts:50-200`, `src/foo.ts:raw`, `db.sqlite:users:42`).
11
+ - `path` — required. Local path, internal URI (`skill://`, `agent://`, `artifact://`, `memory://`, `rule://`, `local://`, `vault://`, `mcp://`), or URL. Append `:<sel>` for line ranges, raw mode, or special modes (e.g. `src/foo.ts:50-200`, `src/foo.ts:raw`, `db.sqlite:users:42`).
12
12
 
13
13
  ## Selectors
14
14
 
@@ -28,7 +28,7 @@ Append `:<sel>` to `path`. The bare path falls back to the default mode.
28
28
 
29
29
  - Reading a directory path returns a depth-limited dirent listing.
30
30
  {{#if IS_HL_MODE}}
31
- - Reading a file with an explicit selector emits a file-hash header and numbered lines: `¶src/foo.ts#1a2b` then `41:def alpha():`. Copy the `¶PATH#HASH` header for anchored edits; ops use bare line numbers. NEVER fabricate the hash.
31
+ - Reading a file with an explicit selector emits a file snapshot tag header and numbered lines: `¶src/foo.ts#0a` then `41:def alpha():`. Copy the `¶PATH#TAG` header for anchored edits; ops use bare line numbers. NEVER fabricate the tag.
32
32
  {{else}}
33
33
  {{#if IS_LINE_NUMBER_MODE}}
34
34
  - Reading a file with an explicit selector returns lines prefixed with line numbers: `41|def alpha():`.
@@ -70,7 +70,7 @@ For `.sqlite`, `.sqlite3`, `.db`, `.db3`:
70
70
 
71
71
  # Internal URIs
72
72
 
73
- `skill://<name>`, `agent://<id>`, `artifact://<id>`, `memory://root`, `rule://<name>`, `local://<name>.md`, `mcp://<uri>` resolve transparently and accept the same line selectors as filesystem paths. Use `artifact://<id>` to recover full output that a previous bash/eval/tool result spilled or truncated.
73
+ `skill://<name>`, `agent://<id>`, `artifact://<id>`, `memory://root`, `rule://<name>`, `local://<name>.md`, `vault://<vault>/<path>`, `mcp://<uri>` resolve transparently and accept the same line selectors as filesystem paths. Use `artifact://<id>` to recover full output that a previous bash/eval/tool result spilled or truncated.
74
74
 
75
75
  <critical>
76
76
  - You MUST use `read` for every file, directory, archive, and URL inspection. `cat`, `head`, `tail`, `less`, `more`, `ls`, `tar`, `unzip`, `curl`, `wget` are FORBIDDEN — any such bash call is a bug, regardless of how short or convenient it looks.
@@ -9,7 +9,7 @@ Searches files using powerful regex matching.
9
9
 
10
10
  <output>
11
11
  {{#if IS_HL_MODE}}
12
- - Text output emits a file-hash header per matched file plus numbered lines: `¶src/login.ts#3c4d`, `*42:if (user.id) {` (match), ` 43:return user;` (context). Copy the header for anchored edits; ops use bare line numbers.
12
+ - Text output emits a file snapshot tag header per matched file plus numbered lines: `¶src/login.ts#1f`, `*42:if (user.id) {` (match), ` 43:return user;` (context). Copy the header for anchored edits; ops use bare line numbers.
13
13
  {{else}}
14
14
  {{#if IS_LINE_NUMBER_MODE}}
15
15
  - Text output is line-number-prefixed
package/src/sdk.ts CHANGED
@@ -10,6 +10,7 @@ import {
10
10
  } from "@oh-my-pi/pi-agent-core";
11
11
  import {
12
12
  type CredentialDisabledEvent,
13
+ isUsageLimitError,
13
14
  type Message,
14
15
  type Model,
15
16
  type SimpleStreamOptions,
@@ -23,6 +24,7 @@ import type { Component } from "@oh-my-pi/pi-tui";
23
24
  import {
24
25
  $env,
25
26
  $flag,
27
+ extractRetryHint,
26
28
  getAgentDbPath,
27
29
  getAgentDir,
28
30
  getProjectDir,
@@ -1885,13 +1887,36 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
1885
1887
  ...streamOptions,
1886
1888
  openrouterVariant: streamOptions?.openrouterVariant ?? openrouterVariant,
1887
1889
  onAuthError: async (provider, oldKey, error) => {
1890
+ const message = error instanceof Error ? error.message : String(error);
1891
+ // streamSimple invokes this for both 401 auth failures AND
1892
+ // rotatable usage-limit errors (Codex usage_limit_reached,
1893
+ // Anthropic usage_limit_reached, etc.). The two need
1894
+ // different storage actions: a real 401 means the credential
1895
+ // is bad and should be marked suspect; a usage limit just
1896
+ // means this account is parked until reset and should be
1897
+ // temporarily blocked so a sibling can pick the request up.
1898
+ if (isUsageLimitError(message)) {
1899
+ const retryAfterMs = extractRetryHint(undefined, message);
1900
+ const switched = await modelRegistry.authStorage.markUsageLimitReached(provider, agent.sessionId, {
1901
+ retryAfterMs,
1902
+ signal: streamOptions?.signal,
1903
+ });
1904
+ logger.debug("Retrying provider request after usage-limit block", {
1905
+ provider,
1906
+ switched,
1907
+ retryAfterMs,
1908
+ error: message,
1909
+ });
1910
+ if (!switched) return undefined;
1911
+ return modelRegistry.getApiKeyForProvider(provider, agent.sessionId);
1912
+ }
1888
1913
  await modelRegistry.authStorage.invalidateCredentialMatching(provider, oldKey, {
1889
1914
  signal: streamOptions?.signal,
1890
1915
  sessionId: agent.sessionId,
1891
1916
  });
1892
1917
  logger.debug("Retrying provider request after credential invalidation", {
1893
1918
  provider,
1894
- error: error instanceof Error ? error.message : String(error),
1919
+ error: message,
1895
1920
  });
1896
1921
  return modelRegistry.getApiKeyForProvider(provider, agent.sessionId);
1897
1922
  },
@@ -18,6 +18,7 @@ import * as fs from "node:fs";
18
18
  import * as path from "node:path";
19
19
  import { scheduler } from "node:timers/promises";
20
20
  import { isPromise } from "node:util/types";
21
+ import type { InMemorySnapshotStore } from "@oh-my-pi/hashline";
21
22
  import {
22
23
  type AfterToolCallContext,
23
24
  type AfterToolCallResult,
@@ -104,6 +105,8 @@ import { onAppendOnlyModeChanged } from "../config/settings";
104
105
  import { RawSseDebugBuffer } from "../debug/raw-sse-buffer";
105
106
  import { loadCapability } from "../discovery";
106
107
  import { expandApplyPatchToEntries, normalizeDiff, normalizeToLF, ParseError, previewPatch, stripBom } from "../edit";
108
+ import { getFileSnapshotStore } from "../edit/file-snapshot-store";
109
+ import { namespaceSessionId as namespacePythonSessionId } from "../eval/py";
107
110
  import {
108
111
  disposeKernelSessionsByOwner,
109
112
  executePython as executePythonCommand,
@@ -209,7 +212,11 @@ import { YieldQueue } from "./yield-queue";
209
212
  /** Session-specific events that extend the core AgentEvent */
210
213
  export type AgentSessionEvent =
211
214
  | AgentEvent
212
- | { type: "auto_compaction_start"; reason: "threshold" | "overflow" | "idle"; action: "context-full" | "handoff" }
215
+ | {
216
+ type: "auto_compaction_start";
217
+ reason: "threshold" | "overflow" | "idle" | "incomplete";
218
+ action: "context-full" | "handoff";
219
+ }
213
220
  | {
214
221
  type: "auto_compaction_end";
215
222
  action: "context-full" | "handoff";
@@ -738,6 +745,7 @@ export class AgentSession {
738
745
  readonly sessionManager: SessionManager;
739
746
  readonly settings: Settings;
740
747
  readonly yieldQueue: YieldQueue;
748
+ fileSnapshotStore?: InMemorySnapshotStore;
741
749
 
742
750
  #powerAssertion: MacOSPowerAssertion | undefined;
743
751
 
@@ -4156,6 +4164,7 @@ export class AgentSession {
4156
4164
  const fileMentionMessages = await generateFileMentionMessages(fileMentions, this.sessionManager.getCwd(), {
4157
4165
  autoResizeImages: this.settings.get("images.autoResize"),
4158
4166
  useHashLines: resolveFileDisplayMode(this).hashLines,
4167
+ snapshotStore: getFileSnapshotStore(this),
4159
4168
  });
4160
4169
  messages.push(...fileMentionMessages);
4161
4170
  }
@@ -5662,10 +5671,14 @@ export class AgentSession {
5662
5671
  * Check if context maintenance or promotion is needed and run it.
5663
5672
  * Called after agent_end and before prompt submission.
5664
5673
  *
5665
- * Three cases (in order):
5666
- * 1. Overflow + promotion: promote to larger model, retry without maintenance
5667
- * 2. Overflow + no promotion target: run context maintenance, auto-retry on same model
5668
- * 3. Threshold: Context over threshold, run context maintenance (no auto-retry)
5674
+ * Four cases (in order):
5675
+ * 1. Input overflow + promotion: promote to larger model, retry without maintenance.
5676
+ * 2. Input overflow + no promotion target: run context maintenance, auto-retry on same model.
5677
+ * 3. Output incomplete (stopReason === "length", e.g. `response.incomplete`): the
5678
+ * model burned its output budget without producing an actionable deliverable
5679
+ * (reasoning-only or truncated). Drop the dead turn, try promotion, otherwise
5680
+ * run compaction/handoff and retry.
5681
+ * 4. Threshold: context over threshold, run context maintenance (no auto-retry).
5669
5682
  *
5670
5683
  * @param assistantMessage The assistant message to check
5671
5684
  * @param skipAbortedCheck If false, include aborted messages (for pre-prompt check). Default: true
@@ -5724,10 +5737,49 @@ export class AgentSession {
5724
5737
  }
5725
5738
  return false;
5726
5739
  }
5740
+
5741
+ // Case 3: Output-side incomplete — `response.incomplete` from OpenAI Responses
5742
+ // (and Codex) maps to stopReason === "length". The model burned its
5743
+ // `max_output_tokens` budget on reasoning/text and emitted no actionable
5744
+ // deliverable. Same recovery class as overflow: promotion if available,
5745
+ // otherwise compaction/handoff. Unlike overflow, the *input* is fine, so we
5746
+ // allow the handoff strategy to actually run.
5747
+ if (sameModel && !errorIsFromBeforeCompaction && assistantMessage.stopReason === "length") {
5748
+ const messages = this.agent.state.messages;
5749
+ if (messages.length > 0 && messages[messages.length - 1].role === "assistant") {
5750
+ this.agent.replaceMessages(messages.slice(0, -1));
5751
+ }
5752
+
5753
+ const promoted = await this.#tryContextPromotion(assistantMessage);
5754
+ if (promoted) {
5755
+ logger.debug("Context promotion triggered by response.incomplete (length stop)", {
5756
+ from: `${assistantMessage.provider}/${assistantMessage.model}`,
5757
+ });
5758
+ this.#scheduleAgentContinue({ delayMs: 100, generation });
5759
+ return false;
5760
+ }
5761
+
5762
+ const incompleteCompactionSettings = this.settings.getGroup("compaction");
5763
+ if (incompleteCompactionSettings.enabled && incompleteCompactionSettings.strategy !== "off") {
5764
+ logger.debug("Compaction triggered by response.incomplete (length stop, no promotion target)", {
5765
+ model: `${assistantMessage.provider}/${assistantMessage.model}`,
5766
+ strategy: incompleteCompactionSettings.strategy,
5767
+ });
5768
+ await this.#runAutoCompaction("incomplete", true, false, allowDefer);
5769
+ } else {
5770
+ // Neither promotion nor compaction is available — surface the dead-end so
5771
+ // the user understands why the turn yielded with nothing.
5772
+ logger.warn("response.incomplete with no recovery path (promotion + compaction both unavailable)", {
5773
+ model: `${assistantMessage.provider}/${assistantMessage.model}`,
5774
+ });
5775
+ }
5776
+ return false;
5777
+ }
5778
+
5727
5779
  const compactionSettings = this.settings.getGroup("compaction");
5728
5780
  if (!compactionSettings.enabled || compactionSettings.strategy === "off") return false;
5729
5781
 
5730
- // Case 2: Threshold - turn succeeded but context is getting large
5782
+ // Case 4: Threshold - turn succeeded but context is getting large
5731
5783
  // Skip if this was an error (non-overflow errors don't have usage data)
5732
5784
  if (assistantMessage.stopReason === "error") return false;
5733
5785
  const pruneResult = await this.#pruneToolOutputs();
@@ -6450,7 +6502,7 @@ export class AgentSession {
6450
6502
  * @returns true when a deferred handoff was scheduled. Inline runs always return false.
6451
6503
  */
6452
6504
  async #runAutoCompaction(
6453
- reason: "overflow" | "threshold" | "idle",
6505
+ reason: "overflow" | "threshold" | "idle" | "incomplete",
6454
6506
  willRetry: boolean,
6455
6507
  deferred = false,
6456
6508
  allowDefer = true,
@@ -6459,10 +6511,14 @@ export class AgentSession {
6459
6511
  if (compactionSettings.strategy === "off") return false;
6460
6512
  if (reason !== "idle" && !compactionSettings.enabled) return false;
6461
6513
  const generation = this.#promptGeneration;
6514
+ // "overflow" and "incomplete" force inline execution because they are recovery
6515
+ // paths the caller wants resolved before scheduling the next turn. "idle" is
6516
+ // triggered by the idle loop and does its own scheduling.
6462
6517
  if (
6463
6518
  !deferred &&
6464
6519
  allowDefer &&
6465
6520
  reason !== "overflow" &&
6521
+ reason !== "incomplete" &&
6466
6522
  reason !== "idle" &&
6467
6523
  compactionSettings.strategy === "handoff"
6468
6524
  ) {
@@ -6477,6 +6533,9 @@ export class AgentSession {
6477
6533
  return true;
6478
6534
  }
6479
6535
 
6536
+ // "overflow" forces context-full because the input itself is broken — a handoff
6537
+ // LLM call would hit the same overflow. "incomplete" is an output-side problem,
6538
+ // so a handoff request on the existing context is still viable.
6480
6539
  let action: "context-full" | "handoff" =
6481
6540
  compactionSettings.strategy === "handoff" && reason !== "overflow" ? "handoff" : "context-full";
6482
6541
  await this.#emitSessionEvent({ type: "auto_compaction_start", reason, action });
@@ -6777,8 +6836,18 @@ export class AgentSession {
6777
6836
  if (willRetry) {
6778
6837
  const messages = this.agent.state.messages;
6779
6838
  const lastMsg = messages[messages.length - 1];
6780
- if (lastMsg?.role === "assistant" && (lastMsg as AssistantMessage).stopReason === "error") {
6781
- this.agent.replaceMessages(messages.slice(0, -1));
6839
+ if (lastMsg?.role === "assistant") {
6840
+ const lastAssistant = lastMsg as AssistantMessage;
6841
+ // Drop the prior turn before retry when it carries no actionable deliverable:
6842
+ // - "error": failure was kept in history but must not re-enter the next turn's prompt.
6843
+ // - reason === "incomplete" && stopReason === "length": truncated output (typically
6844
+ // reasoning-only) — re-running it produces the same dead-end.
6845
+ const shouldDrop =
6846
+ lastAssistant.stopReason === "error" ||
6847
+ (reason === "incomplete" && lastAssistant.stopReason === "length");
6848
+ if (shouldDrop) {
6849
+ this.agent.replaceMessages(messages.slice(0, -1));
6850
+ }
6782
6851
  }
6783
6852
 
6784
6853
  this.#scheduleAgentContinue({ delayMs: 100, generation });
@@ -6812,7 +6881,9 @@ export class AgentSession {
6812
6881
  errorMessage:
6813
6882
  reason === "overflow"
6814
6883
  ? `Context overflow recovery failed: ${errorMessage}`
6815
- : `Auto-compaction failed: ${errorMessage}`,
6884
+ : reason === "incomplete"
6885
+ ? `Incomplete response recovery failed: ${errorMessage}`
6886
+ : `Auto-compaction failed: ${errorMessage}`,
6816
6887
  });
6817
6888
  } finally {
6818
6889
  if (this.#autoCompactionAbortController === autoCompactionAbortController) {
@@ -7521,7 +7592,7 @@ export class AgentSession {
7521
7592
  });
7522
7593
  const result = await executePythonCommand(code, {
7523
7594
  cwd,
7524
- sessionId,
7595
+ sessionId: namespacePythonSessionId(sessionId),
7525
7596
  kernelOwnerId: this.#evalKernelOwnerId,
7526
7597
  kernelMode: this.settings.get("python.kernelMode"),
7527
7598
  onChunk,
@@ -11,6 +11,7 @@ import { systemPromptCapability } from "./capability/system-prompt";
11
11
  import type { SkillsSettings } from "./config/settings";
12
12
  import { type ContextFile, loadCapability, type SystemPrompt as SystemPromptFile } from "./discovery";
13
13
  import { loadSkills, type Skill } from "./extensibility/skills";
14
+ import { hasObsidian } from "./internal-urls/vault-protocol";
14
15
  import customSystemPromptTemplate from "./prompts/system/custom-system-prompt.md" with { type: "text" };
15
16
  import projectPromptTemplate from "./prompts/system/project-prompt.md" with { type: "text" };
16
17
  import systemPromptTemplate from "./prompts/system/system-prompt.md" with { type: "text" };
@@ -569,6 +570,7 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}):
569
570
  mcpDiscoveryServerSummaries,
570
571
  eagerTasks,
571
572
  secretsEnabled,
573
+ hasObsidian: hasObsidian(),
572
574
  };
573
575
  const rendered = prompt.render(resolvedCustomPrompt ? customSystemPromptTemplate : systemPromptTemplate, data);
574
576
  const systemPrompt = [rendered];
@@ -1,11 +1,13 @@
1
1
  import * as path from "node:path";
2
- import { computeFileHash, formatHashlineHeader } from "@oh-my-pi/hashline";
2
+ import { formatHashlineHeader } from "@oh-my-pi/hashline";
3
3
  import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core";
4
4
  import { type AstReplaceChange, type AstReplaceFileChange, astEdit } from "@oh-my-pi/pi-natives";
5
5
  import type { Component } from "@oh-my-pi/pi-tui";
6
6
  import { Text } from "@oh-my-pi/pi-tui";
7
7
  import { $envpos, prompt, untilAborted } from "@oh-my-pi/pi-utils";
8
8
  import * as z from "zod/v4";
9
+ import { getFileSnapshotStore } from "../edit/file-snapshot-store";
10
+ import { normalizeToLF } from "../edit/normalize";
9
11
  import type { RenderResultOptions } from "../extensibility/custom-tools/types";
10
12
  import type { Theme } from "../modes/theme/theme";
11
13
  import astEditDescription from "../prompts/tools/ast-edit.md" with { type: "text" };
@@ -281,14 +283,15 @@ export class AstEditTool implements AgentTool<typeof astEditSchema, AstEditToolD
281
283
  }
282
284
 
283
285
  const useHashLines = resolveFileDisplayMode(this.session).hashLines;
284
- const hashContexts = new Map<string, { fileHash: string }>();
286
+ const hashContexts = new Map<string, { tag: string }>();
285
287
  if (useHashLines) {
288
+ const snapshotStore = getFileSnapshotStore(this.session);
286
289
  for (const relativePath of fileList) {
287
290
  const absolutePath = path.resolve(this.session.cwd, relativePath);
288
291
  try {
289
- const fullText = await Bun.file(absolutePath).text();
290
- const fileHash = computeFileHash(fullText);
291
- hashContexts.set(relativePath, { fileHash });
292
+ const fullText = normalizeToLF(await Bun.file(absolutePath).text());
293
+ const tag = snapshotStore.recordContiguous(absolutePath, 1, fullText.split("\n"), { fullText });
294
+ hashContexts.set(relativePath, { tag });
292
295
  } catch {
293
296
  // Best-effort: if a file disappears between ast-edit and rendering, emit plain line output.
294
297
  }
@@ -326,7 +329,7 @@ export class AstEditTool implements AgentTool<typeof astEditSchema, AstEditToolD
326
329
  const rendered = renderChangesForFile(relativePath);
327
330
  const count = fileReplacementCounts.get(relativePath) ?? 0;
328
331
  const hashContext = hashContexts.get(relativePath);
329
- const hashSuffix = hashContext ? `#${hashContext.fileHash}` : "";
332
+ const hashSuffix = hashContext ? `#${hashContext.tag}` : "";
330
333
  return {
331
334
  headerSuffix: `${hashSuffix} (${formatCount("replacement", count)})`,
332
335
  modelLines: rendered.model,
@@ -346,7 +349,7 @@ export class AstEditTool implements AgentTool<typeof astEditSchema, AstEditToolD
346
349
  }
347
350
  const hashContext = hashContexts.get(relativePath);
348
351
  if (hashContext) {
349
- outputLines.push(formatHashlineHeader(relativePath, hashContext.fileHash));
352
+ outputLines.push(formatHashlineHeader(relativePath, hashContext.tag));
350
353
  }
351
354
  outputLines.push(...rendered.model);
352
355
  displayLines.push(...rendered.display);