@oh-my-pi/pi-coding-agent 15.8.0 → 15.8.3

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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,42 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [15.8.3] - 2026-06-03
6
+ ### Fixed
7
+
8
+ - Fixed Jujutsu workspace detection failing in non-default workspaces created by `jj workspace add`, whose `.jj/repo` is a FILE pointing at the shared repo dir rather than a directory. Detection now matches jj's own criterion (`.jj/repo` present, file or dir) instead of requiring a `.jj/repo/store` directory, and `jj.repo.resolve`'s `storeDir` follows the file indirection to the shared store.
9
+
10
+ ### Changed
11
+
12
+ - Changed the `todo-write` prompt to require initializing every item from a user-supplied multi-step plan as an individual todo task before execution
13
+ - Changed context compaction (prune/shake) to protect reads of the active plan file the same way it already protects `skill://` reads, so the plan stays intact through automatic and manual compaction. Both the canonical `local://PLAN.md` alias and the session's current plan reference path (e.g. a titled `local://<title>.md` after approval) are kept, tolerating read selectors and `local:/` scheme spelling.
14
+
15
+ ## [15.8.2] - 2026-06-03
16
+
17
+ ### Added
18
+
19
+ - Added a bundled TypeScript rule that warns against leaving `@deprecated` compatibility shims behind instead of finishing a refactor.
20
+
21
+ ### Fixed
22
+
23
+ - Fixed `/review`'s uncommitted-change mode in Jujutsu repositories to read `jj diff --git` from the current workspace, so non-default JJ workspaces include their working-copy changes instead of falling back to the colocated Git checkout.
24
+ - Fixed empty assistant stop retry continuations preserving auto-retry state until a non-empty assistant turn completes or recovery reaches its retry cap.
25
+ - Fixed TTSR rule conditions never matching streamed `edit`/`write` tool calls whose wire format obscures the real content (hashline `+` body rows, apply_patch envelopes, JSON-escaped `write` content). The edit and write tools now expose a `matcherDigest` normalization and TTSR matches against the introduced source text, so rule regexes stay universal regardless of the active edit mode.
26
+
27
+ ### Changed
28
+
29
+ - Changed the JJ utility API to mirror Git's scoped helpers: repository operations now live under `jj.repo` (`root`, `resolve`, `is`, `clearRootCache`), and diff file listing is available as `jj.diff.changedFiles`.
30
+
31
+ - Changed the `search_tool_bm25` tool description to name the hidden discoverable built-in tools (e.g. `write`, `find`, `search`, `lsp`, `task`) when `tools.discoveryMode: "all"` is active, so a model can form a targeted discovery query by name instead of guessing or falling back to shell. `mcp-only` mode is unchanged (no built-ins are advertised) and the `Total discoverable tools available: N` count still includes them.
32
+
33
+ ## [15.8.1] - 2026-06-02
34
+
35
+ ### Fixed
36
+
37
+ - Fixed an unhandled `EPIPE` rejection when an MCP stdio server exits between returning the `initialize` response and the client's `notifications/initialized` send. `StdioTransport.notify()` and `#sendResponse()` now route stdin writes through a shared helper that catches synchronous sink failures: `notify()` tears the transport down (firing `onClose`) and surfaces a `Transport closed while sending notification` rejection so `connectToServer()` treats the handshake as a failed connection instead of returning a "connected" handle wrapping a dead transport; `#sendResponse()` stays silent because a dead subprocess has no use for the response. `StdioTransport.close()` is now the authoritative resource teardown — it no longer early-returns when `#handleClose()` has already flipped `#connected`, so the subprocess and read loop are always cleaned up (including in the `connectToServer()` failure path) ([#1710](https://github.com/can1357/oh-my-pi/issues/1710)).
38
+ - Fixed startup model resolution ignoring cached discovery rows for special built-in providers (`google-antigravity`, `google-gemini-cli`, `openai-codex`) until the background refresh completed ([#1721](https://github.com/can1357/oh-my-pi/issues/1721)).
39
+ - Fixed Windows clipboard-image paste keeping `Ctrl+V` unregistered by default. The TUI now registers `Ctrl+V` plus the Windows Terminal-safe `Alt+V` fallback, and the keybinding docs call out when to use the fallback ([#1708](https://github.com/can1357/oh-my-pi/issues/1708)).
40
+
5
41
  ## [15.8.0] - 2026-06-02
6
42
 
7
43
  ### Added
@@ -9215,4 +9251,4 @@ Initial public release.
9215
9251
  - Git branch display in footer
9216
9252
  - Message queueing during streaming responses
9217
9253
  - OAuth integration for Gmail and Google Calendar access
9218
- - HTML export with syntax highlighting and collapsible sections
9254
+ - HTML export with syntax highlighting and collapsible sections
@@ -43,6 +43,10 @@ declare module "@oh-my-pi/pi-tui" {
43
43
  interface Keybindings extends AppKeybindings {
44
44
  }
45
45
  }
46
+ /**
47
+ * Resolve default image-paste shortcuts for the current terminal platform.
48
+ */
49
+ export declare function getDefaultPasteImageKeys(platform?: NodeJS.Platform): KeyId[];
46
50
  /**
47
51
  * All keybindings definitions: TUI + app-specific.
48
52
  */
@@ -228,7 +232,7 @@ export declare const KEYBINDINGS: {
228
232
  readonly description: "Dequeue message";
229
233
  };
230
234
  readonly "app.clipboard.pasteImage": {
231
- readonly defaultKeys: "alt+v" | "ctrl+v";
235
+ readonly defaultKeys: KeyId[];
232
236
  readonly description: "Paste image from clipboard";
233
237
  };
234
238
  readonly "app.clipboard.pasteTextRaw": {
@@ -52,5 +52,11 @@ export declare class EditTool implements AgentTool<TInput> {
52
52
  * internal `name` and `customWireName`, so returned calls route correctly.
53
53
  */
54
54
  get customWireName(): string | undefined;
55
+ /**
56
+ * Normalize streamed args into the source text this edit introduces, so
57
+ * stream matchers (TTSR rules) run against real file content instead of the
58
+ * mode-specific patch grammar.
59
+ */
60
+ matcherDigest(args: unknown): string | undefined;
55
61
  execute(_toolCallId: string, params: EditParams, signal?: AbortSignal, onUpdate?: AgentToolUpdateCallback<EditToolDetails, TInput>, context?: AgentToolContext): Promise<AgentToolResult<EditToolDetails, TInput>>;
56
62
  }
@@ -52,6 +52,14 @@ export interface EditStreamingStrategy<Args = unknown> {
52
52
  * compute returned `null` because args are still too partial).
53
53
  */
54
54
  renderStreamingFallback(args: Args, uiTheme: Theme): string;
55
+ /**
56
+ * Project the (potentially partial) args onto the plain text the edit
57
+ * introduces into files — added lines without patch grammar — so stream
58
+ * matchers (TTSR rules) can run source-level patterns against real content
59
+ * instead of the mode-specific wire format. Returns `undefined` when the
60
+ * args don't yet carry any content.
61
+ */
62
+ matcherDigest(args: Args): string | undefined;
55
63
  }
56
64
  /**
57
65
  * Given an edits array parsed from partial JSON, drop the last entry when the
@@ -23,6 +23,15 @@ export declare class TtsrManager {
23
23
  * assistant prose, thinking text, and unrelated tool argument streams.
24
24
  */
25
25
  checkDelta(delta: string, context: TtsrMatchContext): Rule[];
26
+ /**
27
+ * Replace the scoped buffer with a tool-provided normalized snapshot and
28
+ * return matching rules.
29
+ *
30
+ * Used for tools exposing `matcherDigest`: the digest is recomputed from the
31
+ * full (partial) arguments on every delta, so it replaces the buffer instead
32
+ * of being appended to it.
33
+ */
34
+ checkSnapshot(snapshot: string, context: TtsrMatchContext): Rule[];
26
35
  /** Mark rules as injected (won't trigger again until conditions allow). */
27
36
  markInjected(rulesToMark: Rule[]): void;
28
37
  /** Mark rule names as injected (won't trigger again until conditions allow). */
@@ -5,6 +5,24 @@
5
5
  * Messages are newline-delimited JSON.
6
6
  */
7
7
  import type { MCPRequestOptions, MCPStdioServerConfig, MCPTransport } from "../../mcp/types";
8
+ /** Minimal write surface of `Subprocess.stdin` we need for framed sends. */
9
+ interface FrameSink {
10
+ write(chunk: string): unknown;
11
+ flush(): unknown;
12
+ }
13
+ /**
14
+ * Write a newline-delimited JSON-RPC frame to the subprocess's stdin sink,
15
+ * swallowing synchronous errors so the caller can decide how to react.
16
+ *
17
+ * Bun's `FileSink` may throw synchronously (most reliably on Windows) when
18
+ * the read end of the pipe has been closed by a subprocess that exited
19
+ * between read-loop ticks. Letting that throw escape an `async` method
20
+ * surfaces as an unhandled promise rejection at the call site.
21
+ *
22
+ * Returns `true` when the frame was accepted by the sink, `false` when the
23
+ * sink threw — callers signal transport closure on `false`.
24
+ */
25
+ export declare function writeFrame(stdin: FrameSink, frame: string): boolean;
8
26
  /**
9
27
  * Stdio transport for MCP servers.
10
28
  * Spawns a subprocess and communicates via stdin/stdout.
@@ -30,3 +48,4 @@ export declare class StdioTransport implements MCPTransport {
30
48
  * Create and connect a stdio transport.
31
49
  */
32
50
  export declare function createStdioTransport(config: MCPStdioServerConfig): Promise<StdioTransport>;
51
+ export {};
@@ -0,0 +1,12 @@
1
+ import { type ProtectedToolContext } from "@oh-my-pi/pi-agent-core/compaction/tool-protection";
2
+ /**
3
+ * Build a compaction protection matcher that keeps `read` results for the active
4
+ * plan file intact through prune/shake — the plan analog of skill-read
5
+ * protection. Matches both the canonical `local://PLAN.md` alias and the
6
+ * session's current plan reference path (e.g. a titled `local://<title>.md`), so
7
+ * the plan survives compaction whether the agent reads it by alias or by title.
8
+ *
9
+ * `getPlanReferencePath` is evaluated at match time so a mid-session retitle
10
+ * (plan approval renames `PLAN.md` → `<title>.md`) is honored immediately.
11
+ */
12
+ export declare function createPlanReadMatcher(getPlanReferencePath: () => string): (context: ProtectedToolContext) => boolean;
@@ -41,6 +41,8 @@ export declare class WriteTool implements AgentTool<typeof writeSchema, WriteToo
41
41
  readonly concurrency = "exclusive";
42
42
  readonly loadMode = "discoverable";
43
43
  readonly summary = "Write content to a file (creates or overwrites)";
44
+ /** Stream matchers should see the real file content, not its JSON-escaped argument encoding. */
45
+ matcherDigest(args: unknown): string | undefined;
44
46
  constructor(session: ToolSession);
45
47
  execute(_toolCallId: string, { path, content }: WriteParams, signal?: AbortSignal, _onUpdate?: AgentToolUpdateCallback<WriteToolDetails>, context?: AgentToolContext): Promise<AgentToolResult<WriteToolDetails>>;
46
48
  }
@@ -0,0 +1,49 @@
1
+ /** Result from a completed `jj` subprocess invocation. */
2
+ export interface JjCommandResult {
3
+ /** Process exit code reported by `jj`. */
4
+ exitCode: number;
5
+ /** Captured standard output as UTF-8 text. */
6
+ stdout: string;
7
+ /** Captured standard error as UTF-8 text. */
8
+ stderr: string;
9
+ }
10
+ /** Resolved Jujutsu workspace metadata. */
11
+ export interface JjRepository {
12
+ /** Root directory containing the `.jj` workspace metadata. */
13
+ repoRoot: string;
14
+ /** Path to the shared workspace store directory, resolved through `.jj/repo`'s file indirection for non-default workspaces. */
15
+ storeDir: string;
16
+ }
17
+ /** Options for `jj diff` invocations. */
18
+ export interface DiffOptions {
19
+ /** Optional file paths to restrict the diff with `-- <files>`. */
20
+ readonly files?: readonly string[];
21
+ /** Return only changed file names instead of Git-format diff text. */
22
+ readonly nameOnly?: boolean;
23
+ /** Optional abort signal passed to the spawned `jj` process. */
24
+ readonly signal?: AbortSignal;
25
+ }
26
+ /** Error thrown when a checked `jj` command exits non-zero. */
27
+ export declare class JjCommandError extends Error {
28
+ /** Arguments passed after the common `jj --no-pager --color=never` prefix. */
29
+ readonly args: readonly string[];
30
+ /** Captured command result that caused the failure. */
31
+ readonly result: JjCommandResult;
32
+ /** Create an error for a failed checked `jj` command. */
33
+ constructor(args: readonly string[], result: JjCommandResult);
34
+ }
35
+ /** Run `jj diff --git` for the current workspace commit and return the raw Git-format diff text. */
36
+ export declare const diff: ((cwd: string, options?: DiffOptions) => Promise<string>) & {
37
+ /** List changed file paths. */
38
+ changedFiles(cwd: string, options?: Pick<DiffOptions, "files" | "signal">): Promise<string[]>;
39
+ };
40
+ export declare const repo: {
41
+ /** Clear cached workspace roots. Intended for tests that mutate JJ metadata under an existing path. */
42
+ clearRootCache(): void;
43
+ /** Resolve the current Jujutsu workspace root, or `null` when `cwd` is not in a JJ repository. */
44
+ root(cwd: string): Promise<string | null>;
45
+ /** Full Jujutsu workspace metadata. */
46
+ resolve(cwd: string): Promise<JjRepository | null>;
47
+ /** Check whether `cwd` is inside a Jujutsu repository. */
48
+ is(cwd: string): Promise<boolean>;
49
+ };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/pi-coding-agent",
4
- "version": "15.8.0",
4
+ "version": "15.8.3",
5
5
  "description": "Coding agent CLI with read, bash, edit, write tools and session management",
6
6
  "homepage": "https://omp.sh",
7
7
  "author": "Can Boluk",
@@ -47,14 +47,14 @@
47
47
  "@agentclientprotocol/sdk": "0.22.1",
48
48
  "@babel/parser": "^7.29.7",
49
49
  "@mozilla/readability": "^0.6.0",
50
- "@oh-my-pi/hashline": "15.8.0",
51
- "@oh-my-pi/omp-stats": "15.8.0",
52
- "@oh-my-pi/pi-agent-core": "15.8.0",
53
- "@oh-my-pi/pi-ai": "15.8.0",
54
- "@oh-my-pi/pi-mnemopi": "15.8.0",
55
- "@oh-my-pi/pi-natives": "15.8.0",
56
- "@oh-my-pi/pi-tui": "15.8.0",
57
- "@oh-my-pi/pi-utils": "15.8.0",
50
+ "@oh-my-pi/hashline": "15.8.3",
51
+ "@oh-my-pi/omp-stats": "15.8.3",
52
+ "@oh-my-pi/pi-agent-core": "15.8.3",
53
+ "@oh-my-pi/pi-ai": "15.8.3",
54
+ "@oh-my-pi/pi-mnemopi": "15.8.3",
55
+ "@oh-my-pi/pi-natives": "15.8.3",
56
+ "@oh-my-pi/pi-tui": "15.8.3",
57
+ "@oh-my-pi/pi-utils": "15.8.3",
58
58
  "@puppeteer/browsers": "^3.0.4",
59
59
  "@types/turndown": "5.0.6",
60
60
  "@xterm/headless": "^6.0.0",
@@ -58,6 +58,13 @@ declare module "@oh-my-pi/pi-tui" {
58
58
  interface Keybindings extends AppKeybindings {}
59
59
  }
60
60
 
61
+ /**
62
+ * Resolve default image-paste shortcuts for the current terminal platform.
63
+ */
64
+ export function getDefaultPasteImageKeys(platform: NodeJS.Platform = process.platform): KeyId[] {
65
+ return platform === "win32" ? ["ctrl+v", "alt+v"] : ["ctrl+v"];
66
+ }
67
+
61
68
  /**
62
69
  * All keybindings definitions: TUI + app-specific.
63
70
  */
@@ -120,7 +127,7 @@ export const KEYBINDINGS = {
120
127
  description: "Dequeue message",
121
128
  },
122
129
  "app.clipboard.pasteImage": {
123
- defaultKeys: process.platform === "win32" ? "alt+v" : "ctrl+v",
130
+ defaultKeys: getDefaultPasteImageKeys(),
124
131
  description: "Paste image from clipboard",
125
132
  },
126
133
  "app.clipboard.pasteTextRaw": {
@@ -53,6 +53,17 @@ function discoveryDefaultMaxTokens(api: Api | undefined): number {
53
53
  return api === "anthropic-messages" ? DISCOVERY_DEFAULT_MAX_TOKENS_ANTHROPIC : DISCOVERY_DEFAULT_MAX_TOKENS;
54
54
  }
55
55
 
56
+ const SPECIAL_MODEL_MANAGER_PROVIDER_IDS: readonly string[] = [
57
+ "google-antigravity",
58
+ "google-gemini-cli",
59
+ "openai-codex",
60
+ ];
61
+
62
+ const STARTUP_MODEL_CACHE_PROVIDER_IDS: readonly string[] = [
63
+ ...PROVIDER_DESCRIPTORS.map(descriptor => descriptor.providerId),
64
+ ...SPECIAL_MODEL_MANAGER_PROVIDER_IDS,
65
+ ];
66
+
56
67
  import { registerOAuthProvider, unregisterOAuthProviders } from "@oh-my-pi/pi-ai/utils/oauth";
57
68
  import type { OAuthCredentials, OAuthLoginCallbacks } from "@oh-my-pi/pi-ai/utils/oauth/types";
58
69
  import { isRecord, logger } from "@oh-my-pi/pi-utils";
@@ -1133,28 +1144,28 @@ export class ModelRegistry {
1133
1144
  const configuredDiscoveryProviders = new Set(this.#discoverableProviders.map(provider => provider.provider));
1134
1145
  const cachedModels: Model<Api>[] = [];
1135
1146
  const authoritativeFreshProviders = new Set<string>();
1136
- for (const descriptor of PROVIDER_DESCRIPTORS) {
1137
- if (configuredDiscoveryProviders.has(descriptor.providerId)) {
1147
+ for (const providerId of STARTUP_MODEL_CACHE_PROVIDER_IDS) {
1148
+ if (configuredDiscoveryProviders.has(providerId)) {
1138
1149
  continue;
1139
1150
  }
1140
- const cache = readModelCache<Api>(descriptor.providerId, 24 * 60 * 60 * 1000, Date.now, this.#cacheDbPath);
1151
+ const cache = readModelCache<Api>(providerId, 24 * 60 * 60 * 1000, Date.now, this.#cacheDbPath);
1141
1152
  if (!cache) {
1142
1153
  continue;
1143
1154
  }
1144
1155
  if (cache.fresh && cache.authoritative) {
1145
- authoritativeFreshProviders.add(descriptor.providerId);
1156
+ authoritativeFreshProviders.add(providerId);
1146
1157
  }
1147
1158
  const models = cache.models.map(model =>
1148
- model.provider === descriptor.providerId ? model : { ...model, provider: descriptor.providerId },
1159
+ model.provider === providerId ? model : { ...model, provider: providerId },
1149
1160
  );
1150
- const providerOverride = this.#providerOverrides.get(descriptor.providerId);
1161
+ const providerOverride = this.#providerOverrides.get(providerId);
1151
1162
  const withTransport = providerOverride
1152
1163
  ? models.map(model => this.#applyProviderTransportOverride(model, providerOverride))
1153
1164
  : models;
1154
1165
  const withCompat = providerOverride?.compat
1155
1166
  ? withTransport.map(model => ({ ...model, compat: mergeCompat(model.compat, providerOverride.compat) }))
1156
1167
  : withTransport;
1157
- cachedModels.push(...this.#applyProviderModelOverrides(descriptor.providerId, withCompat));
1168
+ cachedModels.push(...this.#applyProviderModelOverrides(providerId, withCompat));
1158
1169
  }
1159
1170
  return { models: cachedModels, authoritativeFreshProviders };
1160
1171
  }
@@ -17,6 +17,7 @@ import rsResultType from "./rs-result-type.md" with { type: "text" };
17
17
  import tsBareCatch from "./ts-bare-catch.md" with { type: "text" };
18
18
  import tsImportType from "./ts-import-type.md" with { type: "text" };
19
19
  import tsNoAny from "./ts-no-any.md" with { type: "text" };
20
+ import tsNoDeprecatedLeftovers from "./ts-no-deprecated-leftovers.md" with { type: "text" };
20
21
  import tsNoDynamicImport from "./ts-no-dynamic-import.md" with { type: "text" };
21
22
  import tsNoReturnType from "./ts-no-return-type.md" with { type: "text" };
22
23
  import tsNoTinyFunctions from "./ts-no-tiny-functions.md" with { type: "text" };
@@ -40,6 +41,7 @@ export const BUILTIN_RULE_SOURCES: readonly BuiltinRuleSource[] = [
40
41
  { name: "ts-bare-catch", content: tsBareCatch },
41
42
  { name: "ts-import-type", content: tsImportType },
42
43
  { name: "ts-no-any", content: tsNoAny },
44
+ { name: "ts-no-deprecated-leftovers", content: tsNoDeprecatedLeftovers },
43
45
  { name: "ts-no-dynamic-import", content: tsNoDynamicImport },
44
46
  { name: "ts-no-return-type", content: tsNoReturnType },
45
47
  { name: "ts-no-tiny-functions", content: tsNoTinyFunctions },
@@ -0,0 +1,44 @@
1
+ ---
2
+ description: "Do not leave `@deprecated` shims behind after refactors — update call sites and remove the old API"
3
+ condition: "@deprecated"
4
+ scope: "tool:edit(*.ts), tool:edit(*.tsx), tool:write(*.ts), tool:write(*.tsx)"
5
+ ---
6
+
7
+ Do not use `@deprecated` as a substitute for finishing a refactor. If an API is obsolete inside the code you control, update every call site and remove the old name in the same change.
8
+
9
+ ## Why
10
+
11
+ - Deprecated aliases keep two contracts alive.
12
+ - Future maintainers must preserve behavior nobody should call.
13
+ - Tests can pass while production code keeps using the old path.
14
+ - The next refactor has to unwind both the real API and the compatibility layer.
15
+
16
+ ## Avoid
17
+
18
+ ```typescript
19
+ // Bad — leaves a stale compatibility name instead of finishing the cutover.
20
+ /** @deprecated Use loadSettings instead. */
21
+ export const loadConfig = loadSettings;
22
+
23
+ // Bad — preserves an obsolete wrapper after callers can be updated.
24
+ /** @deprecated Use createClient instead. */
25
+ export function makeClient(options: ClientOptions): Client {
26
+ return createClient(options);
27
+ }
28
+ ```
29
+
30
+ ## Use
31
+
32
+ ```typescript
33
+ // Update all imports and call sites to the durable name.
34
+ export function loadSettings(path: string): Settings { ... }
35
+ export function createClient(options: ClientOptions): Client { ... }
36
+ ```
37
+
38
+ ## Exceptions
39
+
40
+ - Public package APIs with a documented migration window.
41
+ - Third-party declarations where the deprecated marker reflects an external contract.
42
+ - Tests that intentionally verify deprecated API behavior during a supported transition.
43
+
44
+ If an exception applies, state the external compatibility requirement. Otherwise, finish the refactor and delete the deprecated symbol.
package/src/edit/index.ts CHANGED
@@ -24,6 +24,7 @@ import applyPatchGrammar from "./modes/apply-patch.lark" with { type: "text" };
24
24
  import { executePatchSingle, type PatchEditEntry, type PatchParams, patchEditSchema } from "./modes/patch";
25
25
  import { executeReplaceSingle, type ReplaceEditEntry, type ReplaceParams, replaceEditSchema } from "./modes/replace";
26
26
  import { type EditToolDetails, type EditToolPerFileResult, getLspBatchRequest, type LspBatchRequest } from "./renderer";
27
+ import { EDIT_MODE_STRATEGIES } from "./streaming";
27
28
 
28
29
  export * from "@oh-my-pi/hashline";
29
30
  export { DEFAULT_EDIT_MODE, type EditMode, normalizeEditMode } from "../utils/edit-mode";
@@ -360,6 +361,15 @@ export class EditTool implements AgentTool<TInput> {
360
361
  return "apply_patch";
361
362
  }
362
363
 
364
+ /**
365
+ * Normalize streamed args into the source text this edit introduces, so
366
+ * stream matchers (TTSR rules) run against real file content instead of the
367
+ * mode-specific patch grammar.
368
+ */
369
+ matcherDigest(args: unknown): string | undefined {
370
+ return EDIT_MODE_STRATEGIES[this.mode].matcherDigest(args);
371
+ }
372
+
363
373
  async execute(
364
374
  _toolCallId: string,
365
375
  params: EditParams,
@@ -69,6 +69,14 @@ export interface EditStreamingStrategy<Args = unknown> {
69
69
  * compute returned `null` because args are still too partial).
70
70
  */
71
71
  renderStreamingFallback(args: Args, uiTheme: Theme): string;
72
+ /**
73
+ * Project the (potentially partial) args onto the plain text the edit
74
+ * introduces into files — added lines without patch grammar — so stream
75
+ * matchers (TTSR rules) can run source-level patterns against real content
76
+ * instead of the mode-specific wire format. Returns `undefined` when the
77
+ * args don't yet carry any content.
78
+ */
79
+ matcherDigest(args: Args): string | undefined;
72
80
  }
73
81
 
74
82
  // -----------------------------------------------------------------------------
@@ -161,6 +169,28 @@ function groupApplyPatchEntriesByPath(entries: readonly ApplyPatchEntry[]): Map<
161
169
  return groups;
162
170
  }
163
171
 
172
+ /**
173
+ * Extract the lines a patch-style payload adds (`+` prefix, excluding `+++ `
174
+ * file headers), stripped of the prefix. When the text carries no added lines,
175
+ * returns the whole text if `fallbackToWhole` (full-content payloads such as a
176
+ * `create` op), otherwise an empty string (grammar-only payloads).
177
+ */
178
+ function extractAddedLines(text: string, fallbackToWhole: boolean): string {
179
+ let added: string | undefined;
180
+ let lineStart = 0;
181
+ while (lineStart <= text.length) {
182
+ let lineEnd = text.indexOf("\n", lineStart);
183
+ if (lineEnd === -1) lineEnd = text.length;
184
+ if (text.charCodeAt(lineStart) === 43 /* + */ && !text.startsWith("+++ ", lineStart)) {
185
+ const line = text.slice(lineStart + 1, lineEnd);
186
+ added = added === undefined ? line : `${added}\n${line}`;
187
+ }
188
+ lineStart = lineEnd + 1;
189
+ }
190
+ if (added === undefined) return fallbackToWhole ? text : "";
191
+ return added;
192
+ }
193
+
164
194
  // -----------------------------------------------------------------------------
165
195
  // Strategies
166
196
  // -----------------------------------------------------------------------------
@@ -196,6 +226,16 @@ const replaceStrategy: EditStreamingStrategy<ReplaceArgs> = {
196
226
  renderStreamingFallback() {
197
227
  return "";
198
228
  },
229
+ matcherDigest(args) {
230
+ const edits = args?.edits;
231
+ if (!Array.isArray(edits)) return undefined;
232
+ let digest: string | undefined;
233
+ for (const edit of edits) {
234
+ if (typeof edit?.new_text !== "string") continue;
235
+ digest = digest === undefined ? edit.new_text : `${digest}\n${edit.new_text}`;
236
+ }
237
+ return digest;
238
+ },
199
239
  };
200
240
 
201
241
  interface PatchArgs {
@@ -225,6 +265,19 @@ const patchStrategy: EditStreamingStrategy<PatchArgs> = {
225
265
  renderStreamingFallback() {
226
266
  return "";
227
267
  },
268
+ matcherDigest(args) {
269
+ const edits = args?.edits;
270
+ if (!Array.isArray(edits)) return undefined;
271
+ let digest: string | undefined;
272
+ for (const edit of edits) {
273
+ if (typeof edit?.diff !== "string") continue;
274
+ // `create` ops carry full file content in `diff` with no +/- markers;
275
+ // pass that content through whole.
276
+ const added = extractAddedLines(edit.diff, true);
277
+ digest = digest === undefined ? added : `${digest}\n${added}`;
278
+ }
279
+ return digest;
280
+ },
228
281
  };
229
282
 
230
283
  interface HashlineArgs {
@@ -378,6 +431,12 @@ const hashlineStrategy: EditStreamingStrategy<HashlineArgs> = {
378
431
  // than a sigil dump.
379
432
  return "";
380
433
  },
434
+ matcherDigest(args) {
435
+ const input = args?.input;
436
+ if (typeof input !== "string") return undefined;
437
+ // Body rows are `+TEXT`; headers and op lines are grammar, never content.
438
+ return extractAddedLines(input, false);
439
+ },
381
440
  };
382
441
 
383
442
  interface ApplyPatchArgs {
@@ -430,6 +489,12 @@ const applyPatchStrategy: EditStreamingStrategy<ApplyPatchArgs> = {
430
489
  renderStreamingFallback() {
431
490
  return "";
432
491
  },
492
+ matcherDigest(args) {
493
+ const input = args?.input;
494
+ if (typeof input !== "string") return undefined;
495
+ // Envelope markers and `@@` hunk headers are grammar, never content.
496
+ return extractAddedLines(input, false);
497
+ },
433
498
  };
434
499
  export const EDIT_MODE_STRATEGIES: Record<EditMode, EditStreamingStrategy<unknown>> = {
435
500
  replace: replaceStrategy as EditStreamingStrategy<unknown>,
@@ -339,7 +339,24 @@ export class TtsrManager {
339
339
  const bufferKey = this.#bufferKey(context);
340
340
  const nextBuffer = `${this.#buffers.get(bufferKey) ?? ""}${delta}`;
341
341
  this.#buffers.set(bufferKey, nextBuffer);
342
+ return this.#matchBuffer(nextBuffer, context);
343
+ }
344
+
345
+ /**
346
+ * Replace the scoped buffer with a tool-provided normalized snapshot and
347
+ * return matching rules.
348
+ *
349
+ * Used for tools exposing `matcherDigest`: the digest is recomputed from the
350
+ * full (partial) arguments on every delta, so it replaces the buffer instead
351
+ * of being appended to it.
352
+ */
353
+ checkSnapshot(snapshot: string, context: TtsrMatchContext): Rule[] {
354
+ const bufferKey = this.#bufferKey(context);
355
+ this.#buffers.set(bufferKey, snapshot);
356
+ return this.#matchBuffer(snapshot, context);
357
+ }
342
358
 
359
+ #matchBuffer(buffer: string, context: TtsrMatchContext): Rule[] {
343
360
  const matches: Rule[] = [];
344
361
  for (const [name, entry] of this.#rules) {
345
362
  if (!this.#canTrigger(name)) {
@@ -351,7 +368,7 @@ export class TtsrManager {
351
368
  if (!this.#matchesGlobalPaths(entry, context)) {
352
369
  continue;
353
370
  }
354
- if (!this.#matchesCondition(entry, nextBuffer)) {
371
+ if (!this.#matchesCondition(entry, buffer)) {
355
372
  continue;
356
373
  }
357
374