claude-code-session-manager 0.112.0 → 0.114.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.
Files changed (75) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/dist/assets/{AgentLibrary-RwPvEgqm.js → AgentLibrary-DZFM7Lto.js} +2 -2
  3. package/dist/assets/{Button-36j3PzYv.js → Button-BJVFKdGH.js} +1 -1
  4. package/dist/assets/{DataModel-DUhANo4l.js → DataModel-CebvcP_y.js} +1 -1
  5. package/dist/assets/{History-BeZzNjmv.js → History-BwISEUhR.js} +2 -2
  6. package/dist/assets/{Hooks-iQ8845Hp.js → Hooks-Dac3smYP.js} +3 -3
  7. package/dist/assets/{Library-DPE1h-1L.js → Library-DTjsUr9W.js} +7 -7
  8. package/dist/assets/{ListDetail-PStOuQAo.js → ListDetail-B1L5TbjS.js} +1 -1
  9. package/dist/assets/{MarkdownEditor-DPw-N2-m.js → MarkdownEditor-BFRhWZD4.js} +1 -1
  10. package/dist/assets/{McpServers-CUyRTNep.js → McpServers-CBjGCMg0.js} +2 -2
  11. package/dist/assets/Memory-DdLi5fqe.js +8 -0
  12. package/dist/assets/{Panel-_VZ_uXNg.js → Panel-BQkkkjex.js} +1 -1
  13. package/dist/assets/{Permissions-PPRmfNbK.js → Permissions-CaACwY0B.js} +3 -3
  14. package/dist/assets/{Plugins-BFcvfvlx.js → Plugins-DJeBwVoh.js} +2 -2
  15. package/dist/assets/ProvenanceBadge--N5j8xiK.js +1 -0
  16. package/dist/assets/{RemoteMachines-D5V0lzgW.js → RemoteMachines-WjWaDHEA.js} +2 -2
  17. package/dist/assets/{SaveBar-Dw64FVdM.js → SaveBar-DFmOjb4_.js} +1 -1
  18. package/dist/assets/{Scheduler-C8s_pJ3I.js → Scheduler-Miap6_aY.js} +8 -8
  19. package/dist/assets/{ScopeSwitcher-D-nKDMAE.js → ScopeSwitcher-CiEujp4L.js} +1 -1
  20. package/dist/assets/Settings-UGpW4P0j.js +4 -0
  21. package/dist/assets/{SkillReferenceGraph-BbZFMHim.js → SkillReferenceGraph-BgyAnxGW.js} +1 -1
  22. package/dist/assets/Skills-BQUpnNfx.js +2 -0
  23. package/dist/assets/SystemPrompt-CQjOgHGT.js +1 -0
  24. package/dist/assets/TagLibrary-CTVpoLl0.js +1 -0
  25. package/dist/assets/{TiptapBody-Cr-hxX8L.js → TiptapBody-BtOcrdRR.js} +1 -1
  26. package/dist/assets/{Toggle-7WpPzcZH.js → Toggle-Chri4G19.js} +1 -1
  27. package/dist/assets/{ViewTabs-9lQCRwWJ.js → ViewTabs-BIenDGVv.js} +1 -1
  28. package/dist/assets/{historyTotals-BrFxw5J1.js → historyTotals-CX8mUxJY.js} +1 -1
  29. package/dist/assets/{index-CTBpf-26.js → index-AVrhGmUO.js} +163 -163
  30. package/dist/assets/{index-Dutcsly-.css → index-HpEbvDiE.css} +1 -1
  31. package/dist/assets/{settingsSchema-Dh5Kl7VO.js → settingsSchema-uqwlznbH.js} +1 -1
  32. package/dist/assets/{useAgentsDirWatch-CMpx0d04.js → useAgentsDirWatch-BaezRtWw.js} +1 -1
  33. package/dist/assets/{useRemotePeers-CI2axd-L.js → useRemotePeers-BuFbBqDt.js} +1 -1
  34. package/dist/index.html +2 -2
  35. package/package.json +1 -1
  36. package/plugins/session-manager-dev/.claude-plugin/plugin.json +1 -1
  37. package/plugins/session-manager-dev/skills/send-feedback/SKILL.md +13 -0
  38. package/scripts/scheduler-mcp-server.cjs +23 -5
  39. package/src/main/build-info.json +4 -4
  40. package/src/main/chatRunner.cjs +18 -1
  41. package/src/main/config.cjs +18 -1
  42. package/src/main/index.cjs +6 -2
  43. package/src/main/ipcSchemas.cjs +27 -34
  44. package/src/main/lib/activeSessions.cjs +13 -0
  45. package/src/main/lib/claude/rateLimitDetect.cjs +29 -10
  46. package/src/main/lib/claude/rateLimitWindow.cjs +23 -11
  47. package/src/main/lib/credentials.cjs +16 -90
  48. package/src/main/lib/feedback-remote/crossProjectFeedback.cjs +107 -9
  49. package/src/main/lib/feedback-remote/humanPromptLedger.cjs +93 -0
  50. package/src/main/lib/mcpToolCatalog.cjs +9 -3
  51. package/src/main/lib/memoryDirResolve.cjs +139 -0
  52. package/src/main/lib/memoryIndex.cjs +98 -0
  53. package/src/main/lib/prd/depSlugResolve.cjs +16 -1
  54. package/src/main/lib/scheduler/finalizeReason.cjs +30 -0
  55. package/src/main/lib/scheduler/flatSweepThrottle.cjs +17 -0
  56. package/src/main/lib/scheduler/runLogRetention.cjs +34 -13
  57. package/src/main/lib/scheduler/schedulerBatch.cjs +8 -12
  58. package/src/main/lib/telemetry/telemetryClient.cjs +8 -4
  59. package/src/main/lib/util/atomicFs.cjs +42 -0
  60. package/src/main/lib/util/loopDelay.cjs +1 -1
  61. package/src/main/memoryAggregate.cjs +15 -8
  62. package/src/main/memoryTool.cjs +151 -22
  63. package/src/main/runVerify.cjs +5 -2
  64. package/src/main/scheduler.cjs +43 -10
  65. package/src/main/supervisor.cjs +45 -22
  66. package/src/main/webRemote.cjs +15 -0
  67. package/src/preload/api.d.ts +47 -52
  68. package/src/preload/index.cjs +25 -21
  69. package/dist/assets/Memory-bZ2xjBGy.js +0 -8
  70. package/dist/assets/ProvenanceBadge-B1WUknak.js +0 -1
  71. package/dist/assets/Settings-CQSrR4mO.js +0 -4
  72. package/dist/assets/Skills-Bzyrh6EQ.js +0 -2
  73. package/dist/assets/SystemPrompt-DP6i3Dx0.js +0 -1
  74. package/dist/assets/TagLibrary-iip0C6Bl.js +0 -1
  75. package/src/main/agentMemory.cjs +0 -246
@@ -1,8 +1,7 @@
1
1
  'use strict';
2
2
 
3
3
  /**
4
- * runLogRetention.cjs — computes (and, only when explicitly opted in,
5
- * applies) a retention policy over `scheduled-plans/runs/`.
4
+ * runLogRetention.cjs — computes and applies a retention policy over `scheduled-plans/runs/`.
6
5
  *
7
6
  * That directory is a machine-level Session-Manager runtime artifact store
8
7
  * (execution logs of the scheduler's own `claude -p` runs), not anything
@@ -26,10 +25,10 @@
26
25
  * slug's own files.
27
26
  *
28
27
  * SAFETY MODEL (the point of this module — see the PRD that added it):
29
- * - Nothing is ever deleted unless the caller passes settings with
30
- * `schedulerRunLogRetention.enabled === true` AND a non-empty policy.
31
- * Everywhere else (computeReport, and applyRetention with no opt-in)
32
- * this module only READS the filesystem.
28
+ * - Retention is ON by default (DEFAULT_RUN_LOG_RETENTION) when settings
29
+ * carry no `schedulerRunLogRetention`. An explicit `enabled: false` (or a
30
+ * config with no non-empty policy) opts out: computeReport, and
31
+ * applyRetention when opted out, only READ the filesystem.
33
32
  * - An entry belonging to a job that is currently live — status `pending`
34
33
  * (queued), `running`, `needs_review`, or `investigating` (the exact
35
34
  * literals scheduler.cjs uses; see LIVE_STATUSES) — is never eligible,
@@ -51,6 +50,16 @@ const schedulerPaths = require('./schedulerPaths.cjs');
51
50
  // checks) — 'completed' and 'failed' are the only terminal ones.
52
51
  const LIVE_STATUSES = new Set(['pending', 'running', 'needs_review', 'investigating']);
53
52
 
53
+ /**
54
+ * Built-in policy applied when settings carry no `schedulerRunLogRetention`:
55
+ * drop runs older than 14 days but always keep each slug's newest 3.
56
+ * (Policy keys follow the module's own names: maxAgeDays / keepPerSlug.)
57
+ */
58
+ const DEFAULT_RUN_LOG_RETENTION = Object.freeze({
59
+ enabled: true,
60
+ policy: Object.freeze({ maxAgeDays: 14, keepPerSlug: 3 }),
61
+ });
62
+
54
63
  const META_SUFFIX = '.meta.json';
55
64
  const DAY_MS = 24 * 60 * 60 * 1000;
56
65
 
@@ -259,11 +268,21 @@ function computeReport(runsDir, policy, opts) {
259
268
  }
260
269
 
261
270
  /**
262
- * True only when settings carry an explicit, well-formed opt-in:
263
- * `schedulerRunLogRetention.enabled === true` plus a non-empty policy.
271
+ * The effective retention config: the caller's `schedulerRunLogRetention`
272
+ * when present, else DEFAULT_RUN_LOG_RETENTION.
264
273
  */
265
- function isRetentionEnabled(settings) {
274
+ function resolveRetentionConfig(settings) {
266
275
  const cfg = settings && settings.schedulerRunLogRetention;
276
+ return cfg == null ? DEFAULT_RUN_LOG_RETENTION : cfg;
277
+ }
278
+
279
+ /**
280
+ * True when the effective config (see resolveRetentionConfig) has
281
+ * `enabled === true` plus a non-empty policy. An absent setting resolves to
282
+ * the default (enabled); an explicit `enabled: false` opts out.
283
+ */
284
+ function isRetentionEnabled(settings) {
285
+ const cfg = resolveRetentionConfig(settings);
267
286
  if (!cfg || cfg.enabled !== true) return false;
268
287
  const policy = cfg.policy;
269
288
  return !!policy && (policy.maxAgeDays != null || policy.keepPerSlug != null);
@@ -372,7 +391,7 @@ function sweepOrphanDirs(runsDir, runningRunIds, errors) {
372
391
  * nothing removed.
373
392
  */
374
393
  function applyRetention(runsDir, settings, opts) {
375
- const cfg = (settings && settings.schedulerRunLogRetention) || null;
394
+ const cfg = resolveRetentionConfig(settings);
376
395
  const policy = (cfg && cfg.policy) || {};
377
396
  const enabled = isRetentionEnabled(settings);
378
397
  const liveKeys = resolveLiveKeysForApply(runsDir, opts, enabled);
@@ -381,7 +400,7 @@ function applyRetention(runsDir, settings, opts) {
381
400
  if (!enabled) {
382
401
  return {
383
402
  deleted: false,
384
- reason: 'dry-run: schedulerRunLogRetention.enabled is not true (or has no policy)',
403
+ reason: 'dry-run: schedulerRunLogRetention is disabled (enabled !== true, or has no policy)',
385
404
  report,
386
405
  };
387
406
  }
@@ -424,8 +443,8 @@ function applyRetention(runsDir, settings, opts) {
424
443
  * applyRetention's own internal queueStore fallback (resolveLiveKeysForApply
425
444
  * above), which exists only as a defensive net for callers who forget to
426
445
  * thread live-job info through, not as this module's primary path. Deletion
427
- * still only happens when the read config carries an explicit
428
- * schedulerRunLogRetention.enabled === true (see isRetentionEnabled).
446
+ * happens unless the read config opts out with
447
+ * schedulerRunLogRetention.enabled === false (see isRetentionEnabled).
429
448
  *
430
449
  * Intended to be called once per app boot (see index.cjs's deferred 30s
431
450
  * startup timer, alongside finalizeClosedDays) — never on its own timer.
@@ -438,6 +457,8 @@ function runBootSweep(opts) {
438
457
  }
439
458
 
440
459
  module.exports = {
460
+ DEFAULT_RUN_LOG_RETENTION,
461
+ resolveRetentionConfig,
441
462
  LIVE_STATUSES,
442
463
  isLiveJob,
443
464
  liveKeysFromJobs,
@@ -15,7 +15,7 @@
15
15
  const path = require('node:path');
16
16
  const os = require('node:os');
17
17
  const { projectJobCap, quietMachineWaitMs } = require('./schedulerConfig.cjs');
18
- const { bareSlug } = require('../prd/depSlugResolve.cjs');
18
+ const { bareSlug, isSatisfiedDepRow } = require('../prd/depSlugResolve.cjs');
19
19
 
20
20
  const DEFAULT_PROJECT_CWD = path.join(os.homedir(), 'Projects', 'session-manager');
21
21
 
@@ -127,20 +127,16 @@ function findUnresolvableDepRoots(projectJobs, satisfiedSlugs = new Set()) {
127
127
 
128
128
  function findBlockingDep(job, projectJobs, satisfiedSlugs = new Set()) {
129
129
  const { rowsForDep, isKnownSatisfied } = makeDepResolver(projectJobs, satisfiedSlugs);
130
- // A dep row is blocking unless it's 'completed', OR it's a 'skipped' row
131
- // stamped `needsReviewAutoResolvedSkip` — the bounded needs_review
132
- // auto-resolve terminal decision (scheduler.cjs's
133
- // applyNeedsReviewAutoResolve). That skip is deliberately NOT the
130
+ // A dep row is blocking unless isSatisfiedDepRow — 'completed', or a
131
+ // `needsReviewAutoResolvedSkip` skip. That skip is deliberately NOT the
134
132
  // "PRD source vanished, a human must author a fresh PRD" skip the
135
- // heldBySkippedDep messaging above describes — it already got every
136
- // bounded chance to resolve itself, so treating it as a permanent
137
- // dependsOn block would defeat the whole point of that auto-resolve pass
138
- // (a chain that never drains without an operator).
139
- const isSatisfiedRow = (dep) => dep.status === 'completed'
140
- || (dep.status === 'skipped' && dep.needsReviewAutoResolvedSkip === true);
133
+ // heldBySkippedDep messaging describes — it already got every bounded
134
+ // chance to resolve itself, so treating it as a permanent dependsOn block
135
+ // would defeat the auto-resolve pass. runVerify.checkDeps MUST use the same
136
+ // predicate, or every run of the dependent is downgraded and re-dispatched.
141
137
  return (job.dependsOn ?? []).find((slug) => {
142
138
  const rows = rowsForDep(slug);
143
- if (rows.length > 0) return rows.some((dep) => !isSatisfiedRow(dep));
139
+ if (rows.length > 0) return rows.some((dep) => !isSatisfiedDepRow(dep));
144
140
  return !isKnownSatisfied(slug);
145
141
  });
146
142
  }
@@ -647,18 +647,20 @@ function describeFailureStatus(status) {
647
647
  return status === 0 ? 'network error' : `HTTP ${status}`;
648
648
  }
649
649
 
650
- function applyFailureBackoff(status) {
650
+ function applyFailureBackoff(status, channel) {
651
651
  if (status === 429 || status === 0 || status >= 500) {
652
652
  S.consecutiveFailures += 1;
653
653
  const ms = Math.min(BACKOFF_BASE_MS * (2 ** (S.consecutiveFailures - 1)), BACKOFF_MAX_MS);
654
654
  S.backoffUntil = Date.now() + ms;
655
655
  S.lastError = { status, message: describeFailureStatus(status), at: Date.now() };
656
+ logWarn('telemetry send failed', { channel, status, consecutiveFailures: S.consecutiveFailures, backoffMs: ms });
656
657
  return;
657
658
  }
658
659
  if (status >= 400 && status < 500) {
659
660
  // Malformed-contract circuit breaker (includes 401) — not retried.
660
661
  S.disabledForProcess = true;
661
662
  S.lastError = { status, message: describeFailureStatus(status), at: Date.now() };
663
+ logWarn('telemetry disabled for process', { status });
662
664
  }
663
665
  }
664
666
 
@@ -705,7 +707,7 @@ async function flushImpl(reason) {
705
707
  result.sent.push(r.recordId);
706
708
  } else {
707
709
  result.failed.push(r.recordId);
708
- applyFailureBackoff(res.status);
710
+ applyFailureBackoff(res.status, channel);
709
711
  sawFailure = true;
710
712
  break;
711
713
  }
@@ -726,7 +728,7 @@ async function flushImpl(reason) {
726
728
  }
727
729
  } else {
728
730
  for (const r of batch) result.failed.push(r.recordId);
729
- applyFailureBackoff(res.status);
731
+ applyFailureBackoff(res.status, channel);
730
732
  sawFailure = true;
731
733
  break;
732
734
  }
@@ -739,7 +741,9 @@ async function flushImpl(reason) {
739
741
  if (safeReason === 'daily' && result.failed.length === 0) {
740
742
  S.settings = await telemetrySettings.save({ lastDailyFlushAt: new Date().toISOString() });
741
743
  }
742
- } catch { /* fail-inert */ }
744
+ } catch (e) {
745
+ logWarn('telemetry flush threw', { error: e && e.message ? e.message : String(e) });
746
+ }
743
747
  S.lastFlushAt = Date.now();
744
748
  S.lastFlushReason = safeReason;
745
749
  return result;
@@ -105,7 +105,49 @@ function readJsonOrSync(file, fallback) {
105
105
  }
106
106
  }
107
107
 
108
+ function defaultIsPidAlive(pid) {
109
+ try {
110
+ process.kill(pid, 0);
111
+ return true;
112
+ } catch (e) {
113
+ return e?.code === 'EPERM'; // exists but owned by someone else
114
+ }
115
+ }
116
+
117
+ /**
118
+ * Remove orphaned `<file>.tmp-<pid>-<ts>-<rand>` files left by crashed
119
+ * writers. Direct children of `dir` only (no recursion); a file goes only when
120
+ * its mtime is older than `olderThanMs` AND its embedded pid is not alive.
121
+ * `.corrupt-*` / `.bak*` forensics never match. Never throws.
122
+ */
123
+ function sweepStaleTmpFiles(dir, { olderThanMs = 3600000, isPidAlive = defaultIsPidAlive } = {}) {
124
+ const removed = [];
125
+ let names;
126
+ try {
127
+ names = fs.readdirSync(dir);
128
+ } catch {
129
+ return { removed };
130
+ }
131
+ const now = Date.now();
132
+ for (const name of names) {
133
+ try {
134
+ const m = /\.tmp-(\d+)-/.exec(name);
135
+ if (!m) continue;
136
+ if (/\.(corrupt|bak)/.test(name)) continue;
137
+ const full = path.join(dir, name);
138
+ const st = fs.lstatSync(full);
139
+ if (!st.isFile()) continue;
140
+ if (now - st.mtimeMs <= olderThanMs) continue;
141
+ if (isPidAlive(Number(m[1]))) continue;
142
+ fs.unlinkSync(full);
143
+ removed.push(full);
144
+ } catch { /* per-file errors are skipped */ }
145
+ }
146
+ return { removed };
147
+ }
148
+
108
149
  module.exports = {
150
+ sweepStaleTmpFiles,
109
151
  tmpPathFor,
110
152
  writeTextAtomic,
111
153
  writeTextAtomicSync,
@@ -11,7 +11,7 @@ const { monitorEventLoopDelay } = require('node:perf_hooks');
11
11
 
12
12
  const LOOP_DELAY_RESOLUTION_MS = 20;
13
13
  // A single beat whose worst loop delay exceeds this is a visible main-thread stall.
14
- const LOOP_STALL_WARN_MS = 250;
14
+ const LOOP_STALL_WARN_MS = 1000;
15
15
 
16
16
  const NS_PER_MS = 1e6;
17
17
  const toMs = (ns) => Math.round(ns / NS_PER_MS * 10) / 10;
@@ -33,8 +33,15 @@ const { MEMORY_SLUG_RE } = require('./lib/memorySlug.cjs');
33
33
  const HOME = os.homedir();
34
34
  const CACHE_WRITER = 'memory-clusters';
35
35
 
36
- function memoryDir(workspace) {
37
- return path.join(HOME, '.claude', 'projects', workspace, 'memory');
36
+ // Resolved through memoryTool.cjs so cwd-addressed callers read Claude Code's
37
+ // native dir (git root + autoMemoryDirectory); no cwd -> legacy workspace dir.
38
+ async function memoryDir(workspace, cwd) {
39
+ const { resolveDir } = require('./memoryTool.cjs');
40
+ try {
41
+ return (await resolveDir({ workspace, cwd })).dir;
42
+ } catch {
43
+ return path.join(HOME, '.claude', 'projects', workspace, 'memory');
44
+ }
38
45
  }
39
46
 
40
47
  function clustersCachePath(cwd) {
@@ -94,15 +101,15 @@ function parseFrontmatter(text) {
94
101
  return { type, description, body: m[2] };
95
102
  }
96
103
 
97
- async function listMemoryFiles(workspace) {
98
- const dir = memoryDir(workspace);
104
+ async function listMemoryFiles(workspace, cwd) {
105
+ const dir = await memoryDir(workspace, cwd);
99
106
  const r = await config.listDir(dir, { filesOnly: true });
100
107
  if (!r.ok) return [];
101
108
  return r.entries.filter((e) => MEMORY_SLUG_RE.test(e.name)).map((e) => e.name).sort();
102
109
  }
103
110
 
104
- async function readMemories(workspace, names) {
105
- const dir = memoryDir(workspace);
111
+ async function readMemories(workspace, names, cwd) {
112
+ const dir = await memoryDir(workspace, cwd);
106
113
  const out = [];
107
114
  for (const name of names) {
108
115
  const abs = path.join(dir, name);
@@ -178,8 +185,8 @@ async function aggregate({ workspace, refresh, cwd }) {
178
185
  return { workspace: ws, generatedAt: null, clusters: [], orphans: [], cached: false };
179
186
  }
180
187
 
181
- const names = await listMemoryFiles(ws);
182
- const memories = await readMemories(ws, names);
188
+ const names = await listMemoryFiles(ws, cwd);
189
+ const memories = await readMemories(ws, names, cwd);
183
190
  const slugs = memories.map((m) => m.slug);
184
191
 
185
192
  let clusters = [];
@@ -32,6 +32,14 @@ const config = require('./config.cjs');
32
32
 
33
33
  const { MEMORY_SLUG_RE: SLUG_RE } = require('./lib/memorySlug.cjs');
34
34
  const { scoreMemories } = require('./lib/memoryStale.cjs');
35
+ const { addIndexLine, removeIndexLines, indexHealth } = require('./lib/memoryIndex.cjs');
36
+ const {
37
+ resolveWorkspaceMemoryDir,
38
+ autoMemoryEnabled,
39
+ resolveAgentMemoryDir,
40
+ listMemoryAgents,
41
+ gitRepoRoot,
42
+ } = require('./lib/memoryDirResolve.cjs');
35
43
 
36
44
  const MAX_FILE_BYTES = 1024 * 1024; // 1 MiB
37
45
  const MAX_ENTRIES = 1000;
@@ -52,13 +60,59 @@ function workspaceDir(workspace) {
52
60
  return path.join(memoryRoot(), workspace, 'memory');
53
61
  }
54
62
 
63
+ /** Read + parse a settings file; missing/malformed -> null. */
64
+ async function readSettings(abs) {
65
+ try {
66
+ const r = await config.readText(abs);
67
+ if (!r.exists) return null;
68
+ const parsed = JSON.parse(r.text);
69
+ return parsed && typeof parsed === 'object' ? parsed : null;
70
+ } catch {
71
+ return null;
72
+ }
73
+ }
74
+
75
+ /**
76
+ * The one place a memory directory is chosen.
77
+ * agent -> native subagent memory dir (root = git root of cwd, or cwd)
78
+ * cwd -> Claude Code's native workspace dir (git root + autoMemoryDirectory)
79
+ * else -> legacy ~/.claude/projects/<workspace>/memory
80
+ * Returns { dir, autoMemoryEnabled }. Throws on an invalid cwd/agent; every path
81
+ * the callers derive from `dir` still goes through config.validatePath/validateWrite.
82
+ */
83
+ async function resolveDir({ workspace, cwd, agent } = {}) {
84
+ const home = os.homedir();
85
+ const ws = validWorkspaceName(workspace) ? workspace : 'default';
86
+ if (typeof cwd !== 'string' || !cwd) {
87
+ if (agent) throw new Error('agent memory requires a cwd');
88
+ return { dir: workspaceDir(ws), autoMemoryEnabled: true };
89
+ }
90
+ const realCwd = config.validatePath(cwd);
91
+ const root = gitRepoRoot(realCwd, require('node:child_process').execFileSync) || realCwd;
92
+ const realRoot = config.validatePath(root);
93
+ // Project-scoped native dirs live under <root>/.claude; validateWrite only
94
+ // permits that subtree for registered roots.
95
+ config.addAllowedRoot(realRoot);
96
+ const settings = {
97
+ user: await readSettings(path.join(home, '.claude', 'settings.json')),
98
+ project: await readSettings(path.join(realRoot, '.claude', 'settings.json')),
99
+ local: await readSettings(path.join(realRoot, '.claude', 'settings.local.json')),
100
+ };
101
+ const enabled = autoMemoryEnabled(settings);
102
+ if (agent) {
103
+ return { dir: resolveAgentMemoryDir({ home, root: realRoot, name: agent.name, scope: agent.scope }), autoMemoryEnabled: enabled };
104
+ }
105
+ const r = resolveWorkspaceMemoryDir({ home, cwd: realCwd, settings, repoRoot: realRoot });
106
+ return { dir: r.dir, autoMemoryEnabled: enabled };
107
+ }
108
+
55
109
  /**
56
110
  * Reject NUL chars, '..' segments, absolute paths, or anything that would
57
111
  * resolve outside the workspace dir. Returns the resolved absolute path.
58
112
  * The downstream config.cjs helpers add a second layer of validation, but
59
113
  * this lets us return a clean error message before we get there.
60
114
  */
61
- function resolveEntryPath(workspace, name) {
115
+ function resolveEntryPath(dir, name) {
62
116
  if (typeof name !== 'string' || !name) {
63
117
  throw new Error('invalid entry name');
64
118
  }
@@ -66,7 +120,6 @@ function resolveEntryPath(workspace, name) {
66
120
  if (!SLUG_RE.test(name)) {
67
121
  throw new Error(`invalid slug (must match ${SLUG_RE.source}): ${name}`);
68
122
  }
69
- const dir = workspaceDir(workspace);
70
123
  const full = path.resolve(dir, name);
71
124
  // path.resolve normalizes; verify the result is still under dir to guard
72
125
  // against bizarre name inputs.
@@ -84,12 +137,15 @@ function validWorkspaceName(name) {
84
137
  return typeof name === 'string' && /^[a-zA-Z0-9-_]{1,256}$/.test(name);
85
138
  }
86
139
 
87
- async function list({ workspace }) {
140
+ async function list({ workspace, cwd, agent }) {
88
141
  const ws = validWorkspaceName(workspace) ? workspace : 'default';
89
- const dir = workspaceDir(ws);
142
+ let resolved;
143
+ try { resolved = await resolveDir({ workspace: ws, cwd, agent }); }
144
+ catch (e) { return { entries: [], workspace: ws, dir: null, autoMemoryEnabled: true, error: e.message }; }
145
+ const { dir } = resolved;
90
146
  const r = await config.listDir(dir, { filesOnly: true });
91
147
  if (!r.ok) {
92
- return { entries: [], workspace: ws, error: r.error };
148
+ return { entries: [], workspace: ws, dir, autoMemoryEnabled: resolved.autoMemoryEnabled, error: r.error };
93
149
  }
94
150
  const entries = r.entries
95
151
  .filter((e) => e.name.endsWith('.md'))
@@ -100,12 +156,14 @@ async function list({ workspace }) {
100
156
  bytes: e.size,
101
157
  }))
102
158
  .sort((a, b) => a.name.localeCompare(b.name));
103
- return { entries, workspace: ws, error: null };
159
+ return { entries, workspace: ws, dir, autoMemoryEnabled: resolved.autoMemoryEnabled, error: null };
104
160
  }
105
161
 
106
- async function read({ workspace, name }) {
162
+ async function read({ workspace, name, cwd, agent }) {
107
163
  const ws = validWorkspaceName(workspace) ? workspace : 'default';
108
- const abs = resolveEntryPath(ws, name);
164
+ let abs;
165
+ try { abs = resolveEntryPath((await resolveDir({ workspace: ws, cwd, agent })).dir, name); }
166
+ catch (e) { return { content: '', exists: false, mtimeMs: 0, bytes: 0, error: e.message }; }
109
167
  const r = await config.readText(abs);
110
168
  if (!r.exists) {
111
169
  return { content: '', exists: false, mtimeMs: 0, bytes: 0, error: r.error };
@@ -119,7 +177,7 @@ async function read({ workspace, name }) {
119
177
  };
120
178
  }
121
179
 
122
- async function write({ workspace, name, content }) {
180
+ async function write({ workspace, name, content, cwd, agent }) {
123
181
  const ws = validWorkspaceName(workspace) ? workspace : 'default';
124
182
  if (typeof content !== 'string') {
125
183
  return { ok: false, error: 'content must be a string' };
@@ -128,7 +186,9 @@ async function write({ workspace, name, content }) {
128
186
  if (bytes > MAX_FILE_BYTES) {
129
187
  return { ok: false, error: `file exceeds 1 MiB cap (${bytes} bytes)` };
130
188
  }
131
- const abs = resolveEntryPath(ws, name);
189
+ let abs;
190
+ try { abs = resolveEntryPath((await resolveDir({ workspace: ws, cwd, agent })).dir, name); }
191
+ catch (e) { return { ok: false, error: e.message }; }
132
192
  // Enforce per-workspace entry cap on creates (writes to existing files are
133
193
  // always allowed regardless of count). Cheap fs.stat check rather than a
134
194
  // full directory enumeration where possible.
@@ -136,7 +196,7 @@ async function write({ workspace, name, content }) {
136
196
  try { await fsp.access(abs, fs.constants.F_OK); }
137
197
  catch { exists = false; }
138
198
  if (!exists) {
139
- const l = await list({ workspace: ws });
199
+ const l = await list({ workspace: ws, cwd, agent });
140
200
  if (l.entries.length >= MAX_ENTRIES) {
141
201
  return { ok: false, error: `workspace at ${MAX_ENTRIES}-entry cap` };
142
202
  }
@@ -149,9 +209,22 @@ async function write({ workspace, name, content }) {
149
209
  }
150
210
  }
151
211
 
152
- async function deleteEntry({ workspace, name }) {
212
+ const INDEX_NAME = 'MEMORY.md';
213
+
214
+ /** Read <dir>/MEMORY.md; missing/unreadable -> null. */
215
+ async function readIndexText(dir) {
216
+ const r = await config.readText(path.join(dir, INDEX_NAME));
217
+ return r.exists ? r.text : null;
218
+ }
219
+
220
+ async function deleteEntry({ workspace, name, cwd, agent }) {
153
221
  const ws = validWorkspaceName(workspace) ? workspace : 'default';
154
- const abs = resolveEntryPath(ws, name);
222
+ let abs;
223
+ let dir;
224
+ try {
225
+ dir = (await resolveDir({ workspace: ws, cwd, agent })).dir;
226
+ abs = resolveEntryPath(dir, name);
227
+ } catch (e) { return { ok: false, error: e.message }; }
155
228
  // Route through config.cjs::validatePath (home-boundary pin, symlink-safe)
156
229
  // plus validateWrite (write-prefix pin) — same contract every other
157
230
  // mutating call in the codebase follows. Complexity: O(1) per call.
@@ -164,16 +237,30 @@ async function deleteEntry({ workspace, name }) {
164
237
  }
165
238
  try {
166
239
  await fsp.unlink(real);
167
- return { ok: true, error: null };
168
240
  } catch (e) {
169
- if (e.code === 'ENOENT') return { ok: true, error: null };
170
- return { ok: false, error: e.message };
241
+ if (e.code !== 'ENOENT') return { ok: false, error: e.message };
242
+ }
243
+ if (name === INDEX_NAME) return { ok: true, error: null };
244
+ try {
245
+ const text = await readIndexText(dir);
246
+ if (text !== null) {
247
+ const next = removeIndexLines(text, name);
248
+ if (next !== text) await config.writeTextAtomic(path.join(dir, INDEX_NAME), next);
249
+ }
250
+ } catch (e) {
251
+ return { ok: true, error: null, indexError: e.message };
171
252
  }
253
+ return { ok: true, error: null };
172
254
  }
173
255
 
174
- async function create({ workspace, name, description }) {
256
+ async function create({ workspace, name, description, cwd, agent }) {
175
257
  const ws = validWorkspaceName(workspace) ? workspace : 'default';
176
- const abs = resolveEntryPath(ws, name);
258
+ let abs;
259
+ let dir;
260
+ try {
261
+ dir = (await resolveDir({ workspace: ws, cwd, agent })).dir;
262
+ abs = resolveEntryPath(dir, name);
263
+ } catch (e) { return { ok: false, error: e.message }; }
177
264
  // Refuse to overwrite — create is strictly "new file".
178
265
  try {
179
266
  await fsp.access(abs, fs.constants.F_OK);
@@ -190,7 +277,16 @@ async function create({ workspace, name, description }) {
190
277
  desc || `# ${stem}`,
191
278
  '',
192
279
  ].join('\n');
193
- return await write({ workspace: ws, name, content: fm });
280
+ const w = await write({ workspace: ws, name, content: fm, cwd, agent });
281
+ if (!w.ok || name === INDEX_NAME) return w;
282
+ try {
283
+ const text = (await readIndexText(dir)) || '';
284
+ const next = addIndexLine(text, { file: name, title: stem, hook: desc.replace(/\n/g, ' ') });
285
+ if (next !== text) await config.writeTextAtomic(path.join(dir, INDEX_NAME), next);
286
+ } catch (e) {
287
+ return { ...w, indexError: e.message };
288
+ }
289
+ return w;
194
290
  }
195
291
 
196
292
  /**
@@ -200,9 +296,11 @@ async function create({ workspace, name, description }) {
200
296
  * the dead-ref existence check. Absent/invalid cwd disables dead-ref
201
297
  * detection entirely rather than mass-flagging memories.
202
298
  */
203
- async function stale({ workspace, cwd }) {
299
+ async function stale({ workspace, cwd, agent }) {
204
300
  const ws = validWorkspaceName(workspace) ? workspace : 'default';
205
- const dir = workspaceDir(ws);
301
+ let dir;
302
+ try { dir = (await resolveDir({ workspace: ws, cwd, agent })).dir; }
303
+ catch (e) { return { entries: [], workspace: ws, error: e.message }; }
206
304
  const r = await config.listDir(dir, { filesOnly: true });
207
305
  if (!r.ok) {
208
306
  return { entries: [], workspace: ws, error: r.error };
@@ -236,7 +334,30 @@ async function stale({ workspace, cwd }) {
236
334
  entries.push({ name, mtimeMs: r2.mtimeMs, body: r2.text });
237
335
  }
238
336
 
239
- return { entries: scoreMemories({ entries, now: Date.now(), existsPath }), workspace: ws, error: null };
337
+ const index = indexHealth({ indexText: await readIndexText(dir), files: r.entries.map((e) => e.name).filter((n) => n.endsWith('.md')) });
338
+ return { entries: scoreMemories({ entries, now: Date.now(), existsPath }), workspace: ws, error: null, index };
339
+ }
340
+
341
+ /** Agents (user + project) with their `memory:` frontmatter scope. */
342
+ async function agents({ cwd } = {}) {
343
+ const home = os.homedir();
344
+ let root = null;
345
+ if (typeof cwd === 'string' && cwd) {
346
+ try {
347
+ const realCwd = config.validatePath(cwd);
348
+ root = config.validatePath(gitRepoRoot(realCwd, require('node:child_process').execFileSync) || realCwd);
349
+ } catch {
350
+ root = null;
351
+ }
352
+ }
353
+ return {
354
+ agents: listMemoryAgents({
355
+ home,
356
+ root,
357
+ readDir: (d) => fs.readdirSync(d),
358
+ readText: (f) => fs.readFileSync(f, 'utf8'),
359
+ }),
360
+ };
240
361
  }
241
362
 
242
363
  function registerMemoryHandlers() {
@@ -247,6 +368,7 @@ function registerMemoryHandlers() {
247
368
  ipcMain.handle('memory:delete', v(s.memoryDelete, deleteEntry));
248
369
  ipcMain.handle('memory:create', v(s.memoryCreate, create));
249
370
  ipcMain.handle('memory:stale', v(s.memoryStale, stale));
371
+ ipcMain.handle('memory:agents', v(s.memoryAgents, agents));
250
372
  }
251
373
 
252
374
  module.exports = {
@@ -255,5 +377,12 @@ module.exports = {
255
377
  // exported for tests
256
378
  memoryRoot,
257
379
  workspaceDir,
380
+ resolveDir,
381
+ list,
382
+ read,
383
+ write,
384
+ create,
385
+ deleteEntry,
386
+ agents,
258
387
  stale,
259
388
  };
@@ -41,6 +41,7 @@ const fs = require('node:fs');
41
41
  const path = require('node:path');
42
42
  const { execFileSync } = require('node:child_process');
43
43
  const { parsePrdFile } = require('./lib/prd/prdFrontmatter.cjs');
44
+ const { isSatisfiedDepRow } = require('./lib/prd/depSlugResolve.cjs');
44
45
 
45
46
  const VERDICTS_SCHEMA_VERSION = 1;
46
47
 
@@ -468,7 +469,9 @@ function checkDeps(queueEntry, allJobs, prdBody) {
468
469
  // bare dep name that never matched any row, and ended up on disk with no
469
470
  // queue row at all.
470
471
  if (deps.length === 0) continue;
471
- const unfinished = deps.find((d) => d.status !== 'completed');
472
+ // Same predicate as findBlockingDep — an auto-resolve skip the picker
473
+ // dispatched past must not be re-judged unmet here (same infinite re-run).
474
+ const unfinished = deps.find((d) => !isSatisfiedDepRow(d));
472
475
  if (unfinished) {
473
476
  return { ok: false, reason: `dependsOn: "${depSlug}" is ${unfinished.status} (need completed)` };
474
477
  }
@@ -488,7 +491,7 @@ function checkDeps(queueEntry, allJobs, prdBody) {
488
491
  if (matches.length === 0) continue; // unresolvable fragment — skip
489
492
 
490
493
  for (const dep of matches) {
491
- if (dep.status !== 'completed') {
494
+ if (!isSatisfiedDepRow(dep)) {
492
495
  return {
493
496
  ok: false,
494
497
  reason: `PRD body dep "${frag}" → "${dep.slug}" is ${dep.status} (need completed)`,