Skip to content
Merged
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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,7 @@ Run allowlisted commands in a named tmux session, capture output + exit code.
11. **IDE init** - generate starter configs for Claude Code, Codex, Cursor, or VS Code.
12. **ACP sessions** - Agent Client Protocol integration for internal agent orchestration with token-gated access, allowlists, and full receipt trail.
13. **Background consolidation** - optional `light / REM / deep` pass over recent queue items, with append-only sidecars and no effect on the foreground room loop by default.
14. **model-tidy** - plan/apply tool for moving idle local LLM model directories off a full GPU-box drive onto another mount, leaving a symlink behind. Read-only `plan` by default; `apply` needs an explicit flag plus a verified, cross-filesystem target. See [docs/model-tidy.md](docs/model-tidy.md).

No dependencies. Node.js ≥ 18 only.

Expand Down
255 changes: 255 additions & 0 deletions bin/model-tidy.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,255 @@
#!/usr/bin/env node
// SPDX-License-Identifier: AGPL-3.0-only

/**
* model-tidy CLI.
*
* model-tidy plan [--home <dir>] [--keep-file <path>] [--min-idle-days N]
* [--max-gb N] [--json] [--log-dir <dir>]
* [--report-to-room] [--room <name>] [--config <path>]
* Read-only. Never mutates anything. If a previous `apply` was
* interrupted (crash, kill, power loss), plan DETECTS and reports it
* per unit ("interrupted move found: ...") and refuses to select that
* unit — it does not attempt to fix it. Run `recover` for that.
*
* model-tidy apply --apply --target </mount/path> [same options as plan]
* Runs recovery once at the very start (only reached because --apply
* was given), then copies/verifies/swaps every selected unit.
*
* model-tidy recover [--home <dir>]
* Explicit, mutating recovery pass for interrupted `apply` swaps. Acts
* ONLY on units with a journal record whose on-disk state matches the
* journaled manifest; anything else (including a directory merely
* *named* like a leftover, with no journal) is reported and left
* alone. Safe to run at any time, including on a healthy tree (a
* no-op).
*
* model-tidy --dry-run-remote <host> [--remote-home <dir>]
* ssh's to <host> and runs `plan` there, read-only. Never copies,
* deletes, or symlinks anything. If the remote has no `node`, this
* refuses and exits non-zero rather than installing one.
*
* See docs/model-tidy.md for full documentation.
*/

import { execFileSync, spawnSync } from 'node:child_process';
import { existsSync, readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import { homedir } from 'node:os';
import { planRun, applyRun, recoverInterruptedMoves, writeRunLog } from '../src/model-tidy.mjs';

const __dirname = dirname(fileURLToPath(import.meta.url));
const REPO_ROOT = join(__dirname, '..');
const DEFAULT_LOG_DIR = join(homedir(), '.cache', 'ide-agent-kit', 'model-tidy-logs');
const DEFAULT_KEEP_FILE = join(homedir(), '.config', 'ide-agent-kit', 'model-tidy.keep');

function parseArgs(argv) {
const out = { _: [] };
for (let i = 0; i < argv.length; i++) {
const arg = argv[i];
if (arg.startsWith('--')) {
const eq = arg.indexOf('=');
if (eq !== -1) {
out[arg.slice(2, eq)] = arg.slice(eq + 1);
continue;
}
const key = arg.slice(2);
const next = argv[i + 1];
if (next === undefined || next.startsWith('--')) {
out[key] = true;
} else {
out[key] = next;
i++;
}
} else {
out._.push(arg);
}
}
return out;
}

function printPlan(plan, opts) {
if (opts.json) {
console.log(JSON.stringify(plan, null, 2));
return;
}
console.log(plan.summaryLine);
console.log('');
console.log(`Would move (${plan.selected.length}):`);
const printedGroups = new Set();
for (const r of plan.selected) {
const gib = (r.sizeBytes / 2 ** 30).toFixed(2);
console.log(` [move] ${r.path} (${gib} GiB, ${r.kind}) — ${r.reason}`);
printedGroups.add(r.groupId);
}
console.log('');
console.log(`Skipped (${plan.skipped.length}):`);
for (const r of plan.skipped) {
const gib = (r.sizeBytes / 2 ** 30).toFixed(2);
console.log(` [skip] ${r.path} (${gib} GiB, ${r.kind}) — ${r.reason}`);
}
if (plan.interrupted && plan.interrupted.length > 0) {
console.log('');
console.log(`Interrupted-move findings (${plan.interrupted.length}) — plan never mutates these, run 'model-tidy recover':`);
for (const f of plan.interrupted) {
console.log(` [${f.status}] ${f.path || f.journalFile} — ${f.note}`);
}
}
}

function reportToRoom(summaryLine, args) {
const room = args.room;
if (!room) {
console.error('--report-to-room requires --room <name>');
return false;
}
let apiKey = process.env.IAK_API_KEY;
if (!apiKey && args.config && existsSync(args.config)) {
try {
const cfg = JSON.parse(readFileSync(args.config, 'utf8'));
apiKey = cfg?.poller?.api_key;
} catch (e) {
console.error(`could not read --config ${args.config}: ${e.message}`);
}
}
if (!apiKey) {
console.error('--report-to-room: no API key (set IAK_API_KEY or pass --config pointing at a poller config with poller.api_key)');
return false;
}
const payload = JSON.stringify({ room, body: summaryLine });
try {
execFileSync('curl', ['-sS', '-X', 'POST', 'https://groupmind.one/api/v1/messages',
'-H', `X-API-Key: ${apiKey}`, '-H', 'Content-Type: application/json', '-d', payload],
{ timeout: 15000 });
return true;
} catch (e) {
console.error(`--report-to-room: post failed: ${e.message}`);
return false;
}
}

function runDryRunRemote(host, args) {
console.log(`[dry-run-remote] checking node on ${host}...`);
const check = spawnSync('ssh', ['-o', 'BatchMode=yes', '-o', 'ConnectTimeout=8', host, 'command -v node'], { encoding: 'utf8' });
if (check.status !== 0 || !check.stdout.trim()) {
console.error(`[dry-run-remote] ${host}: node not found on remote. Refusing to install anything.`);
console.error('[dry-run-remote] Install Node.js >= 18 on the remote host, then re-run --dry-run-remote.');
return 1;
}
const nodeVersionOut = check.stdout.trim();
console.log(`[dry-run-remote] ${host}: found node at ${nodeVersionOut}`);

const remoteDir = `/tmp/.model-tidy-dryrun-${process.pid}-${Date.now()}`;
console.log(`[dry-run-remote] copying tool to ${host}:${remoteDir} (temp, read-only run, self-cleaned)...`);
const mkdir = spawnSync('ssh', ['-o', 'BatchMode=yes', host, `mkdir -p '${remoteDir}/bin' '${remoteDir}/src'`], { encoding: 'utf8' });
if (mkdir.status !== 0) {
console.error(`[dry-run-remote] mkdir on remote failed: ${mkdir.stderr}`);
return 1;
}
const scpBin = spawnSync('scp', ['-q', join(REPO_ROOT, 'bin', 'model-tidy.mjs'), `${host}:${remoteDir}/bin/model-tidy.mjs`], { encoding: 'utf8' });
const scpSrc = spawnSync('scp', ['-q', join(REPO_ROOT, 'src', 'model-tidy.mjs'), `${host}:${remoteDir}/src/model-tidy.mjs`], { encoding: 'utf8' });
if (scpBin.status !== 0 || scpSrc.status !== 0) {
console.error(`[dry-run-remote] scp failed: ${scpBin.stderr || ''} ${scpSrc.stderr || ''}`);
spawnSync('ssh', [host, `rm -rf '${remoteDir}'`]);
return 1;
}

const remoteHome = args['remote-home'] || '$HOME';
const remoteCmd = `node '${remoteDir}/bin/model-tidy.mjs' plan --home "${remoteHome}"`;
console.log(`[dry-run-remote] running (read-only): ${remoteCmd}`);
const run = spawnSync('ssh', ['-o', 'BatchMode=yes', host, remoteCmd], { encoding: 'utf8', maxBuffer: 32 * 1024 * 1024 });
console.log(run.stdout || '');
if (run.stderr) console.error(run.stderr);

spawnSync('ssh', [host, `rm -rf '${remoteDir}'`]);
console.log(`[dry-run-remote] cleaned up ${host}:${remoteDir}`);
return run.status === 0 ? 0 : (run.status ?? 1);
}

function main() {
const argv = process.argv.slice(2);
const args = parseArgs(argv);
const mode = args._[0] || (args.apply ? 'apply' : 'plan');

if (args['dry-run-remote']) {
process.exit(runDryRunRemote(args['dry-run-remote'], args));
return;
}

const home = args.home || homedir();
const keepFile = args['keep-file'] || (existsSync(DEFAULT_KEEP_FILE) ? DEFAULT_KEEP_FILE : undefined);
const minIdleDays = args['min-idle-days'] !== undefined ? Number(args['min-idle-days']) : 14;
if (!Number.isFinite(minIdleDays) || minIdleDays < 0) {
console.error(`--min-idle-days must be a finite, non-negative number (got ${JSON.stringify(args['min-idle-days'])}) — refusing, since a NaN here would silently disable the freshness guard`);
process.exit(2);
}
const maxGb = args['max-gb'] !== undefined ? Number(args['max-gb']) : Infinity;
Comment on lines +182 to +187

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reject invalid idle-day values before planning

When --min-idle-days is mistyped, for example --min-idle-days fourteen, Number(...) produces NaN; every ageDays < minIdleDays comparison is then false, silently bypassing the freshness guard and allowing recently modified models to be moved during an apply. Validate that this option is finite and nonnegative before constructing the plan.

Useful? React with 👍 / 👎.

if (!Number.isFinite(maxGb) && args['max-gb'] !== undefined) {
console.error(`--max-gb must be a finite number (got ${JSON.stringify(args['max-gb'])})`);
process.exit(2);
}
const logDir = args['log-dir'] || DEFAULT_LOG_DIR;

if (mode === 'recover') {
const recovered = recoverInterruptedMoves(home);
writeRunLog(logDir, { mode: 'recover', home, recovered, argv });
if (args.json) {
console.log(JSON.stringify(recovered, null, 2));
} else if (recovered.length === 0) {
console.log('recover: nothing to do (no journaled interrupted moves, no stray leftovers found).');
} else {
console.log(`recover: ${recovered.length} finding(s):`);
for (const r of recovered) {
console.log(` [${r.action}] ${r.path || r.journalFile} — ${r.note}`);
}
}
process.exit(recovered.some(r => r.action === 'error') ? 1 : 0);
return;
}

const plan = planRun({ home, keepFile, minIdleDays, maxGb });

const logFile = writeRunLog(logDir, {
mode,
...plan,
argv
});
Comment on lines +213 to +217

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep plan usable without a writable log directory

Every plan invocation calls writeRunLog before entering the plan branch, despite the command's read-only contract. Besides creating files during a purportedly non-mutating run, an absent or unwritable --log-dir makes mkdirSync/appendFileSync throw before the plan is printed, preventing diagnostics on restricted or read-only hosts. Logging should be opt-in or best-effort for plan mode rather than a mandatory write.

Useful? React with 👍 / 👎.


if (mode === 'plan') {
printPlan(plan, args);
console.log(`\n(log: ${logFile})`);
if (args['report-to-room']) reportToRoom(plan.summaryLine, args);
process.exit(0);
}

if (mode === 'apply') {
if (!args.apply) {
console.error('apply mode requires the explicit --apply flag');
process.exit(2);
}
if (!args.target) {
console.error('apply mode requires --target </mount/path>');
process.exit(2);
}
printPlan(plan, args);
const result = applyRun({ plan, target: args.target, home });
writeRunLog(logDir, { mode: 'apply', target: args.target, ...result });
if (!result.ok) {
console.error('\napply FAILED for one or more units — sources for failed units were left untouched:');
for (const err of result.errors) {
console.error(` [error] group ${err.groupId} (${err.step}): ${err.error}`);
}
}
console.log(`\nmoved ${result.moved.length} unit(s), ${result.errors.length} error(s). (log dir: ${logDir})`);
if (args['report-to-room']) {
reportToRoom(`model-tidy apply: moved ${result.moved.length} unit(s), ${result.errors.length} error(s)`, args);
}
process.exit(result.ok ? 0 : 1);
}

console.error(`unknown mode: ${mode} (expected "plan", "apply", or "recover")`);
process.exit(2);
}

main();
16 changes: 16 additions & 0 deletions config/model-tidy.keep.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
# model-tidy KEEP list — example file, NOT active by default.
#
# One path or glob per line. Anything matching is never moved by
# model-tidy, in either plan or apply mode. '#' starts a comment; blank
# lines are ignored. A leading '~' is expanded to the home directory the
# tool is run with (--home, or $HOME by default).
#
# To activate: copy this file to ~/.config/ide-agent-kit/model-tidy.keep
# (the default --keep-file path) or pass --keep-file explicitly, then edit
# it to match what you actually want kept.
#
# Seeded from the owner's notes (do not delete without petrus's word):
~/models/glm53-flash-q6/UD-Q6_K_XL
~/models/GLM-5.3-Flash-exl3-2.05bpw
~/DeepSeek-v4.1-Flash-EXL3-2x-DGX-Sparks
~/models/ltx-2.5
Loading
Loading