Files
callstack__agent-device/scripts/clean-daemon.ts
T
Michał Pierzchała 5462d516d9 chore(daemon): takeover notice, dev state-dir pruning, session state-dir command surface (#754)
* chore(daemon): takeover notice, dev state-dir pruning, session state-dir command surface

Implements the three follow-ups from #737:

1. Print a one-line stderr notice when the client replaces a running
   daemon, stating identity and reason (version mismatch, code-signature
   mismatch, or unreachable). Best effort; never fails the command.

2. Add 'pnpm clean:daemon --prune-dev' to remove worktree-scoped state
   dirs under ~/.agent-device/dev/ that no live daemon owns (same
   pid/start-time liveness check as server-lifecycle) and that have been
   idle for 14+ days. Scoped dirs only; one line printed per removal.

3. Fold 'session state-dir' into the regular command surface: the
   session contract resolves it locally via the new
   client.sessions.stateDir(), the cli.ts pre-dispatch special case is
   removed, and the MCP session tool now exposes the state-dir action.

Closes #737

https://claude.ai/code/session_013WBrUjQ4WRxRkfVruALKX3

* docs: surface clean:daemon --prune-dev in AGENTS.md

Local agents discover daemon state-dir hygiene through AGENTS.md, not
the website docs, so document the prune flag next to the existing
worktree-scoped state-dir guidance.

https://claude.ai/code/session_013WBrUjQ4WRxRkfVruALKX3

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-06-11 07:41:34 +02:00

114 lines
3.2 KiB
TypeScript

import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { resolveDaemonPaths } from '../src/daemon/config.ts';
import {
isAgentDeviceDaemonProcess,
stopProcessForTakeover,
} from '../src/utils/process-identity.ts';
const DAEMON_TERM_TIMEOUT_MS = 15_000;
const DAEMON_KILL_TIMEOUT_MS = 2_000;
const PRUNE_DEV_FLAG = '--prune-dev';
const PRUNE_DEV_MAX_AGE_MS = 14 * 24 * 60 * 60 * 1000;
type DaemonInfo = {
pid?: number;
processStartTime?: string;
};
const paths = resolveDaemonPaths(process.env.AGENT_DEVICE_STATE_DIR);
const info = readDaemonInfo(paths.infoPath);
const daemonPid = readPositivePid(info?.pid);
if (daemonPid !== null) {
await stopProcessForTakeover(daemonPid, {
termTimeoutMs: DAEMON_TERM_TIMEOUT_MS,
killTimeoutMs: DAEMON_KILL_TIMEOUT_MS,
expectedStartTime: info?.processStartTime,
});
}
removeIfPresent(paths.infoPath);
removeIfPresent(paths.lockPath);
if (process.argv.includes(PRUNE_DEV_FLAG)) {
pruneStaleDevStateDirs();
}
function readDaemonInfo(infoPath: string): DaemonInfo | null {
try {
return JSON.parse(fs.readFileSync(infoPath, 'utf8')) as DaemonInfo;
} catch {
return null;
}
}
function removeIfPresent(filePath: string): void {
try {
fs.unlinkSync(filePath);
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
throw error;
}
}
}
// Removes worktree-scoped state dirs under ~/.agent-device/dev/ that no live daemon
// owns and that have been idle past the retention threshold. Never touches the
// global ~/.agent-device root contents.
function pruneStaleDevStateDirs(): void {
const devRoot = path.join(os.homedir(), '.agent-device', 'dev');
const cutoffMs = Date.now() - PRUNE_DEV_MAX_AGE_MS;
for (const dirPath of listDevStateDirs(devRoot)) {
if (hasLiveDaemon(dirPath) || newestMtimeMs(dirPath) > cutoffMs) continue;
fs.rmSync(dirPath, { recursive: true, force: true });
process.stdout.write(`Removed stale daemon state dir: ${dirPath}\n`);
}
}
function listDevStateDirs(devRoot: string): string[] {
let entries: fs.Dirent[];
try {
entries = fs.readdirSync(devRoot, { withFileTypes: true });
} catch {
return [];
}
return entries
.filter((entry) => entry.isDirectory())
.map((entry) => path.join(devRoot, entry.name));
}
function hasLiveDaemon(stateDir: string): boolean {
const dirInfo = readDaemonInfo(path.join(stateDir, 'daemon.json'));
const pid = readPositivePid(dirInfo?.pid);
return pid !== null && isAgentDeviceDaemonProcess(pid, dirInfo?.processStartTime);
}
function readPositivePid(pid: number | undefined): number | null {
if (typeof pid !== 'number') return null;
return Number.isInteger(pid) && pid > 0 ? pid : null;
}
function newestMtimeMs(dirPath: string): number {
let newest = statMtimeMs(dirPath);
let children: fs.Dirent[];
try {
children = fs.readdirSync(dirPath, { withFileTypes: true });
} catch {
return newest;
}
for (const child of children) {
newest = Math.max(newest, statMtimeMs(path.join(dirPath, child.name)));
}
return newest;
}
function statMtimeMs(filePath: string): number {
try {
return fs.statSync(filePath).mtimeMs;
} catch {
return 0;
}
}