diff --git a/src/browser/components/GitStatusIndicator/MultiProjectDivergenceDialog.tsx b/src/browser/components/GitStatusIndicator/MultiProjectDivergenceDialog.tsx
index 19a7b17042..c372123163 100644
--- a/src/browser/components/GitStatusIndicator/MultiProjectDivergenceDialog.tsx
+++ b/src/browser/components/GitStatusIndicator/MultiProjectDivergenceDialog.tsx
@@ -10,6 +10,7 @@ import { cn } from "@/common/lib/utils";
import assert from "@/common/utils/assert";
import type { GitStatus } from "@/common/types/workspace";
import type { MultiProjectGitSummary } from "@/browser/stores/GitStatusStore";
+import { formatRepoCount } from "./gitStatusFormatters";
interface MultiProjectDivergenceDialogProps {
isOpen: boolean;
@@ -18,10 +19,6 @@ interface MultiProjectDivergenceDialogProps {
isRefreshing: boolean;
}
-function formatRepoCount(count: number): string {
- return `${count} ${count === 1 ? "repo" : "repos"}`;
-}
-
function formatLineDelta(additions: number, deletions: number): React.ReactNode {
if (additions === 0 && deletions === 0) {
return 0;
diff --git a/src/browser/components/GitStatusIndicator/MultiProjectGitStatusIndicator.tsx b/src/browser/components/GitStatusIndicator/MultiProjectGitStatusIndicator.tsx
index 937b5eab3a..59c4904280 100644
--- a/src/browser/components/GitStatusIndicator/MultiProjectGitStatusIndicator.tsx
+++ b/src/browser/components/GitStatusIndicator/MultiProjectGitStatusIndicator.tsx
@@ -10,6 +10,7 @@ import { stopKeyboardPropagation } from "@/browser/utils/events";
import { cn } from "@/common/lib/utils";
import assert from "@/common/utils/assert";
import { Tooltip, TooltipContent, TooltipTrigger } from "../Tooltip/Tooltip";
+import { formatRepoCount } from "./gitStatusFormatters";
interface MultiProjectGitStatusIndicatorProps {
workspaceId: string;
@@ -24,10 +25,6 @@ interface ChipPresentation {
className: string;
}
-function formatRepoCount(count: number): string {
- return `${count} ${count === 1 ? "repo" : "repos"}`;
-}
-
function formatCategoryCount(count: number, noun: string): string {
return `${count} ${noun}`;
}
diff --git a/src/browser/components/GitStatusIndicator/gitStatusFormatters.ts b/src/browser/components/GitStatusIndicator/gitStatusFormatters.ts
new file mode 100644
index 0000000000..8ec03ba6cd
--- /dev/null
+++ b/src/browser/components/GitStatusIndicator/gitStatusFormatters.ts
@@ -0,0 +1,4 @@
+/** Pluralize a repo count: "1 repo" / "3 repos". */
+export function formatRepoCount(count: number): string {
+ return `${count} ${count === 1 ? "repo" : "repos"}`;
+}
diff --git a/src/browser/features/Analytics/DelegationChart.tsx b/src/browser/features/Analytics/DelegationChart.tsx
index 9cb5f9c760..5a07a9cf50 100644
--- a/src/browser/features/Analytics/DelegationChart.tsx
+++ b/src/browser/features/Analytics/DelegationChart.tsx
@@ -8,7 +8,7 @@ import {
XAxis,
YAxis,
} from "recharts";
-import assert from "@/common/utils/assert";
+import { capitalize } from "@/common/utils/capitalize";
import { Skeleton } from "@/browser/components/Skeleton/Skeleton";
import type { DelegationSummary } from "@/browser/hooks/useAnalytics";
import {
@@ -51,14 +51,6 @@ const DELEGATION_TOKEN_CATEGORIES: Array<{
{ key: "reasoningTokens", label: "Reasoning", color: TOKEN_CATEGORY_COLORS.reasoningTokens },
];
-function capitalize(s: string): string {
- assert(typeof s === "string", "capitalize expects a string");
- if (s.length === 0) {
- return s;
- }
- return s.charAt(0).toUpperCase() + s.slice(1);
-}
-
function formatCompressionRatio(compressionRatio: number): string {
if (!Number.isFinite(compressionRatio) || compressionRatio <= 0) {
return "N/A";
diff --git a/src/browser/hooks/useStableReference.ts b/src/browser/hooks/useStableReference.ts
index 71fb6915a8..2bcbefbc9d 100644
--- a/src/browser/hooks/useStableReference.ts
+++ b/src/browser/hooks/useStableReference.ts
@@ -51,21 +51,6 @@ export function compareRecords(
return true;
}
-/**
- * Compare two Sets for equality (same size and values).
- *
- * @param prev Previous Set
- * @param next Next Set
- * @returns true if Sets are equal, false otherwise
- */
-export function compareSets(a: Set, b: Set): boolean {
- if (a.size !== b.size) return false;
- for (const item of a) {
- if (!b.has(item)) return false;
- }
- return true;
-}
-
/**
* Compare two Arrays for deep equality (same length and values).
* Uses === for value comparison by default.
diff --git a/src/common/utils/ai/modelDisplay.ts b/src/common/utils/ai/modelDisplay.ts
index e89886378b..8ad9d3f9ab 100644
--- a/src/common/utils/ai/modelDisplay.ts
+++ b/src/common/utils/ai/modelDisplay.ts
@@ -2,6 +2,8 @@
* Formatting utilities for model display names
*/
+import { capitalize } from "../capitalize";
+
/**
* Format a model name for display with proper capitalization and spacing.
*
@@ -170,14 +172,6 @@ export function formatModelDisplayName(modelName: string): string {
return modelName.split("-").map(capitalize).join(" ");
}
-/**
- * Capitalize the first letter of a string
- */
-function capitalize(str: string): string {
- if (!str) return str;
- return str.charAt(0).toUpperCase() + str.slice(1);
-}
-
/**
* Format version numbers: ["4", "5"] -> "4.5"
*/
diff --git a/src/common/utils/asyncEventIterator.ts b/src/common/utils/asyncEventIterator.ts
index 068075f848..8a7c05e5c0 100644
--- a/src/common/utils/asyncEventIterator.ts
+++ b/src/common/utils/asyncEventIterator.ts
@@ -1,71 +1,3 @@
-/**
- * Convert event emitter subscription to async iterator.
- *
- * Handles the common pattern of:
- * 1. Subscribe to events
- * 2. Yield events as async iterator
- * 3. Unsubscribe on cleanup
- *
- * Usage:
- * ```ts
- * yield* asyncEventIterator(
- * (handler) => emitter.on('event', handler),
- * (handler) => emitter.off('event', handler)
- * );
- * ```
- *
- * Or with initialValue for immediate first yield:
- * ```ts
- * yield* asyncEventIterator(
- * (handler) => service.onChange(handler),
- * (handler) => service.offChange(handler),
- * { initialValue: await service.getState() }
- * );
- * ```
- */
-export async function* asyncEventIterator(
- subscribe: (handler: (value: T) => void) => void,
- unsubscribe: (handler: (value: T) => void) => void,
- options?: { initialValue?: T }
-): AsyncGenerator {
- const queue: T[] = [];
- let resolveNext: ((value: T) => void) | null = null;
- let ended = false;
-
- const handler = (value: T) => {
- if (ended) return;
- if (resolveNext) {
- const resolve = resolveNext;
- resolveNext = null;
- resolve(value);
- } else {
- queue.push(value);
- }
- };
-
- subscribe(handler);
-
- try {
- // Yield initial value if provided
- if (options?.initialValue !== undefined) {
- yield options.initialValue;
- }
-
- while (!ended) {
- if (queue.length > 0) {
- yield queue.shift()!;
- } else {
- yield await new Promise((resolve) => {
- resolveNext = resolve;
- });
- }
- }
- } finally {
- ended = true;
- unsubscribe(handler);
- }
-}
-
/**
* Create an async event queue that can be pushed to from event handlers.
*
diff --git a/src/common/utils/capitalize.ts b/src/common/utils/capitalize.ts
new file mode 100644
index 0000000000..6b18cd21c9
--- /dev/null
+++ b/src/common/utils/capitalize.ts
@@ -0,0 +1,7 @@
+/**
+ * Capitalize the first letter of a string.
+ */
+export function capitalize(str: string): string {
+ if (!str) return str;
+ return str.charAt(0).toUpperCase() + str.slice(1);
+}
diff --git a/src/node/orpc/router.ts b/src/node/orpc/router.ts
index b7dcb5cf61..200de3aea3 100644
--- a/src/node/orpc/router.ts
+++ b/src/node/orpc/router.ts
@@ -9,7 +9,7 @@ import {
} from "@/common/constants/muxGatewayOAuth";
import { Err, Ok } from "@/common/types/result";
import { resolveProviderCredentials } from "@/node/utils/providerRequirements";
-import { stripTrailingSlashes } from "@/node/utils/pathUtils";
+import { isPathInsideDir, stripTrailingSlashes } from "@/node/utils/pathUtils";
import { generateWorkspaceIdentity } from "@/node/services/workspaceTitleGenerator";
import type {
UpdateStatus,
@@ -148,14 +148,6 @@ function isErrnoWithCode(error: unknown, code: string): boolean {
return Boolean(error && typeof error === "object" && "code" in error && error.code === code);
}
-function isPathInsideDir(dirPath: string, filePath: string): boolean {
- const resolvedDir = path.resolve(dirPath);
- const resolvedFile = path.resolve(filePath);
- const relative = path.relative(resolvedDir, resolvedFile);
-
- return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
-}
-
function isTrustedProjectPath(context: ORPCContext, projectPath?: string | null): boolean {
return isProjectTrusted(context.config, projectPath);
}
diff --git a/src/node/services/browser/AgentBrowserSessionDiscoveryService.ts b/src/node/services/browser/AgentBrowserSessionDiscoveryService.ts
index 96e522c952..4437147243 100644
--- a/src/node/services/browser/AgentBrowserSessionDiscoveryService.ts
+++ b/src/node/services/browser/AgentBrowserSessionDiscoveryService.ts
@@ -6,6 +6,7 @@ import * as path from "node:path";
import { getErrorMessage } from "@/common/utils/errors";
import { log } from "@/node/services/log";
import { DisposableProcess } from "@/node/utils/disposableExec";
+import { isPathInsideDir } from "@/node/utils/pathUtils";
const CLI_TIMEOUT_MS = 30_000;
const PROCESS_CWD_TIMEOUT_MS = 5_000;
@@ -432,11 +433,6 @@ function normalizeComparablePath(filePath: string): string {
return process.platform === "win32" ? normalized.toLowerCase() : normalized;
}
-function isPathInsideDir(dirPath: string, filePath: string): boolean {
- const relative = path.relative(dirPath, filePath);
- return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
-}
-
async function readPositiveIntegerFile(
readFileFn: typeof fsPromises.readFile,
filePath: string
diff --git a/src/node/services/tools/task_apply_git_patch.ts b/src/node/services/tools/task_apply_git_patch.ts
index 41fa9008e5..1d5bd760fc 100644
--- a/src/node/services/tools/task_apply_git_patch.ts
+++ b/src/node/services/tools/task_apply_git_patch.ts
@@ -12,6 +12,7 @@ import {
import { shellQuote } from "@/common/utils/shell";
import { execBuffered } from "@/node/utils/runtime/helpers";
import { gitNoHooksPrefix } from "@/node/utils/gitNoHooksEnv";
+import { isPathInsideDir } from "@/node/utils/pathUtils";
import {
getSubagentGitPatchMboxPath,
markSubagentGitPatchArtifactApplied,
@@ -77,14 +78,6 @@ function mergeNotes(...notes: Array): string | undefined {
return parts.length > 0 ? parts.join("\n") : undefined;
}
-function isPathInsideDir(dirPath: string, filePath: string): boolean {
- const resolvedDir = path.resolve(dirPath);
- const resolvedFile = path.resolve(filePath);
- const relative = path.relative(resolvedDir, resolvedFile);
-
- return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
-}
-
async function tryRevParseHead(params: {
runtime: ToolConfiguration["runtime"];
cwd: string;
diff --git a/src/node/services/workspaceService.ts b/src/node/services/workspaceService.ts
index 1625e02cc0..2bb2b2dde6 100644
--- a/src/node/services/workspaceService.ts
+++ b/src/node/services/workspaceService.ts
@@ -12,6 +12,7 @@ import { Ok, Err } from "@/common/types/result";
import { askUserQuestionManager } from "@/node/services/askUserQuestionManager";
import { delegatedToolCallManager } from "@/node/services/delegatedToolCallManager";
import { log } from "@/node/services/log";
+import { isPathInsideDir } from "@/node/utils/pathUtils";
import { AgentSession } from "@/node/services/agentSession";
import type { HistoryService } from "@/node/services/historyService";
import type { AIService } from "@/node/services/aiService";
@@ -401,14 +402,6 @@ async function resetForkedSessionUsage(
);
}
-function isPathInsideDir(dirPath: string, filePath: string): boolean {
- const resolvedDir = path.resolve(dirPath);
- const resolvedFile = path.resolve(filePath);
- const relative = path.relative(resolvedDir, resolvedFile);
-
- return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
-}
-
function isPositiveInteger(value: unknown): value is number {
return (
typeof value === "number" && Number.isFinite(value) && Number.isInteger(value) && value > 0
diff --git a/src/node/utils/pathUtils.ts b/src/node/utils/pathUtils.ts
index 236f2239af..4ec464da4c 100644
--- a/src/node/utils/pathUtils.ts
+++ b/src/node/utils/pathUtils.ts
@@ -106,3 +106,21 @@ export async function isGitRepository(projectPath: string): Promise {
return false;
}
}
+
+/**
+ * Check whether `filePath` is equal to or nested inside `dirPath`.
+ *
+ * Both paths are resolved to absolute form first, so relative segments
+ * and missing trailing slashes are handled automatically.
+ *
+ * @example
+ * isPathInsideDir("/home/user/project", "/home/user/project/src/index.ts") // true
+ * isPathInsideDir("/home/user/project", "/home/user/other/file.ts") // false
+ */
+export function isPathInsideDir(dirPath: string, filePath: string): boolean {
+ const resolvedDir = path.resolve(dirPath);
+ const resolvedFile = path.resolve(filePath);
+ const relative = path.relative(resolvedDir, resolvedFile);
+
+ return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
+}