claude-code-session-manager 0.41.0 → 0.42.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/{TiptapBody-BnRle0iw.js → TiptapBody-7JlgGW3C.js} +1 -1
- package/dist/assets/{index-CKY3mHgV.css → index-CxGY-V5G.css} +1 -1
- package/dist/assets/{index-H7rwsoKV.js → index-D4ga1iZ5.js} +681 -681
- package/dist/index.html +2 -2
- package/package.json +1 -1
- package/scripts/lib/watchdogHelpers.cjs +7 -0
- package/src/main/__tests__/opsErrorLog.test.cjs +87 -0
- package/src/main/__tests__/prdCreate.test.cjs +70 -0
- package/src/main/chatRunner.cjs +22 -1
- package/src/main/ipcSchemas.cjs +8 -0
- package/src/main/lib/__tests__/localAdminHttp.test.cjs +30 -11
- package/src/main/lib/__tests__/schedulerBatchDepends.test.cjs +94 -0
- package/src/main/lib/auditLog.cjs +60 -0
- package/src/main/lib/epicMint.cjs +6 -0
- package/src/main/lib/localAdminHttp.cjs +9 -0
- package/src/main/lib/opsErrorLog.cjs +96 -0
- package/src/main/lib/opsOwnership.cjs +3 -0
- package/src/main/lib/prdCreate.cjs +42 -0
- package/src/main/lib/schedulerBatch.cjs +27 -5
- package/src/main/logs.cjs +19 -1
- package/src/main/pty.cjs +23 -0
- package/src/main/scheduler.cjs +51 -0
- package/src/preload/api.d.ts +7 -1
- package/src/preload/index.cjs +5 -2
|
@@ -26,6 +26,7 @@
|
|
|
26
26
|
* - config.cjs writeTextAtomic / writeBinaryAtomic / writeJson / writeJsonSync
|
|
27
27
|
* - lib/epicMint.cjs (raw fs tmp+rename)
|
|
28
28
|
* - lib/queueStore.cjs (raw fs tmp+rename)
|
|
29
|
+
* - lib/opsErrorLog.cjs (raw fs append, JSONL error log)
|
|
29
30
|
* Renderer callers declare their writer id through the IPC payload.
|
|
30
31
|
*/
|
|
31
32
|
|
|
@@ -51,6 +52,8 @@ const OWNERS = Object.freeze({
|
|
|
51
52
|
'feedback': 'feedback',
|
|
52
53
|
// Browser tab scratch saves (DOM captures, screenshots, recorded flows).
|
|
53
54
|
'browser': 'browser',
|
|
55
|
+
// Structured per-tab error log lines (JSONL), tagged for tracing/analysis.
|
|
56
|
+
'logs': 'logs',
|
|
54
57
|
});
|
|
55
58
|
|
|
56
59
|
/**
|
|
@@ -23,6 +23,8 @@ const { kebabCase } = require('./kebabCase.cjs');
|
|
|
23
23
|
const config = require('../config.cjs');
|
|
24
24
|
const { expandHome } = require('./expandHome.cjs');
|
|
25
25
|
const { readBody, sendJson } = require('./localAdminHttp.cjs');
|
|
26
|
+
const { readActiveIndex } = require('./epicMint.cjs');
|
|
27
|
+
const { appendAuditEvent } = require('./auditLog.cjs');
|
|
26
28
|
|
|
27
29
|
const STANDARDS_PATH = path.join(
|
|
28
30
|
__dirname, '..', '..', '..',
|
|
@@ -90,6 +92,29 @@ function buildPrdBody(input) {
|
|
|
90
92
|
return `${fmLines.join('\n')}${bodyLines.join('\n')}`;
|
|
91
93
|
}
|
|
92
94
|
|
|
95
|
+
/**
|
|
96
|
+
* Best-effort fallback for a PRD queued via the scheduler_create_prd MCP tool
|
|
97
|
+
* from inside an Epic's own headless chat session, when the model's tool
|
|
98
|
+
* call omitted `sourcePromptId` — the exact gap that let PRDs 866/867 mint
|
|
99
|
+
* two unrelated sibling Epics instead of joining the Epic that was actually
|
|
100
|
+
* driving the conversation (2026-08-01). `claudeSessionId` is this process's
|
|
101
|
+
* `originClaudeSessionId` input (chatRunner.cjs's SM_CHAT_SESSION_ID env var,
|
|
102
|
+
* inherited down to the MCP server child); looked up against `cwd`'s
|
|
103
|
+
* active-index.json for the Epic whose own claudeSessionId matches (domain
|
|
104
|
+
* model: Epic:claude-session is 1:1). Returns null for a non-Epic tab (plain
|
|
105
|
+
* SessionTab chat) or when the id is stale/unknown — never throws, since this
|
|
106
|
+
* is strictly a convenience default and a miss must fall back to minting a
|
|
107
|
+
* normal Epic exactly like today.
|
|
108
|
+
*/
|
|
109
|
+
function resolveSourcePromptIdFromClaudeSession(cwd, claudeSessionId) {
|
|
110
|
+
if (!claudeSessionId) return null;
|
|
111
|
+
const { sessions } = readActiveIndex(cwd);
|
|
112
|
+
for (const session of Object.values(sessions)) {
|
|
113
|
+
if (session && session.claudeSessionId === claudeSessionId) return session.id;
|
|
114
|
+
}
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
|
|
93
118
|
/**
|
|
94
119
|
* Core create-PRD orchestration: cwd validation, slug derivation, NN
|
|
95
120
|
* allocation, existing-file collision check, body build, write. Shared by
|
|
@@ -130,6 +155,11 @@ async function createPrd(input, remote) {
|
|
|
130
155
|
config.addAllowedRoot(realCwd);
|
|
131
156
|
input = { ...input, cwd };
|
|
132
157
|
|
|
158
|
+
if (!input.sourcePromptId && input.originClaudeSessionId) {
|
|
159
|
+
const resolved = resolveSourcePromptIdFromClaudeSession(cwd, input.originClaudeSessionId);
|
|
160
|
+
if (resolved) input = { ...input, sourcePromptId: resolved };
|
|
161
|
+
}
|
|
162
|
+
|
|
133
163
|
const slug = input.slug || deriveSlugFromTitle(input.title);
|
|
134
164
|
if (!slug || !PRD_CREATE_SLUG_RE.test(slug)) {
|
|
135
165
|
return { ok: false, status: 400, error: 'could not derive a valid kebab-case slug from title; supply "slug" explicitly' };
|
|
@@ -163,6 +193,17 @@ async function createPrd(input, remote) {
|
|
|
163
193
|
return { ok: false, status: 500, error: writeResult?.error ?? 'write failed' };
|
|
164
194
|
}
|
|
165
195
|
|
|
196
|
+
// Trace-back point for "who/what created this PRD" (auditLog.cjs) — every
|
|
197
|
+
// PRD write, whether from the admin/MCP route or the renderer's chat:create-prd
|
|
198
|
+
// IPC handler, lands one record here regardless of outcome path above.
|
|
199
|
+
appendAuditEvent('prd_create', {
|
|
200
|
+
cwd: input.cwd,
|
|
201
|
+
slug: filenameSlug,
|
|
202
|
+
title: input.title,
|
|
203
|
+
sourcePromptId: input.sourcePromptId ?? null,
|
|
204
|
+
sourceTabId: input.sourceTabId ?? null,
|
|
205
|
+
});
|
|
206
|
+
|
|
166
207
|
return { ok: true, nn, filename: `${filenameSlug}.md` };
|
|
167
208
|
}
|
|
168
209
|
|
|
@@ -209,6 +250,7 @@ module.exports = {
|
|
|
209
250
|
readStandards,
|
|
210
251
|
deriveSlugFromTitle,
|
|
211
252
|
buildPrdBody,
|
|
253
|
+
resolveSourcePromptIdFromClaudeSession,
|
|
212
254
|
createPrd,
|
|
213
255
|
registerAdminRoute,
|
|
214
256
|
};
|
|
@@ -49,10 +49,32 @@ function pickForProject(projectJobs, runningSlugsInProject, slots) {
|
|
|
49
49
|
// unchanged (lowest-number-first waves), so an in-flight mixed queue keeps
|
|
50
50
|
// its order without migration.
|
|
51
51
|
const rowBySlug = new Map(projectJobs.map((j) => [j.slug, j]));
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
52
|
+
// A PRD's filename slug carries the auto-allocated `NN-` prefix
|
|
53
|
+
// (prdCreate.cjs builds `${nn}-${slug}`), but a human/agent authoring
|
|
54
|
+
// `dependsOn:` writes the BARE name it chose — it cannot know the number
|
|
55
|
+
// the allocator will hand out. Without this, every such dep resolved to
|
|
56
|
+
// undefined and was silently treated as "already done", turning the whole
|
|
57
|
+
// dependency gate into a no-op (observed live 2026-08-01: 22 PRDs listing
|
|
58
|
+
// `leftnav-two-face-framework` while the real row was
|
|
59
|
+
// `873-leftnav-two-face-framework`, all of them eligible immediately even
|
|
60
|
+
// though the framework PRD they build on had not landed). Resolve a dep by
|
|
61
|
+
// exact slug first, then fall back to matching rows whose slug is that dep
|
|
62
|
+
// with a leading `NN-` stripped.
|
|
63
|
+
const rowsByBareSlug = new Map();
|
|
64
|
+
for (const j of projectJobs) {
|
|
65
|
+
const bare = String(j.slug ?? '').replace(/^\d+-/, '');
|
|
66
|
+
if (!rowsByBareSlug.has(bare)) rowsByBareSlug.set(bare, []);
|
|
67
|
+
rowsByBareSlug.get(bare).push(j);
|
|
68
|
+
}
|
|
69
|
+
/** Every queue row a dep string refers to (exact match, else bare-name match). */
|
|
70
|
+
const rowsForDep = (slug) => {
|
|
71
|
+
const exact = rowBySlug.get(slug);
|
|
72
|
+
if (exact) return [exact];
|
|
73
|
+
return rowsByBareSlug.get(String(slug ?? '').replace(/^\d+-/, '')) ?? [];
|
|
74
|
+
};
|
|
75
|
+
const blockingDep = (j) => (j.dependsOn ?? []).find((slug) => (
|
|
76
|
+
rowsForDep(slug).some((dep) => dep.status !== 'completed')
|
|
77
|
+
));
|
|
56
78
|
|
|
57
79
|
const allPending = projectJobs.filter(
|
|
58
80
|
(j) => j.status === 'pending' && !runningSlugsInProject.has(j.slug),
|
|
@@ -64,7 +86,7 @@ function pickForProject(projectJobs, runningSlugsInProject, slots) {
|
|
|
64
86
|
for (const j of allPending) {
|
|
65
87
|
const dep = blockingDep(j);
|
|
66
88
|
if (!dep) { pending.push(j); continue; }
|
|
67
|
-
if (
|
|
89
|
+
if (rowsForDep(dep).some((d) => d.status === 'failed')) heldByFailedDep.push({ job: j, dep });
|
|
68
90
|
// running/pending/needs_review dep — simply not eligible this tick.
|
|
69
91
|
}
|
|
70
92
|
if (pending.length === 0) {
|
package/src/main/logs.cjs
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
const { app, ipcMain } = require('electron');
|
|
2
2
|
const fs = require('node:fs');
|
|
3
3
|
const path = require('node:path');
|
|
4
|
+
const opsErrorLog = require('./lib/opsErrorLog.cjs');
|
|
4
5
|
|
|
5
6
|
// Rolling daily files under <userData>/logs/. Anything mtime'd older than
|
|
6
7
|
// RETENTION_MS is pruned at boot; we never compact mid-session, since the
|
|
@@ -75,7 +76,24 @@ function writeLine(payload) {
|
|
|
75
76
|
}
|
|
76
77
|
|
|
77
78
|
function registerLogHandlers() {
|
|
78
|
-
ipcMain.on('log:write', (_e, payload) =>
|
|
79
|
+
ipcMain.on('log:write', (_e, payload) => {
|
|
80
|
+
writeLine(payload);
|
|
81
|
+
// Renderer errors that name which project/tab they belong to also land
|
|
82
|
+
// in that project's own session-manager-operations/logs/ — the
|
|
83
|
+
// machine-global file above mixes every project together and isn't
|
|
84
|
+
// taggable, so it's not a substitute for the per-project trace.
|
|
85
|
+
if (payload && payload.level === 'error' && payload.cwd) {
|
|
86
|
+
opsErrorLog.appendError({
|
|
87
|
+
cwd: payload.cwd,
|
|
88
|
+
scope: payload.scope,
|
|
89
|
+
tabId: payload.tabId,
|
|
90
|
+
epicId: payload.epicId,
|
|
91
|
+
tags: Array.isArray(payload.tags) ? payload.tags : [],
|
|
92
|
+
message: payload.message,
|
|
93
|
+
meta: payload.meta,
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
});
|
|
79
97
|
ipcMain.handle('log:dir', () => logDir());
|
|
80
98
|
}
|
|
81
99
|
|
package/src/main/pty.cjs
CHANGED
|
@@ -20,6 +20,7 @@ const { addAllowedRoot } = require('./config.cjs');
|
|
|
20
20
|
const { cleanChildEnv, pathWithUserBins } = require('./lib/cleanEnv.cjs');
|
|
21
21
|
const { checkInsideHome } = require('./lib/insideHome.cjs');
|
|
22
22
|
const { sendIfAlive } = require('./lib/sendToRenderer.cjs');
|
|
23
|
+
const opsErrorLog = require('./lib/opsErrorLog.cjs');
|
|
23
24
|
|
|
24
25
|
// Absolute path to the installed package root (src/main/ -> ../../), shown in
|
|
25
26
|
// the remediation message so the user can cd there and rebuild.
|
|
@@ -146,6 +147,14 @@ class PtyManager {
|
|
|
146
147
|
FORCE_COLOR: '1',
|
|
147
148
|
// Tag the session so hook server / transcript tail can correlate.
|
|
148
149
|
SESSION_MANAGER_TAB_ID: tabId,
|
|
150
|
+
// Tab ID = claudeSessionId by design (see CLAUDE.md domain model), and
|
|
151
|
+
// for an Epic, Terminal/Chat are two views over the SAME session — so
|
|
152
|
+
// this is exactly the id chatRunner.cjs sets for headless chat runs.
|
|
153
|
+
// Without it, a PRD queued via scheduler_create_prd from an
|
|
154
|
+
// interactive Epic terminal (not headless chat) had no way to resolve
|
|
155
|
+
// back to its originating Epic and minted an unrelated sibling one
|
|
156
|
+
// instead (prdCreate.cjs's resolveSourcePromptIdFromClaudeSession).
|
|
157
|
+
SM_CHAT_SESSION_ID: tabId,
|
|
149
158
|
});
|
|
150
159
|
|
|
151
160
|
const shell = process.env.SHELL || '/bin/bash';
|
|
@@ -162,6 +171,13 @@ class PtyManager {
|
|
|
162
171
|
});
|
|
163
172
|
} catch (err) {
|
|
164
173
|
console.error('[pty] pty.spawn threw:', err);
|
|
174
|
+
opsErrorLog.appendError({
|
|
175
|
+
cwd,
|
|
176
|
+
scope: 'pty',
|
|
177
|
+
tabId,
|
|
178
|
+
tags: ['spawn-failed'],
|
|
179
|
+
message: `pty.spawn threw: ${err?.message ?? String(err)}`,
|
|
180
|
+
});
|
|
165
181
|
// Surface the cause in the tab + report a clean exit rather than throwing
|
|
166
182
|
// an opaque IPC error (covers a missing/invalid cwd or a native fault).
|
|
167
183
|
sendIfAlive(this.window, `pty:data:${tabId}`, nativeModuleHelp(`pty.spawn failed: ${err?.message ?? String(err)}`));
|
|
@@ -196,6 +212,13 @@ class PtyManager {
|
|
|
196
212
|
// real interactive shell (which prints a prompt immediately) is exempt.
|
|
197
213
|
if (!gotData && exitCode !== 0 && Date.now() - spawnedAt < 1200) {
|
|
198
214
|
sendIfAlive(this.window, `pty:data:${tabId}`, nativeModuleHelp(`The shell exited immediately (code ${exitCode}).`));
|
|
215
|
+
opsErrorLog.appendError({
|
|
216
|
+
cwd,
|
|
217
|
+
scope: 'pty',
|
|
218
|
+
tabId,
|
|
219
|
+
tags: ['immediate-exit'],
|
|
220
|
+
message: `shell exited immediately (code ${exitCode}, signal ${signal ?? 'none'}) after ${Date.now() - spawnedAt}ms with no output`,
|
|
221
|
+
});
|
|
199
222
|
}
|
|
200
223
|
sendIfAlive(this.window, `pty:exit:${tabId}`, { exitCode, signal });
|
|
201
224
|
this.sessions.delete(tabId);
|
package/src/main/scheduler.cjs
CHANGED
|
@@ -1071,6 +1071,11 @@ function reconcileSourcePromptId(job, parsedSourcePromptId) {
|
|
|
1071
1071
|
* Status is preserved: pending stays pending, completed stays completed.
|
|
1072
1072
|
* Newly-discovered PRDs land as `pending`.
|
|
1073
1073
|
*/
|
|
1074
|
+
// Epic ids already warned about (PRD-bearing but absent from
|
|
1075
|
+
// active-index.json). Module-scoped so the warning is one line per Epic per
|
|
1076
|
+
// process rather than one per 60s reconcile tick.
|
|
1077
|
+
const warnedUnregisteredEpics = new Set();
|
|
1078
|
+
|
|
1074
1079
|
async function reconcile(state) {
|
|
1075
1080
|
// Defence in depth — tickQueue already gates on this, but reconcile is the
|
|
1076
1081
|
// function that would do the damage (every unmatched PRD .md becomes a
|
|
@@ -1163,8 +1168,54 @@ async function reconcile(state) {
|
|
|
1163
1168
|
// for them (that row already exists, durably, in history.jsonl).
|
|
1164
1169
|
const historyArchiveCandidates = [];
|
|
1165
1170
|
|
|
1171
|
+
// Cache active-index.json reads per cwd across this reconcile pass — many
|
|
1172
|
+
// unmatched PRDs typically share a project, and readActiveIndex is a
|
|
1173
|
+
// synchronous fs.readFileSync.
|
|
1174
|
+
const epicStatusCache = new Map(); // cwd -> { sessions }
|
|
1175
|
+
function epicStatus(cwd, epicId) {
|
|
1176
|
+
if (!cwd || !epicId) return null;
|
|
1177
|
+
let idx = epicStatusCache.get(cwd);
|
|
1178
|
+
if (!idx) {
|
|
1179
|
+
idx = readActiveIndex(cwd);
|
|
1180
|
+
epicStatusCache.set(cwd, idx);
|
|
1181
|
+
}
|
|
1182
|
+
return idx.sessions[epicId]?.status ?? null;
|
|
1183
|
+
}
|
|
1184
|
+
|
|
1166
1185
|
for (const [slug, p] of onDisk) {
|
|
1167
1186
|
if (seen.has(slug)) continue;
|
|
1187
|
+
// Security gate: a PRD's file location IS its Epic membership
|
|
1188
|
+
// (prdLocations.deriveEpicIdFromPrdPath / p.epicId). Before this check,
|
|
1189
|
+
// the scheduler queued ANY .md file it found under an Epic's prds/ dir
|
|
1190
|
+
// regardless of that Epic's status or even whether it was ever properly
|
|
1191
|
+
// minted — so a PRD filed under a 'proposed' Epic (agent-proposed,
|
|
1192
|
+
// awaiting human Approve & start via EpicApprovalBar), or one dropped
|
|
1193
|
+
// straight onto disk under a directory that was never registered via
|
|
1194
|
+
// ensureEpic()/active-index.json at all (bypassing the app entirely),
|
|
1195
|
+
// would still execute as a claude -p job with --dangerously-skip-permissions.
|
|
1196
|
+
// Block ONLY an explicit 'proposed' — the actual human gate. The human's
|
|
1197
|
+
// Approve action flips status to 'active' (state/promptSessions.ts) and
|
|
1198
|
+
// the very next reconcile pass picks the PRD up with no other change.
|
|
1199
|
+
//
|
|
1200
|
+
// This deliberately does NOT require a positive 'active' registration.
|
|
1201
|
+
// That stricter form shipped briefly and caused a 6-hour silent outage
|
|
1202
|
+
// (2026-08-01): 23 real PRDs whose Epic dir existed on disk but had no
|
|
1203
|
+
// active-index.json entry were skipped forever with no log line, so the
|
|
1204
|
+
// scheduler looked simply dead. Absence of an entry is not evidence of a
|
|
1205
|
+
// rogue writer — an Epic archived on completion also leaves active-index,
|
|
1206
|
+
// and other paths can create the dir without registering it. Fail-closed
|
|
1207
|
+
// is right for the approval gate; it is wrong as a blanket requirement,
|
|
1208
|
+
// because the failure mode is invisible and unrecoverable by the user.
|
|
1209
|
+
const ownerStatus = epicStatus(p.cwd, p.epicId);
|
|
1210
|
+
if (p.epicId && ownerStatus === 'proposed') {
|
|
1211
|
+
continue;
|
|
1212
|
+
}
|
|
1213
|
+
// Unregistered Epic dir: allowed, but never silently — this is the only
|
|
1214
|
+
// signal that a PRD's Epic is missing from the index it should be in.
|
|
1215
|
+
if (p.epicId && ownerStatus === null && !warnedUnregisteredEpics.has(p.epicId)) {
|
|
1216
|
+
warnedUnregisteredEpics.add(p.epicId);
|
|
1217
|
+
console.warn(`[scheduler] epic ${p.epicId} (${p.cwd}) has PRDs on disk but no active-index.json entry — queuing anyway; register or archive it to silence this`);
|
|
1218
|
+
}
|
|
1168
1219
|
const hist = historyBySlug.get(slug);
|
|
1169
1220
|
if (hist) {
|
|
1170
1221
|
if (hist.status === 'completed') {
|
package/src/preload/api.d.ts
CHANGED
|
@@ -1283,7 +1283,13 @@ export interface SessionManagerAPI {
|
|
|
1283
1283
|
status: () => Promise<McpStatusResult>;
|
|
1284
1284
|
};
|
|
1285
1285
|
logs: {
|
|
1286
|
-
write: (
|
|
1286
|
+
write: (
|
|
1287
|
+
scope: string,
|
|
1288
|
+
level: 'debug' | 'info' | 'warn' | 'error',
|
|
1289
|
+
message: string,
|
|
1290
|
+
meta?: unknown,
|
|
1291
|
+
ctx?: { cwd?: string; tabId?: string; epicId?: string; tags?: string[] },
|
|
1292
|
+
) => void;
|
|
1287
1293
|
dir: () => Promise<string>;
|
|
1288
1294
|
};
|
|
1289
1295
|
config: {
|
package/src/preload/index.cjs
CHANGED
|
@@ -146,8 +146,11 @@ contextBridge.exposeInMainWorld('api', {
|
|
|
146
146
|
status: () => ipcRenderer.invoke('mcp:status'),
|
|
147
147
|
},
|
|
148
148
|
logs: {
|
|
149
|
-
|
|
150
|
-
|
|
149
|
+
// ctx is optional: { cwd, tabId, epicId, tags } — when present and
|
|
150
|
+
// level is 'error', main also appends a tagged line to that project's
|
|
151
|
+
// own session-manager-operations/logs/ (see opsErrorLog.cjs).
|
|
152
|
+
write: (scope, level, message, meta, ctx) =>
|
|
153
|
+
ipcRenderer.send('log:write', { scope, level, message, meta, ...ctx }),
|
|
151
154
|
dir: () => ipcRenderer.invoke('log:dir'),
|
|
152
155
|
},
|
|
153
156
|
config: {
|