claude-memory-admin 1.10.1 → 1.10.2

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/README.md CHANGED
@@ -108,9 +108,25 @@ safety net and not a place you browse.
108
108
  `~/.claude/settings.json`. A value that is neither absolute nor `~/`-prefixed
109
109
  is reported rather than quietly ignored.
110
110
  - **Whether Claude is still writing.** A project with `autoMemoryEnabled` off, or
111
- `CLAUDE_CODE_DISABLE_AUTO_MEMORY` set, has a store that will never grow again,
112
- which on disk is indistinguishable from one Claude has not learned anything
113
- about yet. The project header says which it is, and names the file that decided.
111
+ `CLAUDE_CODE_DISABLE_AUTO_MEMORY` set - in the environment or in the `env`
112
+ block of any settings layer - has a store that will never grow again, which on
113
+ disk is indistinguishable from one Claude has not learned anything about yet.
114
+ The project header says which it is, and names the file that decided.
115
+ - **Which subagent still asks for its memory.** A subagent store exists because
116
+ some agent file carried a `memory: user | project | local` field, and it stays
117
+ exactly where it is after that field moves to another scope or is removed. Each
118
+ store is shown next to the definition that declares it - from `agents/` in the
119
+ user scope and in each repository - and one that nothing declares any more is
120
+ marked `orphan`, one whose agent moved scope `moved`. Because subagent memory
121
+ is part of auto memory, turning auto memory off marks every one of them
122
+ `inert`: the `memory:` field stops having any effect, and the agent starts with
123
+ no memory instructions and no file tools at all.
124
+ - **A config directory that is not `~/.claude`.** `CLAUDE_CONFIG_DIR` moves the
125
+ projects root, the agent definitions, the agent memory, the user `CLAUDE.md` and
126
+ rules, and the settings file together, and all of them are read from wherever it
127
+ points. The Environment tab names the directory and says whether the environment
128
+ or the default chose it. A `~/` written by hand is expanded here rather than left
129
+ to the shell, because `cmd` and PowerShell do not expand it.
114
130
  - **Search across every project**: names, descriptions, bodies and index hooks,
115
131
  with snippets and match highlighting. Press `/` to jump to it.
116
132
  - **Read each memory** with its frontmatter as structured metadata and
@@ -291,15 +307,22 @@ five and shows, per key, the value that wins and the ones it shadows, struck
291
307
  through, each labelled with the file it came from:
292
308
 
293
309
  `autoMemoryEnabled`, `autoMemoryDirectory`, `claudeMdExcludes` and
294
- `cleanupPeriodDays`, plus `CLAUDE_CODE_DISABLE_AUTO_MEMORY` when it is set in the
295
- environment, where it outranks every file.
310
+ `cleanupPeriodDays`, plus `CLAUDE_CODE_DISABLE_AUTO_MEMORY` wherever it is set:
311
+ in the environment, where it outranks every file, or in the `env` block of any of
312
+ the five, which a session exports before it starts and which reading `process.env`
313
+ alone would miss. `CLAUDE_CONFIG_DIR` is named here too, since it decides where
314
+ all five of those files are looked for in the first place.
296
315
 
297
316
  It also names the failures that are otherwise silent:
298
317
 
299
318
  - A settings file that exists but is not valid JSON. Claude Code ignores the
300
319
  whole file, so every value in it is doing nothing, and nothing says so.
301
320
  - A file that parses but is not an object, or cannot be read at all.
302
- - An `autoMemoryDirectory` that is neither absolute nor `~/`-prefixed.
321
+ - An `autoMemoryDirectory` that is neither absolute nor `~/`-prefixed. A Windows
322
+ path is accepted on any platform, because a settings file is routinely shared
323
+ between machines.
324
+ - A `CLAUDE_CONFIG_DIR` that is not absolute, which Claude Code would not accept
325
+ either: the default is used and the fact is reported rather than swallowed.
303
326
  - A value Claude Code accepts the key of but not the number, like a
304
327
  `cleanupPeriodDays` below 1: it shows what is written *and* what applies.
305
328
 
@@ -453,6 +476,13 @@ claude-memory-admin --root /tmp/memory-snapshot
453
476
  ## Safety
454
477
 
455
478
  - Binds `127.0.0.1`; no telemetry, no network calls.
479
+ - Runs on macOS, Linux and Windows. Line endings are the part of that which is not
480
+ cosmetic: a `MEMORY.md` saved by a Windows editor is CRLF, and a carriage return
481
+ is not something JavaScript's `.` matches, so a file like that once parsed as if
482
+ it were empty - no index entries, no frontmatter, every memory an orphan. It is
483
+ read correctly now, and a rewrite ends its lines the way it found them: a file
484
+ that came back half CRLF and half LF would show every line as changed in git, on
485
+ a change you never made.
456
486
  - Reads only `~/.claude/projects`, the agent memory directories and the `CLAUDE.md`
457
487
  chain, unless you switch on the path check above, which then also walks that one
458
488
  project's directory. It only ever reads: no path a memory names is opened, only
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-memory-admin",
3
- "version": "1.10.1",
3
+ "version": "1.10.2",
4
4
  "description": "Browse, audit and prune the auto memory Claude Code keeps under ~/.claude/projects",
5
5
  "keywords": [
6
6
  "claude",
@@ -18,8 +18,37 @@ const AGENT_PROBLEMS = {
18
18
  'name-mismatch': 'Name and filename disagree',
19
19
  'unknown-model': 'Unrecognised model',
20
20
  'unknown-effort': 'Unrecognised effort',
21
+ 'unknown-memory-scope': 'Unrecognised memory scope',
21
22
  };
22
23
 
24
+ function memoryLine(agent, stores) {
25
+ if (!agent.memory) {
26
+ const stale = stores.filter((store) => store.agentName === agent.name && !store.linked);
27
+ if (!stale.length) return null;
28
+ return node('div', { class: ui.agentTools }, [
29
+ node('span', { class: ui.badge('warn'), text: 'no memory:' }),
30
+ node('span', {
31
+ text: ` this agent declares no memory scope, but ${stale.length === 1 ? 'a directory it once wrote' : `${stale.length} directories it once wrote`} ${stale.length === 1 ? 'is' : 'are'} still on disk and nothing loads ${stale.length === 1 ? 'it' : 'them'}.`,
32
+ }),
33
+ ]);
34
+ }
35
+
36
+ const mine = stores.filter((store) => store.agentName === agent.name && store.linked);
37
+ const inert = mine.some((store) => store.inert);
38
+ const count = mine.reduce((sum, store) => sum + store.memoryCount, 0);
39
+
40
+ const parts = [
41
+ node('span', { class: ui.scopeBadge(agent.memory), text: `memory: ${agent.memory}` }),
42
+ ];
43
+ if (inert) parts.push(node('span', { class: ui.badge('warn'), text: 'inert' }));
44
+ parts.push(node('span', {
45
+ text: mine.length
46
+ ? ` ${count} ${count === 1 ? 'memory' : 'memories'} in ${mine.length === 1 ? 'its store' : `${mine.length} stores`}${inert ? ', frozen while auto memory is off' : ''}.`
47
+ : ' no store yet - the directory appears the first time it saves something.',
48
+ }));
49
+ return node('div', { class: ui.agentTools }, parts);
50
+ }
51
+
23
52
  const show = (value) => (value === undefined || value === null ? 'unset' : JSON.stringify(value));
24
53
 
25
54
  const optionText = (option) => (option.note ? `${option.label} (${option.note})` : option.label);
@@ -79,7 +108,12 @@ async function saveAgent(file, field, value) {
79
108
  body: JSON.stringify({ file, field, value }),
80
109
  });
81
110
  if (state.storeId !== id) return;
82
- state.aux.cost = { ...state.aux.cost, agents: data.agents, agentsDirExists: data.agentsDirExists };
111
+ state.aux.cost = {
112
+ ...state.aux.cost,
113
+ agents: data.agents,
114
+ agentsDirExists: data.agentsDirExists,
115
+ agentStores: data.agentStores,
116
+ };
83
117
  toast(value === null ? `Cleared ${field} in ${file}` : `${file}: ${field} = ${value}`);
84
118
  } catch (err) {
85
119
  toast(err.message, { error: true });
@@ -138,21 +172,30 @@ function settingCard(entry, writable) {
138
172
  return card;
139
173
  }
140
174
 
141
- function agentRow(agent, fields) {
175
+ function agentRow(agent, fields, stores) {
142
176
  const row = node('div', { class: ui.agentRow });
143
177
 
144
- row.append(node('div', { class: ui.agentTop }, [
178
+ const top = [
145
179
  node('span', { class: ui.agentName, text: agent.name }),
146
- node('span', { class: ui.agentFile, text: agent.file }),
147
- ]));
180
+ node('span', { class: ui.agentFile, text: agent.projectPath ? `${agent.projectPath}/.claude/agents/${agent.file}` : agent.file }),
181
+ ];
182
+ if (agent.scope === 'project') top.push(node('span', { class: ui.scopeBadge('project'), text: 'project' }));
183
+ row.append(node('div', { class: ui.agentTop }, top));
148
184
 
149
185
  if (agent.description) row.append(node('p', { class: ui.agentDesc, text: agent.description }));
150
186
  if (agent.tools) row.append(node('div', { class: ui.agentTools, text: `tools: ${agent.tools}` }));
151
187
 
152
- row.append(node('div', { class: ui.agentControls }, [
153
- labelled('model', picker(fields.model.options, agent.model, (value) => saveAgent(agent.file, 'model', value))),
154
- labelled('effort', picker(fields.effort.options, agent.effort, (value) => saveAgent(agent.file, 'effort', value))),
155
- ]));
188
+ const memory = memoryLine(agent, stores);
189
+ if (memory) row.append(memory);
190
+
191
+ if (agent.writable) {
192
+ row.append(node('div', { class: ui.agentControls }, [
193
+ labelled('model', picker(fields.model.options, agent.model, (value) => saveAgent(agent.file, 'model', value))),
194
+ labelled('effort', picker(fields.effort.options, agent.effort, (value) => saveAgent(agent.file, 'effort', value))),
195
+ ]));
196
+ } else {
197
+ row.append(node('div', { class: ui.agentTools, text: `model: ${agent.model || 'inherit'}, effort: ${agent.effort || 'default'} - read-only, this file lives in a repository.` }));
198
+ }
156
199
 
157
200
  for (const problem of agent.problems) {
158
201
  row.append(issue(
@@ -181,7 +224,7 @@ function agentsCard(data) {
181
224
  return card;
182
225
  }
183
226
 
184
- for (const agent of data.agents) card.append(agentRow(agent, data.agentFields));
227
+ for (const agent of data.agents) card.append(agentRow(agent, data.agentFields, data.agentStores || []));
185
228
  return card;
186
229
  }
187
230
 
@@ -258,5 +258,29 @@ export function renderIssue(item, memories) {
258
258
  );
259
259
  }
260
260
 
261
+ if (item.kind === 'agent-memory-inert') {
262
+ return issue(
263
+ 'Auto memory is off, so this store is frozen',
264
+ `subagent memory is part of auto memory${item.setBy ? `, and ${item.setBy} turns it off` : ' and it is turned off'}. The memory: field on ${item.agentName} has no effect while that is the case: the agent starts with no memory instructions and no file tools, so nothing here is read and nothing new is written.`,
265
+ { bad },
266
+ );
267
+ }
268
+
269
+ if (item.kind === 'agent-store-orphan') {
270
+ return issue(
271
+ 'Nothing declares this store any more',
272
+ `${item.defined ? `${item.agentName} exists but no longer carries a memory: field` : `no agent named ${item.agentName} was found in any scope`}. A store is created by that field and outlives it, so this directory is still holding what it learned and no session will read it again.`,
273
+ { bad },
274
+ );
275
+ }
276
+
277
+ if (item.kind === 'agent-store-scope-mismatch') {
278
+ return issue(
279
+ `${item.agentName} keeps its memory in the ${item.declaredScope} scope now`,
280
+ `this store is the ${item.scope} one${item.declaredBy ? `, but ${item.declaredBy} declares memory: ${item.declaredScope}` : ''}. The live store is the ${item.declaredScope} one; this is what the agent wrote before the field changed.`,
281
+ { bad },
282
+ );
283
+ }
284
+
261
285
  return issue(item.kind, JSON.stringify(item));
262
286
  }
@@ -11,6 +11,26 @@ function storeSubtitle(store) {
11
11
  return `${scope} · ${store.sublabel}`;
12
12
  }
13
13
 
14
+ function agentMarker(store) {
15
+ if (!String(store.kind).startsWith('agent-') || !store.linkage) return null;
16
+ if (store.inert) {
17
+ return { text: 'inert', title: `Auto memory is off${store.inertBy ? ` (${store.inertBy})` : ''}, so the memory: field has no effect and this store is frozen.` };
18
+ }
19
+ if (store.linked) return null;
20
+ if (store.declaredScope) {
21
+ return {
22
+ text: 'moved',
23
+ title: `${store.agentName} now declares memory: ${store.declaredScope}, so the live store is elsewhere and this one is stale.`,
24
+ };
25
+ }
26
+ return {
27
+ text: 'orphan',
28
+ title: store.defined
29
+ ? `${store.agentName} exists but declares no memory: field any more, so nothing loads this store.`
30
+ : `No agent named ${store.agentName} was found in any scope, so nothing loads this store.`,
31
+ };
32
+ }
33
+
14
34
  function issueTitle(store) {
15
35
  const parts = [];
16
36
  if (store.issueCount) parts.push(`${store.issueCount} to fix in Cleanup`);
@@ -32,6 +52,7 @@ function storeButton(store) {
32
52
  const global = store.kind === 'global';
33
53
  const health = !global && !store.hasMemoryDir ? 'none' : store.severity || 'ok';
34
54
  const off = store.autoMemory && store.autoMemory.known && !store.autoMemory.enabled;
55
+ const marker = agentMarker(store);
35
56
  const active = state.activeSessions.filter((s) => s.storeId === store.id);
36
57
  return node('button', {
37
58
  class: ui.storeItem({ active: store.id === state.storeId, empty: !global && !store.hasMemoryDir }),
@@ -43,6 +64,7 @@ function storeButton(store) {
43
64
  node('span', { class: ui.storeName, text: store.label }),
44
65
  active.length ? node('span', { class: ui.dot('ok'), title: activeTitle(active) }) : null,
45
66
  off ? node('span', { class: ui.offMarker, text: 'off', title: 'Auto memory is disabled for this project' }) : null,
67
+ marker ? node('span', { class: ui.offMarker, text: marker.text, title: marker.title }) : null,
46
68
  global ? null : node('span', { class: ui.storeCount, text: store.hasMemoryDir ? String(store.memoryCount) : '-' }),
47
69
  ]),
48
70
  node('span', { class: ui.storePath, text: storeSubtitle(store) }),
package/server.mjs CHANGED
@@ -13,7 +13,7 @@ import { buildStore } from './src/model.mjs';
13
13
  import { resolveGlobalInstructions, resolveInstructions, summarise } from './src/instructions.mjs';
14
14
  import { settingsReport, summariseSettings } from './src/settings.mjs';
15
15
  import { costReport, writeUserSetting } from './src/cost.mjs';
16
- import { AGENTS_DIR, AGENT_FIELDS, agentsDirExists, listAgents, setAgentField } from './src/agents.mjs';
16
+ import { AGENTS_DIR, AGENT_FIELDS, agentsDirExists, listAllAgents, setAgentField } from './src/agents.mjs';
17
17
  import { listStores } from './src/stores.mjs';
18
18
  import { forgetPath, rememberPath } from './src/pathcache.mjs';
19
19
  import { searchAll } from './src/search.mjs';
@@ -140,6 +140,67 @@ const VERSION = (() => {
140
140
  }
141
141
  })();
142
142
 
143
+ /**
144
+ * Every agent definition on the machine: the user directory plus the agents
145
+ * directory of each repository auto memory has already resolved a path for.
146
+ * Nothing here guesses at a repository that was not confirmed somewhere else.
147
+ */
148
+ function allAgents() {
149
+ const projectPaths = listStores(ROOT)
150
+ .filter((store) => store.kind === 'auto' && store.pathExists)
151
+ .flatMap((store) => [store.path, ...(store.workingDirs || [])]);
152
+ return listAllAgents({ projectPaths });
153
+ }
154
+
155
+ /** Each subagent memory store next to the definition that asks for it, for the agents panel. */
156
+ function agentStoreLinks() {
157
+ return listStores(ROOT)
158
+ .filter((store) => String(store.kind).startsWith('agent-'))
159
+ .map((store) => ({
160
+ id: store.id,
161
+ kind: store.kind,
162
+ agentName: store.agentName,
163
+ projectPath: store.projectPath,
164
+ memoryCount: store.memoryCount,
165
+ declaredBy: store.declaredBy ?? null,
166
+ declaredScope: store.declaredScope ?? null,
167
+ linked: Boolean(store.linked),
168
+ defined: Boolean(store.defined),
169
+ inert: Boolean(store.inert),
170
+ inertBy: store.inertBy ?? null,
171
+ }));
172
+ }
173
+
174
+ /**
175
+ * Hand the address to whatever opens a URL here, and never let that decide
176
+ * whether the server runs.
177
+ *
178
+ * `start` is a cmd.exe builtin rather than a program, so spawning it by name
179
+ * fails on Windows; the documented form is `cmd /c start "" <url>`, where the
180
+ * empty string is the window title `start` would otherwise read the URL as. On
181
+ * Linux `xdg-open` is simply missing on a minimal install and inside some
182
+ * containers. Either way the failure arrives as an async 'error' event, which
183
+ * with no listener is an uncaught exception that would take down a server that
184
+ * had already printed its address and was working perfectly well.
185
+ */
186
+ function openBrowser(address) {
187
+ const [command, args] = process.platform === 'darwin'
188
+ ? ['open', [address]]
189
+ : process.platform === 'win32'
190
+ ? [process.env.COMSPEC || 'cmd.exe', ['/d', '/s', '/c', 'start', '""', address.replace(/&/g, '^&')]]
191
+ : ['xdg-open', [address]];
192
+
193
+ try {
194
+ const child = spawn(command, args, { stdio: 'ignore', detached: true, windowsHide: true });
195
+ child.on('error', () => {
196
+ console.log('Could not open a browser automatically - open the address above yourself.');
197
+ });
198
+ child.unref();
199
+ } catch {
200
+ console.log('Could not open a browser automatically - open the address above yourself.');
201
+ }
202
+ }
203
+
143
204
  function storeProjectDir(store) {
144
205
  // The global store is the user scope itself, which no project owns.
145
206
  if (store.kind === 'global') return null;
@@ -331,10 +392,15 @@ async function handleApi(req, res, url) {
331
392
  if (action === 'cost' && req.method === 'GET') {
332
393
  return sendJson(res, 200, {
333
394
  settings: costReport(),
334
- agents: listAgents(),
395
+ // Project-scope definitions come along read-only: an agent with
396
+ // `memory: project` lives in a repository rather than in the user
397
+ // directory, and leaving it out would show its memory store as belonging
398
+ // to no agent at all.
399
+ agents: allAgents(),
335
400
  agentsDir: AGENTS_DIR,
336
401
  agentsDirExists: agentsDirExists(),
337
402
  agentFields: AGENT_FIELDS,
403
+ agentStores: agentStoreLinks(),
338
404
  });
339
405
  }
340
406
 
@@ -346,8 +412,13 @@ async function handleApi(req, res, url) {
346
412
 
347
413
  if (action === 'cost/agent' && req.method === 'POST') {
348
414
  const body = await readBody(req);
349
- const agents = setAgentField(body.file, body.field, body.value ?? null);
350
- return sendJson(res, 200, { agents, agentsDir: AGENTS_DIR, agentsDirExists: agentsDirExists() });
415
+ setAgentField(body.file, body.field, body.value ?? null);
416
+ return sendJson(res, 200, {
417
+ agents: allAgents(),
418
+ agentsDir: AGENTS_DIR,
419
+ agentsDirExists: agentsDirExists(),
420
+ agentStores: agentStoreLinks(),
421
+ });
351
422
  }
352
423
 
353
424
  if (action === 'delete-preview' && req.method === 'POST') {
@@ -484,10 +555,7 @@ export function startServer({ port, root, open = true } = {}) {
484
555
  const address = `http://localhost:${listenPort}`;
485
556
  console.log(`Memory Admin -> ${address}`);
486
557
  console.log(`Reading -> ${ROOT}`);
487
- if (open && !process.env.NO_OPEN) {
488
- const opener = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open';
489
- spawn(opener, [address], { stdio: 'ignore', detached: true }).unref();
490
- }
558
+ if (open && !process.env.NO_OPEN) openBrowser(address);
491
559
  });
492
560
  return server;
493
561
  }
package/src/agents.mjs CHANGED
@@ -1,9 +1,12 @@
1
- // User-scope subagent definitions: ~/.claude/agents/*.md.
1
+ // Subagent definitions: agents/*.md, in the user scope and in each repository.
2
2
  //
3
- // Not to be confused with ~/.claude/agent-memory (src/stores.mjs), which is what
4
- // those agents *remember*. This module is about what they *are*: a markdown file
5
- // whose frontmatter names the agent and, optionally, pins the model and effort
6
- // it runs at. Pinning a summariser to Haiku while a reviewer stays on Opus is the
3
+ // Not to be confused with agent-memory (src/stores.mjs), which is what those
4
+ // agents *remember*. This module is about what they *are*: a markdown file whose
5
+ // frontmatter names the agent and, optionally, pins the model and effort it runs
6
+ // at, and declares with `memory:` whether it keeps a memory directory at all.
7
+ // That last field is the link between the two: a store exists because some file
8
+ // here asked for it, and a store no file asks for any more is a store nothing
9
+ // loads. Pinning a summariser to Haiku while a reviewer stays on Opus is the
7
10
  // finer-grained version of the CLAUDE_CODE_SUBAGENT_MODEL switch in src/cost.mjs,
8
11
  // which - worth remembering when both are set - outranks everything here.
9
12
  //
@@ -12,13 +15,21 @@
12
15
  // somebody wrote on purpose, and this tool has no view on it.
13
16
 
14
17
  import fs from 'node:fs';
15
- import os from 'node:os';
16
18
  import path from 'node:path';
17
19
 
20
+ import { canonicalPath, configPath } from './config.mjs';
18
21
  import { parseFrontmatter } from './parse.mjs';
19
22
  import { writeFileAtomic } from './mutate.mjs';
20
23
 
21
- export const AGENTS_DIR = path.join(os.homedir(), '.claude', 'agents');
24
+ export const AGENTS_DIR = configPath('agents');
25
+ export const PROJECT_AGENTS_DIR = path.join('.claude', 'agents');
26
+
27
+ /** The scopes `memory:` accepts, and the store kind each one produces. */
28
+ export const MEMORY_SCOPES = {
29
+ user: 'agent-user',
30
+ project: 'agent-project',
31
+ local: 'agent-local',
32
+ };
22
33
 
23
34
  const MODEL_ID = /^claude-[A-Za-z0-9._[\]-]+$/;
24
35
 
@@ -55,6 +66,7 @@ export const AGENT_PROBLEM_SEVERITY = {
55
66
  'name-mismatch': 'warn',
56
67
  'unknown-model': 'warn',
57
68
  'unknown-effort': 'warn',
69
+ 'unknown-memory-scope': 'warn',
58
70
  };
59
71
 
60
72
  function known(field, value) {
@@ -92,9 +104,9 @@ export function safeAgentPath(dir, file) {
92
104
  return full;
93
105
  }
94
106
 
95
- function describeAgent(dir, file) {
107
+ function describeAgent(dir, file, { scope = 'user', writable = true, projectPath = null } = {}) {
96
108
  const full = path.join(dir, file);
97
- const stem = file.replace(/\.md$/, '');
109
+ const stem = path.basename(file).replace(/\.md$/, '');
98
110
 
99
111
  let text;
100
112
  try {
@@ -102,11 +114,15 @@ function describeAgent(dir, file) {
102
114
  } catch (err) {
103
115
  return {
104
116
  file,
117
+ scope,
118
+ writable: false,
119
+ projectPath,
105
120
  name: stem,
106
121
  description: '',
107
122
  tools: '',
108
123
  model: null,
109
124
  effort: null,
125
+ memory: null,
110
126
  bytes: 0,
111
127
  problems: [{ kind: 'no-frontmatter', severity: 'bad', detail: err.message }],
112
128
  };
@@ -117,6 +133,7 @@ function describeAgent(dir, file) {
117
133
  const name = scalar('name') || stem;
118
134
  const model = scalar('model') || null;
119
135
  const effort = scalar('effort') || null;
136
+ const memory = scalar('memory') || null;
120
137
 
121
138
  const problems = [];
122
139
  const flag = (kind, detail) => problems.push({ kind, severity: AGENT_PROBLEM_SEVERITY[kind], detail });
@@ -129,26 +146,39 @@ function describeAgent(dir, file) {
129
146
  if (scalar('name') && scalar('name') !== stem) flag('name-mismatch', `Named "${scalar('name')}" in a file called "${file}".`);
130
147
  if (model && !known('model', model)) flag('unknown-model', `model: ${model} is neither an alias nor a claude- model name.`);
131
148
  if (effort && !known('effort', effort)) flag('unknown-effort', `effort: ${effort} is not one of low, medium, high, xhigh or max.`);
149
+ if (memory && !Object.prototype.hasOwnProperty.call(MEMORY_SCOPES, memory)) {
150
+ flag('unknown-memory-scope', `memory: ${memory} is not one of user, project or local, so this agent keeps no memory directory.`);
151
+ }
132
152
  }
133
153
 
134
154
  return {
135
155
  file,
156
+ scope,
157
+ // Only a plain .md at the top of the user directory is rewritable: writes go
158
+ // through safeAgentPath, which takes a bare basename, and nothing here has
159
+ // any business editing a file inside somebody's repository.
160
+ writable: writable && file === path.basename(file),
161
+ projectPath,
136
162
  name,
137
163
  description: scalar('description'),
138
164
  tools: scalar('tools'),
139
165
  model,
140
166
  effort,
167
+ memory: memory && Object.prototype.hasOwnProperty.call(MEMORY_SCOPES, memory) ? memory : null,
168
+ memoryRaw: memory,
141
169
  bytes: Buffer.byteLength(text, 'utf8'),
142
170
  problems,
143
171
  };
144
172
  }
145
173
 
146
174
  /**
147
- * Every user-scope agent definition. A directory that is not there is the
148
- * ordinary state of a machine whose owner has never written one, not a failure,
149
- * so it answers with an empty list.
175
+ * Every .md under an agents directory, including the subfolders people use to
176
+ * group them. Claude Code scans both agent roots recursively and identifies an
177
+ * agent by its `name` rather than by where the file sits, so a scan that stopped
178
+ * at the top level would miss agents that are running.
150
179
  */
151
- export function listAgents({ dir = AGENTS_DIR } = {}) {
180
+ function agentFiles(dir, prefix = '', depth = 0) {
181
+ if (depth > 4) return [];
152
182
  let entries;
153
183
  try {
154
184
  entries = fs.readdirSync(dir, { withFileTypes: true });
@@ -156,12 +186,54 @@ export function listAgents({ dir = AGENTS_DIR } = {}) {
156
186
  return [];
157
187
  }
158
188
 
159
- return entries
160
- .filter((entry) => entry.isFile() && entry.name.endsWith('.md') && !entry.name.startsWith('.'))
161
- .map((entry) => describeAgent(dir, entry.name))
189
+ const files = [];
190
+ for (const entry of entries) {
191
+ if (entry.name.startsWith('.')) continue;
192
+ const rel = prefix ? `${prefix}/${entry.name}` : entry.name;
193
+ if (entry.isDirectory()) files.push(...agentFiles(path.join(dir, entry.name), rel, depth + 1));
194
+ else if (entry.isFile() && entry.name.endsWith('.md')) files.push(rel);
195
+ }
196
+ return files;
197
+ }
198
+
199
+ /**
200
+ * Every user-scope agent definition. A directory that is not there is the
201
+ * ordinary state of a machine whose owner has never written one, not a failure,
202
+ * so it answers with an empty list.
203
+ */
204
+ export function listAgents({ dir = AGENTS_DIR } = {}) {
205
+ return agentFiles(dir)
206
+ .map((file) => describeAgent(dir, file, { scope: 'user', writable: true }))
207
+ .sort((a, b) => a.name.localeCompare(b.name));
208
+ }
209
+
210
+ /**
211
+ * Project-scope definitions, from <repo>/.claude/agents. These are read and
212
+ * never written: they belong to a repository somebody else may share, and an
213
+ * agent with `memory: project` is normally defined here rather than in the user
214
+ * scope, so leaving them out would orphan the very stores this exists to explain.
215
+ */
216
+ export function listProjectAgents(projectPath, { relative = PROJECT_AGENTS_DIR } = {}) {
217
+ if (!projectPath || !path.isAbsolute(projectPath)) return [];
218
+ const dir = path.join(projectPath, relative);
219
+ return agentFiles(dir)
220
+ .map((file) => describeAgent(dir, file, { scope: 'project', writable: false, projectPath }))
162
221
  .sort((a, b) => a.name.localeCompare(b.name));
163
222
  }
164
223
 
224
+ /** Every definition on the machine this tool can see: user scope plus each repository. */
225
+ export function listAllAgents({ dir = AGENTS_DIR, projectPaths = [] } = {}) {
226
+ const agents = listAgents({ dir });
227
+ const seen = new Set();
228
+ for (const projectPath of projectPaths) {
229
+ const key = canonicalPath(projectPath || '');
230
+ if (!key || seen.has(key)) continue;
231
+ seen.add(key);
232
+ agents.push(...listProjectAgents(projectPath));
233
+ }
234
+ return agents;
235
+ }
236
+
165
237
  /** Whether the directory itself exists, which is what tells an empty list from a missing one. */
166
238
  export function agentsDirExists({ dir = AGENTS_DIR } = {}) {
167
239
  try {
@@ -205,7 +277,11 @@ function insertionIndex(block) {
205
277
  * that changed, never re-emit the document from a parse of it.
206
278
  */
207
279
  export function rewriteAgentField(text, field, value) {
208
- const lines = text.split('\n');
280
+ // Rejoin with whatever the file already used. A checkout on Windows is CRLF
281
+ // throughout, and splitting on \n then rejoining with it would leave every
282
+ // line this function did not touch ending \r\n and the one it did ending \n.
283
+ const eol = /\r\n/.test(text) ? '\r\n' : '\n';
284
+ const lines = text.split(/\r?\n/);
209
285
  if (lines[0]?.trim() !== '---') {
210
286
  throw new Error('This file has no frontmatter block, so there is nothing to set.');
211
287
  }
@@ -230,7 +306,7 @@ export function rewriteAgentField(text, field, value) {
230
306
  block[at] = `${field}: ${value}`;
231
307
  }
232
308
 
233
- return [lines[0], ...block, ...lines.slice(end)].join('\n');
309
+ return [lines[0], ...block, ...lines.slice(end)].join(eol);
234
310
  }
235
311
 
236
312
  function normaliseAgentValue(field, value) {
package/src/checks.mjs CHANGED
@@ -2,6 +2,13 @@ import path from 'node:path';
2
2
  import { parseFrontmatter } from './parse.mjs';
3
3
 
4
4
  export const VALID_TYPES = ['user', 'feedback', 'project', 'reference'];
5
+
6
+ /** The scope each subagent store kind stands for, for the messages below. */
7
+ const STORE_SCOPES = {
8
+ 'agent-user': 'user',
9
+ 'agent-project': 'project',
10
+ 'agent-local': 'local',
11
+ };
5
12
  export const EMPTY_BODY_CHARS = 40;
6
13
  export const EMPTY_INSTRUCTION_CHARS = 10;
7
14
  export const HOOK_ECHO_OVERLAP = 0.9;
@@ -218,6 +225,68 @@ export function checkNoMemoryDespiteSessions(store, memories, retention) {
218
225
  }];
219
226
  }
220
227
 
228
+ /**
229
+ * A subagent store nothing declares any more.
230
+ *
231
+ * The directory only exists because some agent file once carried a `memory:`
232
+ * field naming this scope. Rename the agent, move it to another scope or drop
233
+ * the field and the directory stays exactly where it is, still holding whatever
234
+ * it learned, and no session will ever read it again. That is invisible on disk,
235
+ * which is the whole reason to say it here.
236
+ */
237
+ export function checkAgentStoreOrphan(store) {
238
+ if (!store || !store.linkage || store.linked) return [];
239
+ if (store.declaredScope) return [];
240
+
241
+ return [{
242
+ kind: 'agent-store-orphan',
243
+ severity: 'warn',
244
+ agentName: store.agentName,
245
+ scope: STORE_SCOPES[store.kind] || null,
246
+ defined: Boolean(store.defined),
247
+ }];
248
+ }
249
+
250
+ /** The agent still exists, but its `memory:` names a different scope than this store. */
251
+ export function checkAgentStoreScopeMismatch(store) {
252
+ if (!store || !store.linkage || store.linked) return [];
253
+ if (!store.declaredScope) return [];
254
+
255
+ return [{
256
+ kind: 'agent-store-scope-mismatch',
257
+ severity: 'warn',
258
+ agentName: store.agentName,
259
+ scope: STORE_SCOPES[store.kind] || null,
260
+ declaredScope: store.declaredScope,
261
+ declaredBy: store.declaredBy,
262
+ }];
263
+ }
264
+
265
+ /**
266
+ * Subagent memory is part of auto memory, so turning auto memory off turns this
267
+ * store off with it: the `memory:` field stops having any effect, the agent
268
+ * launches with no memory instructions and no file tools, and the directory can
269
+ * neither be read nor grow. Nothing about the files themselves shows that.
270
+ */
271
+ export function checkAgentMemoryInert(store) {
272
+ if (!store || !store.linkage || !store.inert) return [];
273
+
274
+ return [{
275
+ kind: 'agent-memory-inert',
276
+ severity: 'warn',
277
+ agentName: store.agentName,
278
+ setBy: store.inertBy || null,
279
+ }];
280
+ }
281
+
282
+ export function agentStoreChecks(store) {
283
+ return [
284
+ ...checkAgentMemoryInert(store),
285
+ ...checkAgentStoreOrphan(store),
286
+ ...checkAgentStoreScopeMismatch(store),
287
+ ];
288
+ }
289
+
221
290
  export function sessionChecks(store, memories, retention, { remembered = false, resolveOrigin = null } = {}) {
222
291
  if (!retention) return [];
223
292
  return [