claude-code-session-manager 0.119.1 → 0.121.0
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.
- package/dist/assets/{AgentLibrary-BpW0pGgk.js → AgentLibrary-FO2ei2wB.js} +1 -1
- package/dist/assets/{Button-DZX5guWb.js → Button-CeIOXUPU.js} +1 -1
- package/dist/assets/{DataModel-WG4pG_4z.js → DataModel-D0p__Hil.js} +1 -1
- package/dist/assets/{History-D58whvn_.js → History-DVqQ5azN.js} +1 -1
- package/dist/assets/{Hooks-DCI9ezg4.js → Hooks-DkTnRHyT.js} +1 -1
- package/dist/assets/{Library-YqMi-vsA.js → Library-l7KS6pkE.js} +1 -1
- package/dist/assets/{ListDetail-ap4W5z_Z.js → ListDetail-DDw8fuRT.js} +1 -1
- package/dist/assets/{McpServers-CT0pBTF8.js → McpServers-UUNIxk_3.js} +1 -1
- package/dist/assets/{Memory-BODDUBy4.js → Memory-CNYUoQ8C.js} +1 -1
- package/dist/assets/{Panel-CeOfUS9B.js → Panel-Bb54soc2.js} +1 -1
- package/dist/assets/{Permissions-DyOqpLQx.js → Permissions-D-dNdV3f.js} +1 -1
- package/dist/assets/{Plugins-DBXzKjL2.js → Plugins-DhcIXRhQ.js} +2 -2
- package/dist/assets/{ProvenanceBadge-C47IX1Q6.js → ProvenanceBadge-DEAkDNjA.js} +1 -1
- package/dist/assets/{RemoteMachines-CtkAJvkZ.js → RemoteMachines-xCEgvTGU.js} +1 -1
- package/dist/assets/{SaveBar-YMtzcCzP.js → SaveBar-BSEs7ecA.js} +1 -1
- package/dist/assets/{Scheduler-BNiYg0YD.js → Scheduler-BIKJIU-C.js} +5 -5
- package/dist/assets/{ScopeSwitcher-Dgi8CiDi.js → ScopeSwitcher-DcaFyAV-.js} +1 -1
- package/dist/assets/Settings-vcdWy_Jx.js +4 -0
- package/dist/assets/{SkillReferenceGraph-CaFsccNI.js → SkillReferenceGraph-CLrJWTPd.js} +1 -1
- package/dist/assets/{Skills-BeCMBQ3l.js → Skills-CYM4rBEC.js} +1 -1
- package/dist/assets/{SystemPrompt-C3WmcPEF.js → SystemPrompt-BWNIu-iY.js} +1 -1
- package/dist/assets/{TagLibrary-DP4GH7Dr.js → TagLibrary-CYdwHK6m.js} +1 -1
- package/dist/assets/{TiptapBody-CXCMvRr_.js → TiptapBody-Kt90lHrD.js} +1 -1
- package/dist/assets/{Toggle-TY-Wa8hv.js → Toggle-D1OXWYOz.js} +1 -1
- package/dist/assets/{ViewTabs-CaacRnp6.js → ViewTabs-BeQR3CKh.js} +1 -1
- package/dist/assets/{historyTotals-C28IlVVQ.js → historyTotals-B0qafrRo.js} +1 -1
- package/dist/assets/{index-DdEMkmbi.js → index-Bq9uH08R.js} +201 -201
- package/dist/assets/{index-DAMBm3UN.css → index-Dd7UQabV.css} +1 -1
- package/dist/assets/{settingsSchema-7Yg6dNNB.js → settingsSchema-0ZksSAPB.js} +1 -1
- package/dist/assets/{skillFrontmatter-Dcuys1km.js → skillFrontmatter-C0nh8yfa.js} +1 -1
- package/dist/assets/{useAgentsDirWatch-B0H4M-TT.js → useAgentsDirWatch-DPkoBybS.js} +1 -1
- package/dist/assets/{useRemotePeers-Zp9jchCS.js → useRemotePeers-BDJMUihy.js} +1 -1
- package/dist/index.html +2 -2
- package/package.json +1 -1
- package/src/main/build-info.json +4 -4
- package/src/main/chatRunner.cjs +54 -14
- package/src/main/crashDiagnostics.cjs +18 -13
- package/src/main/health.cjs +129 -4
- package/src/main/index.cjs +57 -18
- package/src/main/ipcSchemas.cjs +18 -4
- package/src/main/lib/activeSessions.cjs +51 -34
- package/src/main/lib/claude/agentModelResolve.cjs +5 -3
- package/src/main/lib/claude/personaToolScope.cjs +68 -0
- package/src/main/lib/epic/archivedTranscript.cjs +91 -0
- package/src/main/lib/epic/archivedTranscriptMigration.cjs +84 -0
- package/src/main/lib/epic/delegationReadiness.cjs +2 -0
- package/src/main/lib/epic/epicSettle.cjs +21 -5
- package/src/main/lib/epic/epicValidationHook.cjs +7 -0
- package/src/main/lib/git/depHealth.cjs +161 -0
- package/src/main/lib/git/worktreeDeps.cjs +158 -5
- package/src/main/lib/machineProfile.cjs +2 -0
- package/src/main/lib/mcpToolCatalog.cjs +3 -1
- package/src/main/lib/opsRetentionSettings.cjs +183 -0
- package/src/main/lib/persona-macro/macroLibrary.cjs +75 -0
- package/src/main/lib/prd/prdCreate.cjs +25 -0
- package/src/main/lib/prd/prdWorktreeReach.cjs +2 -2
- package/src/main/lib/prd/workTypeLibrary.cjs +7 -3
- package/src/main/lib/process/procGroupRss.cjs +61 -0
- package/src/main/lib/remoteTranscriptTail.cjs +69 -2
- package/src/main/lib/scheduler/queueHistory.cjs +68 -9
- package/src/main/lib/scheduler/runLogRetention.cjs +129 -43
- package/src/main/lib/scheduler/schedulerBatch.cjs +7 -3
- package/src/main/lib/scheduler/staleTmpSweep.cjs +117 -0
- package/src/main/lib/telemetry/auditLog.cjs +51 -6
- package/src/main/lib/telemetry/crashTelemetry.cjs +49 -1
- package/src/main/lib/telemetry/opsErrorLog.cjs +30 -10
- package/src/main/lib/telemetry/telemetryClient.cjs +5 -2
- package/src/main/scheduler/prdParser.cjs +50 -0
- package/src/main/scheduler/prdPaths.cjs +61 -16
- package/src/main/scheduler/reviewLadder.cjs +24 -1
- package/src/main/scheduler.cjs +214 -37
- package/src/main/templates/PRD_AUTHORING.md +2 -2
- package/src/main/webRemote.cjs +82 -91
- package/src/preload/api.d.ts +41 -3
- package/src/preload/index.cjs +9 -0
- package/dist/assets/Settings-B4LuoRnd.js +0 -4
|
@@ -9,12 +9,40 @@
|
|
|
9
9
|
* `info/exclude` as `/<name>` — otherwise the link shows as untracked and the
|
|
10
10
|
* job parks as `uncommitted_changes`.
|
|
11
11
|
*
|
|
12
|
+
* pnpm projects are the exception for node_modules: `pnpm install` purges and
|
|
13
|
+
* rebuilds the modules dir THROUGH a symlink, wiping the main checkout's
|
|
14
|
+
* node_modules. So a pnpm worktree gets its own node_modules, installed
|
|
15
|
+
* offline from the shared store (`installed`); the main checkout is never
|
|
16
|
+
* touched. `node_modules/` (any depth) is excluded so nested workspace
|
|
17
|
+
* node_modules stay untracked.
|
|
18
|
+
*
|
|
19
|
+
* Relative `file:`/`link:` deps that point OUTSIDE the repo (e.g.
|
|
20
|
+
* `file:../Bilko/packages/host-kit`) cannot resolve from a worktree, which lives
|
|
21
|
+
* under the session-manager state dir rather than next to the sibling repo — the
|
|
22
|
+
* offline install would ENOENT and leave no node_modules. So before installing,
|
|
23
|
+
* `linkOutOfRepoLocalDeps` creates a symlink at the equivalent location relative
|
|
24
|
+
* to the worktree (first path segment past the `..` hops, e.g.
|
|
25
|
+
* `<worktrees>/<hash>/Bilko -> ~/Projects/Bilko`). That link may therefore sit
|
|
26
|
+
* OUTSIDE the worktree; it is only created where nothing exists, never inside
|
|
27
|
+
* the main checkout, and never overwrites anything.
|
|
28
|
+
*
|
|
29
|
+
* uv projects (`uv.lock`) are the exception for .venv/venv: `uv run` and
|
|
30
|
+
* `uv sync` re-sync the project env, and for an editable project install that
|
|
31
|
+
* rewrites site-packages/<pkg>.pth to the CURRENT tree's src. Through a shared
|
|
32
|
+
* symlink that silently repoints the main checkout's imports at the worktree
|
|
33
|
+
* (social-signals-trader, 2026-10-18), and worktree tests import whichever
|
|
34
|
+
* tree synced last. So a uv worktree gets its own env, built by an offline
|
|
35
|
+
* frozen `uv sync` (hardlinks from uv's cache — cheap); the main env is never
|
|
36
|
+
* linked. If that sync fails the name is skipped and the executor's own `uv`
|
|
37
|
+
* call builds a worktree-local env — never the main one.
|
|
38
|
+
*
|
|
12
39
|
* Never throws; failures land in `skipped`.
|
|
13
40
|
*/
|
|
14
41
|
'use strict';
|
|
15
42
|
|
|
16
43
|
const fs = require('node:fs');
|
|
17
44
|
const path = require('node:path');
|
|
45
|
+
const { execFile } = require('node:child_process');
|
|
18
46
|
const { execGit } = require('./gitExec.cjs');
|
|
19
47
|
|
|
20
48
|
const DEFAULT_DEP_DIRS = ['node_modules', '.venv', 'venv'];
|
|
@@ -34,27 +62,141 @@ async function isIgnored(cwd, name) {
|
|
|
34
62
|
} catch { return false; }
|
|
35
63
|
}
|
|
36
64
|
|
|
37
|
-
|
|
65
|
+
/** True when mainCwd is a pnpm project (lockfile or pnpm modules manifest). Never throws. */
|
|
66
|
+
function isPnpmProject(mainCwd) {
|
|
67
|
+
try {
|
|
68
|
+
return fs.existsSync(path.join(mainCwd, 'pnpm-lock.yaml'))
|
|
69
|
+
|| fs.existsSync(path.join(mainCwd, 'node_modules', '.modules.yaml'));
|
|
70
|
+
} catch { return false; }
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** True when mainCwd is a uv project (uv.lock). Never throws. */
|
|
74
|
+
function isUvProject(mainCwd) {
|
|
75
|
+
try { return fs.existsSync(path.join(mainCwd, 'uv.lock')); } catch { return false; }
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Offline frozen `uv sync` into worktreeCwd/<name>. Resolves true on exit 0; never throws. */
|
|
79
|
+
function installUvEnv(worktreeCwd, name) {
|
|
80
|
+
return new Promise((resolve) => {
|
|
81
|
+
try {
|
|
82
|
+
const env = { ...process.env, UV_PROJECT_ENVIRONMENT: path.join(worktreeCwd, name) };
|
|
83
|
+
delete env.VIRTUAL_ENV;
|
|
84
|
+
execFile(
|
|
85
|
+
'uv',
|
|
86
|
+
['sync', '--frozen', '--offline'],
|
|
87
|
+
{ cwd: worktreeCwd, env, timeout: 180_000, maxBuffer: 16 * 1024 * 1024 },
|
|
88
|
+
(err) => resolve(!err),
|
|
89
|
+
);
|
|
90
|
+
} catch { resolve(false); }
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Top-level importer dirs from pnpm-lock.yaml (`.` is the root). Simple line scan; never throws. */
|
|
95
|
+
function lockImporters(cwd) {
|
|
96
|
+
const out = ['.'];
|
|
97
|
+
try {
|
|
98
|
+
let inImporters = false;
|
|
99
|
+
for (const line of fs.readFileSync(path.join(cwd, 'pnpm-lock.yaml'), 'utf8').split('\n')) {
|
|
100
|
+
if (/^importers:\s*$/.test(line)) { inImporters = true; continue; }
|
|
101
|
+
if (!inImporters) continue;
|
|
102
|
+
if (/^\S/.test(line)) break;
|
|
103
|
+
const m = /^ {2}(\S[^:]*):\s*$/.exec(line);
|
|
104
|
+
if (m) out.push(m[1].replace(/^(['"])(.*)\1$/, '$2'));
|
|
105
|
+
}
|
|
106
|
+
} catch { /* no lockfile */ }
|
|
107
|
+
return [...new Set(out)];
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Relative file:/link: specifiers of a package.json. Never throws. */
|
|
111
|
+
function localSpecs(pkgJson) {
|
|
112
|
+
const specs = [];
|
|
113
|
+
try {
|
|
114
|
+
const pkg = JSON.parse(fs.readFileSync(pkgJson, 'utf8'));
|
|
115
|
+
for (const f of ['dependencies', 'devDependencies', 'optionalDependencies']) {
|
|
116
|
+
for (const v of Object.values(pkg[f] || {})) {
|
|
117
|
+
const m = typeof v === 'string' && /^(?:file|link):(.+)$/.exec(v);
|
|
118
|
+
if (m && !path.isAbsolute(m[1])) specs.push(m[1]);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
} catch { /* unreadable */ }
|
|
122
|
+
return specs;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const within = (parent, p) => p === parent || p.startsWith(parent + path.sep);
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Best-effort: link out-of-repo relative file:/link: dep targets at their
|
|
129
|
+
* worktree-relative location so the offline install can resolve them. Never throws.
|
|
130
|
+
*/
|
|
131
|
+
function linkOutOfRepoLocalDeps(mainCwd, worktreeCwd) {
|
|
132
|
+
try {
|
|
133
|
+
for (const importer of lockImporters(worktreeCwd)) {
|
|
134
|
+
const mainDir = path.resolve(mainCwd, importer);
|
|
135
|
+
const wtDir = path.resolve(worktreeCwd, importer);
|
|
136
|
+
for (const spec of localSpecs(path.join(mainDir, 'package.json'))) {
|
|
137
|
+
try {
|
|
138
|
+
const target = path.resolve(mainDir, spec);
|
|
139
|
+
if (within(mainCwd, target) || !exists(target)) continue;
|
|
140
|
+
const segs = path.normalize(spec).split(path.sep).filter((x) => x && x !== '.');
|
|
141
|
+
let ups = 0;
|
|
142
|
+
while (segs[ups] === '..') ups++;
|
|
143
|
+
const rest = segs.slice(ups);
|
|
144
|
+
if (!rest.length) continue;
|
|
145
|
+
// Link the first segment past the `..` hops, to the matching target ancestor.
|
|
146
|
+
let wtBase = wtDir;
|
|
147
|
+
for (let i = 0; i < ups; i++) wtBase = path.dirname(wtBase);
|
|
148
|
+
const linkPath = path.join(wtBase, rest[0]);
|
|
149
|
+
let linkTarget = target;
|
|
150
|
+
for (let i = 1; i < rest.length; i++) linkTarget = path.dirname(linkTarget);
|
|
151
|
+
if (within(mainCwd, linkPath) || within(worktreeCwd, linkPath) || exists(linkPath)) continue;
|
|
152
|
+
fs.mkdirSync(path.dirname(linkPath), { recursive: true });
|
|
153
|
+
fs.symlinkSync(linkTarget, linkPath, isDir(linkTarget) ? 'dir' : 'file');
|
|
154
|
+
} catch { /* best-effort per dep */ }
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
} catch { /* best-effort */ }
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Offline frozen install in the worktree. Resolves true on exit 0; never throws. */
|
|
161
|
+
function installPnpmModules(worktreeCwd) {
|
|
162
|
+
return new Promise((resolve) => {
|
|
163
|
+
try {
|
|
164
|
+
execFile(
|
|
165
|
+
'pnpm',
|
|
166
|
+
['install', '--offline', '--frozen-lockfile', '--config.confirmModulesPurge=false'],
|
|
167
|
+
{ cwd: worktreeCwd, env: { ...process.env, CI: '1' }, timeout: 180_000, maxBuffer: 16 * 1024 * 1024 },
|
|
168
|
+
(err) => resolve(!err),
|
|
169
|
+
);
|
|
170
|
+
} catch { resolve(false); }
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
async function addExcludeLines(mainCwd, lines) {
|
|
38
175
|
const out = (await execGit(mainCwd, ['rev-parse', '--git-common-dir'], { timeout: 10_000 })).trim();
|
|
39
176
|
const commonDir = path.resolve(mainCwd, out);
|
|
40
177
|
const file = path.join(commonDir, 'info', 'exclude');
|
|
41
178
|
let existing = '';
|
|
42
179
|
try { existing = fs.readFileSync(file, 'utf8'); } catch { /* absent */ }
|
|
43
180
|
const have = new Set(existing.split('\n').map((l) => l.trim()));
|
|
44
|
-
const missing =
|
|
181
|
+
const missing = lines.filter((l) => !have.has(l));
|
|
45
182
|
if (!missing.length) return;
|
|
46
183
|
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
47
184
|
const sep = existing && !existing.endsWith('\n') ? '\n' : '';
|
|
48
185
|
fs.appendFileSync(file, `${sep}${missing.join('\n')}\n`);
|
|
49
186
|
}
|
|
50
187
|
|
|
188
|
+
function addExcludes(mainCwd, names) {
|
|
189
|
+
return addExcludeLines(mainCwd, names.map((n) => `/${n}`));
|
|
190
|
+
}
|
|
191
|
+
|
|
51
192
|
/**
|
|
52
193
|
* @param {{ mainCwd: string, worktreeCwd: string, names?: string[] }} opts
|
|
53
|
-
* @returns {Promise<{ linked: string[], skipped: string[] }>}
|
|
194
|
+
* @returns {Promise<{ linked: string[], skipped: string[], installed: string[] }>}
|
|
54
195
|
*/
|
|
55
196
|
async function linkDependencyDirs({ mainCwd, worktreeCwd, names = DEFAULT_DEP_DIRS }) {
|
|
56
197
|
const linked = [];
|
|
57
198
|
const skipped = [];
|
|
199
|
+
const installed = [];
|
|
58
200
|
for (const name of names) {
|
|
59
201
|
try {
|
|
60
202
|
const src = path.join(mainCwd, name);
|
|
@@ -63,6 +205,17 @@ async function linkDependencyDirs({ mainCwd, worktreeCwd, names = DEFAULT_DEP_DI
|
|
|
63
205
|
skipped.push(name);
|
|
64
206
|
continue;
|
|
65
207
|
}
|
|
208
|
+
if (name === 'node_modules' && isPnpmProject(mainCwd)) {
|
|
209
|
+
await addExcludeLines(mainCwd, ['node_modules/']);
|
|
210
|
+
linkOutOfRepoLocalDeps(mainCwd, worktreeCwd);
|
|
211
|
+
(await installPnpmModules(worktreeCwd) ? installed : skipped).push(name);
|
|
212
|
+
continue;
|
|
213
|
+
}
|
|
214
|
+
if ((name === '.venv' || name === 'venv') && isUvProject(mainCwd)) {
|
|
215
|
+
await addExcludes(mainCwd, [name]);
|
|
216
|
+
(await installUvEnv(worktreeCwd, name) ? installed : skipped).push(name);
|
|
217
|
+
continue;
|
|
218
|
+
}
|
|
66
219
|
// Exclude first: a link without its exclude line would dirty status.
|
|
67
220
|
await addExcludes(mainCwd, [name]);
|
|
68
221
|
fs.symlinkSync(src, dest, 'dir');
|
|
@@ -71,7 +224,7 @@ async function linkDependencyDirs({ mainCwd, worktreeCwd, names = DEFAULT_DEP_DI
|
|
|
71
224
|
skipped.push(name);
|
|
72
225
|
}
|
|
73
226
|
}
|
|
74
|
-
return { linked, skipped };
|
|
227
|
+
return { linked, skipped, installed };
|
|
75
228
|
}
|
|
76
229
|
|
|
77
|
-
module.exports = { linkDependencyDirs, DEFAULT_DEP_DIRS };
|
|
230
|
+
module.exports = { linkDependencyDirs, isPnpmProject, isUvProject, DEFAULT_DEP_DIRS };
|
|
@@ -117,6 +117,8 @@ async function buildMachineProfile(opts = {}) {
|
|
|
117
117
|
let appPath = null;
|
|
118
118
|
if (electronApp) {
|
|
119
119
|
try { appVersion = electronApp.getVersion(); } catch { /* not ready / not a real app object */ }
|
|
120
|
+
// Unpackaged launch with a script path makes getVersion() return Electron's own version.
|
|
121
|
+
if (appVersion && process.versions && appVersion === process.versions.electron) appVersion = null;
|
|
120
122
|
try { appPath = electronApp.getAppPath(); } catch { /* not ready / not a real app object */ }
|
|
121
123
|
}
|
|
122
124
|
if (!appVersion) appVersion = resolvePackageVersion();
|
|
@@ -193,7 +193,9 @@ const MCP_TOOL_CATALOG = [
|
|
|
193
193
|
purpose: "THE ONLY SUPPORTED WAY to cancel a not-yet-terminal scheduler job via the session-manager app's admin "
|
|
194
194
|
+ 'API. A running job is SIGTERM\'d; a pending job is simply retired. There is no "cancelled" job status, so a '
|
|
195
195
|
+ 'cancelled job lands as "failed" with an error naming the cause. Refuses a slug whose job is already terminal '
|
|
196
|
-
+ '(completed/failed/needs_review) — nothing to cancel.'
|
|
196
|
+
+ '(completed/failed/needs_review) — nothing to cancel. A cancelled running job\'s commits are never landed on '
|
|
197
|
+
+ 'the base branch; they stay on the job branch, reported in the result as landed:false plus '
|
|
198
|
+
+ 'jobBranch/jobBranchSha.',
|
|
197
199
|
whenToUse: 'Use to stop a running or pending job that should not continue (e.g. it was queued in error, or scope changed underneath it).',
|
|
198
200
|
whenNotToUse: 'Do not use on an already-terminal job (completed/failed/needs_review) — use scheduler_reset_job if it should run again.',
|
|
199
201
|
exampleArgs: { slug: 'add-mcp-tool-catalog' },
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* opsRetentionSettings — machine-level retention policy for the ops sweeps.
|
|
3
|
+
*
|
|
4
|
+
* Storage: ~/.claude/session-manager/ops-retention.json
|
|
5
|
+
* Shape (DEFAULTS): { runLogs:{maxAgeDays,keepPerSlug}, runsArchiveDays, auditLogMonths,
|
|
6
|
+
* reservedMarkersKeep }
|
|
7
|
+
* Archived transcripts are kept forever (fixed policy, not a setting).
|
|
8
|
+
*
|
|
9
|
+
* Top-level in lib/ (no domain fits: read by scheduler, telemetry and epic sweeps alike).
|
|
10
|
+
* preview() runs the sweeps' own eligibility code in dry-run mode — it deletes nothing.
|
|
11
|
+
* Modelled on updaterSettings.cjs: frozen DEFAULTS, never-throwing read, strict write.
|
|
12
|
+
*/
|
|
13
|
+
'use strict';
|
|
14
|
+
|
|
15
|
+
const fs = require('node:fs');
|
|
16
|
+
const path = require('node:path');
|
|
17
|
+
const os = require('node:os');
|
|
18
|
+
const atomicFs = require('./util/atomicFs.cjs');
|
|
19
|
+
|
|
20
|
+
const DEFAULTS = Object.freeze({
|
|
21
|
+
runLogs: Object.freeze({ maxAgeDays: 14, keepPerSlug: 3 }),
|
|
22
|
+
runsArchiveDays: 30,
|
|
23
|
+
auditLogMonths: 6,
|
|
24
|
+
reservedMarkersKeep: 100,
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
// [min, max] per integer field; out-of-range values clamp, non-integers round.
|
|
28
|
+
const BOUNDS = Object.freeze({
|
|
29
|
+
'runLogs.maxAgeDays': [3, 3650],
|
|
30
|
+
'runLogs.keepPerSlug': [1, 1000],
|
|
31
|
+
runsArchiveDays: [1, 3650],
|
|
32
|
+
auditLogMonths: [1, 120],
|
|
33
|
+
reservedMarkersKeep: [1, 100000],
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
function storePath() {
|
|
37
|
+
return path.join(require('./scheduler/schedulerPaths.cjs').claudeHome(), 'session-manager', 'ops-retention.json');
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const isObj = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
|
|
41
|
+
|
|
42
|
+
function clamp(key, v) {
|
|
43
|
+
const [min, max] = BOUNDS[key];
|
|
44
|
+
return Math.min(max, Math.max(min, Math.round(v)));
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Strict patch validation (used by write() and the IPC set handler). Returns a
|
|
49
|
+
* partial settings object with clamped integers; throws on unknown keys,
|
|
50
|
+
* or wrong types.
|
|
51
|
+
*/
|
|
52
|
+
function validatePatch(patch) {
|
|
53
|
+
if (!isObj(patch)) throw new Error('Invalid ops retention config');
|
|
54
|
+
const out = {};
|
|
55
|
+
const num = (key, v) => {
|
|
56
|
+
if (typeof v !== 'number' || !Number.isFinite(v)) throw new Error(`Invalid ops retention config: ${key}`);
|
|
57
|
+
return clamp(key, v);
|
|
58
|
+
};
|
|
59
|
+
for (const [k, v] of Object.entries(patch)) {
|
|
60
|
+
if (k === 'runLogs') {
|
|
61
|
+
if (!isObj(v)) throw new Error('Invalid ops retention config: runLogs');
|
|
62
|
+
out.runLogs = {};
|
|
63
|
+
for (const [rk, rv] of Object.entries(v)) {
|
|
64
|
+
if (rk !== 'maxAgeDays' && rk !== 'keepPerSlug') throw new Error(`Invalid ops retention config: runLogs.${rk}`);
|
|
65
|
+
out.runLogs[rk] = num(`runLogs.${rk}`, rv);
|
|
66
|
+
}
|
|
67
|
+
} else if (k === 'runsArchiveDays' || k === 'auditLogMonths' || k === 'reservedMarkersKeep') {
|
|
68
|
+
out[k] = num(k, v);
|
|
69
|
+
} else {
|
|
70
|
+
throw new Error(`Invalid ops retention config: unknown key ${k}`);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return out;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Lenient field-by-field sanitize of on-disk data: bad fields are dropped, not fatal. */
|
|
77
|
+
function sanitizeStored(data) {
|
|
78
|
+
if (!isObj(data)) return null;
|
|
79
|
+
const out = {};
|
|
80
|
+
const int = (key, v) => (typeof v === 'number' && Number.isFinite(v) ? clamp(key, v) : undefined);
|
|
81
|
+
if (isObj(data.runLogs)) {
|
|
82
|
+
const m = int('runLogs.maxAgeDays', data.runLogs.maxAgeDays);
|
|
83
|
+
const k = int('runLogs.keepPerSlug', data.runLogs.keepPerSlug);
|
|
84
|
+
if (m !== undefined || k !== undefined) {
|
|
85
|
+
out.runLogs = {
|
|
86
|
+
maxAgeDays: m !== undefined ? m : DEFAULTS.runLogs.maxAgeDays,
|
|
87
|
+
keepPerSlug: k !== undefined ? k : DEFAULTS.runLogs.keepPerSlug,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
for (const key of ['runsArchiveDays', 'auditLogMonths', 'reservedMarkersKeep']) {
|
|
92
|
+
const v = int(key, data[key]);
|
|
93
|
+
if (v !== undefined) out[key] = v;
|
|
94
|
+
}
|
|
95
|
+
return out;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** What is explicitly stored (sanitized), or null when absent/corrupt. Never throws. */
|
|
99
|
+
function readStored() {
|
|
100
|
+
try {
|
|
101
|
+
const raw = JSON.parse(fs.readFileSync(storePath(), 'utf8'));
|
|
102
|
+
return sanitizeStored(raw);
|
|
103
|
+
} catch (e) {
|
|
104
|
+
if (e.code !== 'ENOENT') console.warn('[opsRetentionSettings] read failed:', e.message);
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function withDefaults(stored) {
|
|
110
|
+
const s = stored || {};
|
|
111
|
+
return {
|
|
112
|
+
runLogs: { ...DEFAULTS.runLogs, ...(s.runLogs || {}) },
|
|
113
|
+
runsArchiveDays: s.runsArchiveDays ?? DEFAULTS.runsArchiveDays,
|
|
114
|
+
auditLogMonths: s.auditLogMonths ?? DEFAULTS.auditLogMonths,
|
|
115
|
+
reservedMarkersKeep: s.reservedMarkersKeep ?? DEFAULTS.reservedMarkersKeep,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Effective settings. Never throws: missing/corrupt file yields DEFAULTS. */
|
|
120
|
+
function read() {
|
|
121
|
+
return withDefaults(readStored());
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Validate + merge `patch` over the current settings and persist the full object. */
|
|
125
|
+
function write(patch) {
|
|
126
|
+
const valid = validatePatch(patch);
|
|
127
|
+
const cur = read();
|
|
128
|
+
const next = {
|
|
129
|
+
...cur,
|
|
130
|
+
...valid,
|
|
131
|
+
runLogs: { ...cur.runLogs, ...(valid.runLogs || {}) },
|
|
132
|
+
};
|
|
133
|
+
atomicFs.writeJsonAtomicSync(storePath(), next, { mode: 0o600 });
|
|
134
|
+
return next;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Dry-run report of what each policy would free, via the sweeps' own
|
|
139
|
+
* eligibility code. Read-only. opts: { now, runsDir, plansRoot, jobs, liveKeys }.
|
|
140
|
+
*/
|
|
141
|
+
function preview(opts) {
|
|
142
|
+
const o = opts || {};
|
|
143
|
+
const schedulerPaths = require('./scheduler/schedulerPaths.cjs');
|
|
144
|
+
const runLogRetention = require('./scheduler/runLogRetention.cjs');
|
|
145
|
+
const staleTmpSweep = require('./scheduler/staleTmpSweep.cjs');
|
|
146
|
+
const s = read();
|
|
147
|
+
const now = typeof o.now === 'number' ? o.now : Date.now();
|
|
148
|
+
const runsDir = o.runsDir || schedulerPaths.runsDir();
|
|
149
|
+
const plansRoot = o.plansRoot || schedulerPaths.scheduledPlansRoot();
|
|
150
|
+
|
|
151
|
+
let liveKeys = o.liveKeys;
|
|
152
|
+
if (!liveKeys) {
|
|
153
|
+
let jobs = o.jobs;
|
|
154
|
+
if (!jobs) {
|
|
155
|
+
try {
|
|
156
|
+
jobs = require('./scheduler/queueStore.cjs').readMergedSync().jobs || [];
|
|
157
|
+
} catch {
|
|
158
|
+
jobs = [];
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
liveKeys = runLogRetention.liveKeysFromJobs(jobs, { runsDir });
|
|
162
|
+
}
|
|
163
|
+
const report = runLogRetention.computeReport(runsDir, { ...s.runLogs }, { now, liveKeys });
|
|
164
|
+
const archive = staleTmpSweep.pruneRunsArchive(path.join(plansRoot, 'runs-archive'), {
|
|
165
|
+
maxAgeDays: s.runsArchiveDays,
|
|
166
|
+
now,
|
|
167
|
+
dryRun: true,
|
|
168
|
+
});
|
|
169
|
+
const tmp = staleTmpSweep.sweepStaleTmp(plansRoot, { now, dryRun: true });
|
|
170
|
+
return {
|
|
171
|
+
runLogs: { files: report.eligible.reduce((n, e) => n + e.removeFiles.length, 0) + report.dodFiles.length, bytes: report.eligibleSummary.bytes },
|
|
172
|
+
runsArchive: { entries: archive.removedFiles, bytes: archive.freedBytes },
|
|
173
|
+
tmp: { files: tmp.removedFiles, bytes: tmp.freedBytes },
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function registerIpc(ipcMain) {
|
|
178
|
+
ipcMain.handle('opsRetention:get', () => read());
|
|
179
|
+
ipcMain.handle('opsRetention:set', (_e, patch) => write(patch));
|
|
180
|
+
ipcMain.handle('opsRetention:preview', () => preview());
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
module.exports = { storePath, read, readStored, write, validatePatch, preview, registerIpc, DEFAULTS, BOUNDS };
|
|
@@ -93,6 +93,61 @@ function refineInputs(value, ctx) {
|
|
|
93
93
|
}
|
|
94
94
|
}
|
|
95
95
|
|
|
96
|
+
/** Distinct `{{name}}` placeholder names in first-seen order. */
|
|
97
|
+
function macroPlaceholders(prompt) {
|
|
98
|
+
const names = [];
|
|
99
|
+
for (const m of String(prompt).matchAll(new RegExp(MACRO_PLACEHOLDER_RE.source, 'g'))) {
|
|
100
|
+
if (!names.includes(m[1])) names.push(m[1]);
|
|
101
|
+
}
|
|
102
|
+
return names;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** `{ [name]: default ?? '' }` for every declared input. */
|
|
106
|
+
function defaultMacroValues(inputs) {
|
|
107
|
+
const out = {};
|
|
108
|
+
for (const i of inputs || []) out[i.name] = i.default ?? '';
|
|
109
|
+
return out;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function sanitizeMacroValue(v) {
|
|
113
|
+
// eslint-disable-next-line no-control-regex
|
|
114
|
+
return v.replace(/[\r\n\t]+/g, ' ').replace(/[\u0000-\u001f\u007f]/g, '').trim();
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const MACRO_PROMPT_RENDERED_MAX_LEN = 8000;
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Mirrors `src/renderer/lib/macroPrompt.ts` renderMacroPrompt, but strict for macros
|
|
121
|
+
* without inputs: a placeholder with no declared input is always an error.
|
|
122
|
+
* @returns {{ ok: true, prompt: string } | { ok: false, error: string }}
|
|
123
|
+
*/
|
|
124
|
+
function renderMacroPrompt(prompt, inputs, values) {
|
|
125
|
+
const vals = values && typeof values === 'object' ? values : {};
|
|
126
|
+
const clean = Object.create(null);
|
|
127
|
+
for (const input of inputs || []) {
|
|
128
|
+
const own = Object.prototype.hasOwnProperty.call(vals, input.name) && typeof vals[input.name] === 'string';
|
|
129
|
+
const v = sanitizeMacroValue(own ? vals[input.name] : (input.default ?? ''));
|
|
130
|
+
if (v === '') {
|
|
131
|
+
if (input.required) return { ok: false, error: `"${input.label}" is required` };
|
|
132
|
+
} else if (v.length > MACRO_INPUT_MAX_LEN) {
|
|
133
|
+
return { ok: false, error: `"${input.label}" is longer than ${MACRO_INPUT_MAX_LEN} characters` };
|
|
134
|
+
} else if (input.options && input.options.length > 0 && !input.options.includes(v)) {
|
|
135
|
+
return { ok: false, error: `"${input.label}" must be one of: ${input.options.join(', ')}` };
|
|
136
|
+
}
|
|
137
|
+
clean[input.name] = v;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
for (const name of macroPlaceholders(prompt)) {
|
|
141
|
+
if (!(name in clean)) return { ok: false, error: `prompt uses undeclared input {{${name}}}` };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const rendered = String(prompt).replace(new RegExp(MACRO_PLACEHOLDER_RE.source, 'g'), (_m, n) => clean[n]);
|
|
145
|
+
if (rendered.length > MACRO_PROMPT_RENDERED_MAX_LEN) {
|
|
146
|
+
return { ok: false, error: `rendered prompt is longer than ${MACRO_PROMPT_RENDERED_MAX_LEN} characters` };
|
|
147
|
+
}
|
|
148
|
+
return { ok: true, prompt: rendered };
|
|
149
|
+
}
|
|
150
|
+
|
|
96
151
|
const MacroSchema = z.object({
|
|
97
152
|
id: MacroIdSchema,
|
|
98
153
|
label: MacroLabelSchema,
|
|
@@ -151,6 +206,12 @@ function normalizeCwd(cwd) {
|
|
|
151
206
|
return collapsed.replace(/\/+$/, '');
|
|
152
207
|
}
|
|
153
208
|
|
|
209
|
+
/** True iff the macro is shown for `cwd`: its projects include '*' or the normalized cwd. */
|
|
210
|
+
function macroVisibleIn(macro, cwd) {
|
|
211
|
+
const projects = (macro && macro.projects) || [];
|
|
212
|
+
return projects.includes(ALL_PROJECTS) || projects.includes(normalizeCwd(cwd));
|
|
213
|
+
}
|
|
214
|
+
|
|
154
215
|
function normalizeProjects(projects) {
|
|
155
216
|
const out = [];
|
|
156
217
|
for (const p of projects || []) {
|
|
@@ -414,6 +475,14 @@ async function loadForWrite(filePath, opts) {
|
|
|
414
475
|
* Create (no id) or update (id) a macro. Returns the saved Macro.
|
|
415
476
|
* Throws on invalid input or unknown id.
|
|
416
477
|
*/
|
|
478
|
+
/** Merged-macro check: every `{{name}}` in the prompt must be a declared input. */
|
|
479
|
+
function assertPlaceholdersDeclared(macro) {
|
|
480
|
+
const declared = new Set((macro.inputs || []).map((i) => i.name));
|
|
481
|
+
for (const name of macroPlaceholders(macro.prompt)) {
|
|
482
|
+
if (!declared.has(name)) throw new Error(`prompt placeholder {{${name}}} is not declared in inputs`);
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
|
|
417
486
|
async function saveMacro(input, opts = {}) {
|
|
418
487
|
const parsed = MacroSaveSchema.parse(input);
|
|
419
488
|
const filePath = resolvePath(opts);
|
|
@@ -442,6 +511,7 @@ async function saveMacro(input, opts = {}) {
|
|
|
442
511
|
} else {
|
|
443
512
|
saved.inputs = parsed.inputs;
|
|
444
513
|
}
|
|
514
|
+
assertPlaceholdersDeclared(saved);
|
|
445
515
|
macros[idx] = saved;
|
|
446
516
|
} else {
|
|
447
517
|
const projects = parsed.projects !== undefined ? normalizeProjects(parsed.projects) : [];
|
|
@@ -457,6 +527,7 @@ async function saveMacro(input, opts = {}) {
|
|
|
457
527
|
updatedAt: now,
|
|
458
528
|
};
|
|
459
529
|
if (parsed.inputs && parsed.inputs.length > 0) saved.inputs = parsed.inputs;
|
|
530
|
+
assertPlaceholdersDeclared(saved);
|
|
460
531
|
macros.push(saved);
|
|
461
532
|
}
|
|
462
533
|
await writeStore(filePath, macros);
|
|
@@ -518,6 +589,10 @@ module.exports = {
|
|
|
518
589
|
MacroSaveSchema,
|
|
519
590
|
MacroDeleteSchema,
|
|
520
591
|
MacroSetProjectSchema,
|
|
592
|
+
macroPlaceholders,
|
|
593
|
+
defaultMacroValues,
|
|
594
|
+
renderMacroPrompt,
|
|
595
|
+
macroVisibleIn,
|
|
521
596
|
listMacros,
|
|
522
597
|
saveMacro,
|
|
523
598
|
deleteMacro,
|
|
@@ -29,6 +29,7 @@ const { resolveProjectContext } = require('../projectRootResolve.cjs');
|
|
|
29
29
|
const { fixChainDepthOf, baseSlugOf } = require('../scheduler/fixChainDepth.cjs');
|
|
30
30
|
const { DEFAULT_PRD_AGENT_TYPE, assertAgentTypeWritable } = require('./prdAgentType.cjs');
|
|
31
31
|
const { resolveDepSlug, findNearMatches } = require('./depSlugResolve.cjs');
|
|
32
|
+
const { resolveEpicPrdWriteDir } = require('./prdLocations.cjs');
|
|
32
33
|
const { isFixPlanSlug } = require('../scheduler/fixPlanSlug.cjs');
|
|
33
34
|
const { isIncomplete, resolveChainTerminals, isValidPlanId, mintPlanId, resolveInheritedPlanId } = require('./prdDisposition.cjs');
|
|
34
35
|
const { sizingWarnings } = require('./prdSizing.cjs');
|
|
@@ -443,6 +444,30 @@ async function createPrd(input, remote) {
|
|
|
443
444
|
}
|
|
444
445
|
extraWarnings.push(...await worktreeReachAdvisory(input, cwd));
|
|
445
446
|
|
|
447
|
+
// Duplicate-slug guard (2110/2111 phone-drop-summary-state: duplicate re-issue after archive).
|
|
448
|
+
// Finished rows leave queue.json, so an author can re-issue a landed PRD under a fresh NN.
|
|
449
|
+
// Refused before NN allocation so it burns no number. Fix plans are exempt.
|
|
450
|
+
if (input.sourcePromptId && !isFixPlanSlug(`0-${slug}`)) {
|
|
451
|
+
let epicDirs = [];
|
|
452
|
+
try {
|
|
453
|
+
const prdsDir = resolveEpicPrdWriteDir(cwd, input.sourcePromptId);
|
|
454
|
+
epicDirs = [prdsDir, path.join(path.dirname(prdsDir), 'prds-archived')];
|
|
455
|
+
} catch { /* unsafe epic id — no dirs to scan */ }
|
|
456
|
+
for (const dir of epicDirs) {
|
|
457
|
+
let names = [];
|
|
458
|
+
try { names = await fsp.readdir(dir); } catch { /* missing dir counts as empty */ }
|
|
459
|
+
const dup = names.find((n) => n.endsWith('.md') && n.slice(0, -3).replace(/^\d+-/, '') === slug);
|
|
460
|
+
if (dup) {
|
|
461
|
+
return {
|
|
462
|
+
ok: false,
|
|
463
|
+
status: 409,
|
|
464
|
+
error: `A PRD with slug "${slug}" already exists in this Epic as "${dup}". Its work may already have landed. ` +
|
|
465
|
+
'Use scheduler_update_prd / scheduler_reset_job on it, or pick a distinct slug.',
|
|
466
|
+
};
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
|
|
446
471
|
const nn = await remote.allocateParallelGroup(input.cwd);
|
|
447
472
|
const filenameSlug = `${nn}-${slug}`;
|
|
448
473
|
|
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Incident: burrow PRD 1516-fb-bilko-first-pumpkin named a gitignored runtime path by RELATIVE
|
|
5
5
|
* path (downloads/fb-tracker.db) and an install-cron.sh that baked a worktree path. Scheduled
|
|
6
|
-
* PRDs run in an isolated git job worktree holding only tracked files plus symlinked
|
|
7
|
-
* dirs (DEFAULT_DEP_DIRS), so that path pointed at a file that did not exist there and the job
|
|
6
|
+
* PRDs run in an isolated git job worktree holding only tracked files plus symlinked or (pnpm) worktree-local
|
|
7
|
+
* dependency dirs (DEFAULT_DEP_DIRS), so that path pointed at a file that did not exist there and the job
|
|
8
8
|
* parked needs_human.
|
|
9
9
|
*
|
|
10
10
|
* Two exports: `extractRepoPathCandidates(text)` pulls repo-relative path-looking tokens out of
|
|
@@ -27,6 +27,8 @@ const WORK_TYPES = Object.freeze([
|
|
|
27
27
|
'discussion',
|
|
28
28
|
'build',
|
|
29
29
|
'project-home-builder',
|
|
30
|
+
'art',
|
|
31
|
+
'portfolio',
|
|
30
32
|
]);
|
|
31
33
|
|
|
32
34
|
const WorkTypeSchema = z.enum(WORK_TYPES);
|
|
@@ -37,17 +39,19 @@ const WorkTypeSchema = z.enum(WORK_TYPES);
|
|
|
37
39
|
// legacy-drop preprocessing.
|
|
38
40
|
const LEGACY_WORK_TYPES = Object.freeze(['bilko-host-publisher']);
|
|
39
41
|
|
|
40
|
-
//
|
|
42
|
+
// 'discussion' and 'portfolio' tickets never reach PRD authoring (a domain
|
|
41
43
|
// invariant unrelated to the Epic/PRD tag unification above), so the PRD
|
|
42
44
|
// frontmatter `tag` field — parsed in prdFrontmatter.cjs/.ts and patched via
|
|
43
|
-
// ipcSchemas.cjs's adminPrdFrontmatterPatch — excludes
|
|
45
|
+
// ipcSchemas.cjs's adminPrdFrontmatterPatch — excludes them even though
|
|
44
46
|
// WorkTypeSchema itself (used at PRD-create time) does not.
|
|
45
|
-
const
|
|
47
|
+
const NON_PRD_WORK_TYPES = Object.freeze(['discussion', 'portfolio']);
|
|
48
|
+
const PRD_WORK_TYPES = Object.freeze(WORK_TYPES.filter((t) => !NON_PRD_WORK_TYPES.includes(t)));
|
|
46
49
|
const PrdWorkTypeSchema = z.enum(PRD_WORK_TYPES);
|
|
47
50
|
|
|
48
51
|
module.exports = {
|
|
49
52
|
WORK_TYPES,
|
|
50
53
|
WorkTypeSchema,
|
|
54
|
+
NON_PRD_WORK_TYPES,
|
|
51
55
|
PRD_WORK_TYPES,
|
|
52
56
|
PrdWorkTypeSchema,
|
|
53
57
|
LEGACY_WORK_TYPES,
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* procGroupRss.cjs — summed resident memory (MB) of every process in a
|
|
6
|
+
* process group, read from /proc (Linux only).
|
|
7
|
+
*
|
|
8
|
+
* Why: the scheduler's memory gate double-counted running jobs (2026-10-18).
|
|
9
|
+
* Each job reserves 2500 MB, but part of that is already consumed and thus
|
|
10
|
+
* already missing from MemAvailable; subtracting the full reservation again
|
|
11
|
+
* counts it twice. Scheduler jobs are spawned `detached: true`, so a job's
|
|
12
|
+
* pid leads its own process group and its tsc/vite/node children share that
|
|
13
|
+
* pgid — summing the group's RSS tells the gate how much is already used.
|
|
14
|
+
*
|
|
15
|
+
* Parsing: /proc/<pid>/stat is `pid (comm) state ppid pgrp ... rss ...` and
|
|
16
|
+
* comm may contain spaces and parentheses, so split only after the LAST ')'.
|
|
17
|
+
* In the remainder (0-based): state=0, ppid=1, pgrp=2, rss=21 (pages).
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
const fs = require('node:fs');
|
|
21
|
+
|
|
22
|
+
const PGRP_IDX = 2;
|
|
23
|
+
const RSS_IDX = 21;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* @param {number} pgid
|
|
27
|
+
* @param {{ procRoot?: string, pageSizeKb?: number }} [opts]
|
|
28
|
+
* @returns {number} integer MB (floor); 0 on any non-applicable/failed case
|
|
29
|
+
*/
|
|
30
|
+
function processGroupRssMb(pgid, opts = {}) {
|
|
31
|
+
if (!Number.isInteger(pgid) || pgid <= 0) return 0;
|
|
32
|
+
if (process.platform !== 'linux') return 0;
|
|
33
|
+
const procRoot = opts.procRoot ?? '/proc';
|
|
34
|
+
const pageSizeKb = opts.pageSizeKb ?? 4;
|
|
35
|
+
|
|
36
|
+
let names;
|
|
37
|
+
try {
|
|
38
|
+
names = fs.readdirSync(procRoot);
|
|
39
|
+
} catch {
|
|
40
|
+
return 0;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
let totalKb = 0;
|
|
44
|
+
for (const name of names) {
|
|
45
|
+
if (!/^\d+$/.test(name)) continue;
|
|
46
|
+
try {
|
|
47
|
+
const stat = fs.readFileSync(`${procRoot}/${name}/stat`, 'utf8');
|
|
48
|
+
const close = stat.lastIndexOf(')');
|
|
49
|
+
if (close < 0) continue;
|
|
50
|
+
const fields = stat.slice(close + 1).trim().split(/\s+/);
|
|
51
|
+
if (Number(fields[PGRP_IDX]) !== pgid) continue;
|
|
52
|
+
const rssPages = Number(fields[RSS_IDX]);
|
|
53
|
+
if (Number.isFinite(rssPages) && rssPages > 0) totalKb += rssPages * pageSizeKb;
|
|
54
|
+
} catch {
|
|
55
|
+
// vanished or unreadable entry — skip it
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return Math.floor(totalKb / 1024);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
module.exports = { processGroupRssMb };
|