claude-code-session-manager 0.54.0 → 0.55.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.
package/dist/index.html CHANGED
@@ -7,10 +7,10 @@
7
7
  <link rel="preconnect" href="https://fonts.googleapis.com">
8
8
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
9
9
  <link href="https://fonts.googleapis.com/css2?family=Newsreader:ital,opsz,wght@0,6..72,400;0,6..72,500;0,6..72,600;0,6..72,700;1,6..72,400&family=Geist:wght@300;400;500;600;700&family=IBM+Plex+Mono:wght@400;500;600&display=swap" rel="stylesheet">
10
- <script type="module" crossorigin src="./assets/index-BYOscSQK.js"></script>
10
+ <script type="module" crossorigin src="./assets/index-tW8C4h-7.js"></script>
11
11
  <link rel="modulepreload" crossorigin href="./assets/monaco-editor-BW5C4Iv1.js">
12
12
  <link rel="stylesheet" crossorigin href="./assets/monaco-editor-BTnBOi8r.css">
13
- <link rel="stylesheet" crossorigin href="./assets/index-BEyUC3v9.css">
13
+ <link rel="stylesheet" crossorigin href="./assets/index-CMQ3X1Y6.css">
14
14
  </head>
15
15
  <body class="bg-bg text-fg font-sans antialiased">
16
16
  <div id="root"></div>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-code-session-manager",
3
- "version": "0.54.0",
3
+ "version": "0.55.1",
4
4
  "description": "Local cockpit for the Claude Code CLI — multi-tab terminal, full config surface, scheduler, voice dictation, and live observability.",
5
5
  "type": "module",
6
6
  "main": "src/main/index.cjs",
@@ -12,7 +12,7 @@ const fs = require('node:fs');
12
12
  const fsp = require('node:fs/promises');
13
13
  const os = require('node:os');
14
14
  const path = require('node:path');
15
- const { listPersonas, openProjects, parseTools } = require('../agentLibrary.cjs');
15
+ const { listPersonas, openProjects, parseTools, savePersona, deletePersona, removeOverride } = require('../agentLibrary.cjs');
16
16
 
17
17
  const tmpDirs = [];
18
18
  afterEach(async () => {
@@ -33,6 +33,15 @@ async function mkTmp(prefix) {
33
33
  // home directory config.cjs's validatePath would otherwise enforce.
34
34
  const identityValidatePath = (p) => p;
35
35
 
36
+ // savePersona/deletePersona/removeOverride take an injectable writeTextAtomic
37
+ // too, since the real config.cjs one enforces its own home-dir validatePath
38
+ // regardless of what's injected for the `validatePath` param here.
39
+ async function fakeWriteTextAtomic(abs, text) {
40
+ await fsp.mkdir(path.dirname(abs), { recursive: true });
41
+ await fsp.writeFile(abs, text, 'utf8');
42
+ return { ok: true, mtimeMs: Date.now() };
43
+ }
44
+
36
45
  test('parseTools splits + trims a comma-separated tools frontmatter value', () => {
37
46
  expect(parseTools('Read, Grep, Glob, Bash')).toEqual(['Read', 'Grep', 'Glob', 'Bash']);
38
47
  expect(parseTools('')).toEqual([]);
@@ -74,6 +83,7 @@ test('listPersonas parses frontmatter and reports overridingProjects for open ta
74
83
  'name: builder',
75
84
  'description: Watch git history and drive the next publish.',
76
85
  'tools: Read, Grep, Glob, Bash',
86
+ 'tags: feature, bug',
77
87
  '---',
78
88
  '',
79
89
  'You are the Builder agent.',
@@ -103,6 +113,8 @@ test('listPersonas parses frontmatter and reports overridingProjects for open ta
103
113
 
104
114
  const byName = Object.fromEntries(personas.map((p) => [p.name, p]));
105
115
  expect(byName.builder.description).toBe('Watch git history and drive the next publish.');
116
+ expect(byName.builder.tags).toEqual(['feature', 'bug']);
117
+ expect(byName.debugger.tags).toEqual([]);
106
118
  expect(byName.builder.tools).toEqual(['Read', 'Grep', 'Glob', 'Bash']);
107
119
  expect(byName.builder.body).toContain('You are the Builder agent.');
108
120
  expect(byName.builder.overridingProjects).toEqual([path.basename(projectWithOverlay)]);
@@ -131,3 +143,73 @@ test('listPersonas skips a project cwd that validatePath rejects, rather than th
131
143
  expect(personas).toHaveLength(1);
132
144
  expect(personas[0].overridingProjects).toEqual([]);
133
145
  });
146
+
147
+ test('savePersona writes frontmatter + body, and rejects a non-slug name', async () => {
148
+ const globalDir = await mkTmp('sm-agent-library-save-');
149
+ await savePersona({
150
+ name: 'my-agent',
151
+ description: 'Does a thing.',
152
+ tools: ['Read', 'Grep'],
153
+ model: 'opus',
154
+ color: 'blue',
155
+ tags: ['feature', 'bug'],
156
+ body: 'You are my-agent.',
157
+ globalDir,
158
+ validatePath: identityValidatePath,
159
+ writeTextAtomic: fakeWriteTextAtomic,
160
+ });
161
+ const text = await fsp.readFile(path.join(globalDir, 'my-agent.md'), 'utf8');
162
+ expect(text).toContain('name: my-agent');
163
+ expect(text).toContain('description: Does a thing.');
164
+ expect(text).toContain('tools: Read, Grep');
165
+ expect(text).toContain('model: opus');
166
+ expect(text).toContain('color: blue');
167
+ expect(text).toContain('tags: feature, bug');
168
+ expect(text).toContain('You are my-agent.');
169
+
170
+ await expect(
171
+ savePersona({ name: 'Not Valid', body: '', globalDir, validatePath: identityValidatePath, writeTextAtomic: fakeWriteTextAtomic }),
172
+ ).rejects.toThrow(/lowercase, hyphenated/);
173
+ });
174
+
175
+ test('savePersona with originalName renames — writes the new file and removes the old one', async () => {
176
+ const globalDir = await mkTmp('sm-agent-library-rename-');
177
+ await fsp.writeFile(path.join(globalDir, 'old-name.md'), '---\nname: old-name\n---\nBody.\n');
178
+ await savePersona({
179
+ name: 'new-name',
180
+ originalName: 'old-name',
181
+ description: '',
182
+ tools: [],
183
+ model: 'inherit',
184
+ color: '',
185
+ body: 'Body.',
186
+ globalDir,
187
+ validatePath: identityValidatePath,
188
+ writeTextAtomic: fakeWriteTextAtomic,
189
+ });
190
+ expect(fs.existsSync(path.join(globalDir, 'new-name.md'))).toBe(true);
191
+ expect(fs.existsSync(path.join(globalDir, 'old-name.md'))).toBe(false);
192
+ });
193
+
194
+ test('deletePersona removes the file, and is a no-op when it is already gone', async () => {
195
+ const globalDir = await mkTmp('sm-agent-library-delete-');
196
+ await fsp.writeFile(path.join(globalDir, 'gone-soon.md'), '---\nname: gone-soon\n---\nBody.\n');
197
+ await deletePersona({ name: 'gone-soon', globalDir, validatePath: identityValidatePath });
198
+ expect(fs.existsSync(path.join(globalDir, 'gone-soon.md'))).toBe(false);
199
+ await expect(deletePersona({ name: 'gone-soon', globalDir, validatePath: identityValidatePath })).resolves.toEqual({ ok: true });
200
+ });
201
+
202
+ test('removeOverride deletes a project overlay resolved by project name, and rejects an unknown project', async () => {
203
+ const project = await mkTmp('sm-agent-library-override-');
204
+ await fsp.mkdir(path.join(project, '.claude', 'agents'), { recursive: true });
205
+ const overlayPath = path.join(project, '.claude', 'agents', 'builder.md');
206
+ await fsp.writeFile(overlayPath, 'overlay\n');
207
+ const loadSessions = async () => ({ tabs: [{ cwd: project }] });
208
+
209
+ await removeOverride({ name: 'builder', projectName: path.basename(project), loadSessions, validatePath: identityValidatePath });
210
+ expect(fs.existsSync(overlayPath)).toBe(false);
211
+
212
+ await expect(
213
+ removeOverride({ name: 'builder', projectName: 'not-open', loadSessions, validatePath: identityValidatePath }),
214
+ ).rejects.toThrow(/project not open/);
215
+ });
@@ -31,6 +31,101 @@ function parseTools(raw) {
31
31
  return raw.split(',').map((t) => t.trim()).filter(Boolean);
32
32
  }
33
33
 
34
+ /** Filename-safe persona name: lowercase, hyphenated, matches the `.md` files on disk. */
35
+ const PERSONA_NAME_RE = /^[a-z][a-z0-9-]*$/;
36
+
37
+ /**
38
+ * Serializes a persona's frontmatter + body back to `.md` file text.
39
+ * `tags` is a Claude-Code-agnostic extension of the frontmatter (Claude Code
40
+ * itself never reads it) — the Epic intent tags (tagLibrary.ts's TAG_LIBRARY)
41
+ * this persona is associated with, so Agent Library and Tag Library can each
42
+ * assign/remove the relationship from either side. Stored the same
43
+ * comma-list way as `tools` for one parser to cover both.
44
+ */
45
+ function serializePersona({ name, description, tools, model, color, tags, body }) {
46
+ const lines = ['---', `name: ${name}`];
47
+ if (description) lines.push(`description: ${description}`);
48
+ if (tools && tools.length) lines.push(`tools: ${tools.join(', ')}`);
49
+ if (model && model !== 'inherit') lines.push(`model: ${model}`);
50
+ if (color) lines.push(`color: ${color}`);
51
+ if (tags && tags.length) lines.push(`tags: ${tags.join(', ')}`);
52
+ lines.push('---', '');
53
+ return lines.join('\n') + (body || '').trim() + '\n';
54
+ }
55
+
56
+ /**
57
+ * Writes a global persona `.md` file — `~/.claude/agents/<name>.md`. Pass
58
+ * `originalName` when renaming an existing persona so the old file is
59
+ * removed once the new one is written (no-op if names match).
60
+ */
61
+ async function savePersona({
62
+ name,
63
+ originalName,
64
+ description,
65
+ tools,
66
+ model,
67
+ color,
68
+ tags,
69
+ body,
70
+ globalDir = path.join(os.homedir(), '.claude', 'agents'),
71
+ validatePath = configMgr.validatePath,
72
+ writeTextAtomic = configMgr.writeTextAtomic,
73
+ } = {}) {
74
+ if (!PERSONA_NAME_RE.test(name || '')) {
75
+ throw new Error('agent name must be lowercase, hyphenated (e.g. "my-agent")');
76
+ }
77
+ const target = validatePath(path.join(globalDir, `${name}.md`));
78
+ const text = serializePersona({ name, description, tools, model, color, tags, body });
79
+ await writeTextAtomic(target, text);
80
+ if (originalName && originalName !== name) {
81
+ const oldReal = validatePath(path.join(globalDir, `${originalName}.md`));
82
+ try {
83
+ await fsp.unlink(oldReal);
84
+ } catch (e) {
85
+ if (e.code !== 'ENOENT') throw e;
86
+ }
87
+ }
88
+ return { ok: true, path: target };
89
+ }
90
+
91
+ /** Deletes a global persona `.md` file. Idempotent — missing file is not an error. */
92
+ async function deletePersona({
93
+ name,
94
+ globalDir = path.join(os.homedir(), '.claude', 'agents'),
95
+ validatePath = configMgr.validatePath,
96
+ } = {}) {
97
+ const target = validatePath(path.join(globalDir, `${name}.md`));
98
+ try {
99
+ await fsp.unlink(target);
100
+ } catch (e) {
101
+ if (e.code !== 'ENOENT') throw e;
102
+ }
103
+ return { ok: true };
104
+ }
105
+
106
+ /**
107
+ * Drops a project's local override (`<cwd>/.claude/agents/<name>.md`) so the
108
+ * global definition applies there again. `projectName` is resolved against
109
+ * currently-open tabs (same source `listPersonas` uses for overridingProjects).
110
+ */
111
+ async function removeOverride({
112
+ name,
113
+ projectName,
114
+ loadSessions = sessionsStore.load,
115
+ validatePath = configMgr.validatePath,
116
+ } = {}) {
117
+ const projects = await openProjects({ loadSessions });
118
+ const project = projects.find((p) => p.name === projectName);
119
+ if (!project) throw new Error(`project not open: ${projectName}`);
120
+ const target = validatePath(path.join(project.cwd, '.claude', 'agents', `${name}.md`));
121
+ try {
122
+ await fsp.unlink(target);
123
+ } catch (e) {
124
+ if (e.code !== 'ENOENT') throw e;
125
+ }
126
+ return { ok: true };
127
+ }
128
+
34
129
  /**
35
130
  * Currently-open project tabs, deduped by cwd, as `{ cwd, name }` — `name`
36
131
  * is the last path segment (matches the `projectNameFromCwd` convention used
@@ -103,6 +198,9 @@ async function listPersonas({
103
198
  name: fm.name || fallbackName,
104
199
  description: fm.description || null,
105
200
  tools: parseTools(fm.tools),
201
+ model: fm.model || null,
202
+ color: fm.color || null,
203
+ tags: parseTools(fm.tags),
106
204
  path: real,
107
205
  body: body.trim(),
108
206
  overridingProjects,
@@ -112,4 +210,12 @@ async function listPersonas({
112
210
  return personas;
113
211
  }
114
212
 
115
- module.exports = { listPersonas, openProjects, parseTools };
213
+ module.exports = {
214
+ listPersonas,
215
+ openProjects,
216
+ parseTools,
217
+ savePersona,
218
+ deletePersona,
219
+ removeOverride,
220
+ PERSONA_NAME_RE,
221
+ };
@@ -33,6 +33,7 @@ const chatRunner = require('./chatRunner.cjs');
33
33
  const promptSessionEvents = require('./promptSessionEvents.cjs');
34
34
  const activeIndexMerge = require('./lib/activeIndexMerge.cjs');
35
35
  const agentLibrary = require('./agentLibrary.cjs');
36
+ const { sendIfAlive } = require('./lib/sendToRenderer.cjs');
36
37
  const { resolveBuildTarget } = require('./lib/buildTarget.cjs');
37
38
  const adminHttp = createAdminHttp();
38
39
  scheduler.registerAdminRoutes(adminHttp);
@@ -454,6 +455,32 @@ ipcMain.handle('mcp:status', () => probeMcpStatus());
454
455
  // personas plus, per currently-open project tab, whether that project
455
456
  // overlays the same agent name at `<cwd>/.claude/agents/<name>.md`. Read-only.
456
457
  ipcMain.handle('agents:list-personas', () => agentLibrary.listPersonas());
458
+ // Agent Library CRUD — global personas only (`~/.claude/agents/*.md`); a
459
+ // project's local overlay can only be removed here (removeOverride), never
460
+ // created, since Agent Library is a Home-face, machine-wide surface with no
461
+ // per-tab cwd context of its own. Each mutation broadcasts `agents:changed`
462
+ // so Agent Library and Tag Library — two independently-mounted dockview
463
+ // panels, each with its own component-local `listPersonas()` snapshot —
464
+ // both refresh off the one file on disk instead of one panel going stale
465
+ // after the other edits the agent<->tag relationship.
466
+ function broadcastAgentsChanged() {
467
+ sendIfAlive(mainWindow, 'agents:changed', {});
468
+ }
469
+ ipcMain.handle('agents:save-persona', async (_e, payload) => {
470
+ const result = await agentLibrary.savePersona(payload);
471
+ broadcastAgentsChanged();
472
+ return result;
473
+ });
474
+ ipcMain.handle('agents:delete-persona', async (_e, payload) => {
475
+ const result = await agentLibrary.deletePersona(payload);
476
+ broadcastAgentsChanged();
477
+ return result;
478
+ });
479
+ ipcMain.handle('agents:remove-override', async (_e, payload) => {
480
+ const result = await agentLibrary.removeOverride(payload);
481
+ broadcastAgentsChanged();
482
+ return result;
483
+ });
457
484
 
458
485
  ipcMain.handle('app:engage-rules-path', () => process.env.SESSION_MANAGER_ENGAGE_RULES || null);
459
486
 
@@ -263,12 +263,29 @@ export interface AgentPersona {
263
263
  name: string;
264
264
  description: string | null;
265
265
  tools: string[];
266
+ model: string | null;
267
+ color: string | null;
268
+ /** Epic intent tags (tagLibrary.ts's TAG_LIBRARY) this persona is associated with. */
269
+ tags: string[];
266
270
  path: string;
267
271
  body: string;
268
272
  /** Project names (basename of cwd) whose currently-open tab overlays this agent. */
269
273
  overridingProjects: string[];
270
274
  }
271
275
 
276
+ /** Payload for `agents.savePersona` — creates or overwrites a global persona file. */
277
+ export interface AgentPersonaSaveInput {
278
+ name: string;
279
+ /** Previous filename, when renaming an existing persona. Omit when creating. */
280
+ originalName?: string;
281
+ description: string;
282
+ tools: string[];
283
+ model: string;
284
+ color: string;
285
+ tags: string[];
286
+ body: string;
287
+ }
288
+
272
289
  export interface VoiceHotkeyConfig {
273
290
  accelerator: string;
274
291
  mode: 'hold' | 'toggle';
@@ -1329,6 +1346,11 @@ export interface SessionManagerAPI {
1329
1346
  };
1330
1347
  agents: {
1331
1348
  listPersonas: () => Promise<AgentPersona[]>;
1349
+ savePersona: (payload: AgentPersonaSaveInput) => Promise<{ ok: boolean; path: string }>;
1350
+ deletePersona: (payload: { name: string }) => Promise<{ ok: boolean }>;
1351
+ removeOverride: (payload: { name: string; projectName: string }) => Promise<{ ok: boolean }>;
1352
+ /** Fires after any save/delete/removeOverride — subscribers should re-fetch listPersonas(). */
1353
+ onChanged: (handler: () => void) => () => void;
1332
1354
  };
1333
1355
  logs: {
1334
1356
  write: (
@@ -148,6 +148,16 @@ contextBridge.exposeInMainWorld('api', {
148
148
  },
149
149
  agents: {
150
150
  listPersonas: () => ipcRenderer.invoke('agents:list-personas'),
151
+ savePersona: (payload) => ipcRenderer.invoke('agents:save-persona', payload),
152
+ deletePersona: (payload) => ipcRenderer.invoke('agents:delete-persona', payload),
153
+ removeOverride: (payload) => ipcRenderer.invoke('agents:remove-override', payload),
154
+ // Fired after any save/delete/removeOverride so every mounted
155
+ // subscriber (Agent Library, Tag Library) re-fetches the same file.
156
+ onChanged: (handler) => {
157
+ const listener = () => handler();
158
+ ipcRenderer.on('agents:changed', listener);
159
+ return () => ipcRenderer.removeListener('agents:changed', listener);
160
+ },
151
161
  },
152
162
  logs: {
153
163
  // ctx is optional: { cwd, tabId, epicId, tags } — when present and