crewly 1.20.158 → 1.20.159

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/config/skills/registry.json +55 -4
  2. package/dist/backend/backend/src/constants.d.ts +54 -3
  3. package/dist/backend/backend/src/constants.d.ts.map +1 -1
  4. package/dist/backend/backend/src/constants.js +54 -3
  5. package/dist/backend/backend/src/constants.js.map +1 -1
  6. package/dist/backend/backend/src/controllers/worktree-janitor/worktree-janitor.routes.d.ts +2 -1
  7. package/dist/backend/backend/src/controllers/worktree-janitor/worktree-janitor.routes.d.ts.map +1 -1
  8. package/dist/backend/backend/src/controllers/worktree-janitor/worktree-janitor.routes.js +4 -2
  9. package/dist/backend/backend/src/controllers/worktree-janitor/worktree-janitor.routes.js.map +1 -1
  10. package/dist/backend/backend/src/index.d.ts.map +1 -1
  11. package/dist/backend/backend/src/index.js +17 -2
  12. package/dist/backend/backend/src/index.js.map +1 -1
  13. package/dist/backend/backend/src/services/worktree/low-disk-guard.d.ts +109 -0
  14. package/dist/backend/backend/src/services/worktree/low-disk-guard.d.ts.map +1 -0
  15. package/dist/backend/backend/src/services/worktree/low-disk-guard.js +148 -0
  16. package/dist/backend/backend/src/services/worktree/low-disk-guard.js.map +1 -0
  17. package/dist/backend/backend/src/services/worktree/scratch-janitor.d.ts +140 -0
  18. package/dist/backend/backend/src/services/worktree/scratch-janitor.d.ts.map +1 -0
  19. package/dist/backend/backend/src/services/worktree/scratch-janitor.js +249 -0
  20. package/dist/backend/backend/src/services/worktree/scratch-janitor.js.map +1 -0
  21. package/dist/backend/backend/src/services/worktree/worktree-janitor.git.d.ts +92 -1
  22. package/dist/backend/backend/src/services/worktree/worktree-janitor.git.d.ts.map +1 -1
  23. package/dist/backend/backend/src/services/worktree/worktree-janitor.git.js +207 -1
  24. package/dist/backend/backend/src/services/worktree/worktree-janitor.git.js.map +1 -1
  25. package/dist/backend/backend/src/services/worktree/worktree-janitor.service.d.ts +135 -28
  26. package/dist/backend/backend/src/services/worktree/worktree-janitor.service.d.ts.map +1 -1
  27. package/dist/backend/backend/src/services/worktree/worktree-janitor.service.js +248 -39
  28. package/dist/backend/backend/src/services/worktree/worktree-janitor.service.js.map +1 -1
  29. package/dist/backend/build-info.json +2 -2
  30. package/dist/cli/backend/src/constants.d.ts +54 -3
  31. package/dist/cli/backend/src/constants.d.ts.map +1 -1
  32. package/dist/cli/backend/src/constants.js +54 -3
  33. package/dist/cli/backend/src/constants.js.map +1 -1
  34. package/package.json +1 -1
@@ -0,0 +1,109 @@
1
+ /**
2
+ * Low-disk guard — pure helpers for the disk janitor's free-space check.
3
+ *
4
+ * The janitor checks free space on the volume holding CREWLY_HOME every
5
+ * 10 minutes. Below 15 GB it cleans up at once with shorter idle thresholds;
6
+ * if space is still short it tells the owner (at most once per 24 h, or once
7
+ * per 6 h and marked urgent below 5 GB) what it could not remove and why.
8
+ * This file holds the decisions and the wording; the service does the I/O.
9
+ *
10
+ * @module services/worktree/low-disk-guard
11
+ */
12
+ /** Subset of `fs.StatFs` the guard needs (numbers or bigints). */
13
+ export interface StatFsLike {
14
+ /** Free blocks available to unprivileged users */
15
+ bavail: number | bigint;
16
+ /** Block size in bytes */
17
+ bsize: number | bigint;
18
+ }
19
+ /** `fs.promises.statfs`-compatible probe. */
20
+ export type StatFsFn = (p: string) => Promise<StatFsLike>;
21
+ /** Disk level relative to the thresholds. */
22
+ export type DiskLevel = 'ok' | 'low' | 'critical';
23
+ /** When the owner was last told about low disk (persisted). */
24
+ export interface LowDiskNoticeState {
25
+ /** Last notice of any kind (ms epoch) */
26
+ lastNoticeAt?: number;
27
+ /** Last urgent notice (ms epoch) */
28
+ lastUrgentAt?: number;
29
+ }
30
+ /** Something the janitor looked at and left on disk. */
31
+ export interface KeptItem {
32
+ /** Absolute path */
33
+ path: string;
34
+ /** Size in bytes */
35
+ bytes: number;
36
+ /** Janitor reason code (`dirty`, `not-merged`, `unpushed`, …) */
37
+ reason: string;
38
+ }
39
+ /**
40
+ * Free bytes on the volume holding `p`.
41
+ *
42
+ * @param p - Any path on the volume
43
+ * @param statfs - Probe (default `fs.promises.statfs`)
44
+ * @returns Free bytes for unprivileged users, or null when the probe failed
45
+ */
46
+ export declare function readFreeBytes(p: string, statfs?: StatFsFn): Promise<number | null>;
47
+ /**
48
+ * Classify free space.
49
+ *
50
+ * @param freeBytes - Free bytes
51
+ * @returns `critical` below CRITICAL_DISK_BYTES, `low` below LOW_DISK_BYTES, else `ok`
52
+ */
53
+ export declare function diskLevel(freeBytes: number): DiskLevel;
54
+ /**
55
+ * Idle threshold to use, shortened in low-disk mode.
56
+ *
57
+ * @param normalMs - Threshold in normal mode
58
+ * @param lowDisk - Whether free space is below LOW_DISK_BYTES
59
+ * @returns `normalMs`, or `normalMs / LOW_DISK_IDLE_DIVISOR` floored at LOW_DISK_MIN_IDLE_FLOOR_MS
60
+ *
61
+ * @example
62
+ * ```typescript
63
+ * idleThreshold(24 * HOUR, true); // 12 h
64
+ * idleThreshold(2 * HOUR, true); // 2 h (floor)
65
+ * ```
66
+ */
67
+ export declare function idleThreshold(normalMs: number, lowDisk: boolean): number;
68
+ /**
69
+ * Whether to tell the owner now, and how.
70
+ *
71
+ * @param level - Current disk level (after cleanup)
72
+ * @param state - When the owner was last told
73
+ * @param now - Current time (ms)
74
+ * @returns `urgent`, `normal`, or null (nothing to send)
75
+ */
76
+ export declare function noticeToSend(level: DiskLevel, state: LowDiskNoticeState, now: number): 'urgent' | 'normal' | null;
77
+ /**
78
+ * The state after a notice went out.
79
+ *
80
+ * @param state - Previous state
81
+ * @param kind - What was sent
82
+ * @param now - Send time (ms)
83
+ * @returns New state
84
+ */
85
+ export declare function recordNotice(state: LowDiskNoticeState, kind: 'urgent' | 'normal', now: number): LowDiskNoticeState;
86
+ /**
87
+ * Plain-English reason for a kept item.
88
+ *
89
+ * @param reason - Janitor reason code
90
+ * @returns Short phrase ("has changes that were never committed")
91
+ */
92
+ export declare function describeReason(reason: string): string;
93
+ /**
94
+ * Owner notice for low disk: free space, what was freed, and the biggest
95
+ * items left with the reason, short enough for a phone.
96
+ *
97
+ * @param input - Free bytes, urgency, bytes freed this pass, kept items
98
+ * @returns Title and message
99
+ */
100
+ export declare function buildLowDiskNotice(input: {
101
+ freeBytes: number;
102
+ urgent: boolean;
103
+ freedBytes: number;
104
+ items: KeptItem[];
105
+ }): {
106
+ title: string;
107
+ message: string;
108
+ };
109
+ //# sourceMappingURL=low-disk-guard.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"low-disk-guard.d.ts","sourceRoot":"","sources":["../../../../../../backend/src/services/worktree/low-disk-guard.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAMH,kEAAkE;AAClE,MAAM,WAAW,UAAU;IAC1B,kDAAkD;IAClD,MAAM,EAAE,MAAM,GAAG,MAAM,CAAC;IACxB,0BAA0B;IAC1B,KAAK,EAAE,MAAM,GAAG,MAAM,CAAC;CACvB;AAED,6CAA6C;AAC7C,MAAM,MAAM,QAAQ,GAAG,CAAC,CAAC,EAAE,MAAM,KAAK,OAAO,CAAC,UAAU,CAAC,CAAC;AAE1D,6CAA6C;AAC7C,MAAM,MAAM,SAAS,GAAG,IAAI,GAAG,KAAK,GAAG,UAAU,CAAC;AAElD,+DAA+D;AAC/D,MAAM,WAAW,kBAAkB;IAClC,yCAAyC;IACzC,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,oCAAoC;IACpC,YAAY,CAAC,EAAE,MAAM,CAAC;CACtB;AAED,wDAAwD;AACxD,MAAM,WAAW,QAAQ;IACxB,oBAAoB;IACpB,IAAI,EAAE,MAAM,CAAC;IACb,oBAAoB;IACpB,KAAK,EAAE,MAAM,CAAC;IACd,iEAAiE;IACjE,MAAM,EAAE,MAAM,CAAC;CACf;AAED;;;;;;GAMG;AACH,wBAAsB,aAAa,CAAC,CAAC,EAAE,MAAM,EAAE,MAAM,GAAE,QAAwB,GAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAOvG;AAMD;;;;;GAKG;AACH,wBAAgB,SAAS,CAAC,SAAS,EAAE,MAAM,GAAG,SAAS,CAItD;AAED;;;;;;;;;;;;GAYG;AACH,wBAAgB,aAAa,CAAC,QAAQ,EAAE,MAAM,EAAE,OAAO,EAAE,OAAO,GAAG,MAAM,CAIxE;AAED;;;;;;;GAOG;AACH,wBAAgB,YAAY,CAAC,KAAK,EAAE,SAAS,EAAE,KAAK,EAAE,kBAAkB,EAAE,GAAG,EAAE,MAAM,GAAG,QAAQ,GAAG,QAAQ,GAAG,IAAI,CAQjH;AAED;;;;;;;GAOG;AACH,wBAAgB,YAAY,CAAC,KAAK,EAAE,kBAAkB,EAAE,IAAI,EAAE,QAAQ,GAAG,QAAQ,EAAE,GAAG,EAAE,MAAM,GAAG,kBAAkB,CAElH;AAqBD;;;;;GAKG;AACH,wBAAgB,cAAc,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,CAErD;AAED;;;;;;GAMG;AACH,wBAAgB,kBAAkB,CAAC,KAAK,EAAE;IACzC,SAAS,EAAE,MAAM,CAAC;IAClB,MAAM,EAAE,OAAO,CAAC;IAChB,UAAU,EAAE,MAAM,CAAC;IACnB,KAAK,EAAE,QAAQ,EAAE,CAAC;CAClB,GAAG;IAAE,KAAK,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,MAAM,CAAA;CAAE,CAkBrC"}
@@ -0,0 +1,148 @@
1
+ /**
2
+ * Low-disk guard — pure helpers for the disk janitor's free-space check.
3
+ *
4
+ * The janitor checks free space on the volume holding CREWLY_HOME every
5
+ * 10 minutes. Below 15 GB it cleans up at once with shorter idle thresholds;
6
+ * if space is still short it tells the owner (at most once per 24 h, or once
7
+ * per 6 h and marked urgent below 5 GB) what it could not remove and why.
8
+ * This file holds the decisions and the wording; the service does the I/O.
9
+ *
10
+ * @module services/worktree/low-disk-guard
11
+ */
12
+ import * as fs from 'fs';
13
+ import { WORKTREE_JANITOR_CONSTANTS } from '../../constants.js';
14
+ import { formatBytes } from './worktree-janitor.git.js';
15
+ /**
16
+ * Free bytes on the volume holding `p`.
17
+ *
18
+ * @param p - Any path on the volume
19
+ * @param statfs - Probe (default `fs.promises.statfs`)
20
+ * @returns Free bytes for unprivileged users, or null when the probe failed
21
+ */
22
+ export async function readFreeBytes(p, statfs = defaultStatFs) {
23
+ try {
24
+ const s = await statfs(p);
25
+ return Number(s.bavail) * Number(s.bsize);
26
+ }
27
+ catch {
28
+ return null;
29
+ }
30
+ }
31
+ async function defaultStatFs(p) {
32
+ return fs.promises.statfs(p);
33
+ }
34
+ /**
35
+ * Classify free space.
36
+ *
37
+ * @param freeBytes - Free bytes
38
+ * @returns `critical` below CRITICAL_DISK_BYTES, `low` below LOW_DISK_BYTES, else `ok`
39
+ */
40
+ export function diskLevel(freeBytes) {
41
+ if (freeBytes < WORKTREE_JANITOR_CONSTANTS.CRITICAL_DISK_BYTES)
42
+ return 'critical';
43
+ if (freeBytes < WORKTREE_JANITOR_CONSTANTS.LOW_DISK_BYTES)
44
+ return 'low';
45
+ return 'ok';
46
+ }
47
+ /**
48
+ * Idle threshold to use, shortened in low-disk mode.
49
+ *
50
+ * @param normalMs - Threshold in normal mode
51
+ * @param lowDisk - Whether free space is below LOW_DISK_BYTES
52
+ * @returns `normalMs`, or `normalMs / LOW_DISK_IDLE_DIVISOR` floored at LOW_DISK_MIN_IDLE_FLOOR_MS
53
+ *
54
+ * @example
55
+ * ```typescript
56
+ * idleThreshold(24 * HOUR, true); // 12 h
57
+ * idleThreshold(2 * HOUR, true); // 2 h (floor)
58
+ * ```
59
+ */
60
+ export function idleThreshold(normalMs, lowDisk) {
61
+ if (!lowDisk)
62
+ return normalMs;
63
+ const halved = normalMs / WORKTREE_JANITOR_CONSTANTS.LOW_DISK_IDLE_DIVISOR;
64
+ return Math.max(Math.min(normalMs, WORKTREE_JANITOR_CONSTANTS.LOW_DISK_MIN_IDLE_FLOOR_MS), halved);
65
+ }
66
+ /**
67
+ * Whether to tell the owner now, and how.
68
+ *
69
+ * @param level - Current disk level (after cleanup)
70
+ * @param state - When the owner was last told
71
+ * @param now - Current time (ms)
72
+ * @returns `urgent`, `normal`, or null (nothing to send)
73
+ */
74
+ export function noticeToSend(level, state, now) {
75
+ if (level === 'ok')
76
+ return null;
77
+ if (level === 'critical') {
78
+ const last = state.lastUrgentAt;
79
+ return last === undefined || now - last >= WORKTREE_JANITOR_CONSTANTS.CRITICAL_DISK_NOTIFY_INTERVAL_MS ? 'urgent' : null;
80
+ }
81
+ const last = state.lastNoticeAt;
82
+ return last === undefined || now - last >= WORKTREE_JANITOR_CONSTANTS.LOW_DISK_NOTIFY_INTERVAL_MS ? 'normal' : null;
83
+ }
84
+ /**
85
+ * The state after a notice went out.
86
+ *
87
+ * @param state - Previous state
88
+ * @param kind - What was sent
89
+ * @param now - Send time (ms)
90
+ * @returns New state
91
+ */
92
+ export function recordNotice(state, kind, now) {
93
+ return { ...state, lastNoticeAt: now, ...(kind === 'urgent' ? { lastUrgentAt: now } : {}) };
94
+ }
95
+ const REASON_TEXT = {
96
+ dirty: 'has changes that were never committed',
97
+ 'not-merged': 'has work that is not merged yet',
98
+ unpushed: 'has commits that were never pushed',
99
+ stash: 'has stashed changes',
100
+ 'no-remote': 'is a git repo with no remote, so its history may exist nowhere else',
101
+ 'has-external-worktrees': 'has a worktree somewhere else that depends on it',
102
+ 'contains-worktree': 'holds a worktree that is still in use or not finished',
103
+ recent: 'was used recently',
104
+ locked: 'is locked by a running tool',
105
+ 'process-inside': 'a program is still open in it',
106
+ 'agent-session-inside': 'an agent is working in it',
107
+ 'cwd-probe-failed': 'could not check whether a program is using it',
108
+ 'managed-by-workitem-worktrees': 'belongs to an open task',
109
+ 'status-failed': 'git could not read it',
110
+ 'git-failed': 'git could not read it',
111
+ 'remove-failed': 'deleting it failed',
112
+ };
113
+ /**
114
+ * Plain-English reason for a kept item.
115
+ *
116
+ * @param reason - Janitor reason code
117
+ * @returns Short phrase ("has changes that were never committed")
118
+ */
119
+ export function describeReason(reason) {
120
+ return REASON_TEXT[reason] ?? `kept (${reason})`;
121
+ }
122
+ /**
123
+ * Owner notice for low disk: free space, what was freed, and the biggest
124
+ * items left with the reason, short enough for a phone.
125
+ *
126
+ * @param input - Free bytes, urgency, bytes freed this pass, kept items
127
+ * @returns Title and message
128
+ */
129
+ export function buildLowDiskNotice(input) {
130
+ const title = input.urgent ? 'URGENT: disk almost full' : 'Disk space is running low';
131
+ const lines = [];
132
+ lines.push(`Only ${formatBytes(input.freeBytes)} free on this machine.${input.urgent ? ' Crewly and the agents may stop working soon.' : ''}`);
133
+ lines.push(input.freedBytes > 0
134
+ ? `I cleaned up ${formatBytes(input.freedBytes)} of finished work, but it is not enough.`
135
+ : 'I found nothing more that is safe to delete on my own.');
136
+ const top = [...input.items].sort((a, b) => b.bytes - a.bytes).slice(0, WORKTREE_JANITOR_CONSTANTS.LOW_DISK_REPORT_ITEMS);
137
+ if (top.length > 0) {
138
+ lines.push('', 'Biggest things I left alone:');
139
+ for (const it of top)
140
+ lines.push(`• ${it.path} (${formatBytes(it.bytes)}) — ${describeReason(it.reason)}`);
141
+ lines.push('', 'Tell me which ones can go, or free up space another way.');
142
+ }
143
+ else {
144
+ lines.push('Please free up some space.');
145
+ }
146
+ return { title, message: lines.join('\n') };
147
+ }
148
+ //# sourceMappingURL=low-disk-guard.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"low-disk-guard.js","sourceRoot":"","sources":["../../../../../../backend/src/services/worktree/low-disk-guard.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,140 @@
1
+ /**
2
+ * Scratch janitor — removes stale Claude Code session scratch dirs.
3
+ *
4
+ * Claude Code gives every session a temp dir
5
+ * `<tmp>/claude-<uid>/<project-slug>/<session-uuid>/` (with `scratchpad/`
6
+ * inside). Agents clone whole repos and build test homes there — 0.5–1.8 GB
7
+ * each — and nothing ever deletes them; one Mac filled its disk with dozens
8
+ * of GB of week-old sessions. These are not worktrees, so the worktree rules
9
+ * never see them.
10
+ *
11
+ * ## Roots
12
+ * `<os.tmpdir()>/claude-<uid>`, `/private/tmp/claude-<uid>` and
13
+ * `/tmp/claude-<uid>`, de-duplicated by real path. Only the
14
+ * `<root>/<slug>/<uuid>/` pattern is a unit; anything else in the root
15
+ * (loose files, worktrees such as `<root>/visa-cm-wt`) is left alone here.
16
+ * Symlinks are never followed out of the root.
17
+ *
18
+ * ## A session dir is deleted only when ALL hold
19
+ * 1. Idle: the newest mtime of the dir and everything in its top two levels,
20
+ * and of every git repo's HEAD / index / logs/HEAD inside it, is older
21
+ * than 3 days (halved in low-disk mode, never below 2 h).
22
+ * 2. No process of this user (nor a Crewly agent session) has its cwd
23
+ * inside. If the probe fails, everything is kept.
24
+ * 3. It holds no linked worktree of a repo outside it — those belong to the
25
+ * worktree rules (`git worktree remove`), never `rm -rf`.
26
+ * 4. Every git repo inside (depth ≤ 3, `node_modules` skipped) is safe:
27
+ * - `git status --porcelain --untracked-files=all` is empty (untracked
28
+ * files count, ignored ones do not);
29
+ * - it has a remote (no remote → kept: its commits may exist nowhere else);
30
+ * - `git rev-list HEAD --branches --not --remotes` is empty (no local
31
+ * commit missing from the remote-tracking refs) and there is no stash;
32
+ * - it has no linked worktree outside the session dir (deleting the
33
+ * clone would orphan it).
34
+ * Any git error keeps the dir.
35
+ *
36
+ * @module services/worktree/scratch-janitor
37
+ */
38
+ import { type CommandResult } from './worktree-janitor.git.js';
39
+ /** Why a session dir is kept, or why it may go. */
40
+ export type ScratchReason = 'recent' | 'process-inside' | 'agent-session-inside' | 'cwd-probe-failed' | 'contains-worktree' | 'dirty' | 'no-remote' | 'unpushed' | 'stash' | 'has-external-worktrees' | 'git-failed' | 'stale';
41
+ /** Verdict and outcome for one session dir. */
42
+ export interface ScratchVerdict {
43
+ /** Absolute session dir */
44
+ path: string;
45
+ /** What the janitor would do / did */
46
+ decision: 'remove' | 'keep';
47
+ /** The deciding rule */
48
+ reason: ScratchReason;
49
+ /** Repo (or path) that decided it, and what was found */
50
+ detail?: string;
51
+ /** Ms since last touched, when computed */
52
+ idleMs?: number;
53
+ /** Git repos found inside */
54
+ repos: string[];
55
+ /** True when the dir was deleted */
56
+ removed: boolean;
57
+ /** Bytes freed (measured before deletion), when removed */
58
+ freedBytes?: number;
59
+ /** Error text when deletion failed */
60
+ error?: string;
61
+ }
62
+ /** Result of one scratch sweep. */
63
+ export interface ScratchSweepSummary {
64
+ /** Roots scanned (real paths) */
65
+ roots: string[];
66
+ /** Session dirs removed */
67
+ removed: number;
68
+ /** Dry run only: dirs a real sweep would remove */
69
+ wouldRemove: number;
70
+ /** Session dirs kept */
71
+ kept: number;
72
+ /** Bytes freed by removed dirs */
73
+ freedBytes: number;
74
+ /** Kept dirs by reason */
75
+ keptReasons: Record<string, number>;
76
+ /** Per-dir results */
77
+ sessions: ScratchVerdict[];
78
+ }
79
+ /** A place something is working in (same shape as the worktree janitor's). */
80
+ export interface ScratchBusyCwd {
81
+ /** Absolute directory */
82
+ cwd: string;
83
+ /** `agent-session` for Crewly sessions, `process` for anything else */
84
+ source: 'agent-session' | 'process';
85
+ }
86
+ /** Inputs of one sweep. */
87
+ export interface ScratchSweepInput {
88
+ /** Roots to scan (already resolved) */
89
+ roots: string[];
90
+ /** Busy cwds, or null when the probe failed */
91
+ busy: ScratchBusyCwd[] | null;
92
+ /** Current time (ms) */
93
+ now: number;
94
+ /** Minimum idle time */
95
+ minIdleMs: number;
96
+ /** When true, nothing is deleted */
97
+ dryRun: boolean;
98
+ /** git runner (`args` as for `git`) */
99
+ git: (args: string[]) => Promise<CommandResult>;
100
+ /** Size probe (default `du -sk`) */
101
+ sizeOf?: (p: string) => Promise<number | null>;
102
+ }
103
+ /**
104
+ * The per-user Claude Code temp roots that exist, de-duplicated by real path.
105
+ *
106
+ * @param uid - User id (default: process.getuid())
107
+ * @param tmpDir - System temp dir (default: os.tmpdir())
108
+ * @returns Existing root directories (real paths)
109
+ *
110
+ * @example
111
+ * ```typescript
112
+ * defaultScratchRoots(501); // ['/private/tmp/claude-501']
113
+ * ```
114
+ */
115
+ export declare function defaultScratchRoots(uid?: number, tmpDir?: string): string[];
116
+ /**
117
+ * Session dirs (`<root>/<slug>/<uuid>`) under a root. Symlinked slugs or
118
+ * session dirs are skipped, as is anything not matching the pattern. Async;
119
+ * yields to the event loop every few entries.
120
+ *
121
+ * @param root - A scratch root (real path)
122
+ * @returns Absolute session dirs
123
+ */
124
+ export declare function listSessionDirs(root: string): Promise<string[]>;
125
+ /**
126
+ * Evaluate one session dir. Read-only.
127
+ *
128
+ * @param dir - Session dir
129
+ * @param input - Sweep inputs
130
+ * @returns Verdict (not yet acted on)
131
+ */
132
+ export declare function evaluateSessionDir(dir: string, input: ScratchSweepInput): Promise<ScratchVerdict>;
133
+ /**
134
+ * One sweep over every session dir of every root. Never throws.
135
+ *
136
+ * @param input - Roots, busy cwds, clock, threshold, dry-run flag, git runner
137
+ * @returns Per-dir verdicts and totals
138
+ */
139
+ export declare function sweepScratch(input: ScratchSweepInput): Promise<ScratchSweepSummary>;
140
+ //# sourceMappingURL=scratch-janitor.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"scratch-janitor.d.ts","sourceRoot":"","sources":["../../../../../../backend/src/services/worktree/scratch-janitor.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAoCG;AAMH,OAAO,EAUN,KAAK,aAAa,EAElB,MAAM,2BAA2B,CAAC;AAEnC,mDAAmD;AACnD,MAAM,MAAM,aAAa,GACtB,QAAQ,GACR,gBAAgB,GAChB,sBAAsB,GACtB,kBAAkB,GAClB,mBAAmB,GACnB,OAAO,GACP,WAAW,GACX,UAAU,GACV,OAAO,GACP,wBAAwB,GACxB,YAAY,GACZ,OAAO,CAAC;AAEX,+CAA+C;AAC/C,MAAM,WAAW,cAAc;IAC9B,2BAA2B;IAC3B,IAAI,EAAE,MAAM,CAAC;IACb,sCAAsC;IACtC,QAAQ,EAAE,QAAQ,GAAG,MAAM,CAAC;IAC5B,wBAAwB;IACxB,MAAM,EAAE,aAAa,CAAC;IACtB,yDAAyD;IACzD,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,2CAA2C;IAC3C,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,6BAA6B;IAC7B,KAAK,EAAE,MAAM,EAAE,CAAC;IAChB,oCAAoC;IACpC,OAAO,EAAE,OAAO,CAAC;IACjB,2DAA2D;IAC3D,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,sCAAsC;IACtC,KAAK,CAAC,EAAE,MAAM,CAAC;CACf;AAED,mCAAmC;AACnC,MAAM,WAAW,mBAAmB;IACnC,iCAAiC;IACjC,KAAK,EAAE,MAAM,EAAE,CAAC;IAChB,2BAA2B;IAC3B,OAAO,EAAE,MAAM,CAAC;IAChB,mDAAmD;IACnD,WAAW,EAAE,MAAM,CAAC;IACpB,wBAAwB;IACxB,IAAI,EAAE,MAAM,CAAC;IACb,kCAAkC;IAClC,UAAU,EAAE,MAAM,CAAC;IACnB,0BAA0B;IAC1B,WAAW,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACpC,sBAAsB;IACtB,QAAQ,EAAE,cAAc,EAAE,CAAC;CAC3B;AAED,8EAA8E;AAC9E,MAAM,WAAW,cAAc;IAC9B,yBAAyB;IACzB,GAAG,EAAE,MAAM,CAAC;IACZ,uEAAuE;IACvE,MAAM,EAAE,eAAe,GAAG,SAAS,CAAC;CACpC;AAED,2BAA2B;AAC3B,MAAM,WAAW,iBAAiB;IACjC,uCAAuC;IACvC,KAAK,EAAE,MAAM,EAAE,CAAC;IAChB,+CAA+C;IAC/C,IAAI,EAAE,cAAc,EAAE,GAAG,IAAI,CAAC;IAC9B,wBAAwB;IACxB,GAAG,EAAE,MAAM,CAAC;IACZ,wBAAwB;IACxB,SAAS,EAAE,MAAM,CAAC;IAClB,oCAAoC;IACpC,MAAM,EAAE,OAAO,CAAC;IAChB,uCAAuC;IACvC,GAAG,EAAE,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,OAAO,CAAC,aAAa,CAAC,CAAC;IAChD,oCAAoC;IACpC,MAAM,CAAC,EAAE,CAAC,CAAC,EAAE,MAAM,KAAK,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAAC;CAC/C;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,mBAAmB,CAAC,GAAG,CAAC,EAAE,MAAM,EAAE,MAAM,GAAE,MAAoB,GAAG,MAAM,EAAE,CAaxF;AAED;;;;;;;GAOG;AACH,wBAAsB,eAAe,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,EAAE,CAAC,CAyBrE;AAOD;;;;;;GAMG;AACH,wBAAsB,kBAAkB,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,iBAAiB,GAAG,OAAO,CAAC,cAAc,CAAC,CA0DvG;AAED;;;;;GAKG;AACH,wBAAsB,YAAY,CAAC,KAAK,EAAE,iBAAiB,GAAG,OAAO,CAAC,mBAAmB,CAAC,CAiCzF"}
@@ -0,0 +1,249 @@
1
+ /**
2
+ * Scratch janitor — removes stale Claude Code session scratch dirs.
3
+ *
4
+ * Claude Code gives every session a temp dir
5
+ * `<tmp>/claude-<uid>/<project-slug>/<session-uuid>/` (with `scratchpad/`
6
+ * inside). Agents clone whole repos and build test homes there — 0.5–1.8 GB
7
+ * each — and nothing ever deletes them; one Mac filled its disk with dozens
8
+ * of GB of week-old sessions. These are not worktrees, so the worktree rules
9
+ * never see them.
10
+ *
11
+ * ## Roots
12
+ * `<os.tmpdir()>/claude-<uid>`, `/private/tmp/claude-<uid>` and
13
+ * `/tmp/claude-<uid>`, de-duplicated by real path. Only the
14
+ * `<root>/<slug>/<uuid>/` pattern is a unit; anything else in the root
15
+ * (loose files, worktrees such as `<root>/visa-cm-wt`) is left alone here.
16
+ * Symlinks are never followed out of the root.
17
+ *
18
+ * ## A session dir is deleted only when ALL hold
19
+ * 1. Idle: the newest mtime of the dir and everything in its top two levels,
20
+ * and of every git repo's HEAD / index / logs/HEAD inside it, is older
21
+ * than 3 days (halved in low-disk mode, never below 2 h).
22
+ * 2. No process of this user (nor a Crewly agent session) has its cwd
23
+ * inside. If the probe fails, everything is kept.
24
+ * 3. It holds no linked worktree of a repo outside it — those belong to the
25
+ * worktree rules (`git worktree remove`), never `rm -rf`.
26
+ * 4. Every git repo inside (depth ≤ 3, `node_modules` skipped) is safe:
27
+ * - `git status --porcelain --untracked-files=all` is empty (untracked
28
+ * files count, ignored ones do not);
29
+ * - it has a remote (no remote → kept: its commits may exist nowhere else);
30
+ * - `git rev-list HEAD --branches --not --remotes` is empty (no local
31
+ * commit missing from the remote-tracking refs) and there is no stash;
32
+ * - it has no linked worktree outside the session dir (deleting the
33
+ * clone would orphan it).
34
+ * Any git error keeps the dir.
35
+ *
36
+ * @module services/worktree/scratch-janitor
37
+ */
38
+ import * as fs from 'fs';
39
+ import * as os from 'os';
40
+ import * as path from 'path';
41
+ import { WORKTREE_JANITOR_CONSTANTS } from '../../constants.js';
42
+ import { canonicalPath, diskUsageBytes, EventLoopYielder, findGitCheckouts, isPathInside, latestTreeMtimeMs, latestWorktreeMtimeMs, parsePorcelainWorktrees, readGitFilePointer, } from './worktree-janitor.git.js';
43
+ /**
44
+ * The per-user Claude Code temp roots that exist, de-duplicated by real path.
45
+ *
46
+ * @param uid - User id (default: process.getuid())
47
+ * @param tmpDir - System temp dir (default: os.tmpdir())
48
+ * @returns Existing root directories (real paths)
49
+ *
50
+ * @example
51
+ * ```typescript
52
+ * defaultScratchRoots(501); // ['/private/tmp/claude-501']
53
+ * ```
54
+ */
55
+ export function defaultScratchRoots(uid, tmpDir = os.tmpdir()) {
56
+ const id = uid ?? (typeof process.getuid === 'function' ? process.getuid() : os.userInfo().uid);
57
+ const name = `${WORKTREE_JANITOR_CONSTANTS.SCRATCH_ROOT_PREFIX}${id}`;
58
+ const candidates = [tmpDir, ...WORKTREE_JANITOR_CONSTANTS.SCRATCH_TMP_DIRS].map((d) => path.join(d, name));
59
+ const out = new Set();
60
+ for (const c of candidates) {
61
+ try {
62
+ if (fs.lstatSync(c).isDirectory())
63
+ out.add(fs.realpathSync(c));
64
+ }
65
+ catch {
66
+ // missing
67
+ }
68
+ }
69
+ return [...out];
70
+ }
71
+ /**
72
+ * Session dirs (`<root>/<slug>/<uuid>`) under a root. Symlinked slugs or
73
+ * session dirs are skipped, as is anything not matching the pattern. Async;
74
+ * yields to the event loop every few entries.
75
+ *
76
+ * @param root - A scratch root (real path)
77
+ * @returns Absolute session dirs
78
+ */
79
+ export async function listSessionDirs(root) {
80
+ const out = [];
81
+ const yielder = new EventLoopYielder();
82
+ let slugs;
83
+ try {
84
+ slugs = await fs.promises.readdir(root, { withFileTypes: true });
85
+ }
86
+ catch {
87
+ return out;
88
+ }
89
+ for (const slug of slugs) {
90
+ await yielder.tick();
91
+ if (!slug.isDirectory())
92
+ continue;
93
+ const slugDir = path.join(root, slug.name);
94
+ let sessions;
95
+ try {
96
+ sessions = await fs.promises.readdir(slugDir, { withFileTypes: true });
97
+ }
98
+ catch {
99
+ continue;
100
+ }
101
+ for (const s of sessions) {
102
+ if (!s.isDirectory() || !WORKTREE_JANITOR_CONSTANTS.SCRATCH_SESSION_DIR_PATTERN.test(s.name))
103
+ continue;
104
+ out.push(path.join(slugDir, s.name));
105
+ }
106
+ }
107
+ return out;
108
+ }
109
+ /** Git dir of a checkout (`.git` dir, or the one a `.git` file points to). */
110
+ async function gitDirOf(repo) {
111
+ return repo.kind === 'dir' ? path.join(repo.path, '.git') : readGitFilePointer(repo.path);
112
+ }
113
+ /**
114
+ * Evaluate one session dir. Read-only.
115
+ *
116
+ * @param dir - Session dir
117
+ * @param input - Sweep inputs
118
+ * @returns Verdict (not yet acted on)
119
+ */
120
+ export async function evaluateSessionDir(dir, input) {
121
+ const repos = await findGitCheckouts(dir, WORKTREE_JANITOR_CONSTANTS.SCRATCH_REPO_SEARCH_DEPTH);
122
+ const base = { path: dir, repos: repos.map((r) => r.path), removed: false };
123
+ const keep = (reason, detail, idleMs) => ({
124
+ ...base,
125
+ decision: 'keep',
126
+ reason,
127
+ ...(detail ? { detail } : {}),
128
+ ...(idleMs !== undefined ? { idleMs } : {}),
129
+ });
130
+ let newest = await latestTreeMtimeMs(dir, WORKTREE_JANITOR_CONSTANTS.SCRATCH_MTIME_DEPTH);
131
+ for (const r of repos) {
132
+ const gd = await gitDirOf(r);
133
+ const m = latestWorktreeMtimeMs(r.path, gd);
134
+ if (m !== null && (newest === null || m > newest))
135
+ newest = m;
136
+ if (gd) {
137
+ const logs = await latestTreeMtimeMs(path.join(gd, 'logs'), WORKTREE_JANITOR_CONSTANTS.SCRATCH_MTIME_DEPTH);
138
+ if (logs !== null && (newest === null || logs > newest))
139
+ newest = logs;
140
+ }
141
+ }
142
+ const idleMs = newest === null ? undefined : Math.max(0, input.now - newest);
143
+ if (idleMs === undefined || idleMs < input.minIdleMs)
144
+ return keep('recent', undefined, idleMs);
145
+ if (input.busy === null)
146
+ return keep('cwd-probe-failed', undefined, idleMs);
147
+ const hit = input.busy.find((b) => isPathInside(b.cwd, dir));
148
+ if (hit)
149
+ return keep(hit.source === 'agent-session' ? 'agent-session-inside' : 'process-inside', hit.cwd, idleMs);
150
+ for (const r of repos) {
151
+ if (r.kind === 'file') {
152
+ const common = await input.git(['-C', r.path, 'rev-parse', '--path-format=absolute', '--git-common-dir']);
153
+ if (common.code !== 0)
154
+ return keep('git-failed', `${r.path}: ${common.stderr.trim().slice(0, 200)}`, idleMs);
155
+ if (!isPathInside(common.stdout.trim(), dir))
156
+ return keep('contains-worktree', r.path, idleMs);
157
+ }
158
+ const status = await input.git(['-C', r.path, 'status', '--porcelain', '--untracked-files=all']);
159
+ if (status.code !== 0)
160
+ return keep('git-failed', `${r.path}: ${status.stderr.trim().slice(0, 200)}`, idleMs);
161
+ const dirty = status.stdout.split('\n').filter((l) => l.trim() !== '').length;
162
+ if (dirty > 0)
163
+ return keep('dirty', `${r.path}: ${dirty} changed/untracked path(s)`, idleMs);
164
+ const remotes = await input.git(['-C', r.path, 'remote']);
165
+ if (remotes.code !== 0)
166
+ return keep('git-failed', `${r.path}: ${remotes.stderr.trim().slice(0, 200)}`, idleMs);
167
+ if (remotes.stdout.trim() === '')
168
+ return keep('no-remote', r.path, idleMs);
169
+ const unpushed = await input.git(['-C', r.path, 'rev-list', '--max-count=1', 'HEAD', '--branches', '--not', '--remotes']);
170
+ if (unpushed.code !== 0)
171
+ return keep('git-failed', `${r.path}: ${unpushed.stderr.trim().slice(0, 200)}`, idleMs);
172
+ if (unpushed.stdout.trim() !== '')
173
+ return keep('unpushed', `${r.path}: commit ${unpushed.stdout.trim().slice(0, 12)} is on no remote`, idleMs);
174
+ const stash = await input.git(['-C', r.path, 'rev-parse', '--verify', '--quiet', 'refs/stash']);
175
+ if (stash.code === 0)
176
+ return keep('stash', r.path, idleMs);
177
+ const wts = await input.git(['-C', r.path, 'worktree', 'list', '--porcelain']);
178
+ if (wts.code !== 0)
179
+ return keep('git-failed', `${r.path}: ${wts.stderr.trim().slice(0, 200)}`, idleMs);
180
+ const external = parsePorcelainWorktrees(wts.stdout).find((w) => !w.isMain && !w.prunable && fs.existsSync(w.path) && !isPathInside(w.path, dir));
181
+ if (external)
182
+ return keep('has-external-worktrees', `${r.path} → ${external.path}`, idleMs);
183
+ }
184
+ return { ...base, decision: 'remove', reason: 'stale', idleMs };
185
+ }
186
+ /**
187
+ * One sweep over every session dir of every root. Never throws.
188
+ *
189
+ * @param input - Roots, busy cwds, clock, threshold, dry-run flag, git runner
190
+ * @returns Per-dir verdicts and totals
191
+ */
192
+ export async function sweepScratch(input) {
193
+ const summary = {
194
+ roots: input.roots.map(canonicalPath),
195
+ removed: 0,
196
+ wouldRemove: 0,
197
+ kept: 0,
198
+ freedBytes: 0,
199
+ keptReasons: {},
200
+ sessions: [],
201
+ };
202
+ const sizeOf = input.sizeOf ?? diskUsageBytes;
203
+ for (const root of summary.roots) {
204
+ for (const dir of await listSessionDirs(root)) {
205
+ let v;
206
+ try {
207
+ v = await evaluateSessionDir(dir, input);
208
+ }
209
+ catch (err) {
210
+ v = { path: dir, decision: 'keep', reason: 'git-failed', detail: err instanceof Error ? err.message : String(err), repos: [], removed: false };
211
+ }
212
+ if (v.decision === 'remove' && !input.dryRun)
213
+ await removeSessionDir(root, v, sizeOf);
214
+ summary.sessions.push(v);
215
+ if (v.removed) {
216
+ summary.removed++;
217
+ summary.freedBytes += v.freedBytes ?? 0;
218
+ }
219
+ else if (input.dryRun && v.decision === 'remove')
220
+ summary.wouldRemove++;
221
+ else {
222
+ summary.kept++;
223
+ const key = v.error ? 'remove-failed' : v.reason;
224
+ summary.keptReasons[key] = (summary.keptReasons[key] ?? 0) + 1;
225
+ }
226
+ }
227
+ }
228
+ return summary;
229
+ }
230
+ /** Delete one session dir after re-checking it is a real dir inside its root. */
231
+ async function removeSessionDir(root, v, sizeOf) {
232
+ try {
233
+ const st = await fs.promises.lstat(v.path);
234
+ if (!st.isDirectory() || st.isSymbolicLink() || !(await fs.promises.realpath(v.path)).startsWith(root + path.sep)) {
235
+ v.error = 'not a real directory inside the scratch root';
236
+ return;
237
+ }
238
+ const size = await sizeOf(v.path);
239
+ // fs.rm removes symlinks themselves, never their targets.
240
+ await fs.promises.rm(v.path, { recursive: true, force: true });
241
+ v.removed = true;
242
+ if (size !== null)
243
+ v.freedBytes = size;
244
+ }
245
+ catch (err) {
246
+ v.error = (err instanceof Error ? err.message : String(err)).slice(0, 300);
247
+ }
248
+ }
249
+ //# sourceMappingURL=scratch-janitor.js.map