Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/wiki/Settings-Reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,8 @@ Some things are configured before the server starts, not in the UI:
| `CODEMAN_BASE_URL` | Mounts Codeman under a sub-path behind a reverse proxy that forwards the prefix unchanged. See [Remote Access](Remote-Access). |
| `CODEMAN_MAX_DOWNLOAD_BYTES` | Cap on raw file bodies and downloads. 2 GB by default, `0` for none. |
| `CODEMAN_MAX_REMOTE_FILE_SSH` | Concurrent ssh reads for files in remote cases. 4 by default. |
| `CODEMAN_PATH_PROBE_TIMEOUT_MS` | How long a linked case's folder may take to answer before it is shown as unreachable. 1500 ms by default; raise it for a slow but healthy mount. |
| `CODEMAN_PATH_PROBE_MAX_STALLED` | Unanswered folder checks allowed to pile up before new ones are refused. 3 by default. |

## Gotchas

Expand Down
35 changes: 35 additions & 0 deletions src/config/path-probe.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
/**
* @fileoverview Limits for the bounded path probe (`src/utils/bounded-path-probe.ts`).
*
* A linked case can live on a network mount, and a hard mount that went away makes
* `stat()` wait until the mount comes back. The probe gives up on such a path after
* `PATH_PROBE_TIMEOUT_MS` and answers "unknown", and it stops starting new probes
* once `MAX_STALLED_PATH_PROBES` timed-out stats are still holding libuv threadpool
* workers (the pool is shared by every `fs`, `dns.lookup` and `crypto` call in the
* process, and holds 4 workers unless `UV_THREADPOOL_SIZE` says otherwise).
*
* Both are env-overridable, in the same style as the other config modules. A slow
* but healthy mount (an sshfs that needs a couple of seconds on first touch) may want
* a longer timeout; a server started with a larger `UV_THREADPOOL_SIZE` can afford a
* higher stall cap.
*
* @module config/path-probe
*/

function envInt(name: string, fallback: number, min: number, max: number): number {
const raw = parseInt(process.env[name] || '', 10);
if (!Number.isFinite(raw) || raw <= 0) return fallback;
return Math.max(min, Math.min(max, raw));
}

/** How long a caller waits for one path probe before the answer is "unknown". */
export const PATH_PROBE_TIMEOUT_MS = envInt('CODEMAN_PATH_PROBE_TIMEOUT_MS', 1_500, 100, 60_000);

/**
* Timed-out probes allowed to stay pending before new probes are refused (answered
* "unknown" without a stat). This is a backstop, not the main defence: a stalled
* path already takes its neighbours (same parent directory) out of probing, so the
* cap only engages once three UNRELATED places have stopped answering. The default
* leaves one of libuv's default four workers free for the rest of the process.
*/
export const MAX_STALLED_PATH_PROBES = envInt('CODEMAN_PATH_PROBE_MAX_STALLED', 3, 1, 64);
76 changes: 63 additions & 13 deletions src/hooks-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,6 @@
*/

import { randomBytes } from 'node:crypto';
import { existsSync } from 'node:fs';
import { readFile, writeFile, mkdir, lstat, readdir, realpath, rename, unlink, rmdir, chmod } from 'node:fs/promises';
import { homedir } from 'node:os';
import { join, dirname } from 'node:path';
Expand All @@ -40,6 +39,39 @@ import type { HookEventType } from './types.js';
import { HOOK_TIMEOUT_SECONDS } from './config/auth-config.js';
import { dataPath } from './config/instance.js';
import { readJsonConfig, SETTINGS_PATH } from './web/route-helpers.js';
import { isNearStalledPath, probePath } from './utils/index.js';

/**
* Existence check for a WRITER. Unlike the bounded read-side probe (`probePath`),
* which gives up after a timeout and answers "unknown", this waits for the real
* answer: only ENOENT reads as absent, anything else throws, so a
* stalled or unreadable workspace can never be mistaken for an empty one and
* have its settings recreated over the top. It is async, so a dead mount ties
* up a threadpool worker rather than the event loop.
*/
async function pathExistsForWrite(path: string): Promise<boolean> {
try {
await lstat(path);
return true;
} catch (err) {
if ((err as NodeJS.ErrnoException).code === 'ENOENT') return false;
throw err;
}
}

/**
* Whether a READ-side helper should leave `path` alone: it is definitely absent, or
* it sits on a mount that is not answering (near a stalled probe). An "unknown"
* that is NOT near a stalled probe (the probe was refused for capacity, or the stat
* failed with something other than ENOENT) is not a reason to skip: the caller goes
* on, and its own async read or write settles the question for that one path.
*/
async function absentOrUnreachable(path: string): Promise<'absent' | 'unreachable' | false> {
const state = await probePath(path);
if (state === 'absent') return 'absent';
if (state === 'unknown' && isNearStalledPath(path)) return 'unreachable';
return false;
}

/**
* Serializes read-modify-write access to a `settings.local.json` path. Every
Expand Down Expand Up @@ -558,7 +590,7 @@ export async function stripCaseEnvKeys(casePath: string, keysToRemove: readonly
if (keysToRemove.length === 0) return;

await withSafeSettingsWrite(casePath, 'env-key removal', async (_claudeDir, settingsPath) => {
if (!existsSync(settingsPath)) return;
if (!(await pathExistsForWrite(settingsPath))) return;

let existing: Record<string, unknown>;
try {
Expand Down Expand Up @@ -590,7 +622,7 @@ export async function stripCaseEnvKeys(casePath: string, keysToRemove: readonly
*/
export async function updateCaseEnvVars(casePath: string, envVars: Record<string, string>): Promise<void> {
await withSafeSettingsWrite(casePath, 'env vars', async (claudeDir, settingsPath) => {
if (!existsSync(claudeDir)) {
if (!(await pathExistsForWrite(claudeDir))) {
await mkdir(claudeDir, { recursive: true });
}

Expand Down Expand Up @@ -621,7 +653,7 @@ export async function updateCaseEnvVars(casePath: string, envVars: Record<string
*/
export async function updateCaseModel(casePath: string, model: string | null): Promise<void> {
await withSafeSettingsWrite(casePath, 'model', async (claudeDir, settingsPath) => {
if (!existsSync(claudeDir)) {
if (!(await pathExistsForWrite(claudeDir))) {
await mkdir(claudeDir, { recursive: true });
}

Expand Down Expand Up @@ -650,7 +682,7 @@ export async function updateCaseModel(casePath: string, model: string | null): P
*/
export async function writeHooksConfig(casePath: string): Promise<void> {
await withSafeSettingsWrite(casePath, 'hooks', async (claudeDir, settingsPath) => {
if (!existsSync(claudeDir)) {
if (!(await pathExistsForWrite(claudeDir))) {
await mkdir(claudeDir, { recursive: true });
}

Expand Down Expand Up @@ -698,7 +730,7 @@ export async function writeHooksConfig(casePath: string): Promise<void> {
*/
export async function ensureCodemanHooks(casePath: string): Promise<void> {
await withSafeSettingsWrite(casePath, 'hooks (ensure)', async (claudeDir, settingsPath) => {
if (!existsSync(claudeDir)) {
if (!(await pathExistsForWrite(claudeDir))) {
await mkdir(claudeDir, { recursive: true });
}

Expand Down Expand Up @@ -738,7 +770,7 @@ export async function ensureCodemanHooks(casePath: string): Promise<void> {
* when the hooks aren't ours, so it is cheap enough to call on every Claude spawn.
*/
export async function refreshStaleCodemanHooks(casePath: string): Promise<void> {
if (!existsSync(join(casePath, '.claude', 'settings.local.json'))) return;
if (await absentOrUnreachable(join(casePath, '.claude', 'settings.local.json'))) return;
await withSafeSettingsWrite(casePath, 'hooks (refresh)', async (_claudeDir, settingsPath) => {
let existing: Record<string, unknown>;
try {
Expand Down Expand Up @@ -820,7 +852,13 @@ export async function refreshStaleCodemanHooks(casePath: string): Promise<void>
*/
export async function applyWorkspaceHooks(workspace: string, install?: boolean): Promise<void> {
try {
if (!existsSync(workspace)) return;
const skip = await absentOrUnreachable(workspace);
if (skip === 'unreachable') {
console.warn(
`[hooks] ${workspace} is not responding (unreachable mount?); Codeman hooks not checked or installed`
);
}
if (skip) return;
const shouldInstall = install ?? (await readWorkspaceHooksEnabled());
await (shouldInstall ? ensureCodemanHooks(workspace) : refreshStaleCodemanHooks(workspace));
} catch {
Expand Down Expand Up @@ -883,7 +921,7 @@ export function generateStatusLineCommand(): string {
export async function applyStatusLineConfig(casePath: string, enabled: boolean): Promise<void> {
await withSafeSettingsWrite(casePath, 'statusLine', async (claudeDir, settingsPath) => {
let existing: Record<string, unknown> = {};
if (existsSync(settingsPath)) {
if (await pathExistsForWrite(settingsPath)) {
try {
existing = JSON.parse(await readFile(settingsPath, 'utf-8'));
} catch {
Expand All @@ -898,7 +936,7 @@ export async function applyStatusLineConfig(casePath: string, enabled: boolean):
const desired = generateStatusLineCommand();
if (isOurs && current?.command === desired) return; // already current — skip rewrite
if (current && !isOurs) return; // user has their OWN statusLine — never clobber it
if (!existsSync(claudeDir)) await mkdir(claudeDir, { recursive: true });
if (!(await pathExistsForWrite(claudeDir))) await mkdir(claudeDir, { recursive: true });
existing.statusLine = { type: 'command', command: desired }; // add, or update an out-of-date ours
} else {
if (!isOurs) return; // nothing of ours to remove (leave a user's own statusLine alone)
Expand Down Expand Up @@ -957,7 +995,7 @@ function statusLineExporterScriptContent(): string {
}

async function readStatusLineCommandFromFile(settingsPath: string): Promise<string | undefined> {
if (!existsSync(settingsPath)) return undefined;
if (await absentOrUnreachable(settingsPath)) return undefined;
try {
const parsed = JSON.parse(await readFile(settingsPath, 'utf-8'));
const current = parsed.statusLine as { command?: unknown } | undefined;
Expand Down Expand Up @@ -1103,9 +1141,21 @@ export async function resolveStatusLineCliCommand(
): Promise<string | undefined> {
const settingsPath = join(casePath, '.claude', 'settings.local.json');
let userHasOwnStatusLine = false;
if (existsSync(settingsPath)) {
const skip = await absentOrUnreachable(settingsPath);
// Unreachable: whether the user configured their own statusLine there cannot be
// told, and this must never override a real one, so inject nothing.
if (skip === 'unreachable') return undefined;
if (!skip) {
let raw: string;
try {
raw = await readFile(settingsPath, 'utf-8');
} catch (err) {
// Gone since the probe: nothing to respect. Unreadable: same reason as above.
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') return undefined;
raw = '';
}
try {
const existing = JSON.parse(await readFile(settingsPath, 'utf-8'));
const existing = raw ? JSON.parse(raw) : {};
const current = existing.statusLine as { command?: unknown } | undefined;
if (current && typeof current.command === 'string') {
if (current.command.includes(STATUSLINE_MARKER)) {
Expand Down
5 changes: 5 additions & 0 deletions src/types/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,11 @@ export interface CaseInfo {
location?: 'local' | 'linked-local' | 'remote' | 'docker';
/** Whether this is a linked local folder */
linked?: boolean;
/**
* The case folder did not answer (an unreachable network mount, or an error other
* than "no such file"), so whether it still exists is unknown. Absent = it answered.
*/
unreachable?: boolean;
/**
* Present when Codeman scaffolded this case directory for an AGENT-spawned session
* (the packaged skill's workers, or any spawn naming a parent session), read back
Expand Down
Loading
Loading