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/assets/{TiptapBody-B91BqJWI.js → TiptapBody-g5zDcgLc.js} +1 -1
- package/dist/assets/{index-BEyUC3v9.css → index-CMQ3X1Y6.css} +1 -1
- package/dist/assets/{index-BYOscSQK.js → index-tW8C4h-7.js} +721 -721
- package/dist/index.html +2 -2
- package/package.json +1 -1
- package/src/main/__tests__/agentLibrary.test.cjs +83 -1
- package/src/main/agentLibrary.cjs +107 -1
- package/src/main/index.cjs +27 -0
- package/src/preload/api.d.ts +22 -0
- package/src/preload/index.cjs +10 -0
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-
|
|
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-
|
|
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.
|
|
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 = {
|
|
213
|
+
module.exports = {
|
|
214
|
+
listPersonas,
|
|
215
|
+
openProjects,
|
|
216
|
+
parseTools,
|
|
217
|
+
savePersona,
|
|
218
|
+
deletePersona,
|
|
219
|
+
removeOverride,
|
|
220
|
+
PERSONA_NAME_RE,
|
|
221
|
+
};
|
package/src/main/index.cjs
CHANGED
|
@@ -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
|
|
package/src/preload/api.d.ts
CHANGED
|
@@ -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: (
|
package/src/preload/index.cjs
CHANGED
|
@@ -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
|