praxis-agent 0.57.0 → 0.59.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -27,7 +27,7 @@ sessions, configuration, or compatibility directories.
27
27
 
28
28
  - macOS or Linux
29
29
  - Node.js 24 or newer
30
- - [`ripgrep`](https://github.com/BurntSushi/ripgrep) (`rg`) for the Grep tool
30
+ - [`ripgrep`](https://github.com/BurntSushi/ripgrep) (`rg`) for the Grep and Glob tools
31
31
  - an API key and model ID for an Anthropic, OpenAI-compatible, or OpenAI
32
32
  Responses provider (the stable setup), or the explicitly enabled experimental
33
33
  ChatGPT-backed Codex subscription integration
@@ -124,7 +124,9 @@ troubleshooting. Run `praxis --help` for the authoritative command surface.
124
124
  - **Local agent runtime** — C+ Quiet Operator responsive TUI with a linear
125
125
  `❯` user / `⏺` assistant conversation, `✻` thinking activity, and `!` shell
126
126
  composer grammar, compact stable tool rows, responsive density,
127
- terminal-native background, and a minimal composer/status row. Interactive
127
+ terminal-native background, and a minimal composer/status row. Successful
128
+ background Bash completion bursts collapse in normal reading, while
129
+ failed/stopped notifications remain detailed. Interactive
128
130
  surfaces share the same presentation across terminals, with English
129
131
  permission/configuration choices and a taught `❯` / Up/Down / Enter / Esc
130
132
  interaction grammar. While a regular turn is active, the composer remains
@@ -167,7 +169,8 @@ troubleshooting. Run `praxis --help` for the authoritative command surface.
167
169
  `Ctrl+V` text/image clipboard paste, `Ctrl+Z` shell suspension and `fg`
168
170
  recovery, permission-gated provider-free `!` shell turns that persist shell
169
171
  input/output for later ordinary prompts without creating an assistant turn,
170
- navigable current/per-turn Git diff views, semantic plan/question decision panels with complete
172
+ navigable session-start/current-per-turn Git diff views with bounded per-file
173
+ patches and readable binary/conflict/transient-path notes, semantic plan/question decision panels with complete
171
174
  screen-reader actions, semantic screen projection across selectable surfaces,
172
175
  deterministic resize-aware URL/form elicitation rendering, and measured
173
176
  context budgets; print mode,
@@ -9,7 +9,7 @@ import { type ClaudePermissionMode } from '../permissions/claude-permission-reso
9
9
  import { type TranscriptItem, type TuiDisplayMetadata } from './tui/claude-style.js';
10
10
  import { type TuiMemoryFiles } from './tui/memory-files.js';
11
11
  import { type TuiHistoryChange } from './tui/tui-view-model.js';
12
- import { type TuiDiffSnapshot } from './tui/git-diff.js';
12
+ import { type TuiGitDiffSession, type TuiDiffSnapshot } from './tui/git-diff.js';
13
13
  import { type TuiPermissionBehavior, type TuiPermissionRule } from './tui/permission-settings.js';
14
14
  import { type RecentlyDeniedStore } from './tui/recently-denied.js';
15
15
  import { type AgentColorSelection } from '../core/agent-color.js';
@@ -149,6 +149,7 @@ interface InteractiveAppProps {
149
149
  allowDangerouslySkipPermissions?: boolean;
150
150
  additionalDirectories?: readonly string[];
151
151
  diffLoader?: () => Promise<TuiDiffSnapshot>;
152
+ gitDiffSession?: TuiGitDiffSession;
152
153
  doctorLoader?: (onProgress?: DoctorProgressListener) => Promise<DoctorReport>;
153
154
  fileLoader?: () => Promise<readonly TuiFileEntry[]>;
154
155
  externalEditor?: (prompt: string, options: TuiEditorOptions) => Promise<TuiEditorResult>;
@@ -209,7 +210,7 @@ export interface InteractiveHistoryState {
209
210
  }
210
211
  /** Advances the React-owned transcript plus its exact local mutation fact. */
211
212
  export declare function advanceInteractiveHistoryState(current: InteractiveHistoryState, items: TranscriptItem[], changedFrom: number): InteractiveHistoryState;
212
- export declare function InteractiveApp({ dataPlane, configRoot: suppliedConfigRoot, statePath: suppliedStatePath, factory, initialSessions, initialPrompt, initialHistory, initialSessionColor, signal, onCancel, onTurnChange, onCleanup, onRendererChange, onBackground, axScreenReader, ansiRenderer, allowNewSession, resume, display, terminalWidth, slashCommands, agents, allowDangerouslySkipPermissions, additionalDirectories, diffLoader, doctorLoader, fileLoader, externalEditor, keybindingsConfigRoot, keybindingsFile, keybindingsLoader, keybindingsEditor, memoryFilesLoader, memoryEditor, memoryFolderOpener, suspendProcess, clipboardReader, clipboardWriter, sideQuestionClipboardWriter, exportWriter, permissionRuleStore, sandboxStore: suppliedSandboxStore, recentlyDeniedStore: suppliedRecentlyDeniedStore, terminalSetup: terminalSetupOverride, themeStore, initialThemeSettings, initialThemeLoadError, workspaceDirectoryResolver, workspaceDirectoryCompleter, runtimeSettings: suppliedRuntimeSettings, runtimeSettingsTarget, notificationWriter, notificationDelayMs, elicitationUrlOpener, releaseNotesLoader, settingSources, }: InteractiveAppProps): import("react").JSX.Element;
213
+ export declare function InteractiveApp({ dataPlane, configRoot: suppliedConfigRoot, statePath: suppliedStatePath, factory, initialSessions, initialPrompt, initialHistory, initialSessionColor, signal, onCancel, onTurnChange, onCleanup, onRendererChange, onBackground, axScreenReader, ansiRenderer, allowNewSession, resume, display, terminalWidth, slashCommands, agents, allowDangerouslySkipPermissions, additionalDirectories, diffLoader, gitDiffSession: suppliedGitDiffSession, doctorLoader, fileLoader, externalEditor, keybindingsConfigRoot, keybindingsFile, keybindingsLoader, keybindingsEditor, memoryFilesLoader, memoryEditor, memoryFolderOpener, suspendProcess, clipboardReader, clipboardWriter, sideQuestionClipboardWriter, exportWriter, permissionRuleStore, sandboxStore: suppliedSandboxStore, recentlyDeniedStore: suppliedRecentlyDeniedStore, terminalSetup: terminalSetupOverride, themeStore, initialThemeSettings, initialThemeLoadError, workspaceDirectoryResolver, workspaceDirectoryCompleter, runtimeSettings: suppliedRuntimeSettings, runtimeSettingsTarget, notificationWriter, notificationDelayMs, elicitationUrlOpener, releaseNotesLoader, settingSources, }: InteractiveAppProps): import("react").JSX.Element;
213
214
  export declare function runInteractive(options: {
214
215
  dataPlane?: DataPlane;
215
216
  configRoot?: string;
@@ -33,7 +33,7 @@ import { QuietInkFrame } from './tui/quiet-frame-adapter.js';
33
33
  import { projectQuietScreenFrame, } from './tui/quiet-screen-projector.js';
34
34
  import { projectTuiHelpSurface } from './tui/help-surface-model.js';
35
35
  import { projectTuiHooksSurface, } from './tui/hooks-surface-model.js';
36
- import { loadGitDiff } from './tui/git-diff.js';
36
+ import { createTuiGitDiffSession, } from './tui/git-diff.js';
37
37
  import { projectTuiDiffSurface, } from './tui/diff-surface-model.js';
38
38
  import { filterTuiSessionPickerChoices, projectTuiSessionPicker, tuiSessionPickerChoiceId, } from './tui/session-picker-model.js';
39
39
  import { addTuiPermissionRule, loadTuiPermissionRules, removeTuiPermissionRule, } from './tui/permission-settings.js';
@@ -257,7 +257,7 @@ export function advanceInteractiveHistoryState(current, items, changedFrom) {
257
257
  : createTuiHistoryChange(revision, changedFrom, items, current.items),
258
258
  };
259
259
  }
260
- export function InteractiveApp({ dataPlane = resolveDataPlane(), configRoot: suppliedConfigRoot, statePath: suppliedStatePath, factory, initialSessions, initialPrompt, initialHistory = [], initialSessionColor, signal, onCancel, onTurnChange, onCleanup, onRendererChange, onBackground, axScreenReader = false, ansiRenderer = false, allowNewSession = true, resume, display = { version: 'dev', cwd: process.cwd() }, terminalWidth, slashCommands = EMPTY_SLASH_COMMANDS, agents = EMPTY_AGENTS, allowDangerouslySkipPermissions = false, additionalDirectories = [], diffLoader, doctorLoader, fileLoader, externalEditor = editTuiPrompt, keybindingsConfigRoot, keybindingsFile = ensureTuiKeybindingsFile, keybindingsLoader = loadTuiKeybindings, keybindingsEditor = openTuiEditorFile, memoryFilesLoader, memoryEditor = openTuiEditorFile, memoryFolderOpener = openTuiMemoryFolder, suspendProcess = suspendTuiProcess, clipboardReader = readTuiClipboard, clipboardWriter = writeTuiClipboard, sideQuestionClipboardWriter = writeTuiOsc52Clipboard, exportWriter = writeConversationExport, permissionRuleStore, sandboxStore: suppliedSandboxStore, recentlyDeniedStore: suppliedRecentlyDeniedStore, terminalSetup: terminalSetupOverride, themeStore, initialThemeSettings, initialThemeLoadError, workspaceDirectoryResolver = resolveTuiWorkspaceDirectory, workspaceDirectoryCompleter = completeTuiWorkspaceDirectory, runtimeSettings: suppliedRuntimeSettings, runtimeSettingsTarget, notificationWriter, notificationDelayMs = 6_000, elicitationUrlOpener = openTuiUrl, releaseNotesLoader = (configRoot) => loadClaudeReleaseNotes({ configRoot }), settingSources, }) {
260
+ export function InteractiveApp({ dataPlane = resolveDataPlane(), configRoot: suppliedConfigRoot, statePath: suppliedStatePath, factory, initialSessions, initialPrompt, initialHistory = [], initialSessionColor, signal, onCancel, onTurnChange, onCleanup, onRendererChange, onBackground, axScreenReader = false, ansiRenderer = false, allowNewSession = true, resume, display = { version: 'dev', cwd: process.cwd() }, terminalWidth, slashCommands = EMPTY_SLASH_COMMANDS, agents = EMPTY_AGENTS, allowDangerouslySkipPermissions = false, additionalDirectories = [], diffLoader, gitDiffSession: suppliedGitDiffSession, doctorLoader, fileLoader, externalEditor = editTuiPrompt, keybindingsConfigRoot, keybindingsFile = ensureTuiKeybindingsFile, keybindingsLoader = loadTuiKeybindings, keybindingsEditor = openTuiEditorFile, memoryFilesLoader, memoryEditor = openTuiEditorFile, memoryFolderOpener = openTuiMemoryFolder, suspendProcess = suspendTuiProcess, clipboardReader = readTuiClipboard, clipboardWriter = writeTuiClipboard, sideQuestionClipboardWriter = writeTuiOsc52Clipboard, exportWriter = writeConversationExport, permissionRuleStore, sandboxStore: suppliedSandboxStore, recentlyDeniedStore: suppliedRecentlyDeniedStore, terminalSetup: terminalSetupOverride, themeStore, initialThemeSettings, initialThemeLoadError, workspaceDirectoryResolver = resolveTuiWorkspaceDirectory, workspaceDirectoryCompleter = completeTuiWorkspaceDirectory, runtimeSettings: suppliedRuntimeSettings, runtimeSettingsTarget, notificationWriter, notificationDelayMs = 6_000, elicitationUrlOpener = openTuiUrl, releaseNotesLoader = (configRoot) => loadClaudeReleaseNotes({ configRoot }), settingSources, }) {
261
261
  const { exit, suspendTerminal, waitUntilRenderFlush } = useApp();
262
262
  const settingsDirectory = '.praxis';
263
263
  const keybindingsRoot = useMemo(() => resolve(suppliedConfigRoot ?? keybindingsConfigRoot ?? resolveDataPlaneRoot()), [dataPlane, keybindingsConfigRoot, suppliedConfigRoot]);
@@ -278,7 +278,10 @@ export function InteractiveApp({ dataPlane = resolveDataPlane(), configRoot: sup
278
278
  const [runtimeCwd, setRuntimeCwd] = useState(display.cwd);
279
279
  const runtimeCwdRef = useRef(display.cwd);
280
280
  const runtimeGitignoreRef = useRef(true);
281
- const loadDiffSnapshot = useMemo(() => diffLoader ?? (() => loadGitDiff(runtimeCwd)), [diffLoader, runtimeCwd]);
281
+ const gitDiffSession = useMemo(() => suppliedGitDiffSession ?? createTuiGitDiffSession(display.cwd, signal), [display.cwd, signal, suppliedGitDiffSession]);
282
+ const loadDiffSnapshot = useMemo(() => diffLoader
283
+ ? () => diffLoader()
284
+ : (signal) => gitDiffSession.load(runtimeCwdRef.current, signal), [diffLoader, gitDiffSession]);
282
285
  const loadDoctorReport = useMemo(() => doctorLoader ??
283
286
  (async (onProgress) => {
284
287
  const runtimeSettings = await loadRuntimeSettings(configTarget);
@@ -1959,6 +1962,10 @@ export function InteractiveApp({ dataPlane = resolveDataPlane(), configRoot: sup
1959
1962
  append({
1960
1963
  kind: event.status === 'failed' ? 'warning' : 'notice',
1961
1964
  text: `Task ${event.status} · ${event.summary}`,
1965
+ taskNotification: {
1966
+ taskId: event.taskId,
1967
+ status: event.status,
1968
+ },
1962
1969
  });
1963
1970
  break;
1964
1971
  case 'compact-boundary':
@@ -2817,6 +2824,7 @@ export function InteractiveApp({ dataPlane = resolveDataPlane(), configRoot: sup
2817
2824
  setBusy(true);
2818
2825
  setStatus('changing directory');
2819
2826
  const cwd = await commands.changeCwd(sessionId ?? undefined, inspection.canonicalTarget, inspection.canonicalTarget);
2827
+ await gitDiffSession.prepare(cwd, signal);
2820
2828
  activeSessionTrust.add(cwd);
2821
2829
  runtimeCwdRef.current = cwd;
2822
2830
  setRuntimeCwd(cwd);
@@ -3427,7 +3435,7 @@ export function InteractiveApp({ dataPlane = resolveDataPlane(), configRoot: sup
3427
3435
  }
3428
3436
  if (turnMutatedFilesRef.current) {
3429
3437
  try {
3430
- const snapshot = await loadDiffSnapshot();
3438
+ const snapshot = await loadDiffSnapshot(signal);
3431
3439
  setTurnDiffs((current) => [
3432
3440
  ...current.filter((source) => source.label !== `T${turnNumber}`),
3433
3441
  { label: `T${turnNumber}`, snapshot },
@@ -6679,7 +6687,7 @@ export function InteractiveApp({ dataPlane = resolveDataPlane(), configRoot: sup
6679
6687
  setBusy(true);
6680
6688
  setStatus('loading diff');
6681
6689
  try {
6682
- const current = await loadDiffSnapshot();
6690
+ const current = await loadDiffSnapshot(signal);
6683
6691
  updateMenu({
6684
6692
  kind: 'diff',
6685
6693
  snapshots: [
@@ -7027,6 +7035,7 @@ export async function runInteractive(options) {
7027
7035
  const signal = options.signal
7028
7036
  ? AbortSignal.any([options.signal, controller.signal])
7029
7037
  : controller.signal;
7038
+ const gitDiffSession = createTuiGitDiffSession(options.display?.cwd ?? process.cwd(), signal);
7030
7039
  let currentResume = options.resume;
7031
7040
  let currentInitialPrompt = options.initialPrompt;
7032
7041
  let initialAgentPromptResolved = false;
@@ -7145,7 +7154,7 @@ export async function runInteractive(options) {
7145
7154
  const ansiRendererEnabled = currentRenderer === 'fullscreen' &&
7146
7155
  interactiveTty &&
7147
7156
  options.axScreenReader !== true;
7148
- const instance = render(_jsx(InteractiveApp, { dataPlane: dataPlane, configRoot: configRoot, statePath: statePath, factory: options.factory, initialSessions: initialSessions, slashCommands: initialSlashCommands, agents: initialAgents, initialHistory: history, ...(initialSessionColor === undefined ? {} : { initialSessionColor }), runtimeSettings: currentRuntimeSettings, runtimeSettingsTarget: runtimeSettingsTarget, ...(options.settingSources === undefined
7157
+ const instance = render(_jsx(InteractiveApp, { dataPlane: dataPlane, configRoot: configRoot, statePath: statePath, factory: options.factory, gitDiffSession: gitDiffSession, initialSessions: initialSessions, slashCommands: initialSlashCommands, agents: initialAgents, initialHistory: history, ...(initialSessionColor === undefined ? {} : { initialSessionColor }), runtimeSettings: currentRuntimeSettings, runtimeSettingsTarget: runtimeSettingsTarget, ...(options.settingSources === undefined
7149
7158
  ? {}
7150
7159
  : { settingSources: options.settingSources }), initialThemeSettings: initialThemeSettings, ...(initialThemeLoadError === undefined
7151
7160
  ? {}
@@ -46,7 +46,7 @@ export function projectTuiDiffSurface(input) {
46
46
  if (!input.viewingFile || files.length === 0) {
47
47
  return {
48
48
  kind: 'diff',
49
- title: 'Uncommitted changes (git diff HEAD)',
49
+ title: 'Changes since session start',
50
50
  sourceTabs,
51
51
  currentSource: selectedSource
52
52
  ? { index: sourceIndex, label: source.label }
@@ -101,7 +101,7 @@ export function projectTuiDiffSurface(input) {
101
101
  }));
102
102
  return {
103
103
  kind: 'diff',
104
- title: 'Uncommitted changes (git diff HEAD)',
104
+ title: 'Changes since session start',
105
105
  sourceTabs,
106
106
  currentSource: selectedSource
107
107
  ? { index: sourceIndex, label: source.label }
@@ -9,6 +9,11 @@ export interface TuiDiffSnapshot {
9
9
  additions: number;
10
10
  deletions: number;
11
11
  }
12
+ export interface TuiGitDiffSession {
13
+ prepare(cwd: string, signal?: AbortSignal): Promise<void>;
14
+ load(cwd: string, signal?: AbortSignal): Promise<TuiDiffSnapshot>;
15
+ }
12
16
  export declare function visiblePatchLines(patch: string): readonly string[];
17
+ export declare function createTuiGitDiffSession(initialCwd: string, sessionSignal?: AbortSignal): TuiGitDiffSession;
13
18
  export declare function loadGitDiff(cwd: string): Promise<TuiDiffSnapshot>;
14
19
  //# sourceMappingURL=git-diff.d.ts.map
@@ -1,6 +1,12 @@
1
- import { execFile } from 'node:child_process';
2
- import { promisify } from 'node:util';
3
- const execFileAsync = promisify(execFile);
1
+ import { realpath } from 'node:fs/promises';
2
+ import { resolve } from 'node:path';
3
+ import { BoundedProcessRunner } from '../../platform/bounded-process-runner.js';
4
+ function isOldFileHeader(line) {
5
+ return /^--- (?:a\/|"a\/|\/dev\/null$)/u.test(line);
6
+ }
7
+ function isNewFileHeader(line) {
8
+ return /^\+\+\+ (?:b\/|"b\/|\/dev\/null$)/u.test(line);
9
+ }
4
10
  function changedLines(patch) {
5
11
  let additions = 0;
6
12
  let deletions = 0;
@@ -12,12 +18,6 @@ function changedLines(patch) {
12
18
  }
13
19
  return { additions, deletions };
14
20
  }
15
- function isOldFileHeader(line) {
16
- return /^--- (?:a\/|"a\/|\/dev\/null$)/u.test(line);
17
- }
18
- function isNewFileHeader(line) {
19
- return /^\+\+\+ (?:b\/|"b\/|\/dev\/null$)/u.test(line);
20
- }
21
21
  export function visiblePatchLines(patch) {
22
22
  return patch
23
23
  .split('\n')
@@ -28,95 +28,282 @@ export function visiblePatchLines(patch) {
28
28
  !isNewFileHeader(line) &&
29
29
  !line.startsWith('@@ '));
30
30
  }
31
- async function hasHead(cwd) {
32
- try {
33
- await execFileAsync('git', ['-C', cwd, 'rev-parse', '--verify', '--quiet', 'HEAD'], { encoding: 'utf8' });
34
- return true;
35
- }
36
- catch {
37
- return false;
38
- }
31
+ const CONTROL_LIMIT = 8 * 1024 * 1024;
32
+ const PATCH_LIMIT = 256 * 1024;
33
+ function makeRunner(root, limit = PATCH_LIMIT) {
34
+ return new BoundedProcessRunner({ cwd: root, maxOutputBytes: limit });
39
35
  }
40
- async function diffUnbornPath(cwd, path) {
36
+ function note(path, text) {
37
+ return { path, additions: 0, deletions: 0, patch: text };
38
+ }
39
+ async function discover(cwd, signal) {
41
40
  try {
42
- const result = await execFileAsync('git', [
43
- '-C',
44
- cwd,
45
- 'diff',
46
- '--no-index',
47
- '--no-ext-diff',
48
- '--no-color',
49
- '--unified=3',
50
- '--',
51
- '/dev/null',
52
- path,
53
- ], { encoding: 'utf8', maxBuffer: 8 * 1024 * 1024 });
54
- return result.stdout;
41
+ const process = makeRunner(cwd, CONTROL_LIMIT);
42
+ const rootPromise = process.run({
43
+ command: 'git',
44
+ args: ['-C', cwd, 'rev-parse', '--show-toplevel'],
45
+ timeoutMs: 5_000,
46
+ ...(signal ? { signal } : {}),
47
+ });
48
+ const headPromise = process.run({
49
+ command: 'git',
50
+ args: ['-C', cwd, 'rev-parse', '--verify', '--quiet', 'HEAD'],
51
+ timeoutMs: 5_000,
52
+ ...(signal ? { signal } : {}),
53
+ });
54
+ const [probe, head] = await Promise.all([rootPromise, headPromise]);
55
+ if (probe.code !== 0 ||
56
+ probe.timedOut ||
57
+ probe.truncated ||
58
+ !probe.stdout.trim())
59
+ throw new Error('Not a Git repository.');
60
+ const root = await realpath(resolve(probe.stdout.trim()));
61
+ if (head.timedOut || head.truncated)
62
+ throw new Error('Git HEAD inspection failed.');
63
+ if (head.code === 0 && head.stdout.trim())
64
+ return { root, oid: head.stdout.trim() };
65
+ if (head.code === 1 && !head.stdout.trim() && !head.stderr.trim())
66
+ return { root, oid: null };
67
+ throw new Error('Git HEAD inspection failed.');
55
68
  }
56
69
  catch (error) {
57
- const execError = error;
58
- // `git diff --no-index` exits with code 1 when the inputs differ.
59
- if (execError.code === 1) {
60
- return execError.stdout ?? '';
61
- }
62
- throw error;
70
+ return { error: error instanceof Error ? error : new Error(String(error)) };
63
71
  }
64
72
  }
65
- async function loadUnbornDiff(cwd) {
66
- const stagedResult = await execFileAsync('git', ['-C', cwd, 'diff', '--cached', '--name-only', '-z', '--'], { encoding: 'utf8', maxBuffer: 8 * 1024 * 1024 });
67
- const untrackedResult = await execFileAsync('git', ['-C', cwd, 'ls-files', '--others', '--exclude-standard', '-z'], { encoding: 'utf8', maxBuffer: 8 * 1024 * 1024 });
68
- const stagedPaths = stagedResult.stdout.split('\0').filter(Boolean);
69
- const untrackedPaths = untrackedResult.stdout.split('\0').filter(Boolean);
70
- const files = [];
71
- for (const path of stagedPaths) {
72
- const result = await execFileAsync('git', [
73
+ export function createTuiGitDiffSession(initialCwd, sessionSignal) {
74
+ const baselines = new Map();
75
+ const discoveries = new Map();
76
+ const initialKey = resolve(initialCwd);
77
+ const beginDiscovery = (key, signal) => {
78
+ let pending = discoveries.get(key);
79
+ if (!pending) {
80
+ pending = discover(key, signal).then((result) => {
81
+ if (!('error' in result) && !baselines.has(result.root)) {
82
+ baselines.set(result.root, Promise.resolve(result));
83
+ }
84
+ return result;
85
+ });
86
+ discoveries.set(key, pending);
87
+ }
88
+ return pending;
89
+ };
90
+ // Start both repository and HEAD probes before returning the session so a
91
+ // later commit cannot move the initial baseline while the TUI is mounting.
92
+ const initialReady = beginDiscovery(initialKey, sessionSignal).then(() => undefined);
93
+ const prepare = async (cwd, signal = sessionSignal) => {
94
+ const key = resolve(cwd);
95
+ if (key !== initialKey)
96
+ await initialReady;
97
+ await beginDiscovery(key, signal);
98
+ };
99
+ const load = async (cwd, signal) => {
100
+ await prepare(cwd, signal);
101
+ if (signal?.aborted)
102
+ throw new DOMException('Tool execution aborted', 'AbortError');
103
+ const discovery = await discoveries.get(resolve(cwd));
104
+ const captured = discovery && !('error' in discovery)
105
+ ? await baselines.get(discovery.root)
106
+ : discovery;
107
+ if (!captured)
108
+ throw new Error('Git baseline unavailable.');
109
+ if ('error' in captured)
110
+ throw captured.error;
111
+ const { root, oid } = captured;
112
+ const git = makeRunner(root);
113
+ const control = makeRunner(root, CONTROL_LIMIT);
114
+ const runGit = (args) => git.run({
115
+ command: 'git',
116
+ args,
117
+ timeoutMs: 5_000,
118
+ ...(signal ? { signal } : {}),
119
+ });
120
+ const list = async (args) => {
121
+ const result = await control.run({
122
+ command: 'git',
123
+ args,
124
+ timeoutMs: 5_000,
125
+ ...(signal ? { signal } : {}),
126
+ });
127
+ if (result.code !== 0 || result.timedOut || result.truncated)
128
+ throw new Error('Git diff enumeration failed.');
129
+ return result.stdout.split('\0').filter(Boolean);
130
+ };
131
+ const untracked = await list([
73
132
  '-C',
74
- cwd,
75
- 'diff',
76
- '--cached',
77
- '--no-ext-diff',
78
- '--no-color',
79
- '--unified=3',
80
- '--',
81
- path,
82
- ], { encoding: 'utf8', maxBuffer: 8 * 1024 * 1024 });
83
- files.push({ path, patch: result.stdout, ...changedLines(result.stdout) });
84
- }
85
- for (const path of untrackedPaths) {
86
- const patch = await diffUnbornPath(cwd, path);
87
- files.push({ path, patch, ...changedLines(patch) });
88
- }
89
- return {
90
- files,
91
- additions: files.reduce((total, file) => total + file.additions, 0),
92
- deletions: files.reduce((total, file) => total + file.deletions, 0),
133
+ root,
134
+ 'ls-files',
135
+ '--others',
136
+ '--exclude-standard',
137
+ '-z',
138
+ ]);
139
+ const untrackedSet = new Set(untracked);
140
+ let paths;
141
+ if (oid === null) {
142
+ const cached = await list(['-C', root, 'ls-files', '--cached', '-z']);
143
+ const deleted = new Set(await list(['-C', root, 'ls-files', '--deleted', '-z']));
144
+ paths = [...new Set([...cached, ...untracked])]
145
+ .filter((path) => !deleted.has(path))
146
+ .sort();
147
+ }
148
+ else {
149
+ paths = [
150
+ ...new Set([
151
+ ...(await list([
152
+ '-C',
153
+ root,
154
+ 'diff',
155
+ '--no-ext-diff',
156
+ '--no-renames',
157
+ '--name-only',
158
+ '-z',
159
+ oid,
160
+ '--',
161
+ ])),
162
+ ...(await list([
163
+ '-C',
164
+ root,
165
+ 'diff',
166
+ '--cached',
167
+ '--no-ext-diff',
168
+ '--no-renames',
169
+ '--name-only',
170
+ '-z',
171
+ oid,
172
+ '--',
173
+ ])),
174
+ ...untracked,
175
+ ]),
176
+ ].sort();
177
+ }
178
+ const baselineOid = oid ?? '';
179
+ const files = [];
180
+ for (const path of paths) {
181
+ if (signal?.aborted)
182
+ throw new DOMException('Tool execution aborted', 'AbortError');
183
+ try {
184
+ const conflict = await runGit([
185
+ '-C',
186
+ root,
187
+ 'ls-files',
188
+ '-u',
189
+ '--',
190
+ path,
191
+ ]);
192
+ if (conflict.code !== 0 || conflict.timedOut || conflict.truncated) {
193
+ files.push(note(path, 'Diff unavailable; Git inspection failed.'));
194
+ continue;
195
+ }
196
+ if (conflict.stdout.trim()) {
197
+ files.push(note(path, 'Merge conflict; patch omitted.'));
198
+ continue;
199
+ }
200
+ const numstatArgs = oid === null || untrackedSet.has(path)
201
+ ? [
202
+ '-C',
203
+ root,
204
+ 'diff',
205
+ '--no-index',
206
+ '--no-ext-diff',
207
+ '--numstat',
208
+ '--',
209
+ '/dev/null',
210
+ path,
211
+ ]
212
+ : [
213
+ '-C',
214
+ root,
215
+ 'diff',
216
+ '--no-ext-diff',
217
+ '--no-renames',
218
+ '--numstat',
219
+ baselineOid,
220
+ '--',
221
+ path,
222
+ ];
223
+ const numstat = await (untrackedSet.has(path) ? control : git).run({
224
+ command: 'git',
225
+ args: numstatArgs,
226
+ timeoutMs: 5_000,
227
+ ...(signal ? { signal } : {}),
228
+ });
229
+ const validNumstatDifference = (oid === null || untrackedSet.has(path)) &&
230
+ numstat.code === 1 &&
231
+ !/error|fatal|permission|cannot|no such/iu.test(numstat.stderr);
232
+ if ((numstat.code !== 0 && !validNumstatDifference) ||
233
+ numstat.timedOut ||
234
+ numstat.truncated) {
235
+ files.push(note(path, 'Diff unavailable; Git inspection failed.'));
236
+ continue;
237
+ }
238
+ if (/^-\s+-\s+/u.test(numstat.stdout)) {
239
+ files.push(note(path, 'Binary file; patch omitted.'));
240
+ continue;
241
+ }
242
+ const args = oid === null || untrackedSet.has(path)
243
+ ? [
244
+ '-C',
245
+ root,
246
+ 'diff',
247
+ '--no-index',
248
+ '--no-ext-diff',
249
+ '--no-color',
250
+ '--unified=3',
251
+ '--',
252
+ '/dev/null',
253
+ path,
254
+ ]
255
+ : [
256
+ '-C',
257
+ root,
258
+ 'diff',
259
+ '--no-ext-diff',
260
+ '--no-color',
261
+ '--unified=3',
262
+ baselineOid,
263
+ '--',
264
+ path,
265
+ ];
266
+ const result = await runGit(args);
267
+ if (result.timedOut) {
268
+ files.push(note(path, 'Diff unavailable; Git inspection timed out.'));
269
+ continue;
270
+ }
271
+ const noIndexDifference = (oid === null || untrackedSet.has(path)) &&
272
+ result.code === 1 &&
273
+ !/error|fatal|permission|cannot|no such/iu.test(result.stderr);
274
+ if (result.code !== 0 && !noIndexDifference) {
275
+ const disappeared = /no such|cannot stat|does not exist|not found/iu.test(result.stderr);
276
+ files.push(note(path, disappeared
277
+ ? 'Diff unavailable; path changed during inspection.'
278
+ : 'Diff unavailable; Git inspection failed.'));
279
+ }
280
+ else
281
+ files.push({
282
+ path,
283
+ patch: result.stdout,
284
+ ...changedLines(result.stdout),
285
+ });
286
+ }
287
+ catch (error) {
288
+ if (error instanceof DOMException && error.name === 'AbortError')
289
+ throw error;
290
+ const message = error instanceof Error ? error.message : String(error);
291
+ files.push(note(path, /no such|cannot stat|does not exist|not found/iu.test(message)
292
+ ? 'Diff unavailable; path changed during inspection.'
293
+ : 'Diff unavailable; Git inspection failed.'));
294
+ }
295
+ }
296
+ return {
297
+ files,
298
+ additions: files.reduce((n, f) => n + f.additions, 0),
299
+ deletions: files.reduce((n, f) => n + f.deletions, 0),
300
+ };
93
301
  };
302
+ return { prepare, load };
94
303
  }
95
304
  export async function loadGitDiff(cwd) {
96
- if (!(await hasHead(cwd))) {
97
- return loadUnbornDiff(cwd);
98
- }
99
- const names = await execFileAsync('git', ['-C', cwd, 'diff', '--name-only', '-z', 'HEAD', '--'], { encoding: 'utf8', maxBuffer: 8 * 1024 * 1024 });
100
- const paths = names.stdout.split('\0').filter(Boolean);
101
- const files = [];
102
- for (const path of paths) {
103
- const result = await execFileAsync('git', [
104
- '-C',
105
- cwd,
106
- 'diff',
107
- '--no-ext-diff',
108
- '--no-color',
109
- '--unified=3',
110
- 'HEAD',
111
- '--',
112
- path,
113
- ], { encoding: 'utf8', maxBuffer: 8 * 1024 * 1024 });
114
- files.push({ path, patch: result.stdout, ...changedLines(result.stdout) });
115
- }
116
- return {
117
- files,
118
- additions: files.reduce((total, file) => total + file.additions, 0),
119
- deletions: files.reduce((total, file) => total + file.deletions, 0),
120
- };
305
+ const session = createTuiGitDiffSession(cwd);
306
+ await session.prepare(cwd);
307
+ return session.load(cwd);
121
308
  }
122
309
  //# sourceMappingURL=git-diff.js.map
@@ -1,7 +1,15 @@
1
1
  import type { ModelToolCall } from '../../core/runtime.js';
2
+ export type TaskNotificationMetadata = {
3
+ readonly taskId: string;
4
+ readonly status: 'completed' | 'failed' | 'stopped';
5
+ };
2
6
  export type TranscriptItem = {
3
- kind: 'user' | 'assistant' | 'notice' | 'warning';
7
+ kind: 'user' | 'assistant';
8
+ text: string;
9
+ } | {
10
+ kind: 'notice' | 'warning';
4
11
  text: string;
12
+ taskNotification?: TaskNotificationMetadata;
5
13
  } | {
6
14
  kind: 'local-result';
7
15
  text: string;
@@ -102,6 +110,12 @@ export type TranscriptPresentationEntry = TranscriptPresentationEntryValue & {
102
110
  export declare function transcriptPresentationEntryKey(item: TranscriptItem, sourceIndex: number): string;
103
111
  /** Stable renderer key for a Normal-mode contiguous Read summary. */
104
112
  export declare function transcriptReadSummaryKey(sourceIndex: number): string;
113
+ export declare function isCompletedBackgroundBashNotification(item: TranscriptItem): Extract<TranscriptItem, {
114
+ kind: 'notice' | 'warning';
115
+ }> | undefined;
116
+ export declare function completedBackgroundBashSummary(count: number): Extract<TranscriptItem, {
117
+ kind: 'notice' | 'warning';
118
+ }>;
105
119
  export declare function projectTranscriptPresentation(items: readonly TranscriptItem[], mode: TranscriptPresentationMode): readonly TranscriptPresentationEntry[];
106
120
  export {};
107
121
  //# sourceMappingURL=transcript-presentation.d.ts.map
@@ -1,3 +1,4 @@
1
+ import { isBackgroundBashTaskId } from '../../application/background-task-id.js';
1
2
  /** Stable renderer key for one source Transcript item. */
2
3
  export function transcriptPresentationEntryKey(item, sourceIndex) {
3
4
  if (item.kind === 'tool')
@@ -14,6 +15,24 @@ export function transcriptPresentationEntryKey(item, sourceIndex) {
14
15
  export function transcriptReadSummaryKey(sourceIndex) {
15
16
  return `read-summary-${sourceIndex}`;
16
17
  }
18
+ export function isCompletedBackgroundBashNotification(item) {
19
+ if (item.kind !== 'notice' && item.kind !== 'warning')
20
+ return undefined;
21
+ const metadata = item.taskNotification;
22
+ if (!metadata ||
23
+ metadata.status !== 'completed' ||
24
+ !isBackgroundBashTaskId(metadata.taskId))
25
+ return undefined;
26
+ return item;
27
+ }
28
+ export function completedBackgroundBashSummary(count) {
29
+ if (!Number.isInteger(count) || count <= 0)
30
+ throw new RangeError('count must be a positive integer');
31
+ return {
32
+ kind: 'notice',
33
+ text: `${count} background commands completed`,
34
+ };
35
+ }
17
36
  function pairResults(items) {
18
37
  const toolResults = new Map();
19
38
  const toolResultIndexes = new Map();
@@ -124,6 +143,32 @@ export function projectTranscriptPresentation(items, mode) {
124
143
  const item = items[index];
125
144
  if (!item)
126
145
  continue;
146
+ if (mode === 'normal') {
147
+ const notification = isCompletedBackgroundBashNotification(item);
148
+ if (notification) {
149
+ const notifications = [notification];
150
+ let end = index + 1;
151
+ while (end < items.length) {
152
+ const next = items[end];
153
+ const nextNotification = next
154
+ ? isCompletedBackgroundBashNotification(next)
155
+ : undefined;
156
+ if (!nextNotification)
157
+ break;
158
+ notifications.push(nextNotification);
159
+ end += 1;
160
+ }
161
+ if (notifications.length >= 2) {
162
+ entries.push({
163
+ kind: 'item',
164
+ key: transcriptPresentationEntryKey(item, index),
165
+ item: completedBackgroundBashSummary(notifications.length),
166
+ });
167
+ index = end - 1;
168
+ continue;
169
+ }
170
+ }
171
+ }
127
172
  if (mode === 'normal' &&
128
173
  item.kind === 'tool' &&
129
174
  item.call.name === 'Read') {
@@ -76,6 +76,12 @@ interface TailReadRun {
76
76
  readonly calls: readonly ReadRunCall[];
77
77
  readonly summarized: boolean;
78
78
  }
79
+ interface TailCompletedBackgroundBashRun {
80
+ readonly startSourceIndex: number;
81
+ readonly startPresentationIndex: number;
82
+ readonly nextSourceIndex: number;
83
+ readonly count: number;
84
+ }
79
85
  export interface TranscriptWindowState {
80
86
  readonly history: readonly TranscriptItem[];
81
87
  readonly mode: TranscriptPresentationMode;
@@ -84,6 +90,7 @@ export interface TranscriptWindowState {
84
90
  readonly pendingTools?: PendingNode;
85
91
  readonly pendingShells?: PendingNode;
86
92
  readonly tailReadRun?: TailReadRun;
93
+ readonly tailCompletedBackgroundBashRun?: TailCompletedBackgroundBashRun;
87
94
  readonly revision: number;
88
95
  }
89
96
  export interface TranscriptWindowInput {
@@ -1,4 +1,4 @@
1
- import { projectTranscriptPresentation, transcriptPresentationEntryKey, transcriptReadSummaryKey, } from './transcript-presentation.js';
1
+ import { completedBackgroundBashSummary, isCompletedBackgroundBashNotification, projectTranscriptPresentation, transcriptPresentationEntryKey, transcriptReadSummaryKey, } from './transcript-presentation.js';
2
2
  import { createTranscriptEntryViewportIndex, estimateTranscriptEntryLines, projectTranscriptEntryRange, } from './transcript-viewport.js';
3
3
  const historyChangeLineage = Symbol('tui-history-change-lineage');
4
4
  function retainedHistoryChange(revision, changedFrom, history, previousHistory) {
@@ -465,6 +465,29 @@ function coldTailReadRun(history, pairing) {
465
465
  summarized,
466
466
  };
467
467
  }
468
+ function coldTailCompletedBackgroundBashRun(history, entries) {
469
+ let start = history.length;
470
+ while (start > 0) {
471
+ const candidate = history[start - 1];
472
+ if (!candidate || !isCompletedBackgroundBashNotification(candidate))
473
+ break;
474
+ start -= 1;
475
+ }
476
+ if (start >= history.length)
477
+ return undefined;
478
+ const first = history[start];
479
+ if (!first)
480
+ return undefined;
481
+ const startPresentationIndex = entries.findIndex((entry) => entry.key === transcriptPresentationEntryKey(first, start));
482
+ if (startPresentationIndex < 0)
483
+ return undefined;
484
+ return {
485
+ startSourceIndex: start,
486
+ startPresentationIndex,
487
+ nextSourceIndex: history.length,
488
+ count: history.length - start,
489
+ };
490
+ }
468
491
  function canReuseEntry(previous, next) {
469
492
  if (previous.kind !== next.kind || previous.key !== next.key)
470
493
  return false;
@@ -508,6 +531,9 @@ function coldState(input, previous) {
508
531
  const tailReadRun = input.mode === 'normal'
509
532
  ? coldTailReadRun(input.history, pairing)
510
533
  : undefined;
534
+ const tailCompletedBackgroundBashRun = input.mode === 'normal'
535
+ ? coldTailCompletedBackgroundBashRun(input.history, entries)
536
+ : undefined;
511
537
  return {
512
538
  history: input.history,
513
539
  mode: input.mode,
@@ -516,6 +542,9 @@ function coldState(input, previous) {
516
542
  ...(pairing.pendingTools ? { pendingTools: pairing.pendingTools } : {}),
517
543
  ...(pairing.pendingShells ? { pendingShells: pairing.pendingShells } : {}),
518
544
  ...(tailReadRun ? { tailReadRun } : {}),
545
+ ...(tailCompletedBackgroundBashRun
546
+ ? { tailCompletedBackgroundBashRun }
547
+ : {}),
519
548
  revision: input.revision,
520
549
  };
521
550
  }
@@ -565,6 +594,7 @@ function appendState(input, previous, change) {
565
594
  let pendingTools = previous.pendingTools;
566
595
  let pendingShells = previous.pendingShells;
567
596
  let tailReadRun = previous.tailReadRun;
597
+ let tailCompletedBackgroundBashRun = previous.tailCompletedBackgroundBashRun;
568
598
  for (let offset = 0; offset < suffix.length; offset += 1) {
569
599
  const sourceIndex = previousLength + offset;
570
600
  const item = suffix[offset];
@@ -582,6 +612,38 @@ function appendState(input, previous, change) {
582
612
  tailReadRun.calls.some((call) => call.sourceIndex === pendingTool.sourceIndex)));
583
613
  if (!continuesReadRun)
584
614
  tailReadRun = undefined;
615
+ const notification = input.mode === 'normal'
616
+ ? isCompletedBackgroundBashNotification(item)
617
+ : undefined;
618
+ if (notification) {
619
+ const continuesRun = tailCompletedBackgroundBashRun?.nextSourceIndex === sourceIndex;
620
+ if (continuesRun && tailCompletedBackgroundBashRun) {
621
+ const count = tailCompletedBackgroundBashRun.count + 1;
622
+ const firstItem = input.history[tailCompletedBackgroundBashRun.startSourceIndex] ?? item;
623
+ tree = updateEntry(tree, tailCompletedBackgroundBashRun.startPresentationIndex, {
624
+ kind: 'item',
625
+ key: transcriptPresentationEntryKey(firstItem, tailCompletedBackgroundBashRun.startSourceIndex),
626
+ item: completedBackgroundBashSummary(count),
627
+ }, input.width, input.mode);
628
+ tailCompletedBackgroundBashRun = {
629
+ ...tailCompletedBackgroundBashRun,
630
+ nextSourceIndex: sourceIndex + 1,
631
+ count,
632
+ };
633
+ }
634
+ else {
635
+ const presentationIndex = tree.entryCount;
636
+ tree = appendEntry(tree, entryForItem(item, sourceIndex), input.width, input.mode);
637
+ tailCompletedBackgroundBashRun = {
638
+ startSourceIndex: sourceIndex,
639
+ startPresentationIndex: presentationIndex,
640
+ nextSourceIndex: sourceIndex + 1,
641
+ count: 1,
642
+ };
643
+ }
644
+ continue;
645
+ }
646
+ tailCompletedBackgroundBashRun = undefined;
585
647
  if (item.kind === 'tool') {
586
648
  if (item.call.name === 'Read' && input.mode === 'normal') {
587
649
  if (tailReadRun?.summarized) {
@@ -704,6 +766,9 @@ function appendState(input, previous, change) {
704
766
  ...(pendingTools ? { pendingTools } : {}),
705
767
  ...(pendingShells ? { pendingShells } : {}),
706
768
  ...(tailReadRun ? { tailReadRun } : {}),
769
+ ...(tailCompletedBackgroundBashRun
770
+ ? { tailCompletedBackgroundBashRun }
771
+ : {}),
707
772
  revision: input.revision,
708
773
  };
709
774
  }
@@ -1,9 +1,37 @@
1
- export interface GlobFilesOptions {
1
+ import { type ProcessResult } from '../platform/bounded-process-runner.js';
2
+ export interface GlobSearchRequest {
2
3
  root: string;
3
4
  displayRoot: string;
4
5
  absoluteRoot: string;
5
6
  pattern: string;
6
7
  signal?: AbortSignal;
7
8
  }
8
- export declare function globFiles(options: GlobFilesOptions): Promise<string>;
9
+ export interface GlobSearchResult {
10
+ content: string;
11
+ isError: boolean;
12
+ }
13
+ export interface GlobSearch {
14
+ search(request: GlobSearchRequest): Promise<GlobSearchResult>;
15
+ }
16
+ export interface RipgrepGlobSearchOptions {
17
+ cwd: string;
18
+ timeoutMs: number;
19
+ environment?: Readonly<Record<string, string>>;
20
+ runner?: {
21
+ run(options: {
22
+ command: string;
23
+ args: readonly string[];
24
+ cwd?: string;
25
+ timeoutMs: number;
26
+ signal?: AbortSignal;
27
+ env?: Readonly<Record<string, string>>;
28
+ }): Promise<ProcessResult>;
29
+ };
30
+ }
31
+ export declare class RipgrepGlobSearch implements GlobSearch {
32
+ private readonly options;
33
+ private readonly runner;
34
+ constructor(options: RipgrepGlobSearchOptions);
35
+ search(request: GlobSearchRequest): Promise<GlobSearchResult>;
36
+ }
9
37
  //# sourceMappingURL=glob.d.ts.map
@@ -1,88 +1,97 @@
1
- import { lstat, opendir } from 'node:fs/promises';
2
1
  import { isAbsolute, join, resolve, sep } from 'node:path';
3
2
  import { minimatch } from 'minimatch';
3
+ import { BoundedProcessRunner, joinedProcessOutput, } from '../platform/bounded-process-runner.js';
4
4
  const MAX_RESULTS = 100;
5
+ const MAX_ENUMERATION_BYTES = 2 * 1024 * 1024;
6
+ function portable(value) {
7
+ return sep === '/' ? value : value.split(sep).join('/');
8
+ }
5
9
  function abortError() {
6
10
  return new DOMException('Tool execution aborted', 'AbortError');
7
11
  }
8
- function portablePath(path) {
9
- return sep === '/' ? path : path.split(sep).join('/');
10
- }
11
- function compareMatches(left, right) {
12
- return left.mtimeMs - right.mtimeMs || left.order - right.order;
13
- }
14
- function insertOldest(matches, candidate) {
15
- let low = 0;
16
- let high = matches.length;
17
- while (low < high) {
18
- const middle = Math.floor((low + high) / 2);
19
- const current = matches[middle];
20
- if (current && compareMatches(current, candidate) <= 0)
21
- low = middle + 1;
22
- else
23
- high = middle;
12
+ export class RipgrepGlobSearch {
13
+ options;
14
+ runner;
15
+ constructor(options) {
16
+ this.options = options;
17
+ this.runner =
18
+ options.runner ??
19
+ new BoundedProcessRunner({
20
+ cwd: options.cwd,
21
+ maxOutputBytes: MAX_ENUMERATION_BYTES,
22
+ });
24
23
  }
25
- matches.splice(low, 0, candidate);
26
- if (matches.length > MAX_RESULTS)
27
- matches.pop();
28
- }
29
- export async function globFiles(options) {
30
- const pattern = portablePath(options.pattern);
31
- const absolutePattern = isAbsolute(options.pattern);
32
- const matchBase = !absolutePattern && !pattern.includes('/');
33
- const matches = [];
34
- const directories = [''];
35
- let matchCount = 0;
36
- let order = 0;
37
- while (directories.length > 0) {
38
- if (options.signal?.aborted)
24
+ async search(request) {
25
+ if (request.signal?.aborted)
39
26
  throw abortError();
40
- const relativeDirectory = directories.pop() ?? '';
41
- const directory = await opendir(join(options.root, relativeDirectory));
42
- for await (const entry of directory) {
43
- if (options.signal?.aborted)
44
- throw abortError();
45
- const relativePath = join(relativeDirectory, entry.name);
46
- if (entry.isDirectory()) {
47
- directories.push(relativePath);
48
- continue;
49
- }
50
- if (!entry.isFile())
51
- continue;
52
- const portableRelativePath = portablePath(relativePath);
53
- const absolutePath = portablePath(resolve(options.absoluteRoot, relativePath));
54
- if (pattern !== '' &&
55
- !minimatch(absolutePattern ? absolutePath : portableRelativePath, pattern, { dot: true, matchBase, noext: true })) {
56
- continue;
57
- }
58
- let metadata;
59
- try {
60
- metadata = await lstat(join(options.root, relativePath));
61
- }
62
- catch (error) {
63
- if (error.code === 'ENOENT')
64
- continue;
65
- throw error;
66
- }
67
- if (!metadata.isFile())
68
- continue;
69
- const displayPath = absolutePattern
70
- ? absolutePath
71
- : portablePath(join(options.displayRoot, relativePath));
72
- insertOldest(matches, {
73
- path: displayPath,
74
- mtimeMs: metadata.mtimeMs,
75
- order,
27
+ const hidden = this.options.environment?.CLAUDE_CODE_GLOB_HIDDEN !== 'false';
28
+ const noIgnore = this.options.environment?.CLAUDE_CODE_GLOB_NO_IGNORE !== 'false';
29
+ let result;
30
+ try {
31
+ result = await this.runner.run({
32
+ command: 'rg',
33
+ args: [
34
+ '--files',
35
+ '--null',
36
+ '--sort',
37
+ 'modified',
38
+ ...(hidden ? ['--hidden'] : []),
39
+ ...(noIgnore ? ['--no-ignore'] : []),
40
+ ],
41
+ cwd: request.root,
42
+ timeoutMs: this.options.timeoutMs,
43
+ ...(request.signal ? { signal: request.signal } : {}),
44
+ ...(this.options.environment ? { env: this.options.environment } : {}),
76
45
  });
77
- matchCount += 1;
78
- order += 1;
79
46
  }
47
+ catch (error) {
48
+ if (error instanceof Error && error.name === 'AbortError')
49
+ throw error;
50
+ const message = error instanceof Error ? error.message : String(error);
51
+ throw new Error(`Glob enumeration failed: ${message}`);
52
+ }
53
+ if (result.timedOut)
54
+ return {
55
+ content: `Search timed out after ${this.options.timeoutMs}ms`,
56
+ isError: true,
57
+ };
58
+ if (result.truncated)
59
+ throw new Error('Glob enumeration failed: output truncated');
60
+ if (result.code !== 0) {
61
+ if (result.code === 1 && !result.stdout && !result.stderr)
62
+ return { content: 'No files found', isError: false };
63
+ const output = joinedProcessOutput(result);
64
+ throw new Error(`Glob enumeration failed with exit code ${result.code}${output ? `: ${output}` : ''}`);
65
+ }
66
+ const pattern = portable(request.pattern);
67
+ const absolutePattern = isAbsolute(request.pattern);
68
+ const matchBase = !absolutePattern && !pattern.includes('/');
69
+ const paths = result.stdout.split('\0').filter(Boolean);
70
+ const matched = paths.filter((path) => {
71
+ const relativePath = portable(path);
72
+ const absolutePath = portable(resolve(request.absoluteRoot, relativePath));
73
+ return (pattern === '' ||
74
+ minimatch(absolutePattern ? absolutePath : relativePath, pattern, {
75
+ dot: true,
76
+ matchBase,
77
+ noext: true,
78
+ }));
79
+ });
80
+ if (matched.length === 0)
81
+ return { content: 'No files found', isError: false };
82
+ const display = matched.slice(0, MAX_RESULTS).map((path) => {
83
+ const relativePath = portable(path);
84
+ if (absolutePattern)
85
+ return portable(resolve(request.absoluteRoot, relativePath));
86
+ return portable(join(request.displayRoot, relativePath));
87
+ });
88
+ const content = display.join('\n');
89
+ return {
90
+ content: matched.length > MAX_RESULTS
91
+ ? `${content}\n(Showing 100 of ${matched.length} matching files; ${matched.length - 100} more are not listed. Narrow the pattern or path to see the rest.)`
92
+ : content,
93
+ isError: false,
94
+ };
80
95
  }
81
- if (matchCount === 0)
82
- return 'No files found';
83
- const content = matches.map((match) => match.path).join('\n');
84
- return matchCount > MAX_RESULTS
85
- ? `${content}\n(Showing ${MAX_RESULTS} of ${matchCount} matching files; ${matchCount - MAX_RESULTS} more are not listed. Narrow the pattern or path to see the rest.)`
86
- : content;
87
96
  }
88
97
  //# sourceMappingURL=glob.js.map
@@ -1,4 +1,5 @@
1
1
  import type { ModelToolCall, ModelToolDefinition, ToolExecutionContext, ToolExecutionResult, ToolRegistry } from '../core/runtime.js';
2
+ import { type GlobSearch } from './glob.js';
2
3
  import type { DataPlane } from '../persistence/data-plane.js';
3
4
  export interface LocalToolRegistryOptions {
4
5
  cwd: string;
@@ -17,6 +18,7 @@ export interface LocalToolRegistryOptions {
17
18
  sandbox?: BashSandboxRuntime;
18
19
  homeDirectory?: string;
19
20
  configRoot?: string;
21
+ globSearch?: GlobSearch;
20
22
  }
21
23
  export interface BashSandboxRuntime {
22
24
  shouldUseSandbox(input: {
@@ -46,6 +48,7 @@ export declare class LocalToolRegistry implements ToolRegistry {
46
48
  private readonly maxShellTimeoutMs;
47
49
  private readonly maxSearchTimeoutMs;
48
50
  private readonly processRunner;
51
+ private readonly globSearch;
49
52
  private readonly enableReportFindings;
50
53
  private readonly environment;
51
54
  private readonly sessionEnvironment;
@@ -55,6 +58,7 @@ export declare class LocalToolRegistry implements ToolRegistry {
55
58
  private readonly sessionCwds;
56
59
  private readonly protectedWriteReason;
57
60
  private readonly mutationTargetExisted;
61
+ private readonly preparedGlobRoots;
58
62
  constructor(options: LocalToolRegistryOptions);
59
63
  private assertProtectedWritePath;
60
64
  private assertProtectedBashCommand;
@@ -7,7 +7,7 @@ import sharp from 'sharp';
7
7
  import { countTokens } from '@anthropic-ai/tokenizer';
8
8
  import { commandShell, commandShellArguments, } from '../platform/command-shell.js';
9
9
  import { BoundedProcessRunner, joinedProcessOutput, } from '../platform/bounded-process-runner.js';
10
- import { globFiles } from './glob.js';
10
+ import { RipgrepGlobSearch } from './glob.js';
11
11
  import { countLineChanges } from './line-changes.js';
12
12
  import { editNotebook, formatNotebookForRead } from './notebook.js';
13
13
  import { openPdf } from './pdf.js';
@@ -537,6 +537,7 @@ export class LocalToolRegistry {
537
537
  maxShellTimeoutMs;
538
538
  maxSearchTimeoutMs;
539
539
  processRunner;
540
+ globSearch;
540
541
  enableReportFindings;
541
542
  environment;
542
543
  sessionEnvironment;
@@ -546,6 +547,7 @@ export class LocalToolRegistry {
546
547
  sessionCwds = new Map();
547
548
  protectedWriteReason;
548
549
  mutationTargetExisted = new WeakMap();
550
+ preparedGlobRoots = new WeakMap();
549
551
  constructor(options) {
550
552
  this.cwd = resolve(options.cwd);
551
553
  this.cwdProvider = options.cwdProvider;
@@ -576,6 +578,13 @@ export class LocalToolRegistry {
576
578
  cwd: this.cwd,
577
579
  maxOutputBytes: this.maxOutputBytes,
578
580
  });
581
+ this.globSearch =
582
+ options.globSearch ??
583
+ new RipgrepGlobSearch({
584
+ cwd: this.cwd,
585
+ timeoutMs: this.maxSearchTimeoutMs,
586
+ ...(this.environment ? { environment: this.environment } : {}),
587
+ });
579
588
  }
580
589
  assertProtectedWritePath(filePath) {
581
590
  const reason = this.protectedWriteReason(filePath);
@@ -805,14 +814,16 @@ export class LocalToolRegistry {
805
814
  case 'Glob': {
806
815
  const pathInput = call.input.path;
807
816
  const requestedPath = pathInput === undefined ? '.' : stringInput(call.input, 'path');
808
- await this.globRoot(requestedPath, context);
809
- return {
817
+ const root = await this.globRoot(requestedPath, context);
818
+ const prepared = {
810
819
  ...call,
811
820
  input: {
812
821
  pattern: stringInput(call.input, 'pattern', true),
813
822
  ...(pathInput === undefined ? {} : { path: requestedPath }),
814
823
  },
815
824
  };
825
+ this.preparedGlobRoots.set(prepared, root);
826
+ return prepared;
816
827
  }
817
828
  case 'Grep': {
818
829
  const glob = optionalString(call.input, 'glob');
@@ -857,6 +868,11 @@ export class LocalToolRegistry {
857
868
  if (JSON.stringify(prepared.input) !== JSON.stringify(call.input)) {
858
869
  throw new Error('Tool input changed after permission approval');
859
870
  }
871
+ const approvedGlobRoot = this.preparedGlobRoots.get(call);
872
+ if (approvedGlobRoot !== undefined &&
873
+ this.preparedGlobRoots.get(prepared) !== approvedGlobRoot) {
874
+ throw new Error('Tool input changed after permission approval');
875
+ }
860
876
  const executionTargetExisted = this.mutationTargetExisted.get(prepared);
861
877
  if (approvedTargetExisted !== undefined &&
862
878
  executionTargetExisted !== approvedTargetExisted) {
@@ -1466,37 +1482,21 @@ export class LocalToolRegistry {
1466
1482
  }
1467
1483
  async glob(call, context) {
1468
1484
  const requestedPath = call.input.path === undefined ? '.' : stringInput(call.input, 'path');
1469
- const root = await this.globRoot(requestedPath, context);
1470
- const timeoutSignal = AbortSignal.timeout(this.maxSearchTimeoutMs);
1471
- const searchSignal = context.signal
1472
- ? AbortSignal.any([context.signal, timeoutSignal])
1473
- : timeoutSignal;
1474
- try {
1475
- const content = await globFiles({
1476
- root,
1477
- displayRoot: call.input.path === undefined ? '.' : requestedPath,
1478
- absoluteRoot: isAbsolute(requestedPath)
1479
- ? resolve(requestedPath)
1480
- : resolve(this.currentCwd(context), requestedPath),
1481
- pattern: stringInput(call.input, 'pattern', true),
1482
- signal: searchSignal,
1483
- });
1484
- return {
1485
- content: truncateOutput(content, this.maxOutputBytes),
1486
- isError: false,
1487
- };
1488
- }
1489
- catch (error) {
1490
- if (context.signal?.aborted)
1491
- throw abortError();
1492
- if (timeoutSignal.aborted) {
1493
- return {
1494
- content: `Search timed out after ${this.maxSearchTimeoutMs}ms`,
1495
- isError: true,
1496
- };
1497
- }
1498
- throw error;
1499
- }
1485
+ const root = this.preparedGlobRoots.get(call) ??
1486
+ (await this.globRoot(requestedPath, context));
1487
+ const result = await this.globSearch.search({
1488
+ root,
1489
+ displayRoot: call.input.path === undefined ? '.' : requestedPath,
1490
+ absoluteRoot: isAbsolute(requestedPath)
1491
+ ? resolve(requestedPath)
1492
+ : resolve(this.currentCwd(context), requestedPath),
1493
+ pattern: stringInput(call.input, 'pattern', true),
1494
+ ...(context.signal ? { signal: context.signal } : {}),
1495
+ });
1496
+ return {
1497
+ content: truncateOutput(result.content, this.maxOutputBytes),
1498
+ isError: result.isError,
1499
+ };
1500
1500
  }
1501
1501
  async grep(call, context) {
1502
1502
  const workspaceRoot = await realpath(this.currentCwd(context));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "praxis-agent",
3
- "version": "0.57.0",
3
+ "version": "0.59.0",
4
4
  "description": "Local-first, single-user general agent for the command line.",
5
5
  "license": "MIT",
6
6
  "author": "wuqisen",
@@ -62,7 +62,7 @@
62
62
  "verify:ci-coverage": "node scripts/verify-ci-coverage.mjs",
63
63
  "verify:fixture-contracts": "node scripts/verify-fixture-contracts.mjs",
64
64
  "test:fixtures": "node scripts/run-fixture-contracts.mjs",
65
- "test:eval:baseline": "vitest run src/evals/coding-baseline.test.ts src/evals/project-eval-comparison.test.ts src/evals/apply-patch-admission.test.ts src/evals/lsp-diagnostics-admission.test.ts"
65
+ "test:eval:baseline": "vitest run src/evals/coding-baseline.test.ts src/evals/project-eval-comparison.test.ts src/evals/apply-patch-admission.test.ts src/evals/lsp-diagnostics-admission.test.ts src/evals/glob-ripgrep-admission.test.ts"
66
66
  },
67
67
  "engines": {
68
68
  "node": ">=24"