@oh-my-pi/pi-coding-agent 13.12.7 → 13.12.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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,34 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [13.12.8] - 2026-03-16
6
+
7
+ ### Breaking Changes
8
+
9
+ - Changed `SessionManager.create()` to require explicit `sessionDir` parameter instead of optional—callers must now pass `SessionManager.getDefaultSessionDir(cwd)` to use default behavior
10
+ - Changed `SessionManager.continueRecent()` to require explicit `sessionDir` parameter instead of optional—callers must now pass `SessionManager.getDefaultSessionDir(cwd)` to use default behavior
11
+ - Changed `SessionManager.forkFrom()` to require explicit `sessionDir` parameter instead of optional—callers must now pass `SessionManager.getDefaultSessionDir(cwd)` to use default behavior
12
+ - Changed `SessionManager.list()` signature to accept only `sessionDir` parameter instead of `cwd` and optional `sessionDir`—callers must now compute and pass the session directory explicitly
13
+
14
+ ### Added
15
+
16
+ - Added `SessionManager.getDefaultSessionDir()` static method to explicitly resolve the canonical default session directory for a working directory
17
+ - Added support for quoted paths in grep, ast_grep, and find tools to handle directory names with spaces
18
+ - Added `normalizePathLikeInput` utility function to consistently handle quoted and whitespace-trimmed path inputs
19
+
20
+ ### Changed
21
+
22
+ - Made `sessionDir` parameter optional in `SessionManager.create()`, `SessionManager.continueRecent()`, and `SessionManager.forkFrom()`—callers can now omit it to use the default session directory
23
+ - Changed `SessionManager.list()` signature to accept `cwd` as the first parameter instead of requiring an explicit `sessionDir`—callers can now omit `sessionDir` to use the default for the given working directory
24
+ - Updated `SessionManager.getDefaultSessionDir()` to accept optional `agentDir` parameter for computing session directories within a custom agent root
25
+ - Improved status line path display to strip display roots using canonical path resolution, correctly handling symlink aliases to home and Projects directories
26
+ - Improved error messaging in ast_grep when no matches are found with parse errors, now suggests narrowing `path`/`glob` or setting `lang` to resolve mis-scoped queries
27
+
28
+ ### Fixed
29
+
30
+ - Fixed SDK-created default sessions to honor the configured `agentDir` for session storage, preventing tests from writing stray session directories into the real `~/.omp/agent/sessions` root
31
+ - Fixed session directory resolution to correctly handle symlink-equivalent paths, ensuring aliased home and temp directories resolve to the same session storage location as their real targets
32
+
5
33
  ## [13.12.7] - 2026-03-16
6
34
  ### Changed
7
35
 
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": "13.12.7",
4
+ "version": "13.12.8",
5
5
  "description": "Coding agent CLI with read, bash, edit, write tools and session management",
6
6
  "homepage": "https://github.com/can1357/oh-my-pi",
7
7
  "author": "Can Boluk",
@@ -41,12 +41,12 @@
41
41
  },
42
42
  "dependencies": {
43
43
  "@mozilla/readability": "^0.6",
44
- "@oh-my-pi/omp-stats": "13.12.7",
45
- "@oh-my-pi/pi-agent-core": "13.12.7",
46
- "@oh-my-pi/pi-ai": "13.12.7",
47
- "@oh-my-pi/pi-natives": "13.12.7",
48
- "@oh-my-pi/pi-tui": "13.12.7",
49
- "@oh-my-pi/pi-utils": "13.12.7",
44
+ "@oh-my-pi/omp-stats": "13.12.8",
45
+ "@oh-my-pi/pi-agent-core": "13.12.8",
46
+ "@oh-my-pi/pi-ai": "13.12.8",
47
+ "@oh-my-pi/pi-natives": "13.12.8",
48
+ "@oh-my-pi/pi-tui": "13.12.8",
49
+ "@oh-my-pi/pi-utils": "13.12.8",
50
50
  "@sinclair/typebox": "^0.34",
51
51
  "@xterm/headless": "^6.0",
52
52
  "ajv": "^8.18",
@@ -264,10 +264,7 @@ export class KeybindingsManager {
264
264
  * Get display string for an action.
265
265
  */
266
266
  getDisplayString(action: AppAction): string {
267
- const keys = this.getKeys(action);
268
- if (keys.length === 0) return "";
269
- if (keys.length === 1) return keys[0]!;
270
- return keys.join("/");
267
+ return formatKeyHints(this.getKeys(action));
271
268
  }
272
269
 
273
270
  /**
@@ -1,7 +1,8 @@
1
1
  import * as os from "node:os";
2
+ import * as path from "node:path";
2
3
  import { ThinkingLevel } from "@oh-my-pi/pi-agent-core";
3
4
  import { TERMINAL } from "@oh-my-pi/pi-tui";
4
- import { formatDuration, formatNumber, getProjectDir } from "@oh-my-pi/pi-utils";
5
+ import { formatDuration, formatNumber, getProjectDir, relativePathWithinRoot } from "@oh-my-pi/pi-utils";
5
6
  import { theme } from "../../../modes/theme/theme";
6
7
  import { shortenPath } from "../../../tools/render-utils";
7
8
  import { getContextUsageLevel, getContextUsageThemeColor } from "./context-thresholds";
@@ -17,6 +18,14 @@ function withIcon(icon: string, text: string): string {
17
18
  return icon ? `${icon} ${text}` : text;
18
19
  }
19
20
 
21
+ function stripDisplayRoot(pwd: string): string {
22
+ for (const root of ["/work", path.join(os.homedir(), "Projects")]) {
23
+ const relative = relativePathWithinRoot(root, pwd);
24
+ if (relative) return relative;
25
+ }
26
+ return pwd;
27
+ }
28
+
20
29
  function normalizePremiumRequests(value: number): number {
21
30
  return Math.round((value + Number.EPSILON) * 100) / 100;
22
31
  }
@@ -87,13 +96,12 @@ const pathSegment: StatusLineSegment = {
87
96
 
88
97
  let pwd = getProjectDir();
89
98
 
99
+ if (opts.stripWorkPrefix !== false) {
100
+ pwd = stripDisplayRoot(pwd);
101
+ }
90
102
  if (opts.abbreviate !== false) {
91
103
  pwd = shortenPath(pwd);
92
104
  }
93
- if (opts.stripWorkPrefix !== false) {
94
- if (pwd.startsWith("/work/")) pwd = pwd.slice(6);
95
- else if (pwd.startsWith("~/Projects/")) pwd = pwd.slice(11);
96
- }
97
105
 
98
106
  const maxLen = opts.maxLength ?? 40;
99
107
  if (pwd.length > maxLen) {
@@ -24,6 +24,7 @@ import { DynamicBorder } from "../../modes/components/dynamic-border";
24
24
  import { PythonExecutionComponent } from "../../modes/components/python-execution";
25
25
  import { getMarkdownTheme, getSymbolTheme, theme } from "../../modes/theme/theme";
26
26
  import type { InteractiveModeContext } from "../../modes/types";
27
+ import { buildHotkeysMarkdown } from "../../modes/utils/hotkeys-markdown";
27
28
  import type { AsyncJobSnapshotItem } from "../../session/agent-session";
28
29
  import type { AuthStorage } from "../../session/auth-storage";
29
30
  import { outputMeta } from "../../tools/output-meta";
@@ -509,52 +510,13 @@ export class CommandController {
509
510
  const sttKey = this.ctx.keybindings.getDisplayString("toggleSTT") || "Alt+H";
510
511
  const copyLineKey = this.ctx.keybindings.getDisplayString("copyLine") || "Alt+Shift+L";
511
512
  const copyPromptKey = this.ctx.keybindings.getDisplayString("copyPrompt") || "Alt+Shift+C";
512
- const hotkeys = `
513
- **Navigation**
514
- | Key | Action |
515
- |-----|--------|
516
- | \`Arrow keys\` | Move cursor / browse history (Up when empty) |
517
- | \`Option+Left/Right\` | Move by word |
518
- | \`Ctrl+A\` / \`Home\` / \`Cmd+Left\` | Start of line |
519
- | \`Ctrl+E\` / \`End\` / \`Cmd+Right\` | End of line |
520
-
521
- **Editing**
522
- | Key | Action |
523
- |-----|--------|
524
- | \`Enter\` | Send message |
525
- | \`Shift+Enter\` / \`Alt+Enter\` | New line |
526
- | \`Ctrl+W\` / \`Option+Backspace\` | Delete word backwards |
527
- | \`Ctrl+U\` | Delete to start of line |
528
- | \`Ctrl+K\` | Delete to end of line |
529
- | \`${copyLineKey}\` | Copy current line |
530
- | \`${copyPromptKey}\` | Copy whole prompt |
531
-
532
- **Other**
533
- | Key | Action |
534
- |-----|--------|
535
- | \`Tab\` | Path completion / accept autocomplete |
536
- | \`Escape\` | Cancel autocomplete / abort streaming |
537
- | \`Ctrl+C\` | Clear editor (first) / exit (second) |
538
- | \`Ctrl+D\` | Exit (when editor is empty) |
539
- | \`Ctrl+Z\` | Suspend to background |
540
- | \`Shift+Tab\` | Cycle thinking level |
541
- | \`Ctrl+P\` | Cycle role models (slow/default/smol) |
542
- | \`Shift+Ctrl+P\` | Cycle role models (temporary) |
543
- | \`Alt+P\` | Select model (temporary) |
544
- | \`Ctrl+L\` | Select model (set roles) |
545
- | \`${planModeKey}\` | Toggle plan mode |
546
- | \`Ctrl+R\` | Search prompt history |
547
- | \`${expandToolsKey}\` | Toggle tool output expansion |
548
- | \`Ctrl+T\` | Toggle todo list expansion |
549
- | \`Ctrl+G\` | Edit message in external editor |
550
- | \`${sttKey}\` | Toggle speech-to-text recording |
551
- | \`#\` | Open prompt actions |
552
- | \`/\` | Slash commands |
553
- | \`!\` | Run bash command |
554
- | \`!!\` | Run bash command (excluded from context) |
555
- | \`$\` | Run Python in shared kernel |
556
- | \`$$\` | Run Python (excluded from context) |
557
- `;
513
+ const hotkeys = buildHotkeysMarkdown({
514
+ expandToolsKey,
515
+ planModeKey,
516
+ sttKey,
517
+ copyLineKey,
518
+ copyPromptKey,
519
+ });
558
520
  this.ctx.chatContainer.addChild(new Spacer(1));
559
521
  this.ctx.chatContainer.addChild(new DynamicBorder());
560
522
  this.ctx.chatContainer.addChild(new Text(theme.bold(theme.fg("accent", "Keyboard Shortcuts")), 1, 0));
@@ -535,6 +535,7 @@ export class InputController {
535
535
  keybindings: this.ctx.keybindings,
536
536
  copyCurrentLine: () => this.handleCopyCurrentLine(),
537
537
  copyPrompt: () => this.handleCopyPrompt(),
538
+ undo: prefix => this.ctx.editor.undoPastTransientText(prefix),
538
539
  moveCursorToMessageEnd: () => this.ctx.editor.moveToMessageEnd(),
539
540
  moveCursorToMessageStart: () => this.ctx.editor.moveToMessageStart(),
540
541
  moveCursorToLineStart: () => this.ctx.editor.moveToLineStart(),
@@ -12,12 +12,12 @@ interface PromptActionDefinition {
12
12
  label: string;
13
13
  description: string;
14
14
  keywords: string[];
15
- execute: () => void;
15
+ execute: (prefix: string) => void;
16
16
  }
17
17
 
18
18
  interface PromptActionAutocompleteItem extends AutocompleteItem {
19
19
  actionId: string;
20
- execute: () => void;
20
+ execute: (prefix: string) => void;
21
21
  }
22
22
 
23
23
  interface PromptActionAutocompleteOptions {
@@ -26,6 +26,7 @@ interface PromptActionAutocompleteOptions {
26
26
  keybindings: KeybindingsManager;
27
27
  copyCurrentLine: () => void;
28
28
  copyPrompt: () => void;
29
+ undo: (prefix: string) => void;
29
30
  moveCursorToMessageEnd: () => void;
30
31
  moveCursorToMessageStart: () => void;
31
32
  moveCursorToLineStart: () => void;
@@ -141,6 +142,14 @@ export class PromptActionAutocompleteProvider implements AutocompleteProvider {
141
142
  onApplied?: () => void;
142
143
  } {
143
144
  if (prefix.startsWith("#") && isPromptActionItem(item)) {
145
+ if (item.actionId === "undo") {
146
+ return {
147
+ lines,
148
+ cursorLine,
149
+ cursorCol,
150
+ onApplied: () => item.execute(prefix),
151
+ };
152
+ }
144
153
  const currentLine = lines[cursorLine] || "";
145
154
  const beforePrefix = currentLine.slice(0, cursorCol - prefix.length);
146
155
  const afterCursor = currentLine.slice(cursorCol);
@@ -150,7 +159,7 @@ export class PromptActionAutocompleteProvider implements AutocompleteProvider {
150
159
  lines: newLines,
151
160
  cursorLine,
152
161
  cursorCol: beforePrefix.length,
153
- onApplied: item.execute,
162
+ onApplied: () => item.execute(prefix),
154
163
  };
155
164
  }
156
165
 
@@ -181,6 +190,13 @@ export function createPromptActionAutocompleteProvider(
181
190
  keywords: ["copy", "prompt", "clipboard", "message"],
182
191
  execute: options.copyPrompt,
183
192
  },
193
+ {
194
+ id: "undo",
195
+ label: "Undo",
196
+ description: formatKeyHints(editorKeybindings.getKeys("undo")),
197
+ keywords: ["undo", "revert", "edit", "history"],
198
+ execute: options.undo,
199
+ },
184
200
  {
185
201
  id: "cursor-message-end",
186
202
  label: "Move cursor to end of message",
@@ -0,0 +1,57 @@
1
+ export interface HotkeysMarkdownBindings {
2
+ expandToolsKey: string;
3
+ planModeKey: string;
4
+ sttKey: string;
5
+ copyLineKey: string;
6
+ copyPromptKey: string;
7
+ }
8
+
9
+ export function buildHotkeysMarkdown(bindings: HotkeysMarkdownBindings): string {
10
+ const { expandToolsKey, planModeKey, sttKey, copyLineKey, copyPromptKey } = bindings;
11
+ return [
12
+ "**Navigation**",
13
+ "| Key | Action |",
14
+ "|-----|--------|",
15
+ "| `Arrow keys` | Move cursor / browse history (Up when empty) |",
16
+ "| `Option+Left/Right` | Move by word |",
17
+ "| `Ctrl+A` / `Home` / `Cmd+Left` | Start of line |",
18
+ "| `Ctrl+E` / `End` / `Cmd+Right` | End of line |",
19
+ "",
20
+ "**Editing**",
21
+ "| Key | Action |",
22
+ "|-----|--------|",
23
+ "| `Enter` | Send message |",
24
+ "| `Shift+Enter` / `Alt+Enter` | New line |",
25
+ "| `Ctrl+W` / `Option+Backspace` | Delete word backwards |",
26
+ "| `Ctrl+U` | Delete to start of line |",
27
+ "| `Ctrl+K` | Delete to end of line |",
28
+ `| \`${copyLineKey}\` | Copy current line |`,
29
+ `| \`${copyPromptKey}\` | Copy whole prompt |`,
30
+ "",
31
+ "**Other**",
32
+ "| Key | Action |",
33
+ "|-----|--------|",
34
+ "| `Tab` | Path completion / accept autocomplete |",
35
+ "| `Escape` | Cancel autocomplete / abort streaming |",
36
+ "| `Ctrl+C` | Clear editor (first) / exit (second) |",
37
+ "| `Ctrl+D` | Exit (when editor is empty) |",
38
+ "| `Ctrl+Z` | Suspend to background |",
39
+ "| `Shift+Tab` | Cycle thinking level |",
40
+ "| `Ctrl+P` | Cycle role models (slow/default/smol) |",
41
+ "| `Shift+Ctrl+P` | Cycle role models (temporary) |",
42
+ "| `Alt+P` | Select model (temporary) |",
43
+ "| `Ctrl+L` | Select model (set roles) |",
44
+ `| \`${planModeKey}\` | Toggle plan mode |`,
45
+ "| `Ctrl+R` | Search prompt history |",
46
+ `| \`${expandToolsKey}\` | Toggle tool output expansion |`,
47
+ "| `Ctrl+T` | Toggle todo list expansion |",
48
+ "| `Ctrl+G` | Edit message in external editor |",
49
+ `| \`${sttKey}\` | Toggle speech-to-text recording |`,
50
+ "| `#` | Open prompt actions |",
51
+ "| `/` | Slash commands |",
52
+ "| `!` | Run bash command |",
53
+ "| `!!` | Run bash command (excluded from context) |",
54
+ "| `$` | Run Python in shared kernel |",
55
+ "| `$$` | Run Python (excluded from context) |",
56
+ ].join("\n");
57
+ }
package/src/sdk.ts CHANGED
@@ -191,7 +191,7 @@ export interface CreateAgentSessionOptions {
191
191
  /** Parent task ID prefix for nested artifact naming (e.g., "6-Extensions") */
192
192
  parentTaskPrefix?: string;
193
193
 
194
- /** Session manager. Default: SessionManager.create(cwd) */
194
+ /** Session manager. Default: session stored under the configured agentDir sessions root */
195
195
  sessionManager?: SessionManager;
196
196
 
197
197
  /** Settings instance. Default: Settings.init({ cwd, agentDir }) */
@@ -656,7 +656,11 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
656
656
  setPreferredImageProvider(imageProvider);
657
657
  }
658
658
 
659
- const sessionManager = options.sessionManager ?? logger.time("sessionManager", SessionManager.create, cwd);
659
+ const sessionManager =
660
+ options.sessionManager ??
661
+ logger.time("sessionManager", () =>
662
+ SessionManager.create(cwd, SessionManager.getDefaultSessionDir(cwd, agentDir)),
663
+ );
660
664
  const sessionId = sessionManager.getSessionId();
661
665
  const modelApiKeyAvailability = new Map<string, boolean>();
662
666
  const getModelAvailabilityKey = (candidate: Model): string =>
@@ -21,6 +21,8 @@ import {
21
21
  isEnoent,
22
22
  logger,
23
23
  parseJsonlLenient,
24
+ pathIsWithin,
25
+ resolveEquivalentPath,
24
26
  Snowflake,
25
27
  toError,
26
28
  } from "@oh-my-pi/pi-utils";
@@ -345,7 +347,7 @@ export function migrateSessionEntries(entries: FileEntry[]): void {
345
347
  migrateToCurrentVersion(entries);
346
348
  }
347
349
 
348
- let sessionDirsMigrated = false;
350
+ const migratedSessionRoots = new Set<string>();
349
351
 
350
352
  /**
351
353
  * Merge or rename a legacy session directory into its canonical target.
@@ -375,29 +377,36 @@ function encodeLegacyAbsoluteSessionDirName(cwd: string): string {
375
377
  return `--${resolvedCwd.replace(/^[/\\]/, "").replace(/[/\\:]/g, "-")}--`;
376
378
  }
377
379
 
378
- function pathIsWithin(root: string, candidate: string): boolean {
379
- const relative = path.relative(root, candidate);
380
- return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
381
- }
382
-
383
380
  function encodeRelativeSessionDirName(prefix: string, root: string, cwd: string): string {
384
381
  const relative = path.relative(root, cwd).replace(/[/\\:]/g, "-");
385
- return relative ? `${prefix}-${relative}` : prefix;
382
+ return relative ? (prefix.endsWith("-") ? `${prefix}${relative}` : `${prefix}-${relative}`) : prefix;
383
+ }
384
+
385
+ function getDefaultSessionDirName(cwd: string): { encodedDirName: string; resolvedCwd: string } {
386
+ const resolvedCwd = path.resolve(cwd);
387
+ const canonicalCwd = resolveEquivalentPath(resolvedCwd);
388
+ const home = resolveEquivalentPath(os.homedir());
389
+ const tempRoot = resolveEquivalentPath(os.tmpdir());
390
+ const encodedDirName = pathIsWithin(home, canonicalCwd)
391
+ ? encodeRelativeSessionDirName("-", home, canonicalCwd)
392
+ : pathIsWithin(tempRoot, canonicalCwd)
393
+ ? encodeRelativeSessionDirName("-tmp", tempRoot, canonicalCwd)
394
+ : encodeLegacyAbsoluteSessionDirName(canonicalCwd);
395
+ return { encodedDirName, resolvedCwd };
386
396
  }
387
397
 
388
398
  /**
389
399
  * Migrate old `--<home-encoded>-*--` session dirs to the new `-*` format.
390
- * Runs once on first access, best-effort.
400
+ * Runs once per sessions root on first access, best-effort.
391
401
  */
392
- function migrateHomeSessionDirs(): void {
393
- if (sessionDirsMigrated) return;
394
- sessionDirsMigrated = true;
402
+ function migrateHomeSessionDirs(sessionsRoot: string): void {
403
+ if (migratedSessionRoots.has(sessionsRoot)) return;
404
+ migratedSessionRoots.add(sessionsRoot);
395
405
 
396
406
  const home = os.homedir();
397
407
  const homeEncoded = home.replace(/^[/\\]/, "").replace(/[/\\:]/g, "-");
398
408
  const oldPrefix = `--${homeEncoded}-`;
399
409
  const oldExact = `--${homeEncoded}--`;
400
- const sessionsRoot = getSessionsDir();
401
410
 
402
411
  let entries: string[];
403
412
  try {
@@ -428,8 +437,8 @@ function migrateHomeSessionDirs(): void {
428
437
  }
429
438
  }
430
439
 
431
- function migrateLegacyAbsoluteSessionDir(cwd: string, sessionDir: string): void {
432
- const legacyDir = path.join(getSessionsDir(), encodeLegacyAbsoluteSessionDirName(cwd));
440
+ function migrateLegacyAbsoluteSessionDir(cwd: string, sessionDir: string, sessionsRoot: string): void {
441
+ const legacyDir = path.join(sessionsRoot, encodeLegacyAbsoluteSessionDirName(cwd));
433
442
  if (legacyDir === sessionDir || !fs.existsSync(legacyDir)) return;
434
443
 
435
444
  try {
@@ -439,6 +448,15 @@ function migrateLegacyAbsoluteSessionDir(cwd: string, sessionDir: string): void
439
448
  }
440
449
  }
441
450
 
451
+ function resolveManagedSessionRoot(sessionDir: string, cwd: string): string | undefined {
452
+ const currentDirName = path.basename(sessionDir);
453
+ const { encodedDirName } = getDefaultSessionDirName(cwd);
454
+ if (currentDirName !== encodedDirName && currentDirName !== encodeLegacyAbsoluteSessionDirName(cwd)) {
455
+ return undefined;
456
+ }
457
+ return path.dirname(sessionDir);
458
+ }
459
+
442
460
  /** Exported for compaction.test.ts */
443
461
  export function parseSessionEntries(content: string): FileEntry[] {
444
462
  return parseJsonlLenient<FileEntry>(content);
@@ -633,34 +651,20 @@ export function buildSessionContext(
633
651
  return { messages, thinkingLevel, serviceTier, models, injectedTtsrRules, mode, modeData };
634
652
  }
635
653
 
636
- /**
637
- * Encode a cwd into a safe directory name for session storage.
638
- * Home-relative paths use single-dash format: `/Users/x/Projects/pi` → `-Projects-pi`
639
- * Temp-root paths use `-tmp-` prefixes: `/tmp/foo` → `-tmp-foo`
640
- * Other absolute paths keep the legacy double-dash format for compatibility.
641
- */
642
- function encodeSessionDirName(cwd: string): string {
643
- const resolvedCwd = path.resolve(cwd);
644
- const home = path.resolve(os.homedir());
645
- if (pathIsWithin(home, resolvedCwd)) {
646
- return encodeRelativeSessionDirName("-", home, resolvedCwd);
647
- }
648
- const tempRoot = path.resolve(os.tmpdir());
649
- if (pathIsWithin(tempRoot, resolvedCwd)) {
650
- return encodeRelativeSessionDirName("-tmp", tempRoot, resolvedCwd);
651
- }
652
- return encodeLegacyAbsoluteSessionDirName(resolvedCwd);
653
- }
654
-
655
654
  /**
656
655
  * Compute the default session directory for a cwd.
657
- * Encodes cwd into a safe directory name under ~/.omp/agent/sessions/.
656
+ * Classifies cwd by canonical location so symlink/alias paths resolve to the
657
+ * same home-relative or temp-root directory names as their real targets.
658
658
  */
659
- function getDefaultSessionDir(cwd: string, storage: SessionStorage): string {
660
- const resolvedCwd = path.resolve(cwd);
661
- migrateHomeSessionDirs();
662
- const sessionDir = path.join(getSessionsDir(), encodeSessionDirName(resolvedCwd));
663
- migrateLegacyAbsoluteSessionDir(resolvedCwd, sessionDir);
659
+ function computeDefaultSessionDir(
660
+ cwd: string,
661
+ storage: SessionStorage,
662
+ sessionsRoot: string = getSessionsDir(),
663
+ ): string {
664
+ const { encodedDirName, resolvedCwd } = getDefaultSessionDirName(cwd);
665
+ migrateHomeSessionDirs(sessionsRoot);
666
+ const sessionDir = path.join(sessionsRoot, encodedDirName);
667
+ migrateLegacyAbsoluteSessionDir(resolvedCwd, sessionDir, sessionsRoot);
664
668
  storage.ensureDirSync(sessionDir);
665
669
  return sessionDir;
666
670
  }
@@ -1322,7 +1326,8 @@ export async function resolveResumableSession(
1322
1326
  sessionDir?: string,
1323
1327
  storage: SessionStorage = new FileSessionStorage(),
1324
1328
  ): Promise<ResolvedSessionMatch | undefined> {
1325
- const localSessions = await SessionManager.list(cwd, sessionDir, storage);
1329
+ const localSessionDir = sessionDir ?? SessionManager.getDefaultSessionDir(cwd, undefined, storage);
1330
+ const localSessions = await SessionManager.list(cwd, localSessionDir, storage);
1326
1331
  const localMatch = localSessions.find(session => sessionMatchesResumeArg(session, sessionArg));
1327
1332
  if (localMatch) {
1328
1333
  return { session: localMatch, scope: "local" };
@@ -1489,7 +1494,10 @@ export class SessionManager {
1489
1494
  const resolvedCwd = path.resolve(newCwd);
1490
1495
  if (resolvedCwd === this.cwd) return;
1491
1496
 
1492
- const newSessionDir = getDefaultSessionDir(resolvedCwd, this.storage);
1497
+ const managedSessionsRoot = resolveManagedSessionRoot(this.sessionDir, this.cwd);
1498
+ const newSessionDir = managedSessionsRoot
1499
+ ? computeDefaultSessionDir(resolvedCwd, this.storage, managedSessionsRoot)
1500
+ : computeDefaultSessionDir(resolvedCwd, this.storage);
1493
1501
  let hadSessionFile = false;
1494
1502
 
1495
1503
  if (this.persist && this.#sessionFile) {
@@ -2446,13 +2454,24 @@ export class SessionManager {
2446
2454
  return undefined;
2447
2455
  }
2448
2456
 
2457
+ /**
2458
+ * Resolve the canonical default session directory for a cwd.
2459
+ */
2460
+ static getDefaultSessionDir(
2461
+ cwd: string,
2462
+ agentDir?: string,
2463
+ storage: SessionStorage = new FileSessionStorage(),
2464
+ ): string {
2465
+ return computeDefaultSessionDir(cwd, storage, getSessionsDir(agentDir));
2466
+ }
2467
+
2449
2468
  /**
2450
2469
  * Create a new session.
2451
2470
  * @param cwd Working directory (stored in session header)
2452
2471
  * @param sessionDir Optional session directory. If omitted, uses default (~/.omp/agent/sessions/<encoded-cwd>/).
2453
2472
  */
2454
2473
  static create(cwd: string, sessionDir?: string, storage: SessionStorage = new FileSessionStorage()): SessionManager {
2455
- const dir = sessionDir ?? getDefaultSessionDir(cwd, storage);
2474
+ const dir = sessionDir ?? SessionManager.getDefaultSessionDir(cwd, undefined, storage);
2456
2475
  const manager = new SessionManager(cwd, dir, true, storage);
2457
2476
  manager.#initNewSession();
2458
2477
  return manager;
@@ -2468,7 +2487,7 @@ export class SessionManager {
2468
2487
  sessionDir?: string,
2469
2488
  storage: SessionStorage = new FileSessionStorage(),
2470
2489
  ): Promise<SessionManager> {
2471
- const dir = sessionDir ?? getDefaultSessionDir(cwd, storage);
2490
+ const dir = sessionDir ?? SessionManager.getDefaultSessionDir(cwd, undefined, storage);
2472
2491
  const manager = new SessionManager(cwd, dir, true, storage);
2473
2492
  const forkEntries = structuredClone(await loadEntriesFromFile(sourcePath, storage)) as FileEntry[];
2474
2493
  migrateToCurrentVersion(forkEntries);
@@ -2516,7 +2535,7 @@ export class SessionManager {
2516
2535
  sessionDir?: string,
2517
2536
  storage: SessionStorage = new FileSessionStorage(),
2518
2537
  ): Promise<SessionManager> {
2519
- const dir = sessionDir ?? getDefaultSessionDir(cwd, storage);
2538
+ const dir = sessionDir ?? SessionManager.getDefaultSessionDir(cwd, undefined, storage);
2520
2539
  // Prefer terminal-scoped breadcrumb (handles concurrent sessions correctly)
2521
2540
  const terminalSession = await readTerminalBreadcrumb(cwd);
2522
2541
  const mostRecent = terminalSession ?? (await findMostRecentSession(dir, storage));
@@ -2549,7 +2568,7 @@ export class SessionManager {
2549
2568
  sessionDir?: string,
2550
2569
  storage: SessionStorage = new FileSessionStorage(),
2551
2570
  ): Promise<SessionInfo[]> {
2552
- const dir = sessionDir ?? getDefaultSessionDir(cwd, storage);
2571
+ const dir = sessionDir ?? SessionManager.getDefaultSessionDir(cwd, undefined, storage);
2553
2572
  try {
2554
2573
  const files = storage.listFilesSync(dir, "*.jsonl");
2555
2574
  return await collectSessionsFromFiles(files, storage);
@@ -17,6 +17,7 @@ import type { OutputMeta } from "./output-meta";
17
17
  import {
18
18
  combineSearchGlobs,
19
19
  hasGlobPathChars,
20
+ normalizePathLikeInput,
20
21
  parseSearchPath,
21
22
  resolveMultiSearchPath,
22
23
  resolveToCwd,
@@ -110,8 +111,8 @@ export class AstEditTool implements AgentTool<typeof astEditSchema, AstEditToolD
110
111
  };
111
112
  let searchPath: string | undefined;
112
113
  let scopePath: string | undefined;
113
- let globFilter = params.glob?.trim() || undefined;
114
- const rawPath = params.path?.trim();
114
+ let globFilter = params.glob ? normalizePathLikeInput(params.glob) || undefined : undefined;
115
+ const rawPath = params.path ? normalizePathLikeInput(params.path) || undefined : undefined;
115
116
  if (rawPath) {
116
117
  const internalRouter = this.session.internalRouter;
117
118
  if (internalRouter?.canHandle(rawPath)) {
@@ -17,6 +17,7 @@ import type { OutputMeta } from "./output-meta";
17
17
  import {
18
18
  combineSearchGlobs,
19
19
  hasGlobPathChars,
20
+ normalizePathLikeInput,
20
21
  parseSearchPath,
21
22
  resolveMultiSearchPath,
22
23
  resolveToCwd,
@@ -98,8 +99,8 @@ export class AstGrepTool implements AgentTool<typeof astGrepSchema, AstGrepToolD
98
99
  };
99
100
  let searchPath: string | undefined;
100
101
  let scopePath: string | undefined;
101
- let globFilter = params.glob?.trim() || undefined;
102
- const rawPath = params.path?.trim();
102
+ let globFilter = params.glob ? normalizePathLikeInput(params.glob) || undefined : undefined;
103
+ const rawPath = params.path ? normalizePathLikeInput(params.path) || undefined : undefined;
103
104
  if (rawPath) {
104
105
  const internalRouter = this.session.internalRouter;
105
106
  if (internalRouter?.canHandle(rawPath)) {
@@ -194,10 +195,13 @@ export class AstGrepTool implements AgentTool<typeof astGrepSchema, AstGrepToolD
194
195
  };
195
196
 
196
197
  if (result.matches.length === 0) {
198
+ const noMatchMessage = dedupedParseErrors.length
199
+ ? "No matches found. Parse issues mean the query may be mis-scoped; narrow `path`/`glob` or set `lang` before concluding absence."
200
+ : "No matches found";
197
201
  const parseMessage = dedupedParseErrors.length
198
202
  ? `\n${formatParseErrors(dedupedParseErrors).join("\n")}`
199
203
  : "";
200
- return toolResult(baseDetails).text(`No matches found${parseMessage}`).done();
204
+ return toolResult(baseDetails).text(`${noMatchMessage}${parseMessage}`).done();
201
205
  }
202
206
 
203
207
  const useHashLines = resolveFileDisplayMode(this.session).hashLines;
@@ -343,6 +347,12 @@ export const astGrepToolRenderer = {
343
347
  const header = renderStatusLine({ icon: "warning", title: "AST Grep", description, meta }, uiTheme);
344
348
  const lines = [header, formatEmptyMessage("No matches found", uiTheme)];
345
349
  if (details?.parseErrors?.length) {
350
+ lines.push(
351
+ uiTheme.fg(
352
+ "warning",
353
+ "Query may be mis-scoped; narrow `path`/`glob` or set `lang` before concluding absence",
354
+ ),
355
+ );
346
356
  const capped = details.parseErrors.slice(0, PARSE_ERRORS_LIMIT);
347
357
  for (const err of capped) {
348
358
  lines.push(uiTheme.fg("warning", ` - ${err}`));
package/src/tools/find.ts CHANGED
@@ -24,7 +24,7 @@ import {
24
24
  import type { ToolSession } from ".";
25
25
  import { applyListLimit } from "./list-limit";
26
26
  import { formatFullOutputReference, type OutputMeta } from "./output-meta";
27
- import { parseFindPattern, resolveMultiFindPattern, resolveToCwd } from "./path-utils";
27
+ import { normalizePathLikeInput, parseFindPattern, resolveMultiFindPattern, resolveToCwd } from "./path-utils";
28
28
  import { formatCount, formatEmptyMessage, formatErrorMessage, PREVIEW_LIMITS } from "./render-utils";
29
29
  import { ToolAbortError, ToolError, throwIfAborted } from "./tool-errors";
30
30
  import { toolResult } from "./tool-result";
@@ -103,7 +103,7 @@ export class FindTool implements AgentTool<typeof findSchema, FindToolDetails> {
103
103
  const relative = path.relative(this.session.cwd, targetPath).replace(/\\/g, "/");
104
104
  return relative.length === 0 ? "." : relative;
105
105
  };
106
- const normalizedPattern = pattern.trim().replace(/\\/g, "/");
106
+ const normalizedPattern = normalizePathLikeInput(pattern).replace(/\\/g, "/");
107
107
  if (!normalizedPattern) {
108
108
  throw new ToolError("Pattern must not be empty");
109
109
  }
package/src/tools/grep.ts CHANGED
@@ -19,6 +19,7 @@ import { formatFullOutputReference, type OutputMeta } from "./output-meta";
19
19
  import {
20
20
  combineSearchGlobs,
21
21
  hasGlobPathChars,
22
+ normalizePathLikeInput,
22
23
  parseSearchPath,
23
24
  resolveMultiSearchPath,
24
25
  resolveToCwd,
@@ -119,10 +120,10 @@ export class GrepTool implements AgentTool<typeof grepSchema, GrepToolDetails> {
119
120
  };
120
121
  let searchPath: string;
121
122
  let scopePath: string;
122
- let globFilter = glob?.trim() || undefined;
123
+ let globFilter = glob ? normalizePathLikeInput(glob) || undefined : undefined;
123
124
  const internalRouter = this.session.internalRouter;
124
125
  if (searchDir?.trim()) {
125
- const rawPath = searchDir.trim();
126
+ const rawPath = normalizePathLikeInput(searchDir);
126
127
  if (internalRouter?.canHandle(rawPath)) {
127
128
  if (hasGlobPathChars(rawPath)) {
128
129
  throw new ToolError(`Glob patterns are not supported for internal URLs: ${rawPath}`);
@@ -122,6 +122,10 @@ export function stripOuterDoubleQuotes(input: string): string {
122
122
  return input.startsWith('"') && input.endsWith('"') && input.length > 1 ? input.slice(1, -1) : input;
123
123
  }
124
124
 
125
+ export function normalizePathLikeInput(input: string): string {
126
+ return stripOuterDoubleQuotes(input.trim());
127
+ }
128
+
125
129
  const GLOB_PATH_CHARS = ["*", "?", "[", "{"] as const;
126
130
 
127
131
  export function hasGlobPathChars(filePath: string): boolean {