claude-code-session-manager 0.122.1 → 0.123.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (93) hide show
  1. package/dist/assets/{AgentLibrary-C2qUX9WU.js → AgentLibrary-T14aM8ie.js} +2 -2
  2. package/dist/assets/{Button-ssBzvFha.js → Button-Dz1kV8Z_.js} +1 -1
  3. package/dist/assets/DataModel-DdYRn2Ec.js +1 -0
  4. package/dist/assets/{History-BRdRp1J_.js → History-zQPvqwE7.js} +2 -2
  5. package/dist/assets/{Hooks--hiGPBQy.js → Hooks-BTrbT742.js} +3 -3
  6. package/dist/assets/{Library-0XYvkhh2.js → Library-BwQ6GRHR.js} +7 -7
  7. package/dist/assets/ListDetail-CunxAhRC.js +1 -0
  8. package/dist/assets/{McpServers-CCjHSwFm.js → McpServers-DpoyxXaO.js} +2 -2
  9. package/dist/assets/{Memory-C7RXoVO4.js → Memory-KDtGYMc3.js} +8 -8
  10. package/dist/assets/{Panel-C92O_aAH.js → Panel-xXmUBo8l.js} +1 -1
  11. package/dist/assets/{Permissions-DuTJ1FUb.js → Permissions-lIzTUrW2.js} +3 -3
  12. package/dist/assets/{Plugins-DSXsP4hM.js → Plugins-BA1Zqk69.js} +2 -2
  13. package/dist/assets/{ProvenanceBadge-CWdDkU1m.js → ProvenanceBadge-CmeJG2Kw.js} +1 -1
  14. package/dist/assets/{SaveBar-ob2dJJjH.js → SaveBar-BTJg_9Uc.js} +1 -1
  15. package/dist/assets/{Scheduler-Du4HgPKY.js → Scheduler-CISTDNMU.js} +7 -7
  16. package/dist/assets/{ScopeSwitcher-Dv_6M_Ci.js → ScopeSwitcher-C6NWvzoj.js} +1 -1
  17. package/dist/assets/Settings-BBoGJ34v.js +4 -0
  18. package/dist/assets/{SkillReferenceGraph-Dx1Qs8Y0.js → SkillReferenceGraph-IJv60QAJ.js} +1 -1
  19. package/dist/assets/Skills-L6nGcNF1.js +2 -0
  20. package/dist/assets/SystemPrompt-Cm7I2gJz.js +1 -0
  21. package/dist/assets/TagLibrary-BxPwoPE6.js +1 -0
  22. package/dist/assets/{TiptapBody-Cihe5er3.js → TiptapBody-CR-Zr3Pz.js} +1 -1
  23. package/dist/assets/{Toggle-CpYVC8bF.js → Toggle-BFhlpZ-n.js} +1 -1
  24. package/dist/assets/{ViewTabs-C3FN6P_C.js → ViewTabs-CuGlZ9Ed.js} +1 -1
  25. package/dist/assets/{historyTotals-C4Rs37Cd.js → historyTotals-Dxv9Fzrs.js} +1 -1
  26. package/dist/assets/{index-Dd7UQabV.css → index-BnKaYERU.css} +1 -1
  27. package/dist/assets/{index-S-F5jp_h.js → index-Dx8owDeQ.js} +151 -151
  28. package/dist/assets/{settingsSchema-BZcdzHTQ.js → settingsSchema-DaqFSAQH.js} +1 -1
  29. package/dist/assets/{skillFrontmatter-EfNERt4b.js → skillFrontmatter-BIJddxVB.js} +1 -1
  30. package/dist/assets/{useAgentsDirWatch-BfbrFvB3.js → useAgentsDirWatch-1hjZ4knk.js} +1 -1
  31. package/dist/index.html +2 -2
  32. package/package.json +3 -3
  33. package/plugins/session-manager-dev/skills/develop/SKILL.md +29 -25
  34. package/scripts/scheduler-mcp-server.cjs +124 -74
  35. package/src/main/agentLibrary.cjs +8 -3
  36. package/src/main/build-info.json +4 -4
  37. package/src/main/crashDiagnostics.cjs +24 -0
  38. package/src/main/historyAggregator.cjs +41 -7
  39. package/src/main/index.cjs +13 -3
  40. package/src/main/ipcSchemas.cjs +30 -22
  41. package/src/main/lib/activeSessions.cjs +93 -32
  42. package/src/main/lib/epic/delegationReadiness.cjs +2 -0
  43. package/src/main/lib/epic/delegationSelfHeal.cjs +77 -0
  44. package/src/main/lib/epic/epicMint.cjs +1 -1
  45. package/src/main/lib/epic/epicStandingInstructions.cjs +73 -0
  46. package/src/main/lib/epic/guardSpawnSettings.cjs +76 -0
  47. package/src/main/lib/feedback-remote/crossProjectFeedback.cjs +9 -2
  48. package/src/main/lib/feedback-remote/remoteClientSessions.cjs +3 -11
  49. package/src/main/lib/feedback-remote/remoteE2eCrypto.cjs +1 -1
  50. package/src/main/lib/git/gitWorktree.cjs +9 -4
  51. package/src/main/lib/git/worktreeDeps.cjs +13 -2
  52. package/src/main/lib/git/worktreeSetup.cjs +212 -0
  53. package/src/main/lib/mcpToolCatalog.cjs +31 -2
  54. package/src/main/lib/persona-macro/agentPersonaSchema.cjs +4 -0
  55. package/src/main/lib/persona-macro/shippedPersonaSeeds.cjs +23 -0
  56. package/src/main/lib/prd/prdAgentType.cjs +28 -0
  57. package/src/main/lib/prd/prdBodyPatch.cjs +138 -0
  58. package/src/main/lib/prd/prdCreate.cjs +205 -32
  59. package/src/main/lib/prd/prdGateFiles.cjs +31 -0
  60. package/src/main/lib/scheduler/definitionOfDone.cjs +24 -3
  61. package/src/main/lib/scheduler/queueStore.cjs +5 -4
  62. package/src/main/lib/scheduler/scheduleJobSchema.cjs +1 -0
  63. package/src/main/lib/scheduler/schedulerBatch.cjs +43 -5
  64. package/src/main/lib/scheduler/schedulerPaths.cjs +2 -0
  65. package/src/main/lib/scheduler/watchdogHelpers.cjs +114 -22
  66. package/src/main/lib/telemetry/mainHeapSnapshot.cjs +69 -0
  67. package/src/main/lib/telemetry/telemetryBoot.cjs +25 -3
  68. package/src/main/lib/telemetry/telemetryClient.cjs +12 -0
  69. package/src/main/lib/telemetry/telemetryCounters.cjs +25 -2
  70. package/src/main/pty.cjs +0 -53
  71. package/src/main/runVerify.cjs +38 -11
  72. package/src/main/scheduler/prdParser.cjs +3 -0
  73. package/src/main/scheduler/prdPaths.cjs +48 -2
  74. package/src/main/scheduler.cjs +124 -29
  75. package/src/main/seedDevPlugin.cjs +1 -1
  76. package/src/main/seedSchedulerMcp.cjs +1 -1
  77. package/src/main/templates/PRD_AUTHORING.md +8 -4
  78. package/src/main/webRemote.cjs +38 -132
  79. package/src/preload/api.d.ts +4 -52
  80. package/src/preload/index.cjs +0 -24
  81. package/src/seed/agents/dev-lead.md +2 -1
  82. package/src/seed/agents/validator.md +2 -1
  83. package/dist/assets/DataModel-CENoTvc2.js +0 -1
  84. package/dist/assets/ListDetail-CkKXAM59.js +0 -1
  85. package/dist/assets/RemoteMachines-BFmbIGpW.js +0 -3
  86. package/dist/assets/Settings-DMoI0cqB.js +0 -4
  87. package/dist/assets/Skills-DYWQTQB1.js +0 -2
  88. package/dist/assets/SystemPrompt-Df0IfOuI.js +0 -1
  89. package/dist/assets/TagLibrary-BuMsGx-2.js +0 -1
  90. package/dist/assets/useRemotePeers-CKVakQPc.js +0 -1
  91. package/src/main/lib/feedback-remote/relayClientAuth.cjs +0 -133
  92. package/src/main/remoteClient.cjs +0 -334
  93. package/src/main/remoteClientIpc.cjs +0 -133
@@ -27,6 +27,7 @@ const { app, powerSaveBlocker, powerMonitor, crashReporter } = require('electron
27
27
  const fs = require('node:fs');
28
28
  const path = require('node:path');
29
29
  const { reportCrash, classifyUncleanShutdown, reportChildProcessGone } = require('./lib/telemetry/crashTelemetry.cjs');
30
+ const { maybeCaptureMainHeap } = require('./lib/telemetry/mainHeapSnapshot.cjs');
30
31
  const { createLoopDelayMonitor, stallVerdict } = require('./lib/util/loopDelay.cjs');
31
32
 
32
33
  let logs = null;
@@ -124,6 +125,7 @@ function sampleMemory() {
124
125
  main: {
125
126
  rssMb: Math.round(mu.rss / 1048576),
126
127
  heapUsedMb: Math.round(mu.heapUsed / 1048576),
128
+ heapTotalMb: Math.round(mu.heapTotal / 1048576),
127
129
  externalMb: Math.round(mu.external / 1048576),
128
130
  },
129
131
  powerBlockerHeld: (() => {
@@ -152,6 +154,25 @@ function persistSentinel(clean) {
152
154
  } catch { /* best-effort */ }
153
155
  }
154
156
 
157
+ let heapSkipLogged = false;
158
+ function tryMainHeapSnapshot() {
159
+ try {
160
+ const t0 = Date.now();
161
+ const heapUsedMb = process.memoryUsage().heapUsed / 1048576;
162
+ const r = maybeCaptureMainHeap({ heapUsedMb });
163
+ if (r.captured) {
164
+ logs?.writeLine({ scope: 'crash-diag', level: 'warn', message: '[crash-diag] main heap snapshot',
165
+ meta: { filePath: r.filePath, ms: Date.now() - t0, heapUsedMb: Math.round(heapUsedMb) } });
166
+ } else if (r.reason !== 'below-threshold' && r.reason !== 'already-captured' && !heapSkipLogged) {
167
+ heapSkipLogged = true;
168
+ logs?.writeLine({ scope: 'crash-diag', level: 'warn', message: '[crash-diag] main heap snapshot skipped',
169
+ meta: { reason: r.reason, heapUsedMb: Math.round(heapUsedMb) } });
170
+ }
171
+ } catch (err) {
172
+ try { logs?.writeLine({ scope: 'crash-diag', level: 'error', message: `[crash-diag] main heap snapshot failed: ${err && err.message}` }); } catch { /* ignore */ }
173
+ }
174
+ }
175
+
155
176
  function heartbeat() {
156
177
  lastSample = sampleMemory();
157
178
  const loop = loopMonitor ? loopMonitor.snapshot() : null;
@@ -167,11 +188,14 @@ function heartbeat() {
167
188
  meta: {
168
189
  totalMb: lastSample.totalMb,
169
190
  mainRssMb: lastSample.main.rssMb,
191
+ mainHeapUsedMb: lastSample.main.heapUsedMb,
192
+ mainHeapTotalMb: lastSample.main.heapTotalMb,
170
193
  powerBlockerHeld: lastSample.powerBlockerHeld,
171
194
  top: top.map((p) => `${p.type}:${p.mb}MB`),
172
195
  ...loop,
173
196
  },
174
197
  });
198
+ tryMainHeapSnapshot();
175
199
  const stall = loop && stallVerdict(loop);
176
200
  if (stall) logs?.writeLine({ scope: 'crash-diag', ...stall, meta: { ...loop } });
177
201
  }
@@ -553,10 +553,30 @@ function rollupMapToRows(map, finalizedDates) {
553
553
  * opts.dryRun (optional): computes what would be finalized without writing
554
554
  * anything to the rollup file.
555
555
  */
556
- async function finalizeClosedDays({ budgetMs, dryRun = false } = {}) {
556
+ async function finalizeClosedDays({ budgetMs, dryRun = false, cursorPath } = {}) {
557
557
  const deadline = typeof budgetMs === 'number' ? Date.now() + budgetMs : null;
558
558
  const today = localDate(new Date());
559
559
 
560
+ // Resumable cursor: { cycleStartedAt, lastDir, dates }. `dates` carries the
561
+ // closed days seen by earlier partial calls of this cycle so the completing
562
+ // call can stamp all of them. Missing/corrupt file => fresh cycle.
563
+ const atomicFs = require('./lib/util/atomicFs.cjs');
564
+ let resolvedCursor = cursorPath;
565
+ if (!resolvedCursor) {
566
+ try { resolvedCursor = require('./lib/scheduler/schedulerPaths.cjs').historyRollupCursorPath(); } catch { resolvedCursor = null; }
567
+ }
568
+ let cycleStartedAt = Date.now();
569
+ let lastDir = '';
570
+ const dates = new Set();
571
+ if (resolvedCursor) {
572
+ const c = atomicFs.readJsonOrSync(resolvedCursor, null);
573
+ if (c && typeof c === 'object' && typeof c.lastDir === 'string' && c.lastDir) {
574
+ lastDir = c.lastDir;
575
+ if (typeof c.cycleStartedAt === 'number') cycleStartedAt = c.cycleStartedAt;
576
+ if (Array.isArray(c.dates)) for (const d of c.dates) if (typeof d === 'string') dates.add(d);
577
+ }
578
+ }
579
+
560
580
  let projectDirs;
561
581
  try {
562
582
  projectDirs = await fsp.readdir(PROJECTS_DIR, { withFileTypes: true });
@@ -566,10 +586,12 @@ async function finalizeClosedDays({ budgetMs, dryRun = false } = {}) {
566
586
 
567
587
  projectDirs = projectDirs
568
588
  .filter((e) => e.isDirectory())
569
- .sort((a, b) => a.name.localeCompare(b.name));
589
+ .sort((a, b) => a.name.localeCompare(b.name))
590
+ .filter((e) => !lastDir || e.name.localeCompare(lastDir) > 0);
570
591
 
571
592
  const buckets = new Map();
572
593
  let partial = false;
594
+ let walkedLast = lastDir;
573
595
 
574
596
  for (const projEntry of projectDirs) {
575
597
  if (deadline !== null && Date.now() >= deadline) {
@@ -581,7 +603,7 @@ async function finalizeClosedDays({ budgetMs, dryRun = false } = {}) {
581
603
  const projectDir = path.join(PROJECTS_DIR, encodedCwd);
582
604
 
583
605
  let files;
584
- try { files = await fsp.readdir(projectDir, { withFileTypes: true }); } catch { continue; }
606
+ try { files = await fsp.readdir(projectDir, { withFileTypes: true }); } catch { walkedLast = encodedCwd; continue; }
585
607
 
586
608
  for (const fileEntry of files) {
587
609
  if (!fileEntry.name.endsWith('.jsonl')) continue;
@@ -600,23 +622,35 @@ async function finalizeClosedDays({ budgetMs, dryRun = false } = {}) {
600
622
  if (!buckets.has(key)) buckets.set(key, emptyBucket(sessionDate, encodedCwd));
601
623
  foldParsedIntoBucket(buckets.get(key), parsed);
602
624
  }
625
+ walkedLast = encodedCwd; // advance only after the directory is fully walked
603
626
  }
604
627
 
605
- const dates = new Set();
606
628
  const entries = [];
607
629
  for (const b of buckets.values()) {
608
630
  dates.add(b.date);
609
631
  entries.push(...bucketToRollupLines(b));
610
632
  }
611
- // Only stamp dates as finalized (safe to trust with no live re-scan) when
612
- // the walk visited every project directory within budget — a partial walk
613
- // may be missing contributions from directories not yet visited.
633
+ // Only stamp dates as finalized (safe to trust with no live re-scan) once a
634
+ // full cycle has covered every project directory — possibly across calls.
614
635
  if (!partial) {
615
636
  for (const date of dates) entries.push(finalizedMarkerLine(date));
616
637
  }
617
638
 
618
639
  if (!dryRun) {
619
640
  await appendRollupAndNotify(entries);
641
+ if (resolvedCursor) {
642
+ try {
643
+ if (partial) {
644
+ if (walkedLast !== lastDir) {
645
+ await atomicFs.writeJsonAtomic(resolvedCursor, { cycleStartedAt, lastDir: walkedLast, dates: Array.from(dates) });
646
+ }
647
+ } else {
648
+ await fsp.rm(resolvedCursor, { force: true });
649
+ }
650
+ } catch {
651
+ // Cursor is an optimisation; a failed write just means a re-walk.
652
+ }
653
+ }
620
654
  }
621
655
  return { finalizedDates: partial ? [] : Array.from(dates), partial };
622
656
  }
@@ -122,7 +122,6 @@ const heapSnapshot = require('./heapSnapshot.cjs');
122
122
  const filesIpc = require('./files.cjs');
123
123
  const { registerDocEditHandlers, attachWindow: attachDocEditWindow } = require('./docEdit.cjs');
124
124
  const webRemote = require('./webRemote.cjs');
125
- const { registerRemoteClientHandlers } = require('./remoteClientIpc.cjs');
126
125
  const { registerAgentsHandlers } = require('./agentsIpc.cjs');
127
126
  const { registerTelemetryHandlers } = require('./telemetryIpc.cjs');
128
127
  const { registerUpdaterIpc } = require('./updaterIpc.cjs');
@@ -627,8 +626,20 @@ ipcMain.handle('app:delegation-readiness', validated(schemas.delegationReadiness
627
626
  try {
628
627
  await ensureGuardsInstalled(cwd);
629
628
  } catch { /* auto-install must never block the readiness probe itself */ }
629
+ const first = await checkDelegationReadiness({ ...payload, cwd });
630
+ const failing = (first?.checks || []).some((c) => !c.ok && (c.id === 'dev-plugin' || c.id === 'scheduler-mcp'));
631
+ if (!failing) return first;
632
+ // Repair at point of use (at most once per check per app run) — the boot seeders give up after 3 attempts.
633
+ try {
634
+ await require('./lib/epic/delegationSelfHeal.cjs').selfHealDelegation({
635
+ checks: first.checks,
636
+ homeDir: os.homedir(),
637
+ deps: { awaitBootSeeding: () => bootSeedingPromise, cwd },
638
+ });
639
+ } catch { /* self-heal never blocks the probe */ }
630
640
  return checkDelegationReadiness({ ...payload, cwd });
631
641
  }));
642
+ let bootSeedingPromise = Promise.resolve();
632
643
 
633
644
  // Every guard-install attempt is logged from MAIN, not the renderer — a
634
645
  // press that never reaches this process (e.g. a renderer talking to an
@@ -1041,7 +1052,6 @@ git.register(ipcMain);
1041
1052
  filesIpc.registerFilesHandlers();
1042
1053
  registerDocEditHandlers();
1043
1054
  webRemote.registerRemoteHandlers();
1044
- registerRemoteClientHandlers(() => mainWindow);
1045
1055
  registerAgentsHandlers({ ipcMain, getWindow: () => mainWindow });
1046
1056
  try {
1047
1057
  registerUpdaterIpc({ ipcMain, getWindow: () => mainWindow, ptyManager });
@@ -1455,7 +1465,7 @@ app.whenReady().then(async () => {
1455
1465
  // Seeders run through ONE serial chain (runSeeders.cjs): plugin install and
1456
1466
  // `mcp add --scope user` both write Claude's config files, and concurrent
1457
1467
  // writes can drop an entry. Fire-and-forget relative to window creation.
1458
- runSeedersSerially([
1468
+ bootSeedingPromise = runSeedersSerially([
1459
1469
  { name: 'dev-plugin', run: () => seedDevPlugin({ logger: console, writeLog: logs.writeLine }) },
1460
1470
  { name: 'agent-personas', run: () => seedAgentPersonas({ logger: console, writeLog: logs.writeLine }) },
1461
1471
  { name: 'scheduler-mcp', run: () => seedSchedulerMcp({ logger: console, writeLog: logs.writeLine }) },
@@ -450,6 +450,18 @@ const schedulerCreatePrd = z.object({
450
450
  // computed target (from the SAME backlog tree the UI renders — see
451
451
  // lib/backlogTree.ts); the main-process handler only validates the rewrite
452
452
  // is safe (prdDisposition.cjs), never re-derives "the" terminal itself.
453
+ // Batch create (createPrdBatch): up to 40 entries, each the exact single-create
454
+ // shape. Cross-entry rules (cycles, duplicate slugs, dependsOn naming siblings)
455
+ // live in prdCreate.cjs; this only bounds the envelope.
456
+ const PRD_BATCH_MAX = 40;
457
+ const adminCreatePrds = z.object({
458
+ prds: z.array(schedulerCreatePrd).min(1).max(PRD_BATCH_MAX),
459
+ cwd: z.string().min(1).max(4096).regex(NO_NEWLINE_RE, 'must not contain newlines').optional(),
460
+ originClaudeSessionId: z.string().min(1).max(200).optional(),
461
+ originProjectRoot: z.string().min(1).max(4096).regex(NO_NEWLINE_RE, 'must not contain newlines').optional(),
462
+ sourcePromptId: z.string().min(1).max(128).regex(NO_NEWLINE_RE, 'must not contain newlines').optional(),
463
+ });
464
+
453
465
  const scheduleSetPrdDisposition = z.object({
454
466
  slug: z.string().regex(SCHEDULE_SLUG_RE),
455
467
  cwd: z.string().min(1).max(4096).optional(),
@@ -554,9 +566,18 @@ const adminUpdatePrd = z.object({
554
566
  (s) => Buffer.byteLength(s, 'utf8') <= PRD_WRITE_MAX_BYTES,
555
567
  `body must be ≤ ${PRD_WRITE_MAX_BYTES} bytes`,
556
568
  ).optional(),
557
- }).refine(
558
- (it) => (it.frontmatter && Object.keys(it.frontmatter).length > 0) || it.body !== undefined,
559
- 'at least one of frontmatter or body is required',
569
+ // Structured section patches, applied via prdBodyPatch.cjs (re-validated and
570
+ // re-rendered). Mutually exclusive with `body` (enforced in updatePrd too).
571
+ gate: z.array(z.string().min(1).max(2000)).min(1).max(50).optional(),
572
+ files: z.array(z.string().min(1).max(1000)).min(1).max(200).optional(),
573
+ acceptanceCriteria: z.array(z.string().min(1).max(2000)).min(1).max(100).optional(),
574
+ }).strict().refine(
575
+ (it) => (it.frontmatter && Object.keys(it.frontmatter).length > 0) || it.body !== undefined
576
+ || it.gate !== undefined || it.files !== undefined || it.acceptanceCriteria !== undefined,
577
+ 'at least one of frontmatter, body, gate, files or acceptanceCriteria is required',
578
+ ).refine(
579
+ (it) => it.body === undefined || (it.gate === undefined && it.files === undefined && it.acceptanceCriteria === undefined),
580
+ 'body cannot be combined with gate, files or acceptanceCriteria',
560
581
  );
561
582
 
562
583
  const adminCancelJob = z.object({
@@ -1188,7 +1209,7 @@ function validated(schema, handler) {
1188
1209
  // Commands are split into three tiers:
1189
1210
  // READ_COMMANDS — return data; allowed when remoteEnabled=true.
1190
1211
  // SAS_GATED_READS — return sensitive user data (sessions, PRDs, logs,
1191
- // transcript summaries); additionally require
1212
+ // transcript summaries, machine hostname); additionally require
1192
1213
  // _e2eAuthenticated=true (SAS confirmed by user).
1193
1214
  // A compromised relay cannot exfiltrate this data from
1194
1215
  // a session that has not been SAS-confirmed.
@@ -1198,22 +1219,17 @@ function validated(schema, handler) {
1198
1219
  // ALLOWED_COMMANDS is the union, kept for existing import compatibility.
1199
1220
  //
1200
1221
  // Ungated READ_COMMANDS (justify each):
1201
- // cmd:app:version — exposes only the app semver string; no user data.
1202
- // cmd:app:info — exposes only app semver + OS platform string; no user data.
1203
1222
  // cmd:session:unsubscribe — teardown lifecycle; returns nothing sensitive.
1204
1223
  const READ_COMMANDS = new Set([
1205
- 'cmd:app:version',
1206
- 'cmd:app:info',
1207
1224
  // v2 mobile: unsubscribe is a teardown lifecycle call with no data payload.
1208
1225
  'cmd:session:unsubscribe',
1209
1226
  ]);
1210
1227
 
1211
1228
  // Sensitive reads — return user data; require SAS confirmation same as MUTATE.
1212
1229
  const SAS_GATED_READS = new Set([
1213
- 'cmd:sessions:load',
1214
- // Same data the phone reads on demand (projects + their active Epics).
1215
- 'cmd:sessions:list',
1216
- 'cmd:history:aggregate',
1230
+ // Web app's multi-desktop picker: { version, platform, hostname }. The
1231
+ // hostname identifies the machine, so this is SAS-gated.
1232
+ 'cmd:app:info',
1217
1233
  // subscribe initiates a live stream of session state/summary — sensitive.
1218
1234
  'cmd:session:subscribe',
1219
1235
  // Picker data (projects, personas, tags, macros) for the phone's New Session.
@@ -1238,17 +1254,8 @@ const SAS_GATED_READS = new Set([
1238
1254
  ]);
1239
1255
 
1240
1256
  const MUTATE_COMMANDS = new Set([
1241
- 'cmd:sessions:save',
1242
- 'cmd:pty:spawn',
1243
- 'cmd:pty:write',
1244
- // pty:kill terminates a live session; pty:resize drives the geometry of the
1245
- // user's interactive PTY — both write live process state, so they are gated
1246
- // behind remoteControlEnabled + SAS like every other mutation. A read-only
1247
- // mobile mirror has no business killing or resizing the desktop's session.
1248
- 'cmd:pty:kill',
1249
- 'cmd:pty:resize',
1250
1257
  // Pushes a prompt into an open tab's chat queue — a real mutation of that
1251
- // tab's session, same tier as pty:write.
1258
+ // tab's session, a mutation.
1252
1259
  'cmd:chat:send',
1253
1260
  // Starts a new Session on the desktop via the renderer's launchSession.
1254
1261
  'cmd:session:create',
@@ -1299,6 +1306,7 @@ module.exports = {
1299
1306
  scheduleReadLog,
1300
1307
  scheduleWritePrd,
1301
1308
  schedulerCreatePrd,
1309
+ adminCreatePrds,
1302
1310
  scheduleSetPrdDisposition,
1303
1311
  scheduleArchivePrd,
1304
1312
  scheduleRetagPrd,
@@ -14,7 +14,8 @@ const os = require('node:os');
14
14
  const path = require('node:path');
15
15
  const { KIND_CONFIG: WORKTREE_KIND_CONFIG } = require('./git/gitWorktree.cjs');
16
16
  const { OPS_ROOT_DIR } = require('./opsOwnership.cjs');
17
- const { classifyCwd, worktreeMainRootOf } = require('./cwdClassify.cjs');
17
+ const cwdClassify = require('./cwdClassify.cjs');
18
+ const { worktreeMainRootOf } = cwdClassify;
18
19
 
19
20
  const HOME = os.homedir();
20
21
  const TMPDIR = os.tmpdir();
@@ -55,7 +56,7 @@ const TAIL_BYTES = 64 * 1024;
55
56
  // and leave the tail with no complete `cwd` row. Only on that miss the tail
56
57
  // grows (x16 per step) up to MAX_TAIL_BYTES, then up to MAX_FALLBACK_FILES
57
58
  // newest transcripts are tried before the project is given up on.
58
- const MAX_TAIL_BYTES = 16 * 1024 * 1024;
59
+ const MAX_TAIL_BYTES = 1024 * 1024;
59
60
  const MAX_FALLBACK_FILES = 5;
60
61
  const TAIL_GROWTH = 16;
61
62
 
@@ -87,11 +88,45 @@ const CACHE_TTL_MS = 120_000;
87
88
  // Bounds how many project dirs the async rescan reads concurrently.
88
89
  const RESCAN_CONCURRENCY = 32;
89
90
  const EMPTY_CWDS = Object.freeze([]);
90
- const rawScanCache = new Map(); // projectsDir -> { dirMtimeMs, cachedAt, records, refreshPromise }
91
+ // projectsDir -> { dirMtimeMs, cachedAt, records, dirs, refreshPromise }
92
+ // `dirs` is Map<projectDirName, {rawCwd, newestMtimeMs, newestFile, newestSize}> — the
93
+ // per-dir record cache that lets a changed projectsDir rescan only the added dirs.
94
+ const rawScanCache = new Map();
95
+
96
+ // rawCwd -> classifyCwd result. Never holds kind 'unknown' (an unresolvable
97
+ // .git may resolve later). Bounded; cleared when exceeded or on a full first scan.
98
+ const CLASSIFY_MEMO_MAX = 20000;
99
+ const classifyMemo = new Map();
100
+ function classifyMemoized(rawCwd) {
101
+ const hit = classifyMemo.get(rawCwd);
102
+ if (hit) return hit;
103
+ const classified = cwdClassify.classifyCwd(rawCwd);
104
+ if (classified.kind !== 'unknown') {
105
+ if (classifyMemo.size >= CLASSIFY_MEMO_MAX) classifyMemo.clear();
106
+ classifyMemo.set(rawCwd, classified);
107
+ }
108
+ return classified;
109
+ }
91
110
 
92
- /** Forces the next activeProjectCwds/allProjectCwds call to rescan. */
93
- function bustProjectCwdCache() {
111
+ /**
112
+ * Forces the next activeProjectCwds/allProjectCwds call to revalidate. Keeps the
113
+ * per-dir Map and classify memo (a full rescan of thousands of dirs is the cost
114
+ * this exists to avoid): entries are only marked stale so the next scan takes
115
+ * the incremental path. `deep` also re-lists/re-stats every KNOWN dir, tail-reading
116
+ * only those whose newest transcript changed.
117
+ */
118
+ function bustProjectCwdCache({ deep = false } = {}) {
119
+ for (const entry of rawScanCache.values()) {
120
+ entry.dirMtimeMs = NaN;
121
+ entry.cachedAt = 0;
122
+ if (deep) entry.forceDeep = true;
123
+ }
124
+ }
125
+
126
+ /** Test isolation: the old full clear (cache + classify memo). */
127
+ function __resetProjectCwdCacheForTests() {
94
128
  rawScanCache.clear();
129
+ classifyMemo.clear();
95
130
  }
96
131
 
97
132
  // The ops-root folder name. A transcript's `cwd` can point INSIDE it whenever
@@ -119,7 +154,7 @@ const OPS_DIRNAME = OPS_ROOT_DIR;
119
154
  * resolution) lives in cwdClassify.cjs.
120
155
  */
121
156
  function projectRootOf(cwd) {
122
- return classifyCwd(cwd).projectRoot ?? cwd;
157
+ return cwdClassify.classifyCwd(cwd).projectRoot ?? cwd;
123
158
  }
124
159
 
125
160
  /**
@@ -160,11 +195,19 @@ function lastCwdInLines(lines) {
160
195
  return null;
161
196
  }
162
197
 
198
+ /** True when the cached entry's newest transcript is unchanged (name, mtime, size). */
199
+ function sameNewest(prev, newest) {
200
+ return !!prev && prev.newestFile === newest.tf && prev.newestMtimeMs === newest.mtimeMs
201
+ && prev.newestSize === newest.size;
202
+ }
203
+
163
204
  /**
164
- * scanOneProjectDirSync(projDir) → {rawCwd, newestMtimeMs} | null
205
+ * scanOneProjectDirSync(projDir, prev?) → {rawCwd, newestMtimeMs} | null
165
206
  * Newest *.jsonl's mtime plus its last parseable `cwd`, for one project dir.
207
+ * With `prev` (a cached entry), the tail read is skipped entirely when the newest
208
+ * transcript's name, mtime and size are unchanged.
166
209
  */
167
- function scanOneProjectDirSync(projDir) {
210
+ function scanOneProjectDirSync(projDir, prev) {
168
211
  let entries;
169
212
  try {
170
213
  entries = fs.readdirSync(projDir).filter((f) => f.endsWith('.jsonl'));
@@ -173,22 +216,26 @@ function scanOneProjectDirSync(projDir) {
173
216
  const files = [];
174
217
  for (const tf of entries) {
175
218
  const fp = path.join(projDir, tf);
176
- try { files.push({ fp, mtimeMs: fs.statSync(fp).mtimeMs }); } catch { continue; }
219
+ try { const st = fs.statSync(fp); files.push({ fp, tf, mtimeMs: st.mtimeMs, size: st.size }); } catch { continue; }
177
220
  }
178
- if (!files.length) return null;
221
+ if (!files.length) return { rawCwd: null, newestFile: null, newestMtimeMs: 0, newestSize: 0 };
179
222
  files.sort((x, y) => y.mtimeMs - x.mtimeMs);
180
223
  const newestMtimeMs = files[0].mtimeMs;
224
+ const newestFile = files[0].tf;
225
+ const newestSize = files[0].size;
226
+ if (sameNewest(prev, files[0])) return { ...prev, newestMtimeMs };
181
227
 
182
228
  for (const { fp } of files.slice(0, MAX_FALLBACK_FILES)) {
183
229
  let size = TAIL_BYTES;
184
230
  for (;;) {
185
231
  const rawCwd = lastCwdInLines(readTailLines(fp, size));
186
- if (rawCwd) return { rawCwd, newestMtimeMs };
232
+ if (rawCwd) return { rawCwd, newestMtimeMs, newestFile, newestSize };
187
233
  if (size >= MAX_TAIL_BYTES || size >= fileSizeSync(fp)) break;
188
234
  size = Math.min(size * TAIL_GROWTH, MAX_TAIL_BYTES);
189
235
  }
190
236
  }
191
- return null;
237
+ // No cwd found: cache the negative result under the same newest-file fingerprint.
238
+ return { rawCwd: null, newestMtimeMs, newestFile, newestSize };
192
239
  }
193
240
 
194
241
  /**
@@ -237,14 +284,21 @@ function scanProjectsDir(projectsDir) {
237
284
  let slugs;
238
285
  try { slugs = fs.readdirSync(projectsDir); } catch { rawScanCache.delete(projectsDir); return null; }
239
286
 
240
- const records = [];
287
+ // Incremental: reuse every already-known dir's entry, scan only added names,
288
+ // drop names that vanished.
289
+ if (!cached) classifyMemo.clear();
290
+ const prevDirs = cached ? cached.dirs : null;
291
+ const deep = !!(cached && cached.forceDeep);
292
+ const dirs = new Map();
241
293
  for (const slug of slugs) {
242
- const rec = scanOneProjectDirSync(path.join(projectsDir, slug));
243
- if (rec) records.push(rec);
294
+ const known = prevDirs && prevDirs.get(slug);
295
+ if (known && !deep) { dirs.set(slug, known); continue; }
296
+ const rec = scanOneProjectDirSync(path.join(projectsDir, slug), known || undefined);
297
+ if (rec) dirs.set(slug, rec);
244
298
  }
245
299
 
246
- Object.freeze(records);
247
- rawScanCache.set(projectsDir, { dirMtimeMs, cachedAt: now0, records, refreshPromise: null });
300
+ const records = Object.freeze([...dirs.values()].filter((d) => d.rawCwd));
301
+ rawScanCache.set(projectsDir, { dirMtimeMs, cachedAt: now0, records, dirs, refreshPromise: null });
248
302
  return records;
249
303
  }
250
304
 
@@ -271,10 +325,10 @@ async function readTailLinesAsync(filePath, maxBytes) {
271
325
  }
272
326
 
273
327
  /**
274
- * scanOneProjectDirAsync(projDir) → Promise<{rawCwd, newestMtimeMs} | null>
328
+ * scanOneProjectDirAsync(projDir, prev?) → Promise<{rawCwd, newestMtimeMs} | null>
275
329
  * fs.promises counterpart of scanOneProjectDirSync, used only by the async rescan.
276
330
  */
277
- async function scanOneProjectDirAsync(projDir) {
331
+ async function scanOneProjectDirAsync(projDir, prev) {
278
332
  let entries;
279
333
  try {
280
334
  entries = (await fsp.readdir(projDir)).filter((f) => f.endsWith('.jsonl'));
@@ -283,24 +337,28 @@ async function scanOneProjectDirAsync(projDir) {
283
337
  const files = [];
284
338
  for (const tf of entries) {
285
339
  const fp = path.join(projDir, tf);
286
- try { files.push({ fp, mtimeMs: (await fsp.stat(fp)).mtimeMs }); } catch { continue; }
340
+ try { const st = await fsp.stat(fp); files.push({ fp, tf, mtimeMs: st.mtimeMs, size: st.size }); } catch { continue; }
287
341
  }
288
- if (!files.length) return null;
342
+ if (!files.length) return { rawCwd: null, newestFile: null, newestMtimeMs: 0, newestSize: 0 };
289
343
  files.sort((x, y) => y.mtimeMs - x.mtimeMs);
290
344
  const newestMtimeMs = files[0].mtimeMs;
345
+ const newestFile = files[0].tf;
346
+ const newestSize = files[0].size;
347
+ if (sameNewest(prev, files[0])) return { ...prev, newestMtimeMs };
291
348
 
292
349
  for (const { fp } of files.slice(0, MAX_FALLBACK_FILES)) {
293
350
  let size = TAIL_BYTES;
294
351
  for (;;) {
295
352
  const rawCwd = lastCwdInLines(await readTailLinesAsync(fp, size));
296
- if (rawCwd) return { rawCwd, newestMtimeMs };
353
+ if (rawCwd) return { rawCwd, newestMtimeMs, newestFile, newestSize };
297
354
  let fileSize = 0;
298
355
  try { fileSize = (await fsp.stat(fp)).size; } catch { /* gone: stop growing */ }
299
356
  if (size >= MAX_TAIL_BYTES || size >= fileSize) break;
300
357
  size = Math.min(size * TAIL_GROWTH, MAX_TAIL_BYTES);
301
358
  }
302
359
  }
303
- return null;
360
+ // No cwd found: cache the negative result under the same newest-file fingerprint.
361
+ return { rawCwd: null, newestMtimeMs, newestFile, newestSize };
304
362
  }
305
363
 
306
364
  /**
@@ -311,25 +369,27 @@ async function scanOneProjectDirAsync(projDir) {
311
369
  * (missing dir, unreadable) is left to throw/reject — startAsyncRefresh's
312
370
  * caller catches it, logs once, and keeps the old cached record.
313
371
  */
314
- async function rescanProjectsDirAsync(projectsDir) {
372
+ async function rescanProjectsDirAsync(projectsDir, prevDirs) {
315
373
  const dirMtimeMs = (await fsp.stat(projectsDir)).mtimeMs;
316
374
  const slugs = await fsp.readdir(projectsDir);
317
375
 
318
- const records = [];
376
+ const dirs = new Map();
319
377
  let nextIdx = 0;
320
378
  async function worker() {
321
379
  for (;;) {
322
380
  const i = nextIdx++;
323
381
  if (i >= slugs.length) return;
324
- const rec = await scanOneProjectDirAsync(path.join(projectsDir, slugs[i]));
325
- if (rec) records.push(rec);
382
+ const rec = await scanOneProjectDirAsync(
383
+ path.join(projectsDir, slugs[i]), prevDirs && prevDirs.get(slugs[i]),
384
+ );
385
+ if (rec) dirs.set(slugs[i], rec);
326
386
  }
327
387
  }
328
388
  const workerCount = Math.min(RESCAN_CONCURRENCY, slugs.length);
329
389
  await Promise.all(Array.from({ length: workerCount }, worker));
330
390
 
331
- Object.freeze(records);
332
- return { dirMtimeMs, records };
391
+ const records = Object.freeze([...dirs.values()].filter((d) => d.rawCwd));
392
+ return { dirMtimeMs, records, dirs };
333
393
  }
334
394
 
335
395
  /**
@@ -345,12 +405,12 @@ function startAsyncRefresh(projectsDir) {
345
405
  if (!entry) return Promise.resolve();
346
406
  if (entry.refreshPromise) return entry.refreshPromise;
347
407
 
348
- const promise = rescanProjectsDirAsync(projectsDir)
408
+ const promise = rescanProjectsDirAsync(projectsDir, entry.dirs)
349
409
  .then((result) => {
350
410
  const current = rawScanCache.get(projectsDir);
351
411
  if (!current) return; // busted mid-flight; nothing to swap into
352
412
  rawScanCache.set(projectsDir, {
353
- dirMtimeMs: result.dirMtimeMs, cachedAt: Date.now(), records: result.records, refreshPromise: null,
413
+ dirMtimeMs: result.dirMtimeMs, cachedAt: Date.now(), records: result.records, dirs: result.dirs, refreshPromise: null,
354
414
  });
355
415
  })
356
416
  .catch((err) => {
@@ -394,7 +454,7 @@ function deriveCwds(records, maxAgeMin, maxCwds, tmpDropRoots) {
394
454
  // Normalize an ops-internal cwd up to its project root BEFORE any of the
395
455
  // checks below — the stray-ops-root incident (see OPS_DIRNAME above) got
396
456
  // through precisely because such a path is absolute and does exist.
397
- const classified = classifyCwd(rawCwd);
457
+ const classified = classifyMemoized(rawCwd);
398
458
  // `unknown` (a .git file we cannot resolve) is non-registrable: registering
399
459
  // it would hand every consumer a project whose ops writes are refused.
400
460
  if (classified.kind === 'unknown') return;
@@ -487,5 +547,6 @@ module.exports = {
487
547
  worktreeMainRootOf,
488
548
  isSstHarnessCwd,
489
549
  bustProjectCwdCache,
550
+ __resetProjectCwdCacheForTests,
490
551
  __waitForPendingRefresh,
491
552
  };
@@ -880,4 +880,6 @@ module.exports = {
880
880
  INLINE_IMPLEMENTATION_GUARD_MATCHER,
881
881
  SELF_SCHEDULE_GUARD_SCRIPT,
882
882
  SELF_SCHEDULE_GUARD_MATCHER,
883
+ GUARD_DEFS,
884
+ checkGuard,
883
885
  };
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Point-of-use repair for the two delegation preconditions the boot seeders
3
+ * give up on after MAX_ATTEMPTS: the session-manager-dev plugin and the
4
+ * session-manager-scheduler MCP registration. Called by the
5
+ * app:delegation-readiness handler when either check is failing. Each check id
6
+ * is attempted at most once per app process, regardless of the seed marker.
7
+ * Never throws; failures go to opsErrorLog (scope 'delegationSelfHeal').
8
+ */
9
+ const fs = require('node:fs');
10
+ const path = require('node:path');
11
+ const os = require('node:os');
12
+
13
+ const attempted = new Set();
14
+
15
+ function readSettings(homeDir) {
16
+ try { return JSON.parse(fs.readFileSync(path.join(homeDir, '.claude', 'settings.json'), 'utf8')); } catch { return null; }
17
+ }
18
+
19
+ function defaultDeps(homeDir) {
20
+ const dev = require('../../seedDevPlugin.cjs');
21
+ const mcp = require('../../seedSchedulerMcp.cjs');
22
+ return {
23
+ install: (opts) => require('../../pluginInstall.cjs').install(opts),
24
+ mcpRepair: async () => {
25
+ await mcp.runClaudeMcpRemove(); // best-effort: add refuses when a stale entry exists
26
+ return mcp.runClaudeMcpAdd(mcp.serverScriptPath());
27
+ },
28
+ writeMarker: (id, state) => (id === 'dev-plugin' ? dev.writeMarker(state, homeDir) : mcp.writeMarker(state)),
29
+ readSettings,
30
+ log: (entry) => require('../telemetry/opsErrorLog.cjs').appendError(entry),
31
+ awaitBootSeeding: async () => {},
32
+ };
33
+ }
34
+
35
+ /**
36
+ * @param {{ checks: Array<{id:string, ok:boolean}>, homeDir?: string, deps?: object }} args
37
+ */
38
+ async function selfHealDelegation({ checks, homeDir = os.homedir(), deps = {} } = {}) {
39
+ const d = { ...defaultDeps(homeDir), ...deps };
40
+ const failing = new Set((checks || []).filter((c) => c && !c.ok).map((c) => c.id));
41
+ const todo = ['dev-plugin', 'scheduler-mcp'].filter((id) => failing.has(id) && !attempted.has(id));
42
+ if (!todo.length) return { attempted: [] };
43
+ for (const id of todo) attempted.add(id); // claim synchronously: concurrent probes can't double-run
44
+ try { await d.awaitBootSeeding?.(); } catch { /* boot seeding failures are logged elsewhere */ }
45
+
46
+ const done = [];
47
+ for (const id of todo) {
48
+ try {
49
+ let r;
50
+ if (id === 'dev-plugin') {
51
+ const enabled = d.readSettings(homeDir)?.enabledPlugins?.['session-manager-dev@session-manager'];
52
+ if (enabled === false) continue; // deliberate disable stays
53
+ r = await d.install({ slug: 'session-manager-dev', marketplace: { add: 'bundled', name: 'session-manager' } });
54
+ } else {
55
+ r = await d.mcpRepair();
56
+ }
57
+ done.push(id);
58
+ if (r?.ok) d.writeMarker(id, { done: true, attempts: 0 });
59
+ else throw new Error(r?.error || `exit ${r?.exitCode}`);
60
+ } catch (err) {
61
+ try {
62
+ d.log({
63
+ cwd: d.cwd,
64
+ scope: 'delegationSelfHeal',
65
+ level: 'error',
66
+ message: `${id} self-heal failed: ${err?.message ?? err}`,
67
+ meta: { check: id },
68
+ });
69
+ } catch { /* logging is best-effort */ }
70
+ }
71
+ }
72
+ return { attempted: done };
73
+ }
74
+
75
+ function _resetForTests() { attempted.clear(); }
76
+
77
+ module.exports = { selfHealDelegation, _resetForTests };
@@ -382,7 +382,7 @@ function ensureEpic(cwd, { goalText, tag, epicId: explicitEpicId, status = 'prop
382
382
 
383
383
  const prdDir = resolveEpicPrdWriteDir(cwd, epicId);
384
384
  fs.mkdirSync(prdDir, { recursive: true });
385
- telemetryCounters.trackEpicCreate();
385
+ telemetryCounters.trackEpicCreate({ tag, agentType });
386
386
  return { epicId, prdDir, created: true };
387
387
  });
388
388
  }