@oh-my-pi/pi-coding-agent 15.11.2 → 15.11.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.
@@ -0,0 +1,32 @@
1
+ /** Subset of the LSP `FormattingOptions` we send. */
2
+ export interface LspFormattingOptions {
3
+ tabSize: number;
4
+ insertSpaces: boolean;
5
+ trimTrailingWhitespace: boolean;
6
+ insertFinalNewline: boolean;
7
+ trimFinalNewlines: boolean;
8
+ }
9
+ interface DetectedIndent {
10
+ tabSize?: number;
11
+ insertSpaces?: boolean;
12
+ }
13
+ /**
14
+ * Sniff `insertSpaces` and the indent unit from `content`.
15
+ *
16
+ * Walks the buffer once: the first indented line decides spaces vs tabs; for
17
+ * space indents, the GCD of all space-indent widths gives the stride (so a
18
+ * 2/4/6 file reports `2`, a 4/8 file reports `4`). Returns `undefined` for any
19
+ * field the content does not pin so a higher-precedence override (editorconfig)
20
+ * can win without being overwritten by sniffing noise.
21
+ */
22
+ export declare function detectIndentFromContent(content: string): DetectedIndent;
23
+ /**
24
+ * Resolve the `FormattingOptions` payload for a `textDocument/formatting` request
25
+ * targeting `filePath` with `content`.
26
+ *
27
+ * The two fields that actually affect on-disk bytes (`tabSize`, `insertSpaces`)
28
+ * are layered: editorconfig wins, then content sniffing, then the fallback.
29
+ * Trim/final-newline flags are static.
30
+ */
31
+ export declare function resolveFormatOptions(filePath: string, content: string): LspFormattingOptions;
32
+ export {};
@@ -75,7 +75,35 @@ export declare class MnemopiSessionState {
75
75
  }>, sourceId: string): Promise<void>;
76
76
  attachSessionListeners(): void;
77
77
  maybeRecallOnAgentStart(): Promise<void>;
78
- dispose(): void;
78
+ /**
79
+ * Drain in-flight fact extraction and run beam consolidation on every owned
80
+ * bank, after capturing the current transcript. Mirrors the manual
81
+ * `/memory enqueue` slash command, but stops short of closing the DBs so
82
+ * callers can keep using the state. {@link dispose} composes this with the
83
+ * close step so normal session shutdown promotes working memory to
84
+ * episodic/gists/graph automatically (see issue #2320).
85
+ *
86
+ * Aliased subagent states share `scoped` (and therefore the actual SQLite
87
+ * banks) with their parent. `consolidate()` deliberately does NOT
88
+ * short-circuit on `aliasOf`: `forceRetainCurrentSession` already guards
89
+ * itself, and an explicit `/memory enqueue` invoked from within a subagent
90
+ * still needs to flush extractions and sleep the parent's shared banks —
91
+ * otherwise enqueue would report success while leaving the subagent's
92
+ * retained memories unconsolidated until the parent eventually shuts down
93
+ * (PR #2327 review).
94
+ */
95
+ consolidate(): Promise<void>;
96
+ /**
97
+ * Release the per-session resources. Defaults to running {@link consolidate}
98
+ * before closing handles so normal session shutdown promotes working memory
99
+ * into long-term storage. Callers that are about to delete the DB files —
100
+ * e.g. `mnemopiBackend.clear` — pass `{ consolidate: false }` to skip the
101
+ * extraction/sleep pass, since spending tokens on memories that will be
102
+ * wiped on the next line is wasted work (PR #2327 review).
103
+ */
104
+ dispose(options?: {
105
+ consolidate?: boolean;
106
+ }): Promise<void>;
79
107
  }
80
108
  export declare function getMnemopiScopedDbPaths(config: MnemopiBackendConfig): readonly string[];
81
109
  export declare function getMnemopiScopedBanks(config: MnemopiBackendConfig): readonly string[];
@@ -135,7 +135,7 @@ export declare function parseSearchPath(filePath: string): ParsedSearchPath;
135
135
  export declare function parseSearchPathPreferringLiteral(filePath: string, cwd: string): Promise<ParsedSearchPath>;
136
136
  export declare function parseFindPattern(pattern: string): ParsedFindPattern;
137
137
  export declare function combineSearchGlobs(prefixGlob?: string, suffixGlob?: string): string | undefined;
138
- export declare function resolveExplicitSearchPaths(pathItems: string[], cwd: string, suffixGlob?: string): Promise<ResolvedMultiSearchPath | undefined>;
138
+ export declare function resolveExplicitSearchPaths(pathItems: string[], cwd: string, suffixGlob?: string, fanOutFileItems?: boolean): Promise<ResolvedMultiSearchPath | undefined>;
139
139
  export declare function resolveExplicitFindPatterns(patternItems: string[], cwd: string): Promise<ResolvedMultiFindPattern | undefined>;
140
140
  /**
141
141
  * Result of partitioning a list of user-supplied paths/globs into entries whose
@@ -176,6 +176,10 @@ export interface ToolScopeOptions {
176
176
  trackImmutableSources?: boolean;
177
177
  /** Honor `exactFilePaths` from {@link resolveExplicitSearchPaths} (search-only). */
178
178
  surfaceExactFilePaths?: boolean;
179
+ /** Fan plain-file entries out into per-target scans instead of folding them
180
+ * into a directory walk's glob union (search-only: the caller must dedupe
181
+ * matches from overlapping targets). */
182
+ fanOutFileTargets?: boolean;
179
183
  /** Extra hint appended to "Path not found" when stat fails and the user supplied multiple paths. */
180
184
  multipathStatHint?: string;
181
185
  /** Calling session's settings — forwarded to the internal-URL router so caller-aware handlers (issue://, pr://) honor it. */
@@ -177,7 +177,7 @@ export declare const stage: {
177
177
  };
178
178
  /** Create a commit with the given message (passed via stdin). */
179
179
  export declare function commit(cwd: string, message: string, options?: CommitOptions): Promise<GitCommandResult>;
180
- /** Push the current branch. */
180
+ /** Push the current branch (branch-scoped: never follows tags). */
181
181
  export declare function push(cwd: string, options?: PushOptions): Promise<void>;
182
182
  /** Checkout a ref. */
183
183
  export declare function checkout(cwd: string, ref: string, signal?: AbortSignal): Promise<void>;
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.11.2",
4
+ "version": "15.11.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,16 +47,16 @@
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.11.2",
51
- "@oh-my-pi/omp-stats": "15.11.2",
52
- "@oh-my-pi/pi-agent-core": "15.11.2",
53
- "@oh-my-pi/pi-ai": "15.11.2",
54
- "@oh-my-pi/pi-catalog": "15.11.2",
55
- "@oh-my-pi/pi-mnemopi": "15.11.2",
56
- "@oh-my-pi/pi-natives": "15.11.2",
57
- "@oh-my-pi/pi-tui": "15.11.2",
58
- "@oh-my-pi/pi-utils": "15.11.2",
59
- "@oh-my-pi/snapcompact": "15.11.2",
50
+ "@oh-my-pi/hashline": "15.11.3",
51
+ "@oh-my-pi/omp-stats": "15.11.3",
52
+ "@oh-my-pi/pi-agent-core": "15.11.3",
53
+ "@oh-my-pi/pi-ai": "15.11.3",
54
+ "@oh-my-pi/pi-catalog": "15.11.3",
55
+ "@oh-my-pi/pi-mnemopi": "15.11.3",
56
+ "@oh-my-pi/pi-natives": "15.11.3",
57
+ "@oh-my-pi/pi-tui": "15.11.3",
58
+ "@oh-my-pi/pi-utils": "15.11.3",
59
+ "@oh-my-pi/snapcompact": "15.11.3",
60
60
  "@opentelemetry/api": "^1.9.1",
61
61
  "@opentelemetry/context-async-hooks": "^2.7.1",
62
62
  "@opentelemetry/exporter-trace-otlp-proto": "^0.218.0",
@@ -4,18 +4,10 @@
4
4
  */
5
5
  import { getOrCreateClient, notifySaved, sendRequest, syncContent } from "../../lsp/client";
6
6
  import { applyTextEditsToString } from "../../lsp/edits";
7
+ import { resolveFormatOptions } from "../../lsp/format-options";
7
8
  import type { Diagnostic, LinterClient, LspClient, ServerConfig, TextEdit } from "../../lsp/types";
8
9
  import { fileToUri } from "../../lsp/utils";
9
10
 
10
- /** Default formatting options for LSP */
11
- const DEFAULT_FORMAT_OPTIONS = {
12
- tabSize: 3,
13
- insertSpaces: true,
14
- trimTrailingWhitespace: true,
15
- insertFinalNewline: true,
16
- trimFinalNewlines: true,
17
- };
18
-
19
11
  /**
20
12
  * LSP-based linter client implementation.
21
13
  * Wraps the existing LSP client infrastructure.
@@ -56,7 +48,7 @@ export class LspLinterClient implements LinterClient {
56
48
  // Request formatting
57
49
  const edits = (await sendRequest(client, "textDocument/formatting", {
58
50
  textDocument: { uri },
59
- options: DEFAULT_FORMAT_OPTIONS,
51
+ options: resolveFormatOptions(filePath, content),
60
52
  })) as TextEdit[] | null;
61
53
 
62
54
  if (!edits || edits.length === 0) {
@@ -0,0 +1,119 @@
1
+ /**
2
+ * Per-file LSP `FormattingOptions` resolution.
3
+ *
4
+ * Replaces the historical hardcoded `{ tabSize: 3, insertSpaces: true }` default
5
+ * that fed every `textDocument/formatting` request — it silently re-indented
6
+ * 2-space YAML (and any LSP-formatted file) on every write/edit (issue #2329).
7
+ *
8
+ * Precedence, highest to lowest:
9
+ * 1. `.editorconfig` in the file's chain (`indent_style`, `indent_size`, `tab_width`).
10
+ * 2. Indent detected from the file content the agent is about to write.
11
+ * 3. Hardcoded fallback — 2 spaces, matching the dominant convention for YAML,
12
+ * JSON, JS/TS, Python (PEP 8 is 4 but most LSP servers honour their own
13
+ * defaults when ours don't disagree), and most config formats. The previous
14
+ * `3` default was an unusual stride that actively damaged every file with
15
+ * a 2/4-space convention.
16
+ */
17
+ import { getEditorConfigFormatting } from "@oh-my-pi/pi-utils";
18
+
19
+ /** Subset of the LSP `FormattingOptions` we send. */
20
+ export interface LspFormattingOptions {
21
+ tabSize: number;
22
+ insertSpaces: boolean;
23
+ trimTrailingWhitespace: boolean;
24
+ insertFinalNewline: boolean;
25
+ trimFinalNewlines: boolean;
26
+ }
27
+
28
+ /** Sensible fallback when neither `.editorconfig` nor file content pins the indent. */
29
+ const FALLBACK_TAB_SIZE = 2;
30
+ const FALLBACK_INSERT_SPACES = true;
31
+
32
+ /** Static flags we always pass — these have no per-file analogue and match common formatter expectations. */
33
+ const TRIM_OPTIONS = {
34
+ trimTrailingWhitespace: true,
35
+ insertFinalNewline: true,
36
+ trimFinalNewlines: true,
37
+ } as const;
38
+
39
+ interface DetectedIndent {
40
+ tabSize?: number;
41
+ insertSpaces?: boolean;
42
+ }
43
+
44
+ /**
45
+ * Sniff `insertSpaces` and the indent unit from `content`.
46
+ *
47
+ * Walks the buffer once: the first indented line decides spaces vs tabs; for
48
+ * space indents, the GCD of all space-indent widths gives the stride (so a
49
+ * 2/4/6 file reports `2`, a 4/8 file reports `4`). Returns `undefined` for any
50
+ * field the content does not pin so a higher-precedence override (editorconfig)
51
+ * can win without being overwritten by sniffing noise.
52
+ */
53
+ export function detectIndentFromContent(content: string): DetectedIndent {
54
+ if (content.length === 0) return {};
55
+
56
+ let insertSpaces: boolean | undefined;
57
+ let unit = 0;
58
+
59
+ // Split is the cheapest reliable line walk on arbitrary text; the
60
+ // per-line regex matches are O(leading whitespace) so total cost is
61
+ // linear in the file's indented prefix bytes.
62
+ for (const line of content.split("\n")) {
63
+ // Skip blank/whitespace-only lines — they carry no indent signal.
64
+ if (line.length === 0 || line.trim().length === 0) continue;
65
+
66
+ const first = line[0];
67
+ if (first !== " " && first !== "\t") continue;
68
+
69
+ if (insertSpaces === undefined) {
70
+ insertSpaces = first === " ";
71
+ }
72
+
73
+ // Tab-indented file: the unit is one tab per level; tabSize is a
74
+ // display concern, leave it to caller defaults / editorconfig.
75
+ if (first === "\t") continue;
76
+
77
+ // Space-indented: count the leading spaces (stop at first tab to avoid
78
+ // mixing). GCD across non-zero widths converges on the stride.
79
+ let n = 0;
80
+ while (n < line.length && line[n] === " ") n++;
81
+ if (n === 0) continue;
82
+ unit = unit === 0 ? n : gcd(unit, n);
83
+ }
84
+
85
+ const result: DetectedIndent = {};
86
+ if (insertSpaces !== undefined) result.insertSpaces = insertSpaces;
87
+ if (unit > 0 && insertSpaces === true) result.tabSize = unit;
88
+ return result;
89
+ }
90
+
91
+ function gcd(a: number, b: number): number {
92
+ let x = a;
93
+ let y = b;
94
+ while (y !== 0) {
95
+ const t = y;
96
+ y = x % y;
97
+ x = t;
98
+ }
99
+ return x;
100
+ }
101
+
102
+ /**
103
+ * Resolve the `FormattingOptions` payload for a `textDocument/formatting` request
104
+ * targeting `filePath` with `content`.
105
+ *
106
+ * The two fields that actually affect on-disk bytes (`tabSize`, `insertSpaces`)
107
+ * are layered: editorconfig wins, then content sniffing, then the fallback.
108
+ * Trim/final-newline flags are static.
109
+ */
110
+ export function resolveFormatOptions(filePath: string, content: string): LspFormattingOptions {
111
+ const fromConfig = getEditorConfigFormatting(filePath);
112
+ const detected = detectIndentFromContent(content);
113
+
114
+ return {
115
+ tabSize: fromConfig.tabSize ?? detected.tabSize ?? FALLBACK_TAB_SIZE,
116
+ insertSpaces: fromConfig.insertSpaces ?? detected.insertSpaces ?? FALLBACK_INSERT_SPACES,
117
+ ...TRIM_OPTIONS,
118
+ };
119
+ }
package/src/lsp/index.ts CHANGED
@@ -39,6 +39,7 @@ import {
39
39
  flattenWorkspaceTextEdits,
40
40
  rangesOverlap,
41
41
  } from "./edits";
42
+ import { resolveFormatOptions } from "./format-options";
42
43
  import { detectLspmux } from "./lspmux";
43
44
  import {
44
45
  type CodeAction,
@@ -779,15 +780,6 @@ export enum FileFormatResult {
779
780
  FORMATTED = "formatted",
780
781
  }
781
782
 
782
- /** Default formatting options for LSP */
783
- const DEFAULT_FORMAT_OPTIONS = {
784
- tabSize: 3,
785
- insertSpaces: true,
786
- trimTrailingWhitespace: true,
787
- insertFinalNewline: true,
788
- trimFinalNewlines: true,
789
- };
790
-
791
783
  /**
792
784
  * Format content using LSP or custom linter client.
793
785
  *
@@ -834,7 +826,7 @@ async function formatContent(
834
826
  "textDocument/formatting",
835
827
  {
836
828
  textDocument: { uri },
837
- options: DEFAULT_FORMAT_OPTIONS,
829
+ options: resolveFormatOptions(absolutePath, content),
838
830
  },
839
831
  signal,
840
832
  )) as TextEdit[] | null;
@@ -82,7 +82,7 @@ export const mnemopiBackend: MemoryBackend = {
82
82
  hasRecalledForFirstTurn: true,
83
83
  }),
84
84
  );
85
- previous?.dispose();
85
+ await previous?.dispose();
86
86
  return;
87
87
  }
88
88
 
@@ -91,7 +91,7 @@ export const mnemopiBackend: MemoryBackend = {
91
91
  await Promise.all([loadMnemopi(), loadMnemopiCore()]);
92
92
  const state = new MnemopiSessionState({ sessionId, config, session });
93
93
  const previous = setMnemopiSessionState(session, state);
94
- previous?.dispose();
94
+ await previous?.dispose();
95
95
  state.attachSessionListeners();
96
96
  } catch (error) {
97
97
  logger.warn("Mnemopi: backend startup failed; memory backend inert.", { error: String(error) });
@@ -115,7 +115,7 @@ export const mnemopiBackend: MemoryBackend = {
115
115
 
116
116
  async clear(agentDir, _cwd, session): Promise<void> {
117
117
  const previous = session ? setMnemopiSessionState(session, undefined) : undefined;
118
- previous?.dispose();
118
+ await previous?.dispose({ consolidate: false });
119
119
  const config = previous?.config ?? (session ? loadMnemopiConfig(session.settings, agentDir) : undefined);
120
120
  if (!config) return;
121
121
  await loadMnemopiCore();
@@ -136,11 +136,7 @@ export const mnemopiBackend: MemoryBackend = {
136
136
  state = new MnemopiSessionState({ sessionId: session.sessionId, config, session });
137
137
  setMnemopiSessionState(session, state);
138
138
  }
139
- await state?.forceRetainCurrentSession();
140
- // Drain the background fact extraction scheduled by the final retain
141
- // before the process can exit, otherwise the last turn's facts are lost.
142
- await state?.memory.flushExtractions();
143
- state?.memory.sleepAllSessions(false);
139
+ await state?.consolidate();
144
140
  } catch (error) {
145
141
  logger.warn("Mnemopi: enqueue failed.", { error: String(error) });
146
142
  }
@@ -370,12 +370,51 @@ export class MnemopiSessionState {
370
370
  }
371
371
  }
372
372
 
373
- dispose(): void {
373
+ /**
374
+ * Drain in-flight fact extraction and run beam consolidation on every owned
375
+ * bank, after capturing the current transcript. Mirrors the manual
376
+ * `/memory enqueue` slash command, but stops short of closing the DBs so
377
+ * callers can keep using the state. {@link dispose} composes this with the
378
+ * close step so normal session shutdown promotes working memory to
379
+ * episodic/gists/graph automatically (see issue #2320).
380
+ *
381
+ * Aliased subagent states share `scoped` (and therefore the actual SQLite
382
+ * banks) with their parent. `consolidate()` deliberately does NOT
383
+ * short-circuit on `aliasOf`: `forceRetainCurrentSession` already guards
384
+ * itself, and an explicit `/memory enqueue` invoked from within a subagent
385
+ * still needs to flush extractions and sleep the parent's shared banks —
386
+ * otherwise enqueue would report success while leaving the subagent's
387
+ * retained memories unconsolidated until the parent eventually shuts down
388
+ * (PR #2327 review).
389
+ */
390
+ async consolidate(): Promise<void> {
391
+ await this.forceRetainCurrentSession();
392
+ for (const memory of this.scoped.owned) {
393
+ await memory.flushExtractions();
394
+ memory.sleepAllSessions(false);
395
+ }
396
+ }
397
+
398
+ /**
399
+ * Release the per-session resources. Defaults to running {@link consolidate}
400
+ * before closing handles so normal session shutdown promotes working memory
401
+ * into long-term storage. Callers that are about to delete the DB files —
402
+ * e.g. `mnemopiBackend.clear` — pass `{ consolidate: false }` to skip the
403
+ * extraction/sleep pass, since spending tokens on memories that will be
404
+ * wiped on the next line is wasted work (PR #2327 review).
405
+ */
406
+ async dispose(options: { consolidate?: boolean } = {}): Promise<void> {
374
407
  this.unsubscribe?.();
375
408
  this.unsubscribe = undefined;
376
- if (!this.aliasOf) {
377
- for (const memory of this.scoped.owned) memory.close();
409
+ if (this.aliasOf) return;
410
+ if (options.consolidate !== false) {
411
+ try {
412
+ await this.consolidate();
413
+ } catch (error) {
414
+ logger.warn("Mnemopi: consolidation on dispose failed.", { error: String(error) });
415
+ }
378
416
  }
417
+ for (const memory of this.scoped.owned) memory.close();
379
418
  }
380
419
  }
381
420
 
@@ -14,7 +14,14 @@ import {
14
14
  import type { CompactionOutcome } from "@oh-my-pi/pi-agent-core/compaction";
15
15
  import type { AssistantMessage, ImageContent, Message, Model, UsageReport } from "@oh-my-pi/pi-ai";
16
16
  import { modelsAreEqual } from "@oh-my-pi/pi-catalog/models";
17
- import type { Component, EditorTheme, LoaderMessageColorFn, OverlayHandle, SlashCommand } from "@oh-my-pi/pi-tui";
17
+ import type {
18
+ Component,
19
+ EditorTheme,
20
+ LoaderMessageColorFn,
21
+ NativeScrollbackLiveRegion,
22
+ OverlayHandle,
23
+ SlashCommand,
24
+ } from "@oh-my-pi/pi-tui";
18
25
  import {
19
26
  Container,
20
27
  clearRenderCache,
@@ -257,6 +264,19 @@ export interface InteractiveModeOptions {
257
264
  initialMessages?: string[];
258
265
  }
259
266
 
267
+ /**
268
+ * Hosts the working loader and transient status rows. While anything is
269
+ * mounted, every row is live: report a seam at 0 so the engine never commits
270
+ * a still-animating loader to native scrollback (stale `Working…` rows would
271
+ * otherwise pile up above the live one). The transcript's own seam, when
272
+ * present, sits higher and wins (topmost-seam merge in TUI.render).
273
+ */
274
+ class StatusContainer extends Container implements NativeScrollbackLiveRegion {
275
+ getNativeScrollbackLiveRegionStart(): number | undefined {
276
+ return this.children.length > 0 ? 0 : undefined;
277
+ }
278
+ }
279
+
260
280
  export class InteractiveMode implements InteractiveModeContext {
261
281
  session: AgentSession;
262
282
  sessionManager: SessionManager;
@@ -418,7 +438,7 @@ export class InteractiveMode implements InteractiveModeContext {
418
438
  setTerminalTextSizing(settings.get("tui.textSizing") && TERMINAL.textSizing);
419
439
  this.chatContainer = new TranscriptContainer();
420
440
  this.pendingMessagesContainer = new Container();
421
- this.statusContainer = new Container();
441
+ this.statusContainer = new StatusContainer();
422
442
  this.todoContainer = new Container();
423
443
  this.btwContainer = new Container();
424
444
  this.omfgContainer = new Container();
@@ -3213,7 +3213,7 @@ export class AgentSession {
3213
3213
  this.setHindsightSessionState(undefined);
3214
3214
  hindsightState?.dispose();
3215
3215
  const mnemopiState = setMnemopiSessionState(this, undefined);
3216
- mnemopiState?.dispose();
3216
+ await mnemopiState?.dispose();
3217
3217
  this.#disconnectFromAgent();
3218
3218
  if (this.#unsubscribeAppendOnly) {
3219
3219
  this.#unsubscribeAppendOnly();
@@ -729,6 +729,7 @@ async function resolveSearchPathItems(
729
729
  pathItems: string[],
730
730
  cwd: string,
731
731
  suffixGlob?: string,
732
+ fanOutFileItems = false,
732
733
  ): Promise<ResolvedMultiSearchPath | undefined> {
733
734
  if (pathItems.length < 1) {
734
735
  return undefined;
@@ -760,14 +761,27 @@ async function resolveSearchPathItems(
760
761
  }
761
762
  return relativeBasePath === "." ? path.basename(item.absoluteBasePath) : relativeBasePath;
762
763
  });
763
- const rootPath = path.parse(commonBasePath).root;
764
- const isDegenerateRoot = commonBasePath === rootPath && parsedItems.length > 1;
765
- const targets = isDegenerateRoot
766
- ? parsedItems.map(item => ({
767
- basePath: item.absoluteBasePath,
768
- glob: item.parsedPath.glob ? combineSearchGlobs(item.parsedPath.glob, suffixGlob) : suffixGlob,
769
- }))
770
- : undefined;
764
+ // A single walk rooted at the common ancestor is only safe when that
765
+ // ancestor is itself one of the requested scopes (e.g. `.` + `src/foo.ts`):
766
+ // the walk then covers exactly what the caller asked for. When the common
767
+ // ancestor is an unrequested parent (`.` + `~/.gitconfig` → `$HOME`, or
768
+ // disjoint trees → `/`), a collapsed walk traverses every unrelated sibling
769
+ // under it — fan out into per-item targets so each scan stays bounded to a
770
+ // requested path.
771
+ const commonIsRequestedScope = parsedItems.some(item => item.absoluteBasePath === commonBasePath);
772
+ // Walkers prune `.git` unconditionally and honor gitignore, so a plain-file
773
+ // item folded into a directory walk's glob union (`.` + `.git/config`) can
774
+ // silently never match. Callers that dedupe overlapping results opt in via
775
+ // `fanOutFileItems` to get explicit file targets, which bypass the walker.
776
+ const demotesFileItem =
777
+ fanOutFileItems && !allExactFiles && parsedItems.some(item => !item.parsedPath.glob && item.stat.isFile());
778
+ const targets =
779
+ parsedItems.length > 1 && (!commonIsRequestedScope || demotesFileItem)
780
+ ? parsedItems.map(item => ({
781
+ basePath: item.absoluteBasePath,
782
+ glob: item.parsedPath.glob ? combineSearchGlobs(item.parsedPath.glob, suffixGlob) : suffixGlob,
783
+ }))
784
+ : undefined;
771
785
 
772
786
  return {
773
787
  basePath: commonBasePath,
@@ -782,8 +796,9 @@ export async function resolveExplicitSearchPaths(
782
796
  pathItems: string[],
783
797
  cwd: string,
784
798
  suffixGlob?: string,
799
+ fanOutFileItems = false,
785
800
  ): Promise<ResolvedMultiSearchPath | undefined> {
786
- return resolveSearchPathItems([...new Set(pathItems)], cwd, suffixGlob);
801
+ return resolveSearchPathItems([...new Set(pathItems)], cwd, suffixGlob, fanOutFileItems);
787
802
  }
788
803
 
789
804
  async function resolveFindPatternItems(
@@ -928,6 +943,10 @@ export interface ToolScopeOptions {
928
943
  trackImmutableSources?: boolean;
929
944
  /** Honor `exactFilePaths` from {@link resolveExplicitSearchPaths} (search-only). */
930
945
  surfaceExactFilePaths?: boolean;
946
+ /** Fan plain-file entries out into per-target scans instead of folding them
947
+ * into a directory walk's glob union (search-only: the caller must dedupe
948
+ * matches from overlapping targets). */
949
+ fanOutFileTargets?: boolean;
931
950
  /** Extra hint appended to "Path not found" when stat fails and the user supplied multiple paths. */
932
951
  multipathStatHint?: string;
933
952
  /** Calling session's settings — forwarded to the internal-URL router so caller-aware handlers (issue://, pr://) honor it. */
@@ -1024,7 +1043,12 @@ export async function resolveToolSearchScope(opts: ToolScopeOptions): Promise<To
1024
1043
  globFilter = parsedPath.glob;
1025
1044
  scopePath = formatPathRelativeToCwd(searchPath, cwd);
1026
1045
  } else {
1027
- const multiSearchPath = await resolveExplicitSearchPaths(effectivePaths, cwd);
1046
+ const multiSearchPath = await resolveExplicitSearchPaths(
1047
+ effectivePaths,
1048
+ cwd,
1049
+ undefined,
1050
+ opts.fanOutFileTargets === true,
1051
+ );
1028
1052
  if (!multiSearchPath) {
1029
1053
  throw new ToolError("`paths` must contain at least one path or glob");
1030
1054
  }
@@ -795,6 +795,7 @@ export class SearchTool implements AgentTool<typeof searchSchema, SearchToolDeta
795
795
  internalUrlAction: "search",
796
796
  trackImmutableSources: true,
797
797
  surfaceExactFilePaths: true,
798
+ fanOutFileTargets: true,
798
799
  multipathStatHint: " (`paths` entries must each exist relative to cwd)",
799
800
  settings: this.session.settings,
800
801
  signal,
@@ -863,6 +864,7 @@ export class SearchTool implements AgentTool<typeof searchSchema, SearchToolDeta
863
864
  if (searchablePaths.length > 0) {
864
865
  if (exactFilePaths || multiTargets) {
865
866
  const matches: GrepMatch[] = [];
867
+ const seenMatchKeys = new Set<string>();
866
868
  let limitReached = false;
867
869
  let totalMatches = 0;
868
870
  let filesSearched = 0;
@@ -900,6 +902,15 @@ export class SearchTool implements AgentTool<typeof searchSchema, SearchToolDeta
900
902
  filesSearched += targetResult.filesSearched;
901
903
  for (const match of targetResult.matches) {
902
904
  const absolute = path.resolve(target.basePath, match.path);
905
+ // Overlapping targets (a directory plus a file nested
906
+ // inside it) surface the same physical line twice;
907
+ // keep the first occurrence.
908
+ const matchKey = `${absolute}\0${match.lineNumber}`;
909
+ if (seenMatchKeys.has(matchKey)) {
910
+ totalMatches = Math.max(0, totalMatches - 1);
911
+ continue;
912
+ }
913
+ seenMatchKeys.add(matchKey);
903
914
  const rebased = path.relative(searchPath, absolute).replace(/\\/g, "/");
904
915
  matches.push({ ...match, path: rebased });
905
916
  }
package/src/utils/git.ts CHANGED
@@ -1126,9 +1126,14 @@ export async function commit(cwd: string, message: string, options: CommitOption
1126
1126
  return runChecked(cwd, args, { signal: options.signal, stdin: message });
1127
1127
  }
1128
1128
 
1129
- /** Push the current branch. */
1129
+ /** Push the current branch (branch-scoped: never follows tags). */
1130
1130
  export async function push(cwd: string, options: PushOptions = {}): Promise<void> {
1131
- const args = ["push"];
1131
+ // `--no-follow-tags` overrides a user's `push.followTags = true`, which
1132
+ // would otherwise ride every reachable annotated tag along with the
1133
+ // branch — rejected refs ("permission denied") on remotes the user
1134
+ // cannot tag (e.g. PR-head forks), failing the call after the branch
1135
+ // itself already updated. Tool pushes push exactly the named refspec.
1136
+ const args = ["push", "--no-follow-tags"];
1132
1137
  if (options.forceWithLease) args.push("--force-with-lease");
1133
1138
  if (options.remote) args.push(options.remote);
1134
1139
  if (options.refspec) args.push(options.refspec);