claude-code-session-manager 0.37.1 → 0.38.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/bin/cli.cjs +12 -1
- package/dist/assets/{TiptapBody-DXQDiOUx.js → TiptapBody-GMk5LS9Y.js} +1 -1
- package/dist/assets/index-CD_LuJZF.css +32 -0
- package/dist/assets/{index--a8m5_ET.js → index-CQqSHpIq.js} +444 -436
- package/dist/index.html +2 -2
- package/package.json +1 -1
- package/plugins/session-manager-dev/skills/develop/SKILL.md +134 -36
- package/plugins/session-manager-dev/skills/develop/standards.md +25 -0
- package/plugins/session-manager-dev/skills/issue-address/0-select/0-fetch-pool/SKILL.md +38 -0
- package/plugins/session-manager-dev/skills/issue-address/0-select/1-reject-fixed/SKILL.md +64 -0
- package/plugins/session-manager-dev/skills/issue-address/0-select/2-reject-claimed/SKILL.md +71 -0
- package/plugins/session-manager-dev/skills/issue-address/0-select/3-rank-impact/SKILL.md +39 -0
- package/plugins/session-manager-dev/skills/issue-address/0-select/SKILL.md +75 -0
- package/plugins/session-manager-dev/skills/issue-address/1-confirm-open/SKILL.md +39 -0
- package/plugins/session-manager-dev/skills/issue-address/2-claim/SKILL.md +57 -0
- package/plugins/session-manager-dev/skills/issue-address/3-reproduce/SKILL.md +40 -0
- package/plugins/session-manager-dev/skills/issue-address/4-fix/SKILL.md +33 -0
- package/plugins/session-manager-dev/skills/issue-address/5-verify/SKILL.md +40 -0
- package/plugins/session-manager-dev/skills/issue-address/SKILL.md +120 -0
- package/plugins/session-manager-dev/skills/pr-review-sweep/0-fetch/SKILL.md +41 -0
- package/plugins/session-manager-dev/skills/pr-review-sweep/1-classify/SKILL.md +45 -0
- package/plugins/session-manager-dev/skills/pr-review-sweep/2-check-fixed/SKILL.md +49 -0
- package/plugins/session-manager-dev/skills/pr-review-sweep/3-queue/SKILL.md +50 -0
- package/plugins/session-manager-dev/skills/pr-review-sweep/4-land-and-resolve/SKILL.md +60 -0
- package/plugins/session-manager-dev/skills/pr-review-sweep/SKILL.md +124 -0
- package/plugins/session-manager-dev/skills/pr-signal/SKILL.md +67 -0
- package/plugins/session-manager-dev/skills/requesting-code-review/SKILL.md +20 -17
- package/scripts/lib/watchdogHelpers.cjs +296 -166
- package/src/main/__tests__/chat-cancel-terminal.test.cjs +31 -0
- package/src/main/__tests__/chat-exit-close-race.test.cjs +140 -0
- package/src/main/__tests__/classifyTranscriptLine.test.cjs +90 -0
- package/src/main/__tests__/docEdit.test.cjs +164 -2
- package/src/main/__tests__/prdCreate.test.cjs +265 -25
- package/src/main/__tests__/scheduler-admin-routes.test.cjs +150 -0
- package/src/main/__tests__/scheduler-boot-orphans.test.cjs +172 -0
- package/src/main/__tests__/scheduler-committed-in-window.test.cjs +64 -0
- package/src/main/browserAgentServer.cjs +11 -10
- package/src/main/chatRunner.cjs +58 -14
- package/src/main/docEdit.cjs +125 -5
- package/src/main/health.cjs +15 -0
- package/src/main/index.cjs +12 -6
- package/src/main/ipcSchemas.cjs +16 -3
- package/src/main/lib/__tests__/localAdminHttp.test.cjs +120 -0
- package/src/main/lib/classifyTranscriptLine.cjs +89 -0
- package/src/main/lib/localAdminHttp.cjs +157 -0
- package/src/main/lib/personaImportHealth.cjs +161 -0
- package/src/main/lib/prdCreate.cjs +107 -17
- package/src/main/lib/rcaFeedbackHook.cjs +2 -2
- package/src/main/lib/singleInstanceGuard.cjs +20 -0
- package/src/main/scheduler.cjs +223 -56
- package/src/main/sessionsStore.cjs +4 -5
- package/src/main/templates/PRD_AUTHORING.md +4 -4
- package/src/main/transcripts.cjs +1 -85
- package/src/main/webRemote.cjs +3 -3
- package/src/preload/api.d.ts +16 -6
- package/src/preload/index.cjs +9 -2
- package/dist/assets/index-CTTjT08J.css +0 -32
- package/plugins/session-manager-dev/skills/security-review/SKILL.md +0 -105
- package/src/main/__tests__/adminServer.test.cjs +0 -380
- package/src/main/adminServer.cjs +0 -242
|
@@ -9,11 +9,6 @@ const path = require('node:path');
|
|
|
9
9
|
|
|
10
10
|
const { activeProjectCwds } = require('./activeSessions.cjs');
|
|
11
11
|
|
|
12
|
-
// Relative path into src/main/lib/ — the watchdog is external to the app, so
|
|
13
|
-
// we re-use the helpers without importing any Electron code.
|
|
14
|
-
// (Same claudePidAlive + classifyRunOutcome used by scheduler.cjs boot reconciliation.)
|
|
15
|
-
const { claudePidAlive, classifyRunOutcome, ORPHAN_REQUEUE_CAP } = require('../../src/main/lib/reaperHelpers.cjs');
|
|
16
|
-
|
|
17
12
|
// Mirrors scheduler.cjs:215 (single source of truth there; kept in sync here).
|
|
18
13
|
const DEFAULT_HEARTBEAT_PATH = path.join(
|
|
19
14
|
os.homedir(), '.claude', 'session-manager', 'scheduler-heartbeat.log',
|
|
@@ -44,7 +39,7 @@ const TAIL_BYTES = 4096;
|
|
|
44
39
|
* Returns null on missing / empty / unparseable file.
|
|
45
40
|
*
|
|
46
41
|
* Single source of truth for the file-read + reverse-scan logic; used by
|
|
47
|
-
* readLastHeartbeatTs()
|
|
42
|
+
* readLastHeartbeatTs() and heartbeatFresh().
|
|
48
43
|
*/
|
|
49
44
|
function readLastHeartbeat(heartbeatPath = DEFAULT_HEARTBEAT_PATH) {
|
|
50
45
|
let buf;
|
|
@@ -106,175 +101,117 @@ function heartbeatFresh(heartbeatPath = DEFAULT_HEARTBEAT_PATH, maxAgeMs = DEFAU
|
|
|
106
101
|
const DEFAULT_QUEUE_PATH = path.join(
|
|
107
102
|
os.homedir(), '.claude', 'session-manager', 'scheduled-plans', 'queue.json',
|
|
108
103
|
);
|
|
109
|
-
const DEFAULT_RUNS_DIR = path.join(
|
|
110
|
-
os.homedir(), '.claude', 'session-manager', 'scheduled-plans', 'runs',
|
|
111
|
-
);
|
|
112
104
|
const DEFAULT_PRDS_DIR = path.join(
|
|
113
105
|
os.homedir(), '.claude', 'session-manager', 'scheduled-plans', 'prds',
|
|
114
106
|
);
|
|
115
|
-
const
|
|
116
|
-
os.homedir(), '.claude', '
|
|
117
|
-
);
|
|
118
|
-
const DEFAULT_STANDARDS_PATH = path.join(
|
|
119
|
-
os.homedir(), '.claude', 'skills', 'develop', 'standards.md',
|
|
107
|
+
const PLUGIN_CACHE_ROOT = path.join(
|
|
108
|
+
os.homedir(), '.claude', 'plugins', 'cache', 'session-manager', 'session-manager-dev',
|
|
120
109
|
);
|
|
121
110
|
|
|
122
111
|
/**
|
|
123
|
-
*
|
|
124
|
-
*
|
|
125
|
-
* Safe offline reconciliation of queue.json when the in-app scheduler is down.
|
|
112
|
+
* compareVersionsDesc(a, b) → number
|
|
126
113
|
*
|
|
127
|
-
*
|
|
128
|
-
*
|
|
129
|
-
*
|
|
130
|
-
*
|
|
131
|
-
*
|
|
132
|
-
|
|
133
|
-
|
|
114
|
+
* Numeric per-segment version compare (descending) for plugin-cache version
|
|
115
|
+
* dirs like "0.1.0" / "0.2.0" / "0.10.0". Plain lexicographic sort is wrong
|
|
116
|
+
* here — "0.10.0" < "0.2.0" as strings even though 10 > 2 numerically. Falls
|
|
117
|
+
* back to a string compare for any non-numeric segment (e.g. a stray dir name
|
|
118
|
+
* that isn't a version) so listSkillCandidates never throws on garbage input.
|
|
119
|
+
*/
|
|
120
|
+
function compareVersionsDesc(a, b) {
|
|
121
|
+
const as = a.split('.');
|
|
122
|
+
const bs = b.split('.');
|
|
123
|
+
const len = Math.max(as.length, bs.length);
|
|
124
|
+
for (let i = 0; i < len; i++) {
|
|
125
|
+
const an = Number(as[i]);
|
|
126
|
+
const bn = Number(bs[i]);
|
|
127
|
+
if (Number.isFinite(an) && Number.isFinite(bn)) {
|
|
128
|
+
if (an !== bn) return bn - an;
|
|
129
|
+
} else if (as[i] !== bs[i]) {
|
|
130
|
+
return (as[i] ?? '') < (bs[i] ?? '') ? 1 : -1;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return 0;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* listSkillCandidates(cwd, skillName, fileName, pluginCacheRoot?) → string[]
|
|
134
138
|
*
|
|
135
|
-
*
|
|
139
|
+
* Ordered candidate paths for an inlined skill/standards file, most-specific
|
|
140
|
+
* first — the process-feedback SKILL.md and develop standards.md moved out of
|
|
141
|
+
* the legacy ~/.claude/skills/ location into the session-manager-dev plugin:
|
|
136
142
|
*
|
|
137
|
-
*
|
|
143
|
+
* 1. <cwd>/plugins/session-manager-dev/skills/<skillName>/<fileName> — repo-local
|
|
144
|
+
* copy, correct when the swept project vendors the plugin (e.g. session-manager itself).
|
|
145
|
+
* 2. <pluginCacheRoot>/<newest-version>/skills/<skillName>/<fileName> — the installed
|
|
146
|
+
* plugin cache (default ~/.claude/plugins/cache/session-manager/session-manager-dev).
|
|
147
|
+
* Enumerates every version dir present, newest (lexicographically-highest) first.
|
|
148
|
+
* 3. ~/.claude/skills/<skillName>/<fileName> — legacy location, kept last so an
|
|
149
|
+
* old install still works.
|
|
138
150
|
*
|
|
139
|
-
*
|
|
151
|
+
* pluginCacheRoot is overridable for tests; defaults to the real plugin cache.
|
|
140
152
|
*/
|
|
141
|
-
function
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
runsDir = DEFAULT_RUNS_DIR,
|
|
146
|
-
} = {}) {
|
|
147
|
-
// Safety guard: never touch queue.json while the app is alive.
|
|
148
|
-
if (heartbeatFresh(heartbeatPath, maxAgeMs)) {
|
|
149
|
-
return { reconciled: false, reapedCount: 0, errors: [] };
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
// Second guard: stale heartbeat may just mean the app is under load.
|
|
153
|
-
// If the heartbeat carries a pid and that process is still alive, the app
|
|
154
|
-
// is running — do not reap. Backward-compat: if no pid field, fall through
|
|
155
|
-
// to the classic age-only behavior so pre-existing orphan cleanup still works.
|
|
156
|
-
const lastHeartbeat = readLastHeartbeat(heartbeatPath);
|
|
157
|
-
if (lastHeartbeat !== null && typeof lastHeartbeat.pid === 'number') {
|
|
158
|
-
try {
|
|
159
|
-
process.kill(lastHeartbeat.pid, 0); // liveness check only — no signal
|
|
160
|
-
// pid is alive → app is running; abort reap.
|
|
161
|
-
return { reconciled: false, reapedCount: 0, errors: [] };
|
|
162
|
-
} catch {
|
|
163
|
-
// ESRCH → process is gone; proceed with reap.
|
|
164
|
-
}
|
|
165
|
-
}
|
|
153
|
+
function listSkillCandidates(cwd, skillName, fileName, pluginCacheRoot = PLUGIN_CACHE_ROOT) {
|
|
154
|
+
const candidates = [
|
|
155
|
+
path.join(cwd, 'plugins', 'session-manager-dev', 'skills', skillName, fileName),
|
|
156
|
+
];
|
|
166
157
|
|
|
167
|
-
let
|
|
158
|
+
let entries = [];
|
|
168
159
|
try {
|
|
169
|
-
|
|
170
|
-
} catch
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
const errors = [];
|
|
175
|
-
let reapedCount = 0;
|
|
176
|
-
let changed = false;
|
|
177
|
-
// Stamp once so all jobs reapedQueue in the same watchdog run share a finishedAt.
|
|
178
|
-
const reconciledAt = new Date().toISOString();
|
|
179
|
-
|
|
180
|
-
for (const j of (state.jobs ?? [])) {
|
|
181
|
-
if (j.status !== 'running') continue;
|
|
182
|
-
|
|
183
|
-
const logPath = j.runId ? path.join(runsDir, j.runId, `${j.slug}.log`) : null;
|
|
184
|
-
const pid = j.runtime?.pid;
|
|
185
|
-
|
|
186
|
-
// If the orphan is still alive we must NOT classify its log this tick — the
|
|
187
|
-
// process is still running/flushing, so a partial or in-flight log would be
|
|
188
|
-
// misread (e.g. a job about to emit result:success gets read as no_result and
|
|
189
|
-
// re-queued, double-running the same PRD while the original still edits the
|
|
190
|
-
// repo). Instead escalate across ticks (SIGTERM → SIGKILL), matching the
|
|
191
|
-
// scheduler's kill discipline (process group first, then the pid).
|
|
192
|
-
//
|
|
193
|
-
// Bounded so a job can't wedge in 'running' forever: after we've sent BOTH
|
|
194
|
-
// SIGTERM and SIGKILL on prior ticks, the real claude process is gone. A pid
|
|
195
|
-
// still reported "alive" here is an unkillable defunct — or, on macOS (no
|
|
196
|
-
// /proc, so claudePidAlive can't verify identity), a REUSED pid we must stop
|
|
197
|
-
// signalling. Either way we fall through to finalize from the now-settled log.
|
|
198
|
-
if (pid && claudePidAlive(pid)) {
|
|
199
|
-
if (!j.runtime.watchdogSigtermAt) {
|
|
200
|
-
try { process.kill(-pid, 'SIGTERM'); }
|
|
201
|
-
catch { try { process.kill(pid, 'SIGTERM'); } catch { /* ESRCH — died between checks */ } }
|
|
202
|
-
j.runtime.watchdogSigtermAt = reconciledAt;
|
|
203
|
-
changed = true;
|
|
204
|
-
continue; // leave status 'running'; revisit next tick
|
|
205
|
-
}
|
|
206
|
-
if (!j.runtime.watchdogSigkillAt) {
|
|
207
|
-
try { process.kill(-pid, 'SIGKILL'); }
|
|
208
|
-
catch { try { process.kill(pid, 'SIGKILL'); } catch { /* ESRCH */ } }
|
|
209
|
-
j.runtime.watchdogSigkillAt = reconciledAt;
|
|
210
|
-
changed = true;
|
|
211
|
-
continue; // give SIGKILL a tick to land before finalizing
|
|
212
|
-
}
|
|
213
|
-
// SIGTERM + SIGKILL already sent on earlier ticks → stop deferring; the
|
|
214
|
-
// original process is dead. Fall through to classify + finalize.
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
const outcome = logPath ? classifyRunOutcome(logPath) : 'unknown';
|
|
218
|
-
|
|
219
|
-
if (outcome === 'success') {
|
|
220
|
-
j.status = 'completed';
|
|
221
|
-
j.exitCode = 0;
|
|
222
|
-
j.error = null;
|
|
223
|
-
j.finishedAt = reconciledAt;
|
|
224
|
-
delete j.runtime;
|
|
225
|
-
delete j.verifierVerdict;
|
|
226
|
-
} else if (outcome === 'failed') {
|
|
227
|
-
j.status = 'failed';
|
|
228
|
-
j.exitCode = j.exitCode ?? 1;
|
|
229
|
-
j.error = 'orphaned: watchdog found failed result while app was down';
|
|
230
|
-
j.finishedAt = reconciledAt;
|
|
231
|
-
delete j.runtime;
|
|
232
|
-
delete j.verifierVerdict;
|
|
233
|
-
} else {
|
|
234
|
-
// no_result / unknown: interrupted with no evidence of merit failure — re-queue bounded.
|
|
235
|
-
const tries = j.orphanRetries ?? 0;
|
|
236
|
-
if (tries < ORPHAN_REQUEUE_CAP) {
|
|
237
|
-
j.status = 'pending';
|
|
238
|
-
j.runId = null;
|
|
239
|
-
j.startedAt = null;
|
|
240
|
-
j.finishedAt = null;
|
|
241
|
-
j.exitCode = null;
|
|
242
|
-
j.error = `orphaned: watchdog re-queued (attempt ${tries + 1}/${ORPHAN_REQUEUE_CAP})`;
|
|
243
|
-
j.orphanRetries = tries + 1;
|
|
244
|
-
delete j.runtime;
|
|
245
|
-
delete j.verifierVerdict;
|
|
246
|
-
} else {
|
|
247
|
-
j.status = 'failed';
|
|
248
|
-
j.exitCode = j.exitCode ?? 1;
|
|
249
|
-
j.error = `orphaned: watchdog exhausted ${ORPHAN_REQUEUE_CAP} re-queue attempts`;
|
|
250
|
-
j.finishedAt = reconciledAt;
|
|
251
|
-
delete j.runtime;
|
|
252
|
-
delete j.verifierVerdict;
|
|
253
|
-
}
|
|
254
|
-
}
|
|
255
|
-
|
|
256
|
-
reapedCount++;
|
|
257
|
-
changed = true;
|
|
160
|
+
entries = fs.readdirSync(pluginCacheRoot, { withFileTypes: true });
|
|
161
|
+
} catch { /* plugin cache not installed */ }
|
|
162
|
+
const versions = entries.filter((e) => e.isDirectory()).map((e) => e.name).sort(compareVersionsDesc);
|
|
163
|
+
for (const version of versions) {
|
|
164
|
+
candidates.push(path.join(pluginCacheRoot, version, 'skills', skillName, fileName));
|
|
258
165
|
}
|
|
259
166
|
|
|
260
|
-
|
|
261
|
-
|
|
167
|
+
candidates.push(path.join(os.homedir(), '.claude', 'skills', skillName, fileName));
|
|
168
|
+
return candidates;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* resolveSkillFile(cwd, skillName, fileName, pluginCacheRoot?) → string | null
|
|
173
|
+
*
|
|
174
|
+
* Returns the first candidate from listSkillCandidates() that is a readable
|
|
175
|
+
* file, or null if none resolve.
|
|
176
|
+
*/
|
|
177
|
+
function resolveSkillFile(cwd, skillName, fileName, pluginCacheRoot = PLUGIN_CACHE_ROOT) {
|
|
178
|
+
for (const candidate of listSkillCandidates(cwd, skillName, fileName, pluginCacheRoot)) {
|
|
179
|
+
try {
|
|
180
|
+
if (fs.statSync(candidate).isFile()) return candidate;
|
|
181
|
+
} catch { /* try next candidate */ }
|
|
262
182
|
}
|
|
183
|
+
return null;
|
|
184
|
+
}
|
|
263
185
|
|
|
264
|
-
|
|
265
|
-
|
|
186
|
+
/**
|
|
187
|
+
* isPidAlive(pid) → boolean
|
|
188
|
+
*
|
|
189
|
+
* Plain liveness check via process.kill(pid, 0) (no signal sent). Single
|
|
190
|
+
* source of truth for the "is this recorded pid still alive" check used by
|
|
191
|
+
* checkAppLiveness's defense-in-depth relaunch gate.
|
|
192
|
+
*
|
|
193
|
+
* EPERM (pid exists, owned by another user) counts as alive — only ESRCH
|
|
194
|
+
* (no such process) means dead. Watchdog and app run as the same user in
|
|
195
|
+
* practice, so this distinction rarely matters here, but treating EPERM as
|
|
196
|
+
* "dead" would be wrong on its face (the process demonstrably exists).
|
|
197
|
+
*/
|
|
198
|
+
function isPidAlive(pid) {
|
|
199
|
+
if (typeof pid !== 'number' || !Number.isFinite(pid)) return false;
|
|
266
200
|
try {
|
|
267
|
-
|
|
268
|
-
|
|
201
|
+
process.kill(pid, 0);
|
|
202
|
+
return true;
|
|
269
203
|
} catch (e) {
|
|
270
|
-
|
|
271
|
-
try { fs.unlinkSync(tmpPath); } catch { /* best-effort cleanup */ }
|
|
272
|
-
return { reconciled: false, reapedCount: 0, errors };
|
|
204
|
+
return e?.code === 'EPERM';
|
|
273
205
|
}
|
|
274
|
-
|
|
275
|
-
return { reconciled: true, reapedCount, errors };
|
|
276
206
|
}
|
|
277
207
|
|
|
208
|
+
// Offline queue.json reconciliation (orphaned 'running' jobs whose pid died
|
|
209
|
+
// while Electron wasn't running) used to live here as reconcileQueueOffline().
|
|
210
|
+
// PRD 686 moved that responsibility into src/main/scheduler.cjs's own boot
|
|
211
|
+
// path (partitionBootOrphans/applyOrphanOutcome) now that PRD 685's watchdog
|
|
212
|
+
// relaunch means the app is never down long enough to need an external
|
|
213
|
+
// reconciler — the in-app scheduler is queue.json's single owner again.
|
|
214
|
+
|
|
278
215
|
// ── slugify ──────────────────────────────────────────────────────────────────
|
|
279
216
|
|
|
280
217
|
/** Lowercase, non-alphanumeric runs → single `-`, strip leading/trailing `-`. */
|
|
@@ -360,9 +297,21 @@ function hasOpenFeedback(cwd) {
|
|
|
360
297
|
function emitFeedbackPRD(cwd, {
|
|
361
298
|
prdsDir = DEFAULT_PRDS_DIR,
|
|
362
299
|
queuePath = DEFAULT_QUEUE_PATH,
|
|
363
|
-
skillPath
|
|
364
|
-
standardsPath
|
|
300
|
+
skillPath,
|
|
301
|
+
standardsPath,
|
|
302
|
+
pluginCacheRoot = PLUGIN_CACHE_ROOT,
|
|
365
303
|
} = {}) {
|
|
304
|
+
// Explicit overrides (used by tests) are honored verbatim and skip the
|
|
305
|
+
// candidate search entirely — only resolve via candidates when the caller
|
|
306
|
+
// didn't pass a path.
|
|
307
|
+
const skillPathExplicit = skillPath !== undefined;
|
|
308
|
+
const standardsPathExplicit = standardsPath !== undefined;
|
|
309
|
+
if (!skillPathExplicit) {
|
|
310
|
+
skillPath = resolveSkillFile(cwd, 'process-feedback', 'SKILL.md', pluginCacheRoot);
|
|
311
|
+
}
|
|
312
|
+
if (!standardsPathExplicit) {
|
|
313
|
+
standardsPath = resolveSkillFile(cwd, 'develop', 'standards.md', pluginCacheRoot);
|
|
314
|
+
}
|
|
366
315
|
const project = slugify(path.basename(cwd));
|
|
367
316
|
|
|
368
317
|
// De-dup: check queue.json for pending/running feedback job for this project.
|
|
@@ -407,22 +356,40 @@ function emitFeedbackPRD(cwd, {
|
|
|
407
356
|
|
|
408
357
|
// Read and inline skill content.
|
|
409
358
|
let rawSkill = '';
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
359
|
+
if (skillPath) {
|
|
360
|
+
try {
|
|
361
|
+
rawSkill = fs.readFileSync(skillPath, 'utf8');
|
|
362
|
+
} catch { /* handled by the empty-body guard below */ }
|
|
414
363
|
}
|
|
415
364
|
const skillBody = stripFrontmatter(rawSkill).trim();
|
|
416
365
|
|
|
417
366
|
// Read and inline standards — strip H1 so our ## heading is the section anchor.
|
|
418
367
|
let rawStandards = '';
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
368
|
+
if (standardsPath) {
|
|
369
|
+
try {
|
|
370
|
+
rawStandards = fs.readFileSync(standardsPath, 'utf8');
|
|
371
|
+
} catch { /* handled by the empty-body guard below */ }
|
|
423
372
|
}
|
|
424
373
|
const standardsBody = stripLeadingH1(stripFrontmatter(rawStandards)).trim();
|
|
425
374
|
|
|
375
|
+
// Hard-fail rather than silently ship a hollow PRD: if either inline is
|
|
376
|
+
// empty/unreadable, refuse to write anything and name every candidate tried
|
|
377
|
+
// so a future relocation shows up loudly in the watchdog log instead of
|
|
378
|
+
// queueing a template with no procedure and no standards in it.
|
|
379
|
+
if (!skillBody || !standardsBody) {
|
|
380
|
+
const missing = [];
|
|
381
|
+
if (!skillBody) {
|
|
382
|
+
const tried = skillPathExplicit ? [skillPath] : listSkillCandidates(cwd, 'process-feedback', 'SKILL.md', pluginCacheRoot);
|
|
383
|
+
missing.push(`process-feedback SKILL.md (tried: ${tried.join(', ')})`);
|
|
384
|
+
}
|
|
385
|
+
if (!standardsBody) {
|
|
386
|
+
const tried = standardsPathExplicit ? [standardsPath] : listSkillCandidates(cwd, 'develop', 'standards.md', pluginCacheRoot);
|
|
387
|
+
missing.push(`develop standards.md (tried: ${tried.join(', ')})`);
|
|
388
|
+
}
|
|
389
|
+
process.stderr.write(`[emitFeedbackPRD] refusing to emit for ${cwd}: missing inline(s) — ${missing.join('; ')}\n`);
|
|
390
|
+
return { emitted: false, reason: 'missing-inline' };
|
|
391
|
+
}
|
|
392
|
+
|
|
426
393
|
const projectName = path.basename(cwd);
|
|
427
394
|
const body = [
|
|
428
395
|
'---',
|
|
@@ -476,6 +443,158 @@ function emitFeedbackPRD(cwd, {
|
|
|
476
443
|
return { emitted: true, slug, prdPath };
|
|
477
444
|
}
|
|
478
445
|
|
|
446
|
+
// ── app liveness + auto-relaunch ─────────────────────────────────────────────
|
|
447
|
+
//
|
|
448
|
+
// The watchdog's one supervision job: is session-manager itself running, and
|
|
449
|
+
// if not, start it. Reuses the existing scheduler heartbeat (written every
|
|
450
|
+
// 60 s by scheduler.cjs's heartbeatInterval regardless of pause state) as the
|
|
451
|
+
// liveness signal — no second heartbeat file.
|
|
452
|
+
|
|
453
|
+
const DEFAULT_RELAUNCH_STATE_PATH = path.join(
|
|
454
|
+
os.homedir(), '.claude', 'session-manager', 'watchdog-relaunch-state.json',
|
|
455
|
+
);
|
|
456
|
+
const DEFAULT_RELAUNCH_LOG_PATH = path.join(
|
|
457
|
+
os.homedir(), '.claude', 'logs', 'scheduler-watchdog-relaunch.log',
|
|
458
|
+
);
|
|
459
|
+
const DEFAULT_RELAUNCH_DEBOUNCE_MS = 90_000;
|
|
460
|
+
const DEFAULT_MAX_RELAUNCH_ATTEMPTS = 3;
|
|
461
|
+
|
|
462
|
+
/**
|
|
463
|
+
* checkAppLiveness(opts?) → { alive: boolean, reason: string }
|
|
464
|
+
*
|
|
465
|
+
* Primary signal: heartbeatFresh() within the existing max-age window.
|
|
466
|
+
* Defense-in-depth: if the heartbeat is stale, but the pid it last recorded
|
|
467
|
+
* is still alive (app under heavy load, tick just missed), treat as alive
|
|
468
|
+
* rather than relaunching on top of a live process.
|
|
469
|
+
*/
|
|
470
|
+
function checkAppLiveness({
|
|
471
|
+
heartbeatPath = DEFAULT_HEARTBEAT_PATH,
|
|
472
|
+
maxAgeMs = DEFAULT_MAX_AGE_MS,
|
|
473
|
+
} = {}) {
|
|
474
|
+
if (heartbeatFresh(heartbeatPath, maxAgeMs)) {
|
|
475
|
+
return { alive: true, reason: 'heartbeat-fresh' };
|
|
476
|
+
}
|
|
477
|
+
const last = readLastHeartbeat(heartbeatPath);
|
|
478
|
+
if (last !== null && typeof last.pid === 'number' && isPidAlive(last.pid)) {
|
|
479
|
+
return { alive: true, reason: 'pid-alive-heartbeat-stale' };
|
|
480
|
+
}
|
|
481
|
+
return { alive: false, reason: 'stale-and-dead' };
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
/** Default { lastAttemptTs: null, attemptCount: 0 } on missing/unparseable state file. */
|
|
485
|
+
function readRelaunchState(statePath = DEFAULT_RELAUNCH_STATE_PATH) {
|
|
486
|
+
try {
|
|
487
|
+
const raw = JSON.parse(fs.readFileSync(statePath, 'utf8'));
|
|
488
|
+
return {
|
|
489
|
+
lastAttemptTs: typeof raw.lastAttemptTs === 'number' ? raw.lastAttemptTs : null,
|
|
490
|
+
attemptCount: typeof raw.attemptCount === 'number' ? raw.attemptCount : 0,
|
|
491
|
+
};
|
|
492
|
+
} catch {
|
|
493
|
+
return { lastAttemptTs: null, attemptCount: 0 };
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
/** Atomic write: tmp-<pid>-<ts> → rename (mirrors config.cjs writeJsonSync). */
|
|
498
|
+
function writeRelaunchState(statePath, state) {
|
|
499
|
+
fs.mkdirSync(path.dirname(statePath), { recursive: true });
|
|
500
|
+
const tmpPath = `${statePath}.tmp-${process.pid}-${Date.now()}`;
|
|
501
|
+
fs.writeFileSync(tmpPath, JSON.stringify(state, null, 2) + '\n', 'utf8');
|
|
502
|
+
fs.renameSync(tmpPath, statePath);
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
function logRelaunchLine(logPath, message) {
|
|
506
|
+
try {
|
|
507
|
+
fs.mkdirSync(path.dirname(logPath), { recursive: true });
|
|
508
|
+
fs.appendFileSync(logPath, `${new Date().toISOString()} ${message}\n`);
|
|
509
|
+
} catch (e) {
|
|
510
|
+
process.stderr.write(`[watchdog-relaunch] log write failed: ${e?.message}\n`);
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
/**
|
|
515
|
+
* defaultSpawnRelaunch(opts?) — launches a new session-manager instance the
|
|
516
|
+
* same way a user would: `npx claude-code-session-manager@latest` (the
|
|
517
|
+
* documented distribution method — CLAUDE.md's "Distribution" section), so
|
|
518
|
+
* this keeps working across npm publishes with no local path to maintain.
|
|
519
|
+
*
|
|
520
|
+
* Detached + stdio redirected to logPath + unref()'d so the watchdog's own
|
|
521
|
+
* (short-lived) process doesn't block on the launched app and the app
|
|
522
|
+
* survives the watchdog exiting.
|
|
523
|
+
*/
|
|
524
|
+
function defaultSpawnRelaunch({ logPath = DEFAULT_RELAUNCH_LOG_PATH } = {}) {
|
|
525
|
+
const { spawn } = require('node:child_process');
|
|
526
|
+
fs.mkdirSync(path.dirname(logPath), { recursive: true });
|
|
527
|
+
const fd = fs.openSync(logPath, 'a');
|
|
528
|
+
try {
|
|
529
|
+
const child = spawn('npx', ['claude-code-session-manager@latest'], {
|
|
530
|
+
detached: true,
|
|
531
|
+
stdio: ['ignore', fd, fd],
|
|
532
|
+
});
|
|
533
|
+
child.on('error', (e) => {
|
|
534
|
+
logRelaunchLine(logPath, `spawn error: ${e?.message}`);
|
|
535
|
+
});
|
|
536
|
+
child.unref();
|
|
537
|
+
} finally {
|
|
538
|
+
fs.closeSync(fd);
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
/**
|
|
543
|
+
* maybeRelaunchApp(opts?) → { relaunched: boolean, reason: string, attemptCount: number }
|
|
544
|
+
*
|
|
545
|
+
* Debounced, capped auto-relaunch:
|
|
546
|
+
* - alive (heartbeat fresh or pid alive) → no-op, reset attempt state.
|
|
547
|
+
* - dead + attemptCount already at maxAttempts → skip, log diagnostic (give up).
|
|
548
|
+
* - dead + last attempt < debounceMs ago → skip (still booting).
|
|
549
|
+
* - dead + debounce elapsed + under cap → spawn, bump attemptCount, log.
|
|
550
|
+
*
|
|
551
|
+
* attemptCount only resets to 0 once a fresh heartbeat is observed (i.e. a
|
|
552
|
+
* relaunch actually succeeded), so 3 relaunch attempts that each fail to
|
|
553
|
+
* produce a fresh heartbeat permanently cap further attempts until a human
|
|
554
|
+
* intervenes or the app comes up some other way.
|
|
555
|
+
*/
|
|
556
|
+
function maybeRelaunchApp({
|
|
557
|
+
heartbeatPath = DEFAULT_HEARTBEAT_PATH,
|
|
558
|
+
maxAgeMs = DEFAULT_MAX_AGE_MS,
|
|
559
|
+
statePath = DEFAULT_RELAUNCH_STATE_PATH,
|
|
560
|
+
logPath = DEFAULT_RELAUNCH_LOG_PATH,
|
|
561
|
+
debounceMs = DEFAULT_RELAUNCH_DEBOUNCE_MS,
|
|
562
|
+
maxAttempts = DEFAULT_MAX_RELAUNCH_ATTEMPTS,
|
|
563
|
+
now = Date.now(),
|
|
564
|
+
spawnFn = defaultSpawnRelaunch,
|
|
565
|
+
} = {}) {
|
|
566
|
+
const liveness = checkAppLiveness({ heartbeatPath, maxAgeMs });
|
|
567
|
+
const state = readRelaunchState(statePath);
|
|
568
|
+
|
|
569
|
+
if (liveness.alive) {
|
|
570
|
+
if (state.attemptCount !== 0 || state.lastAttemptTs !== null) {
|
|
571
|
+
writeRelaunchState(statePath, { lastAttemptTs: null, attemptCount: 0 });
|
|
572
|
+
}
|
|
573
|
+
return { relaunched: false, reason: 'alive', attemptCount: 0 };
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
if (state.attemptCount >= maxAttempts) {
|
|
577
|
+
logRelaunchLine(
|
|
578
|
+
logPath,
|
|
579
|
+
`giving up: ${state.attemptCount} relaunch attempt(s) already made and heartbeat is still stale — manual intervention required`,
|
|
580
|
+
);
|
|
581
|
+
return { relaunched: false, reason: 'capped', attemptCount: state.attemptCount };
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
if (state.lastAttemptTs !== null && (now - state.lastAttemptTs) < debounceMs) {
|
|
585
|
+
return { relaunched: false, reason: 'debounce', attemptCount: state.attemptCount };
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
const attemptCount = state.attemptCount + 1;
|
|
589
|
+
spawnFn({ logPath });
|
|
590
|
+
writeRelaunchState(statePath, { lastAttemptTs: now, attemptCount });
|
|
591
|
+
logRelaunchLine(
|
|
592
|
+
logPath,
|
|
593
|
+
`relaunch attempt ${attemptCount}/${maxAttempts}: spawning "npx claude-code-session-manager@latest" (heartbeat stale, last-known pid dead)`,
|
|
594
|
+
);
|
|
595
|
+
return { relaunched: true, reason: 'launched', attemptCount };
|
|
596
|
+
}
|
|
597
|
+
|
|
479
598
|
// ── sweep ─────────────────────────────────────────────────────────────────────
|
|
480
599
|
|
|
481
600
|
/**
|
|
@@ -499,8 +618,8 @@ function sweep({
|
|
|
499
618
|
projectsDir,
|
|
500
619
|
prdsDir = DEFAULT_PRDS_DIR,
|
|
501
620
|
queuePath = DEFAULT_QUEUE_PATH,
|
|
502
|
-
skillPath
|
|
503
|
-
standardsPath
|
|
621
|
+
skillPath,
|
|
622
|
+
standardsPath,
|
|
504
623
|
} = {}) {
|
|
505
624
|
const activeOpts = {};
|
|
506
625
|
if (projectsDir !== undefined) activeOpts.projectsDir = projectsDir;
|
|
@@ -682,18 +801,29 @@ module.exports = {
|
|
|
682
801
|
readLastHeartbeat,
|
|
683
802
|
readLastHeartbeatTs,
|
|
684
803
|
heartbeatFresh,
|
|
685
|
-
|
|
804
|
+
isPidAlive,
|
|
686
805
|
hasOpenFeedback,
|
|
806
|
+
resolveSkillFile,
|
|
807
|
+
listSkillCandidates,
|
|
687
808
|
emitFeedbackPRD,
|
|
688
809
|
sweep,
|
|
689
810
|
localDateStr,
|
|
690
811
|
maybeFinalizeHistory,
|
|
691
812
|
tryAcquireLock,
|
|
692
813
|
releaseLock,
|
|
814
|
+
checkAppLiveness,
|
|
815
|
+
readRelaunchState,
|
|
816
|
+
writeRelaunchState,
|
|
817
|
+
defaultSpawnRelaunch,
|
|
818
|
+
maybeRelaunchApp,
|
|
693
819
|
DEFAULT_HEARTBEAT_PATH,
|
|
694
820
|
DEFAULT_MAX_AGE_MS,
|
|
695
821
|
DEFAULT_STAMP_PATH,
|
|
696
822
|
DEFAULT_LOCK_PATH,
|
|
697
823
|
DEFAULT_LOCK_STALE_MS,
|
|
698
824
|
DEFAULT_FINALIZE_BUDGET_MS,
|
|
825
|
+
DEFAULT_RELAUNCH_STATE_PATH,
|
|
826
|
+
DEFAULT_RELAUNCH_LOG_PATH,
|
|
827
|
+
DEFAULT_RELAUNCH_DEBOUNCE_MS,
|
|
828
|
+
DEFAULT_MAX_RELAUNCH_ATTEMPTS,
|
|
699
829
|
};
|
|
@@ -75,6 +75,37 @@ test('cancelling an ACTIVE run broadcasts a terminal event (unsticks the UI)', a
|
|
|
75
75
|
assert.match(terminal[0].payload.message, /cancel/i, 'message names the cancellation');
|
|
76
76
|
});
|
|
77
77
|
|
|
78
|
+
test('cancel() resolves only AFTER the terminal broadcast fires, not immediately on SIGTERM (PRD 718)', async () => {
|
|
79
|
+
// Order-of-events assertion (not timing-based — a real SIGTERM can be fast
|
|
80
|
+
// enough on a lightweight stub child that a fixed-delay race would flake).
|
|
81
|
+
const order = [];
|
|
82
|
+
cr.attachWindow({
|
|
83
|
+
isDestroyed: () => false,
|
|
84
|
+
webContents: {
|
|
85
|
+
isDestroyed: () => false,
|
|
86
|
+
send: (channel, payload) => {
|
|
87
|
+
if (isTerminal(channel) && payload.tabId === 'T2') order.push('terminal');
|
|
88
|
+
},
|
|
89
|
+
},
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
cr.run({ tabId: 'T2', sessionId: 'S2', prompt: 'hello', cwd: process.cwd(), resume: false });
|
|
93
|
+
|
|
94
|
+
for (let i = 0; i < 6; i++) await tick();
|
|
95
|
+
|
|
96
|
+
await cr.cancel('T2').then(() => order.push('resolved'));
|
|
97
|
+
|
|
98
|
+
assert.deepEqual(order, ['terminal', 'resolved'], 'cancel() resolves strictly after the terminal broadcast');
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test('cancel() on a tab with no in-flight or waiting run resolves immediately (no-op)', async () => {
|
|
102
|
+
cr.attachWindow({
|
|
103
|
+
isDestroyed: () => false,
|
|
104
|
+
webContents: { isDestroyed: () => false, send: () => {} },
|
|
105
|
+
});
|
|
106
|
+
await cr.cancel('no-such-tab'); // must resolve, not hang
|
|
107
|
+
});
|
|
108
|
+
|
|
78
109
|
function isTerminal(channel) {
|
|
79
110
|
return (
|
|
80
111
|
channel === 'chat:run:complete' ||
|