@cleocode/core 2026.6.14 → 2026.6.17

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 (152) hide show
  1. package/dist/check/pr-gate.d.ts +6 -2
  2. package/dist/check/pr-gate.d.ts.map +1 -1
  3. package/dist/check/pr-gate.js +14 -32
  4. package/dist/check/pr-gate.js.map +1 -1
  5. package/dist/config/config-repair.d.ts +70 -0
  6. package/dist/config/config-repair.d.ts.map +1 -0
  7. package/dist/config/config-repair.js +248 -0
  8. package/dist/config/config-repair.js.map +1 -0
  9. package/dist/config/registry.d.ts.map +1 -1
  10. package/dist/config/registry.js +4 -4
  11. package/dist/config/registry.js.map +1 -1
  12. package/dist/config.d.ts.map +1 -1
  13. package/dist/config.js +11 -10
  14. package/dist/config.js.map +1 -1
  15. package/dist/docs/export-document.js +58 -16
  16. package/dist/docs/export-document.js.map +2 -2
  17. package/dist/gc/cleanup.d.ts +23 -8
  18. package/dist/gc/cleanup.d.ts.map +1 -1
  19. package/dist/gc/cleanup.js +287 -22
  20. package/dist/gc/cleanup.js.map +1 -1
  21. package/dist/gc/janitor.d.ts +149 -0
  22. package/dist/gc/janitor.d.ts.map +1 -0
  23. package/dist/gc/janitor.js +760 -0
  24. package/dist/gc/janitor.js.map +1 -0
  25. package/dist/internal.d.ts +1 -0
  26. package/dist/internal.d.ts.map +1 -1
  27. package/dist/internal.js +3 -0
  28. package/dist/internal.js.map +1 -1
  29. package/dist/llm/cli-ops.d.ts +31 -4
  30. package/dist/llm/cli-ops.d.ts.map +1 -1
  31. package/dist/llm/cli-ops.js +110 -26
  32. package/dist/llm/cli-ops.js.map +1 -1
  33. package/dist/llm/credential-pool.d.ts +62 -0
  34. package/dist/llm/credential-pool.d.ts.map +1 -1
  35. package/dist/llm/credential-pool.js +161 -0
  36. package/dist/llm/credential-pool.js.map +1 -1
  37. package/dist/llm/cross-provider-selector.d.ts +254 -0
  38. package/dist/llm/cross-provider-selector.d.ts.map +1 -0
  39. package/dist/llm/cross-provider-selector.js +635 -0
  40. package/dist/llm/cross-provider-selector.js.map +1 -0
  41. package/dist/llm/index.d.ts +2 -0
  42. package/dist/llm/index.d.ts.map +1 -1
  43. package/dist/llm/index.js +1 -0
  44. package/dist/llm/index.js.map +1 -1
  45. package/dist/llm/local-model-fit.d.ts +260 -0
  46. package/dist/llm/local-model-fit.d.ts.map +1 -0
  47. package/dist/llm/local-model-fit.js +563 -0
  48. package/dist/llm/local-model-fit.js.map +1 -0
  49. package/dist/llm/plugin-facade.js +883 -231
  50. package/dist/llm/plugin-facade.js.map +3 -3
  51. package/dist/llm/provider-registry/builtin/ollama.d.ts +18 -0
  52. package/dist/llm/provider-registry/builtin/ollama.d.ts.map +1 -1
  53. package/dist/llm/provider-registry/builtin/ollama.js +20 -2
  54. package/dist/llm/provider-registry/builtin/ollama.js.map +1 -1
  55. package/dist/llm/role-resolver.d.ts.map +1 -1
  56. package/dist/llm/role-resolver.js +45 -10
  57. package/dist/llm/role-resolver.js.map +1 -1
  58. package/dist/llm/transports/codex-responses.d.ts +111 -88
  59. package/dist/llm/transports/codex-responses.d.ts.map +1 -1
  60. package/dist/llm/transports/codex-responses.js +456 -258
  61. package/dist/llm/transports/codex-responses.js.map +1 -1
  62. package/dist/release/index.d.ts +2 -2
  63. package/dist/release/index.d.ts.map +1 -1
  64. package/dist/release/index.js +1 -1
  65. package/dist/release/index.js.map +1 -1
  66. package/dist/release/reconcile.d.ts +97 -0
  67. package/dist/release/reconcile.d.ts.map +1 -1
  68. package/dist/release/reconcile.js +412 -5
  69. package/dist/release/reconcile.js.map +1 -1
  70. package/dist/resources/backend.d.ts +155 -0
  71. package/dist/resources/backend.d.ts.map +1 -0
  72. package/dist/resources/backend.js +23 -0
  73. package/dist/resources/backend.js.map +1 -0
  74. package/dist/resources/linux-backend.d.ts +167 -0
  75. package/dist/resources/linux-backend.d.ts.map +1 -0
  76. package/dist/resources/linux-backend.js +259 -0
  77. package/dist/resources/linux-backend.js.map +1 -0
  78. package/dist/resources/monitor.d.ts +230 -0
  79. package/dist/resources/monitor.d.ts.map +1 -0
  80. package/dist/resources/monitor.js +255 -0
  81. package/dist/resources/monitor.js.map +1 -0
  82. package/dist/resources/spawn-wrapper.d.ts +265 -0
  83. package/dist/resources/spawn-wrapper.d.ts.map +1 -0
  84. package/dist/resources/spawn-wrapper.js +304 -0
  85. package/dist/resources/spawn-wrapper.js.map +1 -0
  86. package/dist/selfimprove/budget.d.ts +11 -2
  87. package/dist/selfimprove/budget.d.ts.map +1 -1
  88. package/dist/selfimprove/budget.js +11 -2
  89. package/dist/selfimprove/budget.js.map +1 -1
  90. package/dist/selfimprove/fix-gen-context.d.ts +172 -0
  91. package/dist/selfimprove/fix-gen-context.d.ts.map +1 -0
  92. package/dist/selfimprove/fix-gen-context.js +218 -0
  93. package/dist/selfimprove/fix-gen-context.js.map +1 -0
  94. package/dist/selfimprove/fix-gen.d.ts +285 -0
  95. package/dist/selfimprove/fix-gen.d.ts.map +1 -0
  96. package/dist/selfimprove/fix-gen.js +346 -0
  97. package/dist/selfimprove/fix-gen.js.map +1 -0
  98. package/dist/selfimprove/op-source-map.d.ts +92 -0
  99. package/dist/selfimprove/op-source-map.d.ts.map +1 -0
  100. package/dist/selfimprove/op-source-map.js +187 -0
  101. package/dist/selfimprove/op-source-map.js.map +1 -0
  102. package/dist/selfimprove/probe-helper.d.ts +61 -0
  103. package/dist/selfimprove/probe-helper.d.ts.map +1 -0
  104. package/dist/selfimprove/probe-helper.js +64 -0
  105. package/dist/selfimprove/probe-helper.js.map +1 -0
  106. package/dist/selfimprove/run-loop.d.ts +44 -4
  107. package/dist/selfimprove/run-loop.d.ts.map +1 -1
  108. package/dist/selfimprove/run-loop.js +73 -6
  109. package/dist/selfimprove/run-loop.js.map +1 -1
  110. package/dist/selfimprove/scenarios/seeded-code-regression/golden.json +18 -0
  111. package/dist/selfimprove/scenarios/seeded-code-regression/scenario.json +12 -0
  112. package/dist/sentient/cross-project-hygiene.d.ts +11 -2
  113. package/dist/sentient/cross-project-hygiene.d.ts.map +1 -1
  114. package/dist/sentient/cross-project-hygiene.js +73 -10
  115. package/dist/sentient/cross-project-hygiene.js.map +1 -1
  116. package/dist/sentient/daemon.d.ts.map +1 -1
  117. package/dist/sentient/daemon.js +7 -2
  118. package/dist/sentient/daemon.js.map +1 -1
  119. package/dist/sentient/worktree-dispatch.d.ts.map +1 -1
  120. package/dist/sentient/worktree-dispatch.js +8 -1
  121. package/dist/sentient/worktree-dispatch.js.map +1 -1
  122. package/dist/setup/index.d.ts +1 -0
  123. package/dist/setup/index.d.ts.map +1 -1
  124. package/dist/setup/index.js +1 -0
  125. package/dist/setup/index.js.map +1 -1
  126. package/dist/setup/sections/models-roles.d.ts +32 -1
  127. package/dist/setup/sections/models-roles.d.ts.map +1 -1
  128. package/dist/setup/sections/models-roles.js +118 -19
  129. package/dist/setup/sections/models-roles.js.map +1 -1
  130. package/dist/setup/whoami-summary.d.ts +34 -0
  131. package/dist/setup/whoami-summary.d.ts.map +1 -0
  132. package/dist/setup/whoami-summary.js +106 -0
  133. package/dist/setup/whoami-summary.js.map +1 -0
  134. package/dist/spawn/branch-lock.d.ts.map +1 -1
  135. package/dist/spawn/branch-lock.js +7 -1
  136. package/dist/spawn/branch-lock.js.map +1 -1
  137. package/dist/store/attachment-repair.d.ts +110 -0
  138. package/dist/store/attachment-repair.d.ts.map +1 -0
  139. package/dist/store/attachment-repair.js +326 -0
  140. package/dist/store/attachment-repair.js.map +1 -0
  141. package/dist/store/attachment-store.d.ts.map +1 -1
  142. package/dist/store/attachment-store.js +24 -7
  143. package/dist/store/attachment-store.js.map +1 -1
  144. package/dist/tasks/evidence.d.ts +34 -2
  145. package/dist/tasks/evidence.d.ts.map +1 -1
  146. package/dist/tasks/evidence.js +293 -35
  147. package/dist/tasks/evidence.js.map +1 -1
  148. package/dist/verification/verify-tools.d.ts.map +1 -1
  149. package/dist/verification/verify-tools.js +5 -6
  150. package/dist/verification/verify-tools.js.map +1 -1
  151. package/package.json +22 -12
  152. package/templates/workflows/release-prepare.yml.tmpl +48 -2
@@ -0,0 +1,760 @@
1
+ /**
2
+ * CLEO Janitor — orphan process reaper + stale scope/lock/debris sweep.
3
+ *
4
+ * Composable engine that the GC subsystem and the `cleo janitor run` CLI verb
5
+ * call. Every action is:
6
+ * - Silent: no console output; one JSONL line per action appended to
7
+ * `.cleo/audit/janitor.jsonl`, plus counts in the returned result.
8
+ * - Idempotent: a fully-converged state produces zero actions on re-run.
9
+ * - Dry-run capable: `dryRun: true` reports planned actions without mutating
10
+ * anything.
11
+ *
12
+ * ## Categories
13
+ *
14
+ * | # | Category | Description |
15
+ * |---|--------------------------|------------------------------------------------------|
16
+ * | 1 | `reaped` | Leaked MCP/agent helpers (registration-primary) |
17
+ * | 2 | `scopesStopped` | Cleo-owned systemd transient scopes that exited |
18
+ * | 3 | `locksReclaimed` | Sentient/GC lock files held by dead PIDs |
19
+ * | 4 | `semaphoreSlotsCleared` | Tool-semaphore slot dirs held past staleMs |
20
+ * | 5 | `worktreesPruned` | Orphan worktree directories |
21
+ * | 6 | `worktreesQuarantined` | Dirty/unpushed worktrees quarantined |
22
+ * | 7 | `tmpRemoved` | Stale CLEO temp directories |
23
+ * | 8 | `attachmentsRepaired` | Attachment rows/files repaired |
24
+ * | 9 | `configsRepaired` | Corrupt config files restored |
25
+ *
26
+ * ## Amendment compliance
27
+ *
28
+ * - **Amendment 1 (registration-primary)**: orphan detection uses cleo-owned
29
+ * scope/pgid as PRIMARY discriminator; signature+age only for UNREGISTERED
30
+ * processes when all stdio pipe peers are dead.
31
+ * - **Amendment 2 (regression)**: reparented double-fork of a LIVE session is
32
+ * preserved; same of a DEAD session is reaped.
33
+ * - **Amendment 3 (scope reaping)**: restricted to units starting with
34
+ * `cleo-` prefix inside `cleo.slice`. Never touches `run-*.scope`.
35
+ * - **Amendment 4 (idempotency)**: TERM→KILL escalation spanning runs is
36
+ * legitimate; a fully-converged state produces zero actions on second run.
37
+ * - **Amendment 5 (liveness probe)**: stale locks reclaimed only after
38
+ * verifying the holder PID is dead — never on mtime alone.
39
+ * - **Amendment 6 (silence)**: every action → `.cleo/audit/janitor.jsonl`;
40
+ * zero desktop notifications; zero console noise in non-verbose mode.
41
+ * - **Amendment 7 (tmp debris)**: reuses `gc/cleanup.ts` patterns directly.
42
+ *
43
+ * @module @cleocode/core/gc/janitor
44
+ * @task T11995
45
+ * @epic T11992
46
+ */
47
+ import { spawnSync } from 'node:child_process';
48
+ import { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync, readlinkSync, rmSync, statSync, writeFileSync, } from 'node:fs';
49
+ import { join } from 'node:path';
50
+ import { repairConfigFile } from '../config/config-repair.js';
51
+ import { getCleoHome, resolveOrCwd } from '../paths.js';
52
+ import { repairAttachmentStore } from '../store/attachment-repair.js';
53
+ import { pruneOrphanTempDirs, pruneOrphanWorktrees } from './cleanup.js';
54
+ // ---------------------------------------------------------------------------
55
+ // Constants
56
+ // ---------------------------------------------------------------------------
57
+ /**
58
+ * Default minimum age (ms) of an unregistered process before reap eligibility.
59
+ * 10 minutes gives a freshly-spawned MCP server time to register.
60
+ */
61
+ export const DEFAULT_GRACE_MS = 10 * 60 * 1000;
62
+ /**
63
+ * Default SIGTERM→SIGKILL grace period (ms).
64
+ * Matches suite-reaper.ts value for consistency.
65
+ */
66
+ export const SIGTERM_GRACE_MS = 3_000;
67
+ /**
68
+ * Default stale-ms for tool-semaphore slot lock directories.
69
+ * Matches tool-semaphore.ts default.
70
+ */
71
+ export const DEFAULT_SEMAPHORE_STALE_MS = 600_000;
72
+ /**
73
+ * Unit-name prefix for cleo-owned transient scopes (Amendment 3).
74
+ * `cleo-` covers cleo-agent-*, cleo-db-*, cleo-test-*, etc.
75
+ */
76
+ const CLEO_UNIT_PREFIX = 'cleo-';
77
+ /**
78
+ * Units to NEVER stop — intentional long-lived cleo daemons (Amendment 3).
79
+ */
80
+ const DAEMON_UNIT_ALLOWLIST = [
81
+ 'cleo-daemon.service',
82
+ 'cleo-gateway.service',
83
+ 'cleo-daemon',
84
+ 'cleo-gateway',
85
+ ];
86
+ /**
87
+ * Command-line patterns that identify cleo MCP-suite processes.
88
+ * Matched against /proc/<pid>/cmdline (null-byte separated, joined to spaces).
89
+ * Only used for the UNREGISTERED fallback path (Amendment 1).
90
+ */
91
+ const MCP_CMDLINE_SIGNATURES = [
92
+ '@cleocode/cleo',
93
+ '.local/share/cleo',
94
+ 'caamp-mcp',
95
+ 'claude-code-mcp',
96
+ 'pi-mcp',
97
+ ];
98
+ /** Relative path of the janitor audit log (Amendment 6). */
99
+ const JANITOR_AUDIT_REL = 'audit/janitor.jsonl';
100
+ // ---------------------------------------------------------------------------
101
+ // Audit helpers (Amendment 6)
102
+ // ---------------------------------------------------------------------------
103
+ /**
104
+ * Append one structured action entry to the janitor audit log.
105
+ * Synchronous; fails silently (audit is best-effort).
106
+ */
107
+ function auditLog(cleoDir, action, detail, dryRun) {
108
+ try {
109
+ const auditPath = join(cleoDir, JANITOR_AUDIT_REL);
110
+ mkdirSync(join(cleoDir, 'audit'), { recursive: true });
111
+ const entry = JSON.stringify({
112
+ ts: new Date().toISOString(),
113
+ action,
114
+ dryRun,
115
+ agentId: process.env['CLEO_AGENT_ID'] ?? 'cleo',
116
+ ...detail,
117
+ });
118
+ appendFileSync(auditPath, `${entry}\n`, { encoding: 'utf-8' });
119
+ }
120
+ catch {
121
+ // best-effort
122
+ }
123
+ }
124
+ // ---------------------------------------------------------------------------
125
+ // /proc helpers (Linux-only)
126
+ // ---------------------------------------------------------------------------
127
+ /**
128
+ * Check whether a PID is alive via `kill(pid, 0)`.
129
+ * Returns `false` for ESRCH or any other error.
130
+ */
131
+ export function isPidAlive(pid) {
132
+ try {
133
+ process.kill(pid, 0);
134
+ return true;
135
+ }
136
+ catch {
137
+ return false;
138
+ }
139
+ }
140
+ /**
141
+ * Read `/proc/<pid>/cmdline` and return the null-byte-separated content as a
142
+ * space-joined string. Returns `''` on any failure.
143
+ */
144
+ function readProcCmdline(pid) {
145
+ try {
146
+ return readFileSync(`/proc/${pid}/cmdline`, 'utf-8').replace(/\0/g, ' ').trim();
147
+ }
148
+ catch {
149
+ return '';
150
+ }
151
+ }
152
+ /**
153
+ * Approximate process start time as Unix epoch ms by reading
154
+ * `/proc/<pid>/stat` field 22 (starttime in clock ticks since boot) and
155
+ * `/proc/uptime` (seconds since boot).
156
+ *
157
+ * Returns 0 on failure (treated conservatively as "zero age").
158
+ */
159
+ function estimateProcessStartMs(pid) {
160
+ try {
161
+ const stat = readFileSync(`/proc/${pid}/stat`, 'utf-8').trim().split(' ');
162
+ const startTicks = Number.parseInt(stat[21] ?? '0', 10);
163
+ if (!Number.isFinite(startTicks) || startTicks <= 0)
164
+ return 0;
165
+ const uptimeSec = Number.parseFloat(readFileSync('/proc/uptime', 'utf-8').split(' ')[0] ?? '0');
166
+ if (!Number.isFinite(uptimeSec) || uptimeSec <= 0)
167
+ return 0;
168
+ const clkTck = 100; // USER_HZ on Linux x86_64
169
+ const bootEpochMs = Date.now() - uptimeSec * 1000;
170
+ return bootEpochMs + (startTicks / clkTck) * 1000;
171
+ }
172
+ catch {
173
+ return 0;
174
+ }
175
+ }
176
+ /**
177
+ * Enumerate all integer-named entries under `/proc` as PIDs.
178
+ * Returns an empty array when `/proc` is unavailable.
179
+ */
180
+ function enumProcPids() {
181
+ try {
182
+ return readdirSync('/proc')
183
+ .map((e) => Number.parseInt(e, 10))
184
+ .filter((n) => Number.isFinite(n) && n > 0);
185
+ }
186
+ catch {
187
+ return [];
188
+ }
189
+ }
190
+ // ---------------------------------------------------------------------------
191
+ // Systemd helpers
192
+ // ---------------------------------------------------------------------------
193
+ /**
194
+ * List all cleo-owned transient scope unit names via `systemctl --user`.
195
+ *
196
+ * Returns unit names matching `cleo-*.scope` (Amendment 3).
197
+ * Returns `[]` when systemd is unavailable.
198
+ */
199
+ function listCleoScopeUnits() {
200
+ if (process.platform !== 'linux')
201
+ return [];
202
+ try {
203
+ const result = spawnSync('systemctl', ['--user', 'list-units', '--type=scope', '--all', '--no-legend', '--no-pager'], { encoding: 'utf-8', timeout: 10_000 });
204
+ if (result.status !== 0 || !result.stdout)
205
+ return [];
206
+ return result.stdout
207
+ .split('\n')
208
+ .map((line) => line.trim().split(/\s+/)[0] ?? '')
209
+ .filter((name) => name.startsWith(CLEO_UNIT_PREFIX) &&
210
+ name.endsWith('.scope') &&
211
+ !DAEMON_UNIT_ALLOWLIST.includes(name));
212
+ }
213
+ catch {
214
+ return [];
215
+ }
216
+ }
217
+ /**
218
+ * Get the `SubState` of a systemd unit (e.g. `'exited'`, `'failed'`, `'running'`).
219
+ * Returns `''` on failure.
220
+ */
221
+ function getUnitSubState(unitName) {
222
+ try {
223
+ const result = spawnSync('systemctl', ['--user', 'show', unitName, '--property=SubState', '--value'], { encoding: 'utf-8', timeout: 5_000 });
224
+ return (result.stdout ?? '').trim();
225
+ }
226
+ catch {
227
+ return '';
228
+ }
229
+ }
230
+ /**
231
+ * Read `/sys/fs/cgroup<cgroupPath>/cgroup.procs` for a systemd unit.
232
+ * Returns an empty array when not available.
233
+ */
234
+ function getUnitCgroupProcs(unitName) {
235
+ if (process.platform !== 'linux')
236
+ return [];
237
+ try {
238
+ const pathResult = spawnSync('systemctl', ['--user', 'show', unitName, '--property=ControlGroup', '--value'], { encoding: 'utf-8', timeout: 5_000 });
239
+ const cgroupPath = (pathResult.stdout ?? '').trim();
240
+ if (!cgroupPath)
241
+ return [];
242
+ const procsPath = `/sys/fs/cgroup${cgroupPath}/cgroup.procs`;
243
+ if (!existsSync(procsPath))
244
+ return [];
245
+ const contents = readFileSync(procsPath, 'utf-8').trim();
246
+ if (!contents)
247
+ return [];
248
+ return contents
249
+ .split('\n')
250
+ .map((s) => Number.parseInt(s.trim(), 10))
251
+ .filter((n) => Number.isFinite(n) && n > 0);
252
+ }
253
+ catch {
254
+ return [];
255
+ }
256
+ }
257
+ /**
258
+ * Check whether any live process in `/proc` is a member of a given cleo
259
+ * scope unit by cgroup membership.
260
+ * Returns the owning unit name, or `null` if none found.
261
+ */
262
+ function findOwningCleoScope(pid, unitNames) {
263
+ for (const unitName of unitNames) {
264
+ const procs = getUnitCgroupProcs(unitName);
265
+ if (procs.includes(pid))
266
+ return unitName;
267
+ }
268
+ return null;
269
+ }
270
+ // ---------------------------------------------------------------------------
271
+ // Pipe-peer liveness check (Amendment 1 fallback guard)
272
+ // ---------------------------------------------------------------------------
273
+ /**
274
+ * Return `true` when all stdio pipe peers of a process are dead.
275
+ *
276
+ * A reparented double-forked helper of a LIVE session has at least one live
277
+ * pipe peer. If all stdio fds are pipes whose other ends belong to dead
278
+ * processes (or the fds are not pipes), the process is truly orphaned.
279
+ *
280
+ * Returns `false` (conservative — don't reap) on any enumeration failure.
281
+ */
282
+ function areAllStdioPeersDead(pid) {
283
+ if (process.platform !== 'linux')
284
+ return false;
285
+ try {
286
+ const fdDir = `/proc/${pid}/fd`;
287
+ for (const fdName of ['0', '1', '2']) {
288
+ const fdPath = join(fdDir, fdName);
289
+ let target;
290
+ try {
291
+ target = readlinkSync(fdPath);
292
+ }
293
+ catch {
294
+ continue; // fd missing or unreadable — not a pipe
295
+ }
296
+ const inodeMatch = target.match(/^pipe:\[(\d+)\]$/);
297
+ if (!inodeMatch)
298
+ continue; // not a pipe
299
+ const inode = inodeMatch[1];
300
+ // Find any live process that holds the same pipe inode
301
+ const allPids = enumProcPids();
302
+ for (const peerPid of allPids) {
303
+ if (peerPid === pid)
304
+ continue;
305
+ if (!isPidAlive(peerPid))
306
+ continue;
307
+ try {
308
+ const peerFds = readdirSync(`/proc/${peerPid}/fd`);
309
+ for (const peerFd of peerFds) {
310
+ try {
311
+ const peerTarget = readlinkSync(`/proc/${peerPid}/fd/${peerFd}`);
312
+ if (peerTarget === `pipe:[${inode}]`) {
313
+ // Found a live process sharing this pipe
314
+ return false;
315
+ }
316
+ }
317
+ catch {
318
+ // unreadable
319
+ }
320
+ }
321
+ }
322
+ catch {
323
+ // unreadable proc entry
324
+ }
325
+ }
326
+ }
327
+ return true;
328
+ }
329
+ catch {
330
+ return false; // conservative
331
+ }
332
+ }
333
+ // ---------------------------------------------------------------------------
334
+ // Category 1: Orphan process reaping (Amendment 1 & 2)
335
+ // ---------------------------------------------------------------------------
336
+ /**
337
+ * Reap orphaned MCP/agent processes.
338
+ *
339
+ * Primary path (Amendment 1): processes inside DEAD cleo-owned scopes.
340
+ * Fallback path: unregistered processes matching MCP signatures, older than
341
+ * `gracePeriodMs`, whose all stdio pipe peers are dead.
342
+ *
343
+ * Regression guard (Amendment 2): a reparented helper of a LIVE scope is
344
+ * preserved; only helpers of DEAD scopes are reaped.
345
+ */
346
+ function reapOrphanProcesses(opts) {
347
+ const { gracePeriodMs, dryRun, cleoDir, liveUnits, deadUnits } = opts;
348
+ const allUnits = [...liveUnits, ...deadUnits];
349
+ let reaped = 0;
350
+ if (process.platform !== 'linux')
351
+ return 0;
352
+ // PRIMARY: reap processes inside dead cleo scopes
353
+ for (const deadUnit of deadUnits) {
354
+ const procs = getUnitCgroupProcs(deadUnit);
355
+ for (const pid of procs) {
356
+ if (!isPidAlive(pid))
357
+ continue;
358
+ const cmdline = readProcCmdline(pid);
359
+ const startMs = estimateProcessStartMs(pid);
360
+ const ageMs = startMs > 0 ? Date.now() - startMs : gracePeriodMs + 1;
361
+ if (!dryRun) {
362
+ try {
363
+ process.kill(pid, 'SIGTERM');
364
+ }
365
+ catch {
366
+ // ESRCH = already gone — idempotent
367
+ }
368
+ }
369
+ auditLog(cleoDir, 'reap-process', {
370
+ pid,
371
+ cmdline: cmdline.slice(0, 200),
372
+ unit: deadUnit,
373
+ ageMs,
374
+ signal: 'SIGTERM',
375
+ path: 'registration-primary',
376
+ }, dryRun);
377
+ reaped++;
378
+ }
379
+ }
380
+ // FALLBACK: scan /proc for unregistered MCP-signature processes
381
+ const allPids = enumProcPids();
382
+ for (const pid of allPids) {
383
+ if (pid === process.pid)
384
+ continue;
385
+ if (!isPidAlive(pid))
386
+ continue;
387
+ // Skip if owned by any registered cleo scope (live or dead)
388
+ if (findOwningCleoScope(pid, allUnits) !== null)
389
+ continue;
390
+ const cmdline = readProcCmdline(pid);
391
+ if (!MCP_CMDLINE_SIGNATURES.some((sig) => cmdline.includes(sig)))
392
+ continue;
393
+ const startMs = estimateProcessStartMs(pid);
394
+ const ageMs = startMs > 0 ? Date.now() - startMs : 0;
395
+ if (ageMs < gracePeriodMs)
396
+ continue;
397
+ // Amendment 1: only reap when all stdio peers are dead
398
+ if (!areAllStdioPeersDead(pid))
399
+ continue;
400
+ if (!dryRun) {
401
+ try {
402
+ process.kill(pid, 'SIGTERM');
403
+ }
404
+ catch {
405
+ // ESRCH = already gone
406
+ }
407
+ }
408
+ auditLog(cleoDir, 'reap-process', { pid, cmdline: cmdline.slice(0, 200), ageMs, signal: 'SIGTERM', path: 'signature-fallback' }, dryRun);
409
+ reaped++;
410
+ }
411
+ return reaped;
412
+ }
413
+ // ---------------------------------------------------------------------------
414
+ // Category 2: Dead cleo-owned transient scopes (Amendment 3)
415
+ // ---------------------------------------------------------------------------
416
+ /**
417
+ * Stop and reset-failed all dead cleo-owned transient scope units.
418
+ *
419
+ * A scope is "dead" when `SubState` is `exited`, `failed`, or `dead`,
420
+ * OR when its `cgroup.procs` is empty (Amendment 3 precondition).
421
+ *
422
+ * Operations are no-ops on already-stopped scopes (idempotent).
423
+ */
424
+ function stopDeadScopes(opts) {
425
+ const { unitNames, dryRun, cleoDir } = opts;
426
+ let stopped = 0;
427
+ const deadUnits = [];
428
+ for (const unitName of unitNames) {
429
+ // NEVER touch allowlisted daemons (Amendment 3)
430
+ if (DAEMON_UNIT_ALLOWLIST.includes(unitName))
431
+ continue;
432
+ const subState = getUnitSubState(unitName);
433
+ const procs = getUnitCgroupProcs(unitName);
434
+ const isDead = subState === 'exited' ||
435
+ subState === 'failed' ||
436
+ subState === 'dead' ||
437
+ (subState !== 'running' && procs.length === 0);
438
+ if (!isDead)
439
+ continue;
440
+ deadUnits.push(unitName);
441
+ if (!dryRun) {
442
+ spawnSync('systemctl', ['--user', 'stop', unitName], {
443
+ stdio: 'ignore',
444
+ timeout: 10_000,
445
+ });
446
+ spawnSync('systemctl', ['--user', 'reset-failed', unitName], {
447
+ stdio: 'ignore',
448
+ timeout: 5_000,
449
+ });
450
+ }
451
+ auditLog(cleoDir, 'stop-dead-scope', { unit: unitName, subState, cgroupProcs: procs.length }, dryRun);
452
+ stopped++;
453
+ }
454
+ return { stopped, deadUnits };
455
+ }
456
+ // ---------------------------------------------------------------------------
457
+ // Category 3: Stale PID lock files (Amendment 5)
458
+ // ---------------------------------------------------------------------------
459
+ /**
460
+ * Reclaim stale PID-based lock files whose recorded PID is dead.
461
+ *
462
+ * Covers:
463
+ * - `.cleo/sentient.lock`
464
+ * - `.cleo/gc.lock`
465
+ *
466
+ * NEVER reclaims on mtime alone — liveness probe first (Amendment 5).
467
+ * Truncates rather than deletes (matching the `acquireLock` reclaim pattern in
468
+ * `sentient/daemon.ts:593`).
469
+ */
470
+ function reclaimStalePidLocks(opts) {
471
+ const { cleoDir, dryRun } = opts;
472
+ const candidates = [join(cleoDir, 'sentient.lock'), join(cleoDir, 'gc.lock')];
473
+ let reclaimed = 0;
474
+ for (const lockPath of candidates) {
475
+ if (!existsSync(lockPath))
476
+ continue;
477
+ try {
478
+ const contents = readFileSync(lockPath, 'utf-8').trim();
479
+ const pid = Number.parseInt(contents, 10);
480
+ if (!Number.isFinite(pid) || pid <= 0)
481
+ continue;
482
+ // Amendment 5: liveness probe — skip if alive
483
+ if (isPidAlive(pid))
484
+ continue;
485
+ // PID dead → stale lock
486
+ if (!dryRun) {
487
+ writeFileSync(lockPath, '', 'utf-8');
488
+ }
489
+ auditLog(cleoDir, 'reclaim-pid-lock', { path: lockPath, stalePid: pid }, dryRun);
490
+ reclaimed++;
491
+ }
492
+ catch {
493
+ // unreadable — skip
494
+ }
495
+ }
496
+ return reclaimed;
497
+ }
498
+ // ---------------------------------------------------------------------------
499
+ // Category 4: Stale tool-semaphore slot directories (Amendment 5)
500
+ // ---------------------------------------------------------------------------
501
+ /**
502
+ * Clear stale proper-lockfile lock-directories under getCleoHome()/locks/tool-star.
503
+ *
504
+ * proper-lockfile creates slot.lock/ directories. A slot is stale when:
505
+ * 1. Its mtime is older than staleMs, AND
506
+ * 2. The pid file inside (written by proper-lockfile) records a dead PID.
507
+ *
508
+ * Amendment 5: if the PID inside is alive, skip even if mtime is old.
509
+ */
510
+ function clearStaleSemaphoreSlots(opts) {
511
+ const { dryRun, cleoDir, staleMs = DEFAULT_SEMAPHORE_STALE_MS } = opts;
512
+ const locksRoot = join(getCleoHome(), 'locks');
513
+ if (!existsSync(locksRoot))
514
+ return 0;
515
+ let cleared = 0;
516
+ const now = Date.now();
517
+ try {
518
+ for (const toolDir of readdirSync(locksRoot)) {
519
+ if (!toolDir.startsWith('tool-'))
520
+ continue;
521
+ const toolPath = join(locksRoot, toolDir);
522
+ try {
523
+ if (!statSync(toolPath).isDirectory())
524
+ continue;
525
+ }
526
+ catch {
527
+ continue;
528
+ }
529
+ try {
530
+ for (const entry of readdirSync(toolPath)) {
531
+ if (!entry.endsWith('.lock'))
532
+ continue;
533
+ const lockDirPath = join(toolPath, entry);
534
+ try {
535
+ const st = statSync(lockDirPath);
536
+ if (!st.isDirectory())
537
+ continue;
538
+ if (now - st.mtimeMs < staleMs)
539
+ continue;
540
+ // Amendment 5: read the pid file inside the lock directory
541
+ const pidFile = join(lockDirPath, 'pid');
542
+ if (existsSync(pidFile)) {
543
+ try {
544
+ const pidStr = readFileSync(pidFile, 'utf-8').trim();
545
+ const pid = Number.parseInt(pidStr, 10);
546
+ if (Number.isFinite(pid) && pid > 0 && isPidAlive(pid))
547
+ continue;
548
+ }
549
+ catch {
550
+ // unreadable pid file — fall through to remove
551
+ }
552
+ }
553
+ const ageMsLock = now - st.mtimeMs;
554
+ if (!dryRun) {
555
+ rmSync(lockDirPath, { recursive: true, force: true });
556
+ }
557
+ auditLog(cleoDir, 'clear-semaphore-slot', { lockDir: lockDirPath, ageMsLock }, dryRun);
558
+ cleared++;
559
+ }
560
+ catch {
561
+ // stat/rm failed — skip
562
+ }
563
+ }
564
+ }
565
+ catch {
566
+ // readdir failed — skip
567
+ }
568
+ }
569
+ }
570
+ catch {
571
+ // locksRoot readdir failed
572
+ }
573
+ return cleared;
574
+ }
575
+ // ---------------------------------------------------------------------------
576
+ // Public API
577
+ // ---------------------------------------------------------------------------
578
+ /**
579
+ * Run the CLEO janitor sweep.
580
+ *
581
+ * Runs all enabled categories and returns a structured result with per-category
582
+ * counts. Designed to be called from:
583
+ * - `cleo janitor run [--dry-run]` (manual verb)
584
+ * - GC subsystem sentient tick (daemon mode)
585
+ * - Session start/end (daemon-off lazy mode)
586
+ *
587
+ * @param opts - Janitor options.
588
+ * @returns Structured result.
589
+ *
590
+ * @task T11995
591
+ * @epic T11992
592
+ */
593
+ export async function runJanitor(opts = {}) {
594
+ const { dryRun = false, gracePeriodMs = DEFAULT_GRACE_MS, worktreesRoot, activeTaskIds, skip = {}, } = opts;
595
+ // Resolve cleoDir
596
+ let cleoDir;
597
+ if (opts.cleoDir) {
598
+ cleoDir = opts.cleoDir;
599
+ }
600
+ else {
601
+ try {
602
+ const { resolveCleoDir } = await import('../paths.js');
603
+ cleoDir = resolveCleoDir();
604
+ }
605
+ catch {
606
+ cleoDir = join(resolveOrCwd(), '.cleo');
607
+ }
608
+ }
609
+ // Ensure audit dir (best-effort)
610
+ try {
611
+ mkdirSync(join(cleoDir, 'audit'), { recursive: true });
612
+ }
613
+ catch {
614
+ // ignore
615
+ }
616
+ let reaped = 0;
617
+ let scopesStopped = 0;
618
+ let locksReclaimed = 0;
619
+ let semaphoreSlotsCleared = 0;
620
+ let worktreesPruned = 0;
621
+ let worktreesQuarantined = 0;
622
+ let tmpRemoved = 0;
623
+ let attachmentsRepaired = 0;
624
+ let configsRepaired = 0;
625
+ let errors = 0;
626
+ // ── Category 2: Scope inventory (must run before Category 1) ─────────────
627
+ let liveUnits = [];
628
+ let deadUnits = [];
629
+ if (!skip.scopes && process.platform === 'linux') {
630
+ try {
631
+ const allCleoUnits = listCleoScopeUnits();
632
+ const result = stopDeadScopes({ unitNames: allCleoUnits, dryRun, cleoDir });
633
+ scopesStopped = result.stopped;
634
+ deadUnits = result.deadUnits;
635
+ liveUnits = allCleoUnits.filter((u) => !deadUnits.includes(u));
636
+ }
637
+ catch (err) {
638
+ errors++;
639
+ auditLog(cleoDir, 'error', { category: 'scopes', error: err instanceof Error ? err.message : String(err) }, dryRun);
640
+ }
641
+ }
642
+ // ── Category 1: Orphan process reaping ───────────────────────────────────
643
+ if (!skip.processes && process.platform === 'linux') {
644
+ try {
645
+ reaped = reapOrphanProcesses({ gracePeriodMs, dryRun, cleoDir, liveUnits, deadUnits });
646
+ }
647
+ catch (err) {
648
+ errors++;
649
+ auditLog(cleoDir, 'error', { category: 'processes', error: err instanceof Error ? err.message : String(err) }, dryRun);
650
+ }
651
+ }
652
+ // ── Category 3: Stale PID locks ───────────────────────────────────────────
653
+ if (!skip.locks) {
654
+ try {
655
+ locksReclaimed = reclaimStalePidLocks({ cleoDir, dryRun });
656
+ }
657
+ catch (err) {
658
+ errors++;
659
+ auditLog(cleoDir, 'error', { category: 'locks', error: err instanceof Error ? err.message : String(err) }, dryRun);
660
+ }
661
+ }
662
+ // ── Category 4: Semaphore slots ───────────────────────────────────────────
663
+ if (!skip.semaphores) {
664
+ try {
665
+ semaphoreSlotsCleared = clearStaleSemaphoreSlots({ dryRun, cleoDir });
666
+ }
667
+ catch (err) {
668
+ errors++;
669
+ auditLog(cleoDir, 'error', { category: 'semaphores', error: err instanceof Error ? err.message : String(err) }, dryRun);
670
+ }
671
+ }
672
+ // ── Category 5/6: Worktrees ───────────────────────────────────────────────
673
+ if (!skip.worktrees && activeTaskIds !== undefined) {
674
+ try {
675
+ const wRoot = worktreesRoot ?? join(getCleoHome(), 'worktrees');
676
+ const result = pruneOrphanWorktrees({ worktreesRoot: wRoot, activeTaskIds, dryRun });
677
+ worktreesPruned = result.removed;
678
+ worktreesQuarantined = result.quarantined;
679
+ if (result.errors.length > 0)
680
+ errors += result.errors.length;
681
+ for (const p of result.removedPaths) {
682
+ auditLog(cleoDir, 'prune-worktree', { path: p }, dryRun);
683
+ }
684
+ for (const p of result.quarantinedPaths) {
685
+ auditLog(cleoDir, 'quarantine-worktree', { path: p }, dryRun);
686
+ }
687
+ }
688
+ catch (err) {
689
+ errors++;
690
+ auditLog(cleoDir, 'error', { category: 'worktrees', error: err instanceof Error ? err.message : String(err) }, dryRun);
691
+ }
692
+ }
693
+ // ── Category 7: Stale tmp dirs (Amendment 7) ──────────────────────────────
694
+ if (!skip.tmp) {
695
+ try {
696
+ const tmpResult = pruneOrphanTempDirs({ dryRun });
697
+ tmpRemoved = tmpResult.removed;
698
+ if (tmpResult.errors.length > 0)
699
+ errors += tmpResult.errors.length;
700
+ for (const p of tmpResult.removedPaths) {
701
+ auditLog(cleoDir, 'prune-tmp', { path: p }, dryRun);
702
+ }
703
+ }
704
+ catch (err) {
705
+ errors++;
706
+ auditLog(cleoDir, 'error', { category: 'tmp', error: err instanceof Error ? err.message : String(err) }, dryRun);
707
+ }
708
+ }
709
+ // ── Category 8: Attachment repair ────────────────────────────────────────
710
+ if (!skip.attachments) {
711
+ try {
712
+ const result = await repairAttachmentStore({ dryRun, cwd: join(cleoDir, '..') });
713
+ attachmentsRepaired = result.rowsWithoutFilesCount + result.unreferencedBlobsDeletedCount;
714
+ }
715
+ catch (err) {
716
+ errors++;
717
+ auditLog(cleoDir, 'error', { category: 'attachments', error: err instanceof Error ? err.message : String(err) }, dryRun);
718
+ }
719
+ }
720
+ // ── Category 9: Config repair ─────────────────────────────────────────────
721
+ if (!skip.config) {
722
+ const projectRoot = join(cleoDir, '..');
723
+ const backupDir = join(cleoDir, 'backups');
724
+ const configCandidates = [join(cleoDir, 'config.json'), join(cleoDir, 'project-info.json')];
725
+ for (const configPath of configCandidates) {
726
+ if (!existsSync(configPath))
727
+ continue;
728
+ try {
729
+ // repairConfigFile(configPath, backupDir, cwd): positional args
730
+ const result = await repairConfigFile(configPath, existsSync(backupDir) ? backupDir : null, projectRoot);
731
+ if (result.outcome !== 'healthy') {
732
+ configsRepaired++;
733
+ auditLog(cleoDir, 'repair-config', { configPath, outcome: result.outcome, detail: result.detail }, dryRun);
734
+ }
735
+ }
736
+ catch (err) {
737
+ errors++;
738
+ auditLog(cleoDir, 'error', {
739
+ category: 'config',
740
+ configPath,
741
+ error: err instanceof Error ? err.message : String(err),
742
+ }, dryRun);
743
+ }
744
+ }
745
+ }
746
+ return {
747
+ reaped,
748
+ scopesStopped,
749
+ locksReclaimed,
750
+ semaphoreSlotsCleared,
751
+ worktreesPruned,
752
+ worktreesQuarantined,
753
+ tmpRemoved,
754
+ attachmentsRepaired,
755
+ configsRepaired,
756
+ errors,
757
+ dryRun,
758
+ };
759
+ }
760
+ //# sourceMappingURL=janitor.js.map