socraticode
Version:
SocratiCode — MCP server for local codebase indexing, semantic search, and code dependency graphs. All private, all local via Docker.
608 lines • 34.5 kB
JavaScript
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright (C) 2026 Giancarlo Erra - Altaire Limited
import path from "node:path";
import { collectionName, projectIdFromPath } from "../config.js";
import { getWatcherMode, mergeExtraExtensions, QDRANT_MODE } from "../constants.js";
import { awaitGraphBuild, getGraphBuildInProgressProjects, invalidateGraphCache, invalidateGraphCacheForIdentity, isGraphBuildInProgress } from "../services/code-graph.js";
import { getContextIndexingInProgressProjects } from "../services/context-artifacts.js";
import { ensureQdrantReady, isDockerAvailable } from "../services/docker.js";
import { finishGitIndex, gitRefreshStatus, prepareGitIndex, stopGitRefresh, withGitRefreshNotice } from "../services/git-refresh.js";
import { ensureEffectiveEmbeddingReady } from "../services/index-profile.js";
import { getIndexingInProgressProjects, getIndexingProgress, indexProject, invalidateProjectHashesForIdentity, isIndexingInProgress, removeProjectIndex, requestCancellation, setIndexingProgress, updateProjectIndex } from "../services/indexer.js";
import { isProjectIdentityLocked, isProjectLocked, terminateLockHolder } from "../services/lock.js";
import { logger } from "../services/logger.js";
import { getProjectReclamationInventory, loadEffectiveIndexProfileForCollection, removeProjectReclamationEntry, } from "../services/qdrant.js";
import { acquireReclamationBarrier, WRITER_OPERATIONS } from "../services/reclamation-barrier.js";
import { dropSymbolGraphCache } from "../services/symbol-graph-cache.js";
import { resetSymbolGraphCollectionCache } from "../services/symbol-graph-store.js";
import { getWatchedProjects, isWatching, startWatching, startWatchingAutomatically, stopWatching } from "../services/watcher.js";
const DOCKER_NOT_AVAILABLE_MESSAGE = [
"❌ Docker is not available.",
"",
"SocratiCode requires Docker to manage the Qdrant container.",
"",
"To fix this:",
" 1. Install Docker Desktop from https://www.docker.com/products/docker-desktop/",
" 2. Make sure Docker Desktop is running (check for the whale icon in your system tray/menu bar)",
" 3. Try this command again",
"",
"Alternatively, set QDRANT_MODE=external and point QDRANT_URL at a remote Qdrant server.",
"",
"Run codebase_health for a full infrastructure diagnostic.",
].join("\n");
async function ensureInfrastructure(projectPath, onProgress) {
const messages = [];
const docker = await ensureQdrantReady(onProgress);
if (docker.pulled)
messages.push("Pulled Qdrant Docker image.");
if (docker.started)
messages.push("Started Qdrant container.");
const resolvedPath = path.resolve(projectPath);
const collection = collectionName(projectIdFromPath(resolvedPath));
const profile = await loadEffectiveIndexProfileForCollection(collection);
const readiness = await ensureEffectiveEmbeddingReady(profile, onProgress);
if (readiness.imagePulled)
messages.push("Pulled Ollama Docker image.");
if (readiness.containerStarted)
messages.push("Started Ollama container.");
if (readiness.modelPulled)
messages.push(`Pulled ${profile.embedding.model} model.`);
return messages;
}
function formatIndexingInProgressMessage(resolvedPath, requestedTool) {
const progress = getIndexingProgress(resolvedPath);
const lines = [
`⚠ Indexing is already in progress for: ${resolvedPath}`,
`Cannot run ${requestedTool} — please wait for the current operation to finish.`,
"",
];
if (progress) {
lines.push(`Operation: ${progress.type === "full-index" ? "Full index" : "Incremental update"}`);
lines.push(`Phase: ${progress.phase}`);
}
lines.push("", "Use codebase_status to check current indexing state.");
return lines.join("\n");
}
function formatPruneInventory() {
return getProjectReclamationInventory().then((inventory) => {
if (inventory.entries.length === 0 && inventory.unrecognisedMetadata.length === 0 && inventory.unattributedCollections.length === 0) {
return "No stored project identities found.";
}
const lines = [
"Stored project inventory:",
"",
"Path state is local observation only. An absent path on this host is not proof that an identity is stale: in a shared store the path may exist only where the index was written.",
"Apply requires the exact identity, its confirmation token, and acknowledgeNoRemoteWriters: true.",
];
for (const entry of inventory.entries) {
lines.push("", `Identity: ${entry.identity}`);
lines.push(` Path: ${entry.projectPath ?? "(unknown)"}`);
lines.push(` Path state: ${entry.pathState}`);
if (entry.canonicalPath)
lines.push(` Canonical path: ${entry.canonicalPath}`);
lines.push(` Collections: ${entry.resourceCollections.join(", ") || "(none)"}`);
for (const record of entry.metadataRecords) {
const facts = [
record.indexingStatus ? `status=${record.indexingStatus}` : null,
record.lastIndexedAt ? `indexed=${record.lastIndexedAt}` : null,
record.lastBuiltAt ? `built=${record.lastBuiltAt}` : null,
record.builtByVersion ? `by=${record.builtByVersion}` : null,
record.projectPath && record.projectPath !== entry.projectPath ? `path=${record.projectPath}` : null,
].filter((fact) => fact !== null);
lines.push(` Metadata: ${record.collectionName} [point ${record.pointId}]${facts.length ? ` ${facts.join(" ")}` : ""}`);
}
if (entry.inProgress)
lines.push(" Indexing in progress: a metadata record is mid-write; deletion is refused until it completes");
if (entry.possibleSuperseded)
lines.push(" Advisory: possible-superseded (another identity has this canonical path)");
for (const reason of entry.manualInspectionReasons)
lines.push(` Manual inspection required: ${reason}`);
lines.push(` Confirmation token: ${entry.confirmationToken}`);
}
if (inventory.unrecognisedMetadata.length > 0) {
lines.push("", `${inventory.unrecognisedMetadata.length} metadata point${inventory.unrecognisedMetadata.length === 1 ? "" : "s"} could not be attributed to an identity and cannot be deleted by this tool:`);
for (const unrecognised of inventory.unrecognisedMetadata) {
lines.push(` point ${unrecognised.pointId}: collectionName=${unrecognised.collectionName ?? "(none)"} projectPath=${unrecognised.projectPath ?? "(none)"} — ${unrecognised.reason}`);
}
}
if (inventory.unattributedCollections.length > 0) {
lines.push("", `${inventory.unattributedCollections.length} collection${inventory.unattributedCollections.length === 1 ? "" : "s"} could not be attributed to one identity and cannot be deleted by this tool:`);
for (const unattributed of inventory.unattributedCollections) {
lines.push(` ${unattributed.name} — ${unattributed.reason}`);
}
}
return lines.join("\n");
});
}
/** Why a fresh inventory forbids deleting the identity, or null; activity is judged separately. */
function pruneRefusal(entry, identity, confirmationToken) {
if (!entry)
return null;
if (entry.confirmationToken !== confirmationToken) {
return `Refusing to delete ${identity}: the inventory changed. Run codebase_prune again and use its new confirmation token.`;
}
if (entry.requiresManualInspection) {
return `Refusing to delete ${identity}: its identity cannot be established safely (${entry.manualInspectionReasons.join("; ")}).`;
}
if (entry.inProgress) {
return `Refusing to delete ${identity}: a metadata record reports indexing in progress.`;
}
return null;
}
function identityOfPath(projectPath) {
try {
return projectIdFromPath(projectPath);
}
catch {
return null;
}
}
/** What is writing to the identity, or null. Cross-process locks are read only before the barrier, which then holds them. */
async function reclamationActivity(identity, storedPath, crossProcess) {
const resolvedStored = storedPath === null ? null : path.resolve(storedPath);
// Matched by stored path too, so a path-hash entry whose directory is now pinned is still caught.
const touches = (paths) => paths.some((candidate) => {
const resolved = path.resolve(candidate);
return resolved === resolvedStored || identityOfPath(resolved) === identity;
});
if (touches(getIndexingInProgressProjects()))
return "indexing is in progress";
if (touches(getWatchedProjects()))
return "a watcher is running";
if (touches(getGraphBuildInProgressProjects()))
return "a graph build is in progress";
if (touches(getContextIndexingInProgressProjects()))
return "context artifacts are being indexed";
if (crossProcess) {
for (const operation of WRITER_OPERATIONS) {
if (await isProjectIdentityLocked(identity, operation))
return `its ${operation} lock is held by another process`;
}
}
return null;
}
async function findPruneEntry(identity) {
return (await getProjectReclamationInventory()).entries.find((candidate) => candidate.identity === identity);
}
/** Reuse exact-identity prune barriers and verification, with optional automatic-retirement authorization. */
export async function reclaimProjectIdentity(identity, confirmationToken, validateRetirement) {
const preview = await findPruneEntry(identity);
if (!preview)
return `No resources remain for identity ${identity}. Nothing to delete.`;
const previewRefusal = pruneRefusal(preview, identity, confirmationToken);
if (previewRefusal)
return previewRefusal;
let activity;
try {
activity = await reclamationActivity(identity, preview.projectPath, true);
}
catch (err) {
return `Refusing to delete ${identity}: ${err instanceof Error ? err.message : String(err)}`;
}
if (activity)
return `Refusing to delete ${identity}: ${activity}.`;
const barrier = await acquireReclamationBarrier(identity);
if (!barrier)
return `Refusing to delete ${identity}: a writer holds one of its locks, or the reclamation barrier could not be taken.`;
try {
const entry = await findPruneEntry(identity);
if (!entry)
return `No resources remain for identity ${identity}. Nothing to delete.`;
const refusal = pruneRefusal(entry, identity, confirmationToken);
if (refusal)
return refusal;
const lateActivity = await reclamationActivity(identity, entry.projectPath, false);
if (lateActivity)
return `Refusing to delete ${identity}: ${lateActivity}.`;
if (barrier.isCompromised())
return `Refusing to delete ${identity}: its reclamation barrier was lost to another process.`;
let retirementRefusal = validateRetirement ? await validateRetirement(entry) : null;
if (retirementRefusal)
return `Refusing to delete ${identity}: ${retirementRefusal}.`;
const mayContinue = validateRetirement ? async () => {
retirementRefusal = await validateRetirement(entry);
return retirementRefusal === null && !barrier.isCompromised();
} : () => !barrier.isCompromised();
const outcomes = validateRetirement
? await removeProjectReclamationEntry(entry, mayContinue, true)
: await removeProjectReclamationEntry(entry, mayContinue);
const lostBarrier = barrier.isCompromised();
invalidateGraphCacheForIdentity(identity);
if (entry.projectPath)
invalidateGraphCache(entry.projectPath);
invalidateProjectHashesForIdentity(identity);
dropSymbolGraphCache(identity);
resetSymbolGraphCollectionCache();
const remaining = await findPruneEntry(identity);
const leftover = remaining ? [
...remaining.resourceCollections.map((resource) => `collection ${resource}`),
...remaining.metadataRecords.map((record) => `metadata ${record.collectionName} [point ${record.pointId}]`),
] : [];
const lines = outcomes.map((outcome) => ` ${outcome.outcome}: ${outcome.kind} ${outcome.resource}${outcome.error ? ` (${outcome.error})` : ""}`);
if (lostBarrier || retirementRefusal || outcomes.some((outcome) => outcome.outcome !== "deleted") || leftover.length > 0) {
return [
`Cleanup for ${identity} is incomplete.`,
...(lostBarrier ? ["The reclamation barrier was lost during cleanup; deletion stopped at the first write after the loss."] : []),
...(retirementRefusal ? [`Automatic cleanup stopped: ${retirementRefusal}.`] : []),
...lines,
...(leftover.length > 0 ? ["Still stored after deletion:", ...leftover.map((item) => ` ${item}`)] : []),
"Inspect the inventory before retrying; a repeated apply with a fresh token is safe.",
].join("\n");
}
return [`Removed all inventoried resources for identity: ${identity}`, ...lines].join("\n");
}
finally {
await barrier.release();
}
}
export async function handleIndexTool(name, args) {
const projectPath = args.projectPath || process.cwd();
const progressMessages = [];
const onProgress = (msg) => {
progressMessages.push(msg);
logger.info(msg, { tool: name, projectPath });
};
switch (name) {
case "codebase_index": {
// Concurrency guard: if already indexing, return progress
const resolved = path.resolve(projectPath);
if (isIndexingInProgress(resolved)) {
return formatIndexingInProgressMessage(resolved, "codebase_index");
}
// Check Docker availability before anything else (managed mode only)
if (QDRANT_MODE === "managed" && !(await isDockerAvailable())) {
return DOCKER_NOT_AVAILABLE_MESSAGE;
}
// Set up infrastructure progress tracking so codebase_status shows what's happening
setIndexingProgress(resolved, {
type: "full-index",
startedAt: Date.now(),
filesTotal: 0,
filesProcessed: 0,
phase: "preparing infrastructure",
});
const infraProgress = (msg) => {
setIndexingProgress(resolved, {
type: "full-index",
startedAt: Date.now(),
filesTotal: 0,
filesProcessed: 0,
phase: msg,
});
logger.info(msg, { tool: "codebase_index", projectPath: resolved });
};
// Infrastructure setup is synchronous — we need Docker/Ollama running before indexing
let infraMessages;
try {
infraMessages = await ensureInfrastructure(resolved, infraProgress);
}
catch (error) {
// Clear the progress on infra failure
setIndexingProgress(resolved, null);
const msg = error instanceof Error ? error.message : String(error);
return `Infrastructure setup failed:\n\n${msg}\n\nRun codebase_health for a full diagnostic.`;
}
// Fire-and-forget: start indexing in the background
const bgOnProgress = (msg) => {
logger.info(msg, { tool: "codebase_index", projectPath: resolved });
};
const extraExts = mergeExtraExtensions(args.extraExtensions);
const gitOperation = await prepareGitIndex(resolved, extraExts.size > 0 ? extraExts : undefined);
// Clear infra progress — indexProject will set its own progress
setIndexingProgress(resolved, null);
// Start indexing — do NOT await. Runs in the background on the event loop.
indexProject(resolved, bgOnProgress, extraExts.size > 0 ? extraExts : undefined, gitOperation?.target)
.then(async (result) => {
await finishGitIndex(resolved, gitOperation, result);
logger.info("Background indexing completed", {
projectPath: resolved,
filesIndexed: result.filesIndexed,
chunksCreated: result.chunksCreated,
cancelled: result.cancelled,
});
// Auto-start watcher only if indexing completed (not cancelled)
if (!result.cancelled && !isWatching(resolved)) {
const started = await startWatchingAutomatically(resolved);
if (started) {
logger.info("Auto-started file watcher", { projectPath: resolved });
}
}
})
.catch(async (err) => {
const message = err instanceof Error ? err.message : String(err);
await finishGitIndex(resolved, gitOperation, { cancelled: false, skipped: message });
logger.error("Background indexing failed", { projectPath: resolved, error: message });
});
// Return immediately with instructions for the LLM
const lines = [
...infraMessages,
`Indexing started in the background for: ${resolved}`,
"",
"IMPORTANT: Indexing is now running asynchronously.",
"Call codebase_status to check progress. Keep calling it periodically until progress reaches 100%.",
"Once complete, you can use codebase_search to query the indexed codebase.",
];
if (getWatcherMode() === "git" && !gitOperation)
lines.push("", gitRefreshStatus(resolved));
return lines.join("\n");
}
case "codebase_update": {
// Concurrency guard: prevent duplicate indexing
const resolved = path.resolve(projectPath);
if (isIndexingInProgress(resolved)) {
return formatIndexingInProgressMessage(resolved, "codebase_update");
}
// Check Docker availability before anything else (managed mode only)
if (QDRANT_MODE === "managed" && !(await isDockerAvailable())) {
return DOCKER_NOT_AVAILABLE_MESSAGE;
}
const infraMessages = await ensureInfrastructure(resolved, onProgress);
const updateExtraExts = mergeExtraExtensions(args.extraExtensions);
const gitOperation = await prepareGitIndex(resolved, updateExtraExts.size > 0 ? updateExtraExts : undefined);
let result;
try {
result = await updateProjectIndex(projectPath, onProgress, updateExtraExts.size > 0 ? updateExtraExts : undefined, gitOperation?.target);
await finishGitIndex(resolved, gitOperation, result);
}
catch (err) {
await finishGitIndex(resolved, gitOperation, { cancelled: false, skipped: err instanceof Error ? err.message : String(err) });
throw err;
}
const lines = [
...infraMessages,
result.skipped ? `Index update skipped: ${result.skipped}` : `Updated project index: ${projectPath}`,
`Added: ${result.added}`,
`Updated: ${result.updated}`,
`Removed: ${result.removed}`,
`New chunks: ${result.chunksCreated}`,
"",
"Progress:",
...progressMessages,
];
// Auto-start watcher after successful update (not cancelled)
if (!result.cancelled && !isWatching(resolved)) {
const started = await startWatchingAutomatically(resolved);
if (started) {
logger.info("Auto-started file watcher after update", { projectPath: resolved });
}
}
if (getWatcherMode() === "git")
lines.push("", gitRefreshStatus(resolved));
return lines.join("\n");
}
case "codebase_remove": {
const resolved = path.resolve(projectPath);
stopGitRefresh(resolved);
// How long to wait for a SIGTERM'd cross-process to release its lock
const SIGNAL_TIMEOUT_MS = 10_000;
// How long to wait for a same-process batch to drain after cancellation.
// Embedding a large batch (hundreds of chunks) via an external API can take
// several minutes. 5 minutes is a safe upper bound; the loop exits as soon
// as the batch finishes, so in the common case it completes in seconds.
const DRAIN_TIMEOUT_MS = 5 * 60_000;
/**
* Poll until isProjectLocked returns false or the timeout elapses.
* Used after sending SIGTERM so we wait for the other process to exit
* and release the lock before proceeding with the destructive delete.
*/
async function waitForLockRelease(operation) {
const start = Date.now();
while (Date.now() - start < SIGNAL_TIMEOUT_MS) {
if (!(await isProjectLocked(resolved, operation)))
return;
await new Promise((r) => setTimeout(r, 200));
}
logger.warn("Lock was not released within timeout after SIGTERM, proceeding with remove", {
projectPath: resolved,
operation,
});
}
// 1a. Stop same-process watcher
if (isWatching(resolved)) {
await stopWatching(resolved);
logger.info("Stopped watcher before removing index", { projectPath: resolved });
}
// 1b. Terminate watcher in another process (cross-process)
if (await isProjectLocked(resolved, "watch")) {
const { terminated, pid } = await terminateLockHolder(resolved, "watch");
if (terminated) {
logger.info("Sent SIGTERM to cross-process watcher before remove", { pid, projectPath: resolved });
await waitForLockRelease("watch");
}
}
// 2a. Cancel same-process indexing and drain.
// Track whether indexing was in-flight in THIS process so we do not
// fall through to the cross-process path below (getLockHolderPid
// explicitly guards against returning our own PID, so terminateLockHolder
// can never send ourselves a signal).
const wasIndexingInThisProcess = isIndexingInProgress(resolved);
if (wasIndexingInThisProcess) {
requestCancellation(resolved);
logger.info("Requested cancellation of in-progress indexing before remove", { projectPath: resolved });
const drainStart = Date.now();
while (isIndexingInProgress(resolved) && Date.now() - drainStart < DRAIN_TIMEOUT_MS) {
await new Promise((r) => setTimeout(r, 200));
}
if (isIndexingInProgress(resolved)) {
// The current batch is still running. Deleting the Qdrant collection now
// would cause "Not Found" upsert errors as the batch finishes writing.
// We cannot forcibly interrupt an in-process async batch — cancellation
// fires at the next batch boundary. Refuse the remove and let the user retry.
logger.warn("In-process indexing did not drain within 5 min timeout, refusing remove to prevent data corruption", { projectPath: resolved });
return [
`⚠ Cannot remove index for ${projectPath}: indexing is still running.`,
"",
"Cancellation has been requested. The current embedding batch will finish shortly.",
"Please wait a moment and try codebase_remove again, or call codebase_stop first.",
].join("\n");
}
}
// 2b. Terminate indexing in another process (cross-process only — skip if we
// already handled it in-process, because terminateLockHolder cannot signal our
// own PID and would otherwise log a misleading "failed to terminate" warning).
if (!wasIndexingInThisProcess && (await isProjectLocked(resolved, "index"))) {
const { terminated, pid } = await terminateLockHolder(resolved, "index");
if (terminated) {
logger.info("Sent SIGTERM to cross-process indexing before remove", { pid, projectPath: resolved });
await waitForLockRelease("index");
}
else {
logger.warn("Failed to terminate cross-process indexing, proceeding with remove", { projectPath: resolved });
}
}
// 3. Wait for any in-flight graph build to finish
if (isGraphBuildInProgress(resolved)) {
logger.info("Waiting for in-flight graph build to finish before remove", { projectPath: resolved });
await awaitGraphBuild(resolved);
}
await removeProjectIndex(projectPath);
return `Removed index for: ${projectPath}`;
}
case "codebase_prune": {
if (args.apply !== true)
return formatPruneInventory();
const identity = args.identity;
const confirmationToken = args.confirmationToken;
if (typeof identity !== "string" || typeof confirmationToken !== "string") {
return "Prune apply requires the exact identity and confirmationToken returned by codebase_prune.";
}
if (args.acknowledgeNoRemoteWriters !== true) {
return [
`Refusing to delete ${identity}: acknowledgeNoRemoteWriters is not true.`,
"Locks on this host cannot see an indexer on another host sharing this Qdrant. Confirm that no other host is writing to this identity, then pass acknowledgeNoRemoteWriters: true.",
].join("\n");
}
return reclaimProjectIdentity(identity, confirmationToken);
}
case "codebase_stop": {
const resolved = path.resolve(projectPath);
// Case 1: This process is indexing — cancel in-memory
if (isIndexingInProgress(resolved)) {
const requested = requestCancellation(resolved);
if (!requested) {
return `No indexing operation is currently running for: ${resolved}`;
}
const progress = getIndexingProgress(resolved);
const phase = progress?.phase ?? "unknown";
const batches = progress?.batchesProcessed ?? 0;
const totalBatches = progress?.batchesTotal ?? "?";
return [
`Cancellation requested for: ${resolved}`,
`Current phase: ${phase} (batch ${batches}/${totalBatches})`,
"",
"The indexing operation will stop after the current batch finishes and checkpoints.",
"All progress up to that point is preserved — re-run codebase_index to resume.",
].join("\n");
}
// Case 2: Another process (orphan) holds the lock — try to SIGTERM it
if (await isProjectLocked(resolved, "index")) {
const { terminated, pid } = await terminateLockHolder(resolved, "index");
if (terminated) {
return [
`Sent termination signal to orphan indexing process (PID ${pid}) for: ${resolved}`,
"",
"The orphan process should shut down gracefully within a few seconds.",
"All checkpointed progress is preserved — re-run codebase_index to resume.",
].join("\n");
}
if (pid !== null) {
return [
`Found orphan indexing process (PID ${pid}) for: ${resolved}, but failed to terminate it.`,
"",
`You can manually kill it: kill ${pid}`,
"All checkpointed progress is preserved — re-run codebase_index to resume.",
].join("\n");
}
}
return `No indexing operation is currently running for: ${resolved}`;
}
case "codebase_watch": {
const action = args.action;
const watcherMode = getWatcherMode();
if (action === "start") {
if (watcherMode === "off" || watcherMode === "git") {
return [
`File watcher disabled by SOCRATICODE_WATCHER=${watcherMode}.`,
"Set SOCRATICODE_WATCHER=manual or auto and restart the MCP server before starting it.",
].join("\n");
}
await ensureInfrastructure(projectPath);
// Catch any changes made while the watcher was not running before starting it.
const resolved = path.resolve(projectPath);
let updateSummary = "";
try {
const result = await updateProjectIndex(resolved, onProgress);
const changed = result.added + result.updated + result.removed;
if (changed > 0) {
updateSummary = `\nCaught up ${changed} change(s) since last session: ${result.added} added, ${result.updated} updated, ${result.removed} removed.`;
}
else {
updateSummary = "\nIndex is already up to date.";
}
}
catch (err) {
// Non-fatal — watcher still starts even if catch-up update fails
const msg = err instanceof Error ? err.message : String(err);
logger.warn("codebase_watch: catch-up update failed (non-fatal)", { projectPath: resolved, error: msg });
updateSummary = `\nWarning: could not run catch-up update (${msg}). Watcher started anyway.`;
}
if (!isWatching(resolved)) {
const started = await startWatching(resolved);
if (!started) {
// Check if another process holds the watch lock
if (await isProjectLocked(resolved, "watch")) {
return `Already watched by another process: ${projectPath}${updateSummary}`;
}
return `Failed to start watching: ${projectPath}${updateSummary}`;
}
}
return `Started watching: ${projectPath}${updateSummary}`;
}
if (action === "stop") {
await stopWatching(projectPath);
if (watcherMode === "git")
return "No native file watcher is enabled. Git refresh remains enabled by SOCRATICODE_WATCHER=git.";
return `Stopped watching: ${projectPath}`;
}
// status
const watched = getWatchedProjects();
const resolved = path.resolve(projectPath);
// Check if the current project is watched by another process (cross-process lock)
const watchedByOtherProcess = !watched.includes(resolved) && await isProjectLocked(resolved, "watch");
if (watcherMode === "git") {
return withGitRefreshNotice(resolved, async () => [
"File watcher: disabled (SOCRATICODE_WATCHER=git)",
...(watched.includes(resolved) || watchedByOtherProcess ? ["Warning: an active file watcher in another configuration may still update this shared index."] : []),
].join("\n"));
}
if (watcherMode === "off") {
const lines = ["File watcher: disabled (SOCRATICODE_WATCHER=off)"];
if (watched.includes(resolved)) {
lines.push("Warning: this process still has an active watcher. Restart the MCP server to apply the changed environment setting.");
}
else if (watchedByOtherProcess) {
lines.push("Warning: another MCP process is still watching this project. Set SOCRATICODE_WATCHER=off for every process that uses this checkout.");
}
return lines.join("\n");
}
if (watched.length === 0 && !watchedByOtherProcess) {
if (watcherMode === "manual") {
return [
"No projects are currently being watched.",
"Automatic watcher startup is disabled by SOCRATICODE_WATCHER=manual; use codebase_watch with action=start to start it explicitly.",
].join("\n");
}
return "No projects are currently being watched.";
}
const statusItems = watched.map((p) => ` - ${p}`);
if (watchedByOtherProcess) {
statusItems.push(` - ${resolved} (watched by another process)`);
}
const modeLine = watcherMode === "manual" ? "\nWatcher mode: manual (started explicitly)" : "";
return `Currently watching:\n${statusItems.join("\n")}${modeLine}`;
}
default:
return `Unknown tool: ${name}`;
}
}
//# sourceMappingURL=index-tools.js.map