claude-code-session-manager 0.42.0 → 0.44.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.
@@ -86,7 +86,7 @@ const queueOps = require('./queueOps.cjs');
86
86
  // match ROOT/QUEUE_PATH below since both resolve the same ~/.claude/session-manager
87
87
  // home-dir layout.
88
88
  const { resolvePrdsDirs, resolvePrdWriteDir, listEpicPrdDirs, listArchivedPrdDirs } = require('./lib/prdLocations.cjs');
89
- const { ensureEpic, appendPrdCreatedEvent, removeEpic, readActiveIndex } = require('./lib/epicMint.cjs');
89
+ const { ensureEpic, appendPrdCreatedEvent, readActiveIndex } = require('./lib/epicMint.cjs');
90
90
 
91
91
  // ---------- origin session resolution (PRD 832) ----------
92
92
  // An Epic IS a tagged claude session — job rows carry the originating
@@ -3710,6 +3710,15 @@ function registerScheduleHandlers() {
3710
3710
  // configuration tab.
3711
3711
  ipcMain.handle('schedule:session-slots', () => sessionSlots.snapshot());
3712
3712
 
3713
+ // Home-tab control for the same pool: user-set cap in [0, 10], default 5.
3714
+ // 0 pauses new claude -p launches machine-wide without killing anything
3715
+ // already running. SM_SESSION_SLOTS (if set) still overrides this at read
3716
+ // time — sessionSlots.snapshot().envOverride tells the UI to disable itself.
3717
+ ipcMain.handle('schedule:set-session-slots', validated(schemas.setSessionSlotsSchema, async (data) => {
3718
+ sessionSlots.setCap(data.cap);
3719
+ return sessionSlots.snapshot();
3720
+ }));
3721
+
3713
3722
  ipcMain.handle('schedule:health', async () => {
3714
3723
  const state = await readQueue();
3715
3724
  const runningJobs = [];
@@ -4178,13 +4187,6 @@ async function init() {
4178
4187
 
4179
4188
  // remote — callable from webRemote.cjs without going through IPC.
4180
4189
  const remote = {
4181
- async getState() {
4182
- const state = await readQueue();
4183
- await reconcile(state);
4184
- await writeQueue(state);
4185
- return buildScheduleStatePayload(state, { withPaths: true });
4186
- },
4187
-
4188
4190
  // `cwd` is optional: prdCreate.cjs's create flow passes the target
4189
4191
  // project's cwd explicitly (the file may not exist yet, so there's
4190
4192
  // nothing for findPrdDir to search for); the renderer's slug-only IPC
@@ -4220,33 +4222,12 @@ const remote = {
4220
4222
  }
4221
4223
  },
4222
4224
 
4223
- async readLog(slug, runId) {
4224
- const logPath = path.resolve(path.join(RUNS_DIR, runId, `${slug}.log`));
4225
- if (!logPath.startsWith(RUNS_DIR + path.sep)) {
4226
- return { ok: false, error: 'invalid slug or runId' };
4227
- }
4228
- try {
4229
- // realpath resolves symlinks; re-check boundary to block a rogue agent job
4230
- // that places a symlink inside RUNS_DIR pointing outside the safe root.
4231
- const real = await fsp.realpath(logPath);
4232
- if (!real.startsWith(RUNS_DIR + path.sep)) {
4233
- return { ok: false, error: 'invalid slug or runId' };
4234
- }
4235
- const text = await fsp.readFile(real, 'utf8');
4236
- return { ok: true, text };
4237
- } catch (e) {
4238
- return { ok: false, error: e?.message };
4239
- }
4240
- },
4241
-
4242
4225
  // `cwd` optional — see readPrd's comment above; prdCreate.cjs's create
4243
4226
  // flow supplies it (the destination project dir for a brand-new file that
4244
4227
  // doesn't exist yet, so findPrdDir would return nothing to write into).
4245
4228
  async writePrd(slug, body, cwd) {
4246
4229
  let dir;
4247
4230
  let epicTrace = null;
4248
- let epicCreated = false;
4249
- let epicId = null;
4250
4231
  if (cwd) {
4251
4232
  // Edit-in-place if this slug already lives anywhere under this project
4252
4233
  // (legacy flat dir or any Epic's prds/); otherwise this is a CREATE,
@@ -4258,29 +4239,37 @@ const remote = {
4258
4239
  if (candidate && fs.existsSync(candidate)) { dir = d; break; }
4259
4240
  }
4260
4241
  if (!dir) {
4242
+ // Every PRD must join an EXISTING, already-human-approved Epic —
4243
+ // mintIfMissing:false means this never conjures a new one. Only
4244
+ // Epics born from explicit human intent (New Epic UI, or
4245
+ // /propose-epic + Approve & start) may write PRDs; this write path
4246
+ // is not itself a human-intent gate, so it must not become one by
4247
+ // accident.
4248
+ const { fm } = splitFrontmatter(body);
4261
4249
  try {
4262
- const { fm } = splitFrontmatter(body);
4263
4250
  const epic = await ensureEpic(cwd, {
4264
- goalText: fm.title || slug,
4265
- tag: fm.tag,
4266
- // An Epic-conversation dispatch already has its Epic — join it.
4267
4251
  // fm.sourcePromptId must be an existing Epic's promptSessionId
4268
4252
  // (== its active-index.json sessions key), NOT a
4269
4253
  // PromptTicket.id — epicMint.cjs's ensureEpic looks it up via
4270
4254
  // `index.sessions[explicitEpicId]` (see epicMint.cjs ~line 73),
4271
4255
  // a literal-equality join. Any other id (e.g. a PromptTicket.id)
4272
- // simply won't match and mints a sibling Epic instead of joining.
4256
+ // simply won't match and this call throws below.
4273
4257
  epicId: fm.sourcePromptId,
4258
+ mintIfMissing: false,
4259
+ // Join-only call (mintIfMissing:false, explicit epicId): the
4260
+ // explicitEpicId branch in ensureEpic returns before `source` is
4261
+ // ever read, so this is a no-op today — kept for symmetry/audit
4262
+ // trail if that join path ever grows a source-touch (PRD 902/905).
4263
+ source: { producer: 'scheduler-dispatch', prdSlug: slug },
4274
4264
  });
4275
4265
  dir = epic.prdDir;
4276
4266
  epicTrace = epic.epicId;
4277
- epicCreated = epic.created === true;
4278
- epicId = epic.epicId;
4279
4267
  } catch (e) {
4280
- // Epic mint must never block a PRD write — fall back to the
4281
- // legacy flat dir and log loudly.
4282
- console.error(`[scheduler] ensureEpic failed for ${slug}: ${e?.message}`);
4283
- dir = prdDirForCwd(cwd);
4268
+ return {
4269
+ ok: false,
4270
+ error: `no existing Epic to join (sourcePromptId=${fm.sourcePromptId ?? 'none'}): ${e?.message ?? 'unknown error'}. `
4271
+ + 'Create the Epic first via the New Epic UI or /propose-epic + Approve & start, then pass its id as sourcePromptId.',
4272
+ };
4284
4273
  }
4285
4274
  }
4286
4275
  await fsp.mkdir(dir, { recursive: true });
@@ -4289,30 +4278,11 @@ const remote = {
4289
4278
  if (dir === PRDS_DIR) ensureDirs();
4290
4279
  }
4291
4280
 
4292
- // PRD 825: if this call minted a brand-new Epic (ensureEpic's `created`)
4293
- // and the write below never lands, don't strand an empty Epic dir —
4294
- // best-effort remove `<epic>/prds` then `<epic>` itself, only when empty.
4295
- // PRD 851: also drop the Epic's active-index.json entry (sessions/events)
4296
- // so an orphaned seed-prompt-only Epic doesn't linger in the Epics nav.
4297
- // Gated on epicCreated (never fires when ensureEpic joined an existing
4298
- // Epic) so a pre-existing Epic's history is never touched.
4299
- const cleanupEmptyMintedEpic = async () => {
4300
- if (!epicCreated || !epicId) return;
4301
- try {
4302
- const entries = await fsp.readdir(dir);
4303
- if (entries.length === 0) {
4304
- await fsp.rmdir(dir);
4305
- const epicRootDir = path.dirname(dir);
4306
- const epicRootEntries = await fsp.readdir(epicRootDir);
4307
- if (epicRootEntries.length === 0) await fsp.rmdir(epicRootDir);
4308
- }
4309
- } catch { /* best-effort only */ }
4310
- try { removeEpic(cwd, epicId); } catch { /* best-effort only */ }
4311
- };
4312
-
4281
+ // writePrd only ever JOINS an existing Epic now (mintIfMissing:false
4282
+ // above) — it can never mint one, so there is no orphaned-mint case left
4283
+ // to roll back here (contrast the old PRD 825/851 cleanup, removed).
4313
4284
  const resolved = safeSlugPathIn(dir, slug);
4314
4285
  if (!resolved) {
4315
- await cleanupEmptyMintedEpic();
4316
4286
  return { ok: false, error: 'invalid slug' };
4317
4287
  }
4318
4288
  try {
@@ -4324,23 +4294,20 @@ const remote = {
4324
4294
  // symlink.
4325
4295
  const realParent = await fsp.realpath(path.dirname(resolved));
4326
4296
  if (realParent !== dir && !realParent.startsWith(dir + path.sep)) {
4327
- await cleanupEmptyMintedEpic();
4328
4297
  return { ok: false, error: 'invalid slug' };
4329
4298
  }
4330
4299
  const existing = await fsp.lstat(resolved).catch(() => null);
4331
4300
  if (existing && existing.isSymbolicLink()) {
4332
- await cleanupEmptyMintedEpic();
4333
4301
  return { ok: false, error: 'invalid slug' };
4334
4302
  }
4335
4303
  await config.writeTextAtomic(resolved, body, { writer: 'scheduler' });
4336
4304
  const stat = await fsp.stat(resolved);
4337
4305
  if (epicTrace) {
4338
- // Best-effort: record the dispatch on the minted Epic's event chain.
4306
+ // Best-effort: record the dispatch on the Epic's event chain.
4339
4307
  try { await appendPrdCreatedEvent(cwd, epicTrace, slug); } catch { /* trace only */ }
4340
4308
  }
4341
4309
  return { ok: true, bytesWritten: stat.size };
4342
4310
  } catch (e) {
4343
- await cleanupEmptyMintedEpic();
4344
4311
  return { ok: false, error: e?.message ?? 'write failed' };
4345
4312
  }
4346
4313
  },
@@ -4378,28 +4345,6 @@ const remote = {
4378
4345
  // reuses the same allocator the file-based /develop authoring path relies
4379
4346
  // on implicitly, rather than re-deriving NN here.
4380
4347
  allocateParallelGroup,
4381
-
4382
- async runNow() {
4383
- await clearPause('run-now');
4384
- runDueJobs().catch((e) => logs.writeLine({
4385
- level: 'error', scope: 'scheduler',
4386
- message: 'runDueJobs error (remote:run-now)', meta: { error: e?.message },
4387
- }));
4388
- return { ok: true };
4389
- },
4390
-
4391
- async setConfig(partial) {
4392
- const cfg = await mutate((state) => {
4393
- const { supervisor: supPartial, ...rest } = partial;
4394
- state.config = { ...state.config, ...rest };
4395
- if (supPartial !== undefined) {
4396
- state.config.supervisor = { ...(state.config.supervisor ?? {}), ...supPartial };
4397
- }
4398
- return state.config;
4399
- });
4400
- await rescheduleTimer();
4401
- return { ok: true, config: cfg };
4402
- },
4403
4348
  };
4404
4349
 
4405
4350
  // Registers the two job-management admin HTTP routes (PRD 689 — moved
@@ -392,7 +392,11 @@ the user may or may not have open.
392
392
 
393
393
  ### Fallback: writing the PRD file directly
394
394
 
395
- When the app is not running, first mint (or join) an Epic — `node <session-manager-repo>/scripts/mint-epic.cjs <cwd> "<goal>" [feature|bug|discussion]`; its last stdout line is the prds dir — then write `<NN>-<slug>.md` by hand into that
395
+ When the app is not running, first join the EXISTING, already-human-approved Epic you're already
396
+ working inside — `node <session-manager-repo>/scripts/mint-epic.cjs <cwd> <epic-id>`; its last
397
+ stdout line is the prds dir. This only joins; it never creates an Epic, and errors out if
398
+ `<epic-id>` doesn't already exist — get a human to create/approve the Epic first (New Epic UI, or
399
+ `/propose-epic` + Approve & start) if it doesn't. Then write `<NN>-<slug>.md` by hand into that
396
400
  `<cwd>/session-manager-operations/scheduler/epics/<epic-id>/prds/` dir (the flat `scheduler/prds/` is RETIRED and auto-archived unexecuted at boot), add `sourcePromptId: <epic-id>` to the frontmatter so the job keeps its Epic linkage, following the frontmatter rules in §6 and the
397
401
  body conventions the rest of this guide describes (`# Goal`, `# Acceptance criteria`,
398
402
  `# Implementation notes`, `## Engineering standards` inlined verbatim — see `/develop`'s output
@@ -1054,7 +1054,6 @@ function getDispatchMap() {
1054
1054
 
1055
1055
  const { manager: ptyManager } = require('./pty.cjs');
1056
1056
  const sessionsStore = require('./sessionsStore.cjs');
1057
- const scheduler = require('./scheduler.cjs');
1058
1057
  const { remote: histRemote } = require('./historyAggregator.cjs');
1059
1058
  const chatRunner = require('./chatRunner.cjs');
1060
1059
 
@@ -1095,37 +1094,6 @@ function getDispatchMap() {
1095
1094
  return { ok: true };
1096
1095
  },
1097
1096
 
1098
- 'cmd:schedule:state': async () =>
1099
- scheduler.remote.getState(),
1100
-
1101
- 'cmd:schedule:read-prd': async (payload) => {
1102
- const parsed = schemas.scheduleSlug.parse(payload);
1103
- return scheduler.remote.readPrd(parsed.slug);
1104
- },
1105
-
1106
- 'cmd:schedule:read-log': async (payload) => {
1107
- const parsed = schemas.scheduleReadLog.parse(payload);
1108
- return scheduler.remote.readLog(parsed.slug, parsed.runId);
1109
- },
1110
-
1111
- 'cmd:schedule:write-prd': async (payload) => {
1112
- const parsed = schemas.scheduleWritePrd.parse(payload);
1113
- return scheduler.remote.writePrd(parsed.slug, parsed.body);
1114
- },
1115
-
1116
- 'cmd:schedule:reset-job': async (payload) => {
1117
- const parsed = schemas.scheduleSlug.parse(payload);
1118
- return scheduler.remote.resetJob(parsed.slug);
1119
- },
1120
-
1121
- 'cmd:schedule:run-now': async () =>
1122
- scheduler.remote.runNow(),
1123
-
1124
- 'cmd:schedule:set-config': async (payload) => {
1125
- const parsed = schemas.setConfigSchema.default({}).parse(payload ?? {});
1126
- return scheduler.remote.setConfig(parsed);
1127
- },
1128
-
1129
1097
  'cmd:history:aggregate': async (payload) => {
1130
1098
  const parsed = schemas.historyAggregate.parse(payload);
1131
1099
  return histRemote.aggregate(parsed);
@@ -1390,8 +1390,16 @@ export interface SessionManagerAPI {
1390
1390
  };
1391
1391
  schedule: {
1392
1392
  state: () => Promise<ScheduleStateSnapshot>;
1393
- /** Machine-wide claude -p slot pool (sessionSlots.cjs): total/inUse/holders. */
1394
- sessionSlots: () => Promise<{ total: number; inUse: number; holders: { owner: string; at: string }[] }>;
1393
+ /** Machine-wide claude -p slot pool (sessionSlots.cjs): total/inUse/holders + [min,max]/default/envOverride. */
1394
+ sessionSlots: () => Promise<{
1395
+ total: number; inUse: number; holders: { owner: string; at: string }[];
1396
+ min: number; max: number; default: number; envOverride: boolean;
1397
+ }>;
1398
+ /** Set the slot pool's user cap (Home tab), [0, 10]; 0 pauses new launches. Ignored if SM_SESSION_SLOTS is set. */
1399
+ setSessionSlots: (cap: number) => Promise<{
1400
+ total: number; inUse: number; holders: { owner: string; at: string }[];
1401
+ min: number; max: number; default: number; envOverride: boolean;
1402
+ }>;
1395
1403
  setConfig: (partial: Partial<ScheduleConfig & { supervisor?: Partial<SupervisorConfig> }>) => Promise<{ ok: boolean; config: ScheduleConfig }>;
1396
1404
  resetJob: (slug: string) => Promise<{ ok: boolean; error?: string }>;
1397
1405
  runNow: () => Promise<{ ok: boolean }>;
@@ -254,6 +254,7 @@ contextBridge.exposeInMainWorld('api', {
254
254
  schedule: {
255
255
  state: () => ipcRenderer.invoke('schedule:state'),
256
256
  sessionSlots: () => ipcRenderer.invoke('schedule:session-slots'),
257
+ setSessionSlots: (cap) => ipcRenderer.invoke('schedule:set-session-slots', { cap }),
257
258
  setConfig: (partial) => ipcRenderer.invoke('schedule:set-config', partial),
258
259
  resetJob: (slug) => ipcRenderer.invoke('schedule:reset-job', { slug }),
259
260
  runNow: () => ipcRenderer.invoke('schedule:run-now'),