claude-code-session-manager 0.10.5 → 0.11.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (31) hide show
  1. package/dist/assets/{TiptapBody-BroECZ_z.js → TiptapBody-CRmtVlnv.js} +1 -1
  2. package/dist/assets/{cssMode-Crq-Rykh.js → cssMode-jKB9FuI1.js} +1 -1
  3. package/dist/assets/{freemarker2-B6CC21Ql.js → freemarker2-BPjwx9LW.js} +1 -1
  4. package/dist/assets/{handlebars-BLgR-12n.js → handlebars-BLoV3dqv.js} +1 -1
  5. package/dist/assets/{html-CiQkt_KY.js → html-D-6YItp_.js} +1 -1
  6. package/dist/assets/{htmlMode-Cy8mc91p.js → htmlMode-Dtc92cqk.js} +1 -1
  7. package/dist/assets/{index-DW-tvyin.css → index-4x5C_duH.css} +1 -1
  8. package/dist/assets/{index-DU-o-LEm.js → index-DG1rozxP.js} +1286 -1255
  9. package/dist/assets/{javascript-CHNCN8qj.js → javascript-BQcvFYdn.js} +1 -1
  10. package/dist/assets/{jsonMode-BSN7mvBT.js → jsonMode-CnFmKTUr.js} +1 -1
  11. package/dist/assets/{liquid-B0kmZauA.js → liquid-ogD2CQU1.js} +1 -1
  12. package/dist/assets/{lspLanguageFeatures-DI_RToRa.js → lspLanguageFeatures-DKVkL-Ro.js} +1 -1
  13. package/dist/assets/{mdx-BSF-fsyJ.js → mdx-BHbTIp7G.js} +1 -1
  14. package/dist/assets/{python-DUl3Fmgk.js → python-fa-8uToX.js} +1 -1
  15. package/dist/assets/{razor-Df7WxBjo.js → razor-D4OJJrW7.js} +1 -1
  16. package/dist/assets/{tsMode-qccVs0_G.js → tsMode-J9ZwPyiC.js} +1 -1
  17. package/dist/assets/{typescript-BEwM5qbq.js → typescript-1H3_b_hk.js} +1 -1
  18. package/dist/assets/{xml-CCtx-_Kw.js → xml-Djys1Yq_.js} +1 -1
  19. package/dist/assets/{yaml-B66nOkCW.js → yaml-CAZ0iMcZ.js} +1 -1
  20. package/dist/index.html +2 -2
  21. package/package.json +1 -1
  22. package/src/main/agentMemory.cjs +267 -0
  23. package/src/main/files.cjs +346 -0
  24. package/src/main/git.cjs +333 -0
  25. package/src/main/historyAggregator.cjs +70 -0
  26. package/src/main/index.cjs +12 -0
  27. package/src/main/ipcSchemas.cjs +62 -0
  28. package/src/main/projectSkills.cjs +124 -0
  29. package/src/main/superagent.cjs +202 -0
  30. package/src/preload/api.d.ts +203 -0
  31. package/src/preload/index.cjs +47 -0
@@ -0,0 +1,333 @@
1
+ /**
2
+ * Git IPC handlers — richer status data than the StatusBar-only `app:git-branch`.
3
+ *
4
+ * Ported (in spirit) from ClaudeCodeUnleashed's src/main/ipc/git.ts. Built
5
+ * fresh for our conventions: argv-array spawn (never shell:true), validatePath
6
+ * gating, zod-validated IPC payloads, 5s timeout ceiling, 5s per-cwd cache.
7
+ *
8
+ * Channels:
9
+ * git:status → { branch, ahead, behind, uncommittedCount, files[] } | null
10
+ * git:file-status → { [absolutePath]: 'modified' | 'added' | 'deleted' |
11
+ * 'renamed' | 'untracked' | 'staged' | 'conflict' }
12
+ *
13
+ * "Not a git repo" / spawn errors / timeouts all surface as `null` (status)
14
+ * or `{}` (file-status) so the renderer can render unconditionally.
15
+ *
16
+ * Future readers: this file is for OBSERVATION only. No mutation commands
17
+ * (commit/push/pull/add) — those belong to a separate, much-more-restricted
18
+ * surface if they ever land. The whole point of the cache is so the planned
19
+ * file-tree sidebar can poll cheaply without spawning git for every render.
20
+ */
21
+
22
+ const { spawn } = require('node:child_process');
23
+ const path = require('node:path');
24
+ const { schemas } = require('./ipcSchemas.cjs');
25
+ const { validatePath } = require('./config.cjs');
26
+
27
+ const GIT_TIMEOUT_MS = 5_000;
28
+ const CACHE_TTL_MS = 5_000;
29
+ const MAX_BUFFER = 10 * 1024 * 1024; // 10 MiB — same cap Unleashed uses
30
+
31
+ // Per-cwd in-memory caches. Keyed by the realpath-validated cwd so symlinks
32
+ // to the same repo dedupe. Each entry: { value, expiresAt }.
33
+ const statusCache = new Map();
34
+ const fileStatusCache = new Map();
35
+
36
+ /**
37
+ * Run `git <args>` in cwd with a hard 5s SIGKILL ceiling. Resolves
38
+ * { ok:true, stdout } on exit 0, { ok:false, code, stderr } otherwise.
39
+ * Never throws — IPC handlers want a discriminated result, not an exception.
40
+ *
41
+ * argv-array spawn — never `shell: true`. cwd MUST already be validatePath-checked.
42
+ */
43
+ function runGit(args, cwd) {
44
+ return new Promise((resolve) => {
45
+ let proc;
46
+ try {
47
+ proc = spawn('git', args, {
48
+ cwd,
49
+ windowsHide: true,
50
+ // Inherit env minus anything that could change git behaviour; default
51
+ // is fine here — we explicitly do NOT want `cleanChildEnv` because
52
+ // git relies on $HOME/$PATH/$GIT_* to find config + ssh credentials.
53
+ });
54
+ } catch (err) {
55
+ resolve({ ok: false, code: -1, stderr: `spawn failed: ${err?.message ?? String(err)}` });
56
+ return;
57
+ }
58
+
59
+ const stdoutChunks = [];
60
+ const stderrChunks = [];
61
+ let stdoutLen = 0;
62
+ let stderrLen = 0;
63
+ let timedOut = false;
64
+
65
+ const killTimer = setTimeout(() => {
66
+ timedOut = true;
67
+ try { proc.kill('SIGKILL'); } catch { /* already dead */ }
68
+ }, GIT_TIMEOUT_MS);
69
+
70
+ proc.stdout.on('data', (b) => {
71
+ if (stdoutLen < MAX_BUFFER) {
72
+ stdoutChunks.push(b);
73
+ stdoutLen += b.length;
74
+ }
75
+ });
76
+ proc.stderr.on('data', (b) => {
77
+ if (stderrLen < MAX_BUFFER) {
78
+ stderrChunks.push(b);
79
+ stderrLen += b.length;
80
+ }
81
+ });
82
+
83
+ proc.on('error', (err) => {
84
+ clearTimeout(killTimer);
85
+ resolve({ ok: false, code: -1, stderr: `spawn error: ${err?.message ?? String(err)}` });
86
+ });
87
+
88
+ proc.on('close', (code) => {
89
+ clearTimeout(killTimer);
90
+ const stdout = Buffer.concat(stdoutChunks).toString('utf8');
91
+ const stderr = Buffer.concat(stderrChunks).toString('utf8');
92
+ if (timedOut) {
93
+ resolve({ ok: false, code: -1, stderr: `timeout after ${GIT_TIMEOUT_MS}ms\n${stderr}` });
94
+ return;
95
+ }
96
+ if (code === 0) {
97
+ resolve({ ok: true, stdout, stderr });
98
+ } else {
99
+ resolve({ ok: false, code: typeof code === 'number' ? code : -1, stderr });
100
+ }
101
+ });
102
+ });
103
+ }
104
+
105
+ /**
106
+ * Unquote a git path. With porcelain v1 *without* -z, git quotes paths
107
+ * containing special chars (spaces, unicode, control bytes). We use -z so
108
+ * paths come through raw NUL-separated, but kept as a defensive shim in
109
+ * case a future caller drops -z.
110
+ */
111
+ function unquoteGitPath(p) {
112
+ if (p.startsWith('"') && p.endsWith('"')) {
113
+ return p.slice(1, -1)
114
+ .replace(/\\n/g, '\n')
115
+ .replace(/\\t/g, '\t')
116
+ .replace(/\\"/g, '"')
117
+ .replace(/\\\\/g, '\\');
118
+ }
119
+ return p;
120
+ }
121
+
122
+ /**
123
+ * Map porcelain XY pair to one of our enum values. Mirrors Unleashed's
124
+ * git-file-status handler, with the same conflict-detection rules.
125
+ *
126
+ * ' M' → modified '??' → untracked
127
+ * 'A ' → added 'AM' → added (index) — staged-then-modified
128
+ * ' D' → deleted 'D ' → deleted
129
+ * 'R ' → renamed 'C ' → added (copy treated as new)
130
+ * 'U.' / '.U' / 'DD' / 'AA' → conflict
131
+ * any other staged change → staged
132
+ */
133
+ function mapStatus(indexChar, workChar) {
134
+ if (indexChar === '?' && workChar === '?') return 'untracked';
135
+ if (
136
+ indexChar === 'U' || workChar === 'U' ||
137
+ (indexChar === 'D' && workChar === 'D') ||
138
+ (indexChar === 'A' && workChar === 'A')
139
+ ) return 'conflict';
140
+ if (indexChar === 'A') return 'added';
141
+ if (indexChar === 'D' || workChar === 'D') return 'deleted';
142
+ if (indexChar === 'R') return 'renamed';
143
+ if (indexChar === 'C') return 'added';
144
+ if (indexChar !== ' ' && indexChar !== '?') return 'staged';
145
+ if (workChar === 'M') return 'modified';
146
+ return 'modified';
147
+ }
148
+
149
+ /**
150
+ * Parse `git status --porcelain=v1 -b -u -z` output.
151
+ *
152
+ * First entry (when -b is set) is the branch header:
153
+ * "## main...origin/main [ahead 1, behind 2]"
154
+ * "## HEAD (no branch)" — detached
155
+ * "## No commits yet on main"
156
+ *
157
+ * Remaining entries are NUL-separated "XY <path>" records. Renamed/copied
158
+ * records consume TWO entries (new path, then original path).
159
+ *
160
+ * Returns { branch, ahead, behind, files }.
161
+ */
162
+ function parsePorcelain(stdout, cwd) {
163
+ const entries = stdout.split('\0').filter((e) => e.length > 0);
164
+ const out = {
165
+ branch: 'HEAD',
166
+ ahead: 0,
167
+ behind: 0,
168
+ files: [],
169
+ };
170
+
171
+ let i = 0;
172
+ // Branch header
173
+ if (entries.length > 0 && entries[0].startsWith('## ')) {
174
+ const header = entries[0].slice(3);
175
+ if (header.startsWith('No commits yet on ')) {
176
+ out.branch = header.slice('No commits yet on '.length).split(' ')[0];
177
+ } else if (header.startsWith('HEAD (no branch)')) {
178
+ out.branch = 'HEAD';
179
+ } else {
180
+ // "main" or "main...origin/main [ahead 1, behind 2]"
181
+ const m = /^([^.\s]+)(?:\.\.\.([^\s]+))?(?:\s+\[([^\]]+)\])?/.exec(header);
182
+ if (m) {
183
+ out.branch = m[1];
184
+ const bracket = m[3];
185
+ if (bracket) {
186
+ const am = /ahead (\d+)/.exec(bracket);
187
+ const bm = /behind (\d+)/.exec(bracket);
188
+ if (am) out.ahead = parseInt(am[1], 10) || 0;
189
+ if (bm) out.behind = parseInt(bm[1], 10) || 0;
190
+ }
191
+ }
192
+ }
193
+ i = 1;
194
+ }
195
+
196
+ while (i < entries.length) {
197
+ const entry = entries[i];
198
+ if (entry.length < 3) { i++; continue; }
199
+ const indexChar = entry[0];
200
+ const workChar = entry[1];
201
+ // Format with -z: "XY <path>" — note SINGLE space between XY and path.
202
+ let filePath = entry.substring(3);
203
+
204
+ // Renamed/copied → next entry is the original path; skip it.
205
+ if (indexChar === 'R' || indexChar === 'C') {
206
+ i++;
207
+ }
208
+
209
+ filePath = unquoteGitPath(filePath);
210
+ const absolutePath = path.resolve(cwd, filePath);
211
+ const status = mapStatus(indexChar, workChar);
212
+
213
+ out.files.push({
214
+ path: absolutePath,
215
+ relativePath: filePath,
216
+ status,
217
+ indexStatus: indexChar,
218
+ workTreeStatus: workChar,
219
+ });
220
+
221
+ i++;
222
+ }
223
+
224
+ return out;
225
+ }
226
+
227
+ /**
228
+ * git:status(cwd) — full status. Returns null if not a git repo / git fails.
229
+ *
230
+ * Cached per realpath-cwd for 5s so the renderer can poll cheaply (the
231
+ * planned file-tree sidebar will tick this every couple seconds).
232
+ */
233
+ async function getStatus(cwd) {
234
+ let realCwd;
235
+ try {
236
+ realCwd = validatePath(cwd);
237
+ } catch {
238
+ return null;
239
+ }
240
+
241
+ const now = Date.now();
242
+ const cached = statusCache.get(realCwd);
243
+ if (cached && cached.expiresAt > now) return cached.value;
244
+
245
+ // Single porcelain call gets us branch + ahead/behind + files in one shot.
246
+ const result = await runGit(
247
+ ['status', '--porcelain=v1', '-b', '-u', '-z'],
248
+ realCwd,
249
+ );
250
+
251
+ if (!result.ok) {
252
+ // "not a git repository" or any other failure → null
253
+ statusCache.set(realCwd, { value: null, expiresAt: now + CACHE_TTL_MS });
254
+ return null;
255
+ }
256
+
257
+ const parsed = parsePorcelain(result.stdout, realCwd);
258
+ const value = {
259
+ branch: parsed.branch,
260
+ ahead: parsed.ahead,
261
+ behind: parsed.behind,
262
+ uncommittedCount: parsed.files.length,
263
+ files: parsed.files,
264
+ };
265
+ statusCache.set(realCwd, { value, expiresAt: now + CACHE_TTL_MS });
266
+ return value;
267
+ }
268
+
269
+ /**
270
+ * git:file-status(cwd) — `{ absPath: status }` map. Returns `{}` if not a
271
+ * git repo / git fails. Same 5s per-cwd cache.
272
+ */
273
+ async function getFileStatus(cwd) {
274
+ let realCwd;
275
+ try {
276
+ realCwd = validatePath(cwd);
277
+ } catch {
278
+ return {};
279
+ }
280
+
281
+ const now = Date.now();
282
+ const cached = fileStatusCache.get(realCwd);
283
+ if (cached && cached.expiresAt > now) return cached.value;
284
+
285
+ const result = await runGit(
286
+ ['status', '--porcelain=v1', '-u', '-z'],
287
+ realCwd,
288
+ );
289
+
290
+ if (!result.ok) {
291
+ fileStatusCache.set(realCwd, { value: {}, expiresAt: now + CACHE_TTL_MS });
292
+ return {};
293
+ }
294
+
295
+ // No -b here, so parsePorcelain's branch-header branch will simply not fire.
296
+ const parsed = parsePorcelain(result.stdout, realCwd);
297
+ const map = {};
298
+ for (const f of parsed.files) {
299
+ map[f.path] = f.status;
300
+ }
301
+ fileStatusCache.set(realCwd, { value: map, expiresAt: now + CACHE_TTL_MS });
302
+ return map;
303
+ }
304
+
305
+ /** Drop cached entries — useful for tests / forced refresh. Not wired to IPC. */
306
+ function clearCache() {
307
+ statusCache.clear();
308
+ fileStatusCache.clear();
309
+ }
310
+
311
+ function register(ipcMain) {
312
+ ipcMain.handle('git:status', async (_e, payload) => {
313
+ const parsed = schemas.gitStatus.safeParse(payload);
314
+ if (!parsed.success) return null;
315
+ return getStatus(parsed.data.cwd);
316
+ });
317
+
318
+ ipcMain.handle('git:file-status', async (_e, payload) => {
319
+ const parsed = schemas.gitFileStatus.safeParse(payload);
320
+ if (!parsed.success) return {};
321
+ return getFileStatus(parsed.data.cwd);
322
+ });
323
+ }
324
+
325
+ module.exports = {
326
+ register,
327
+ // Exposed for unit-test / sanity-check use.
328
+ getStatus,
329
+ getFileStatus,
330
+ parsePorcelain,
331
+ mapStatus,
332
+ clearCache,
333
+ };
@@ -105,6 +105,34 @@ async function parseJSONL(filePath, stat) {
105
105
  return acc;
106
106
  }
107
107
 
108
+ /** Lightweight per-file meta: { firstTs, lastTs, inputTokens, outputTokens, skipped }.
109
+ * Powers the `history:list-conversations` IPC used by the Overview detailed-
110
+ * stats panel. Single-pass O(L) scan, only honors ts + usage blocks. */
111
+ async function parseConversationMeta(filePath, stat) {
112
+ const meta = { firstTs: null, lastTs: null, inputTokens: 0, outputTokens: 0, skipped: false };
113
+ if (stat.size > MAX_FILE_BYTES) { meta.skipped = true; return meta; }
114
+ let text;
115
+ try { text = await fsp.readFile(filePath, 'utf8'); } catch { return meta; }
116
+ const lines = text.split('\n');
117
+ for (const raw of lines) {
118
+ const line = raw.trim();
119
+ if (!line) continue;
120
+ let obj;
121
+ try { obj = JSON.parse(line); } catch { continue; }
122
+ const ts = obj.ts ?? obj.timestamp;
123
+ if (ts) {
124
+ if (meta.firstTs === null) meta.firstTs = ts;
125
+ meta.lastTs = ts;
126
+ }
127
+ const usage = obj.usage ?? obj.message?.usage;
128
+ if (usage && typeof usage === 'object') {
129
+ if (typeof usage.inputTokens === 'number') meta.inputTokens += usage.inputTokens;
130
+ if (typeof usage.outputTokens === 'number') meta.outputTokens += usage.outputTokens;
131
+ }
132
+ }
133
+ return meta;
134
+ }
135
+
108
136
  function registerHistoryAggregatorHandlers() {
109
137
  ipcMain.handle('history:aggregate', async (_e, rawReq) => {
110
138
  // Wire the historyAggregate schema (previously defined but never used).
@@ -208,6 +236,48 @@ function registerHistoryAggregatorHandlers() {
208
236
  const scannedMs = Date.now() - t0;
209
237
  return { rows, partial, scannedMs, skippedLargeFiles };
210
238
  });
239
+
240
+ /** Per-conversation metadata: one row per JSONL with derived duration +
241
+ * token totals. Used by the Overview detailed-stats panel to compute
242
+ * hourly/daily distribution + top-projects. */
243
+ ipcMain.handle('history:list-conversations', async () => {
244
+ const t0 = Date.now();
245
+ const conversations = [];
246
+ let projectEntries;
247
+ try {
248
+ projectEntries = await fsp.readdir(PROJECTS_DIR, { withFileTypes: true });
249
+ } catch {
250
+ return { conversations: [], scannedMs: Date.now() - t0 };
251
+ }
252
+ for (const ent of projectEntries) {
253
+ if (!ent.isDirectory()) continue;
254
+ const projectDir = path.join(PROJECTS_DIR, ent.name);
255
+ const projectFolder = '/' + ent.name.replace(/-/g, '/');
256
+ let files;
257
+ try { files = await fsp.readdir(projectDir, { withFileTypes: true }); } catch { continue; }
258
+ for (const f of files) {
259
+ if (!f.isFile() || !f.name.endsWith('.jsonl')) continue;
260
+ const filePath = path.join(projectDir, f.name);
261
+ let stat;
262
+ try { stat = await fsp.stat(filePath); } catch { continue; }
263
+ const meta = await parseConversationMeta(filePath, stat);
264
+ const firstTs = meta.firstTs || new Date(stat.mtimeMs).toISOString();
265
+ const duration =
266
+ meta.firstTs && meta.lastTs
267
+ ? Math.max(0, Date.parse(meta.lastTs) - Date.parse(meta.firstTs))
268
+ : undefined;
269
+ conversations.push({
270
+ timestamp: firstTs,
271
+ projectFolder,
272
+ stats: {
273
+ ...(duration !== undefined ? { duration } : {}),
274
+ estimatedTokens: meta.inputTokens + meta.outputTokens,
275
+ },
276
+ });
277
+ }
278
+ }
279
+ return { conversations, scannedMs: Date.now() - t0 };
280
+ });
211
281
  }
212
282
 
213
283
  module.exports = { registerHistoryAggregatorHandlers };
@@ -24,7 +24,12 @@ const otel = require('./otel.cjs');
24
24
  const otelSettings = require('./otelSettings.cjs');
25
25
  const { registerHistoryAggregatorHandlers } = require('./historyAggregator.cjs');
26
26
  const memoryTool = require('./memoryTool.cjs');
27
+ const agentMemory = require('./agentMemory.cjs');
27
28
  const { registerDocEditorHandlers } = require('./docEditor.cjs');
29
+ const git = require('./git.cjs');
30
+ const superagent = require('./superagent.cjs');
31
+ const { registerProjectSkillsHandlers } = require('./projectSkills.cjs');
32
+ const filesIpc = require('./files.cjs');
28
33
  const { resolveClaudeBin } = require('./lib/claudeBin.cjs');
29
34
  const { assertCwdInsideHome } = require('./lib/insideHome.cjs');
30
35
 
@@ -184,6 +189,7 @@ async function rebootApp() {
184
189
  scheduler.attachWindow(mainWindow);
185
190
  watchers.attachWindow(mainWindow);
186
191
  pluginInstall.attachWindow(mainWindow);
192
+ superagent.attachWindow(mainWindow);
187
193
  rebooting = false;
188
194
  return;
189
195
  }
@@ -630,7 +636,12 @@ queueOps.registerQueueOpsHandlers();
630
636
  registerHistoryAggregatorHandlers();
631
637
  pluginInstall.registerPluginInstallHandlers();
632
638
  memoryTool.registerMemoryHandlers();
639
+ agentMemory.registerAgentMemoryHandlers();
633
640
  registerDocEditorHandlers();
641
+ git.register(ipcMain);
642
+ superagent.registerSuperAgentHandlers();
643
+ registerProjectSkillsHandlers();
644
+ filesIpc.registerFilesHandlers();
634
645
 
635
646
  // OTEL telemetry export (opt-in via ~/.config/session-manager/otel.json).
636
647
  ipcMain.handle('otel:get-config', async () => otelSettings.load());
@@ -842,6 +853,7 @@ app.whenReady().then(async () => {
842
853
  scheduler.attachWindow(mainWindow);
843
854
  watchers.attachWindow(mainWindow);
844
855
  pluginInstall.attachWindow(mainWindow);
856
+ superagent.attachWindow(mainWindow);
845
857
  scheduler.init().catch((e) => {
846
858
  logs.writeLine({ scope: 'scheduler', level: 'error', message: 'init failed', meta: { error: e?.message } });
847
859
  });
@@ -212,6 +212,36 @@ const memoryCreate = z.object({
212
212
  description: z.string().max(2048).optional(),
213
213
  }).strict();
214
214
 
215
+ // ──────────────────────────────────────────── Per-subagent memory
216
+ // Distinct from the workspace-scoped Memory tool: agentMemory is keyed by
217
+ // subagent name (the .md filename in ~/.claude/agents/, e.g. "code-reviewer"),
218
+ // not by cwd. Storage lives at ~/.claude/session-manager/agent-memory/<agentId>.json.
219
+ // Regex caps must stay in lockstep with agentMemory.cjs AGENT_ID_RE / ENTRY_ID_RE.
220
+ const AGENT_MEMORY_ID_RE = /^[A-Za-z0-9._-]{1,128}$/;
221
+ const AGENT_MEMORY_CATEGORY = z.enum(['command', 'preference', 'pattern', 'failure', 'workflow']);
222
+ const AGENT_MEMORY_MAX_BODY = 1024 * 1024; // 1 MiB — must match MAX_BODY_BYTES in agentMemory.cjs
223
+
224
+ const agentMemoryList = z.object({
225
+ agentId: z.string().regex(AGENT_MEMORY_ID_RE),
226
+ }).strict();
227
+
228
+ const agentMemoryGet = z.object({
229
+ agentId: z.string().regex(AGENT_MEMORY_ID_RE),
230
+ entryId: z.string().regex(AGENT_MEMORY_ID_RE),
231
+ }).strict();
232
+
233
+ const agentMemorySet = z.object({
234
+ agentId: z.string().regex(AGENT_MEMORY_ID_RE),
235
+ entryId: z.string().regex(AGENT_MEMORY_ID_RE),
236
+ body: z.string().max(AGENT_MEMORY_MAX_BODY),
237
+ category: AGENT_MEMORY_CATEGORY.optional(),
238
+ }).strict();
239
+
240
+ const agentMemoryDelete = z.object({
241
+ agentId: z.string().regex(AGENT_MEMORY_ID_RE),
242
+ entryId: z.string().regex(AGENT_MEMORY_ID_RE),
243
+ }).strict();
244
+
215
245
  // ──────────────────────────────────────────── History
216
246
  const DATE_YYYY_MM_DD = /^\d{4}-\d{2}-\d{2}$/;
217
247
 
@@ -283,6 +313,16 @@ const appGitBranch = z.object({
283
313
  cwd: z.string().min(1).max(4096),
284
314
  }).passthrough();
285
315
 
316
+ // git:status / git:file-status — see src/main/git.cjs. cwd is validatePath'd
317
+ // inside the handler (allowedRoots = home), so the schema only enforces shape.
318
+ const gitStatus = z.object({
319
+ cwd: z.string().min(1).max(4096),
320
+ }).passthrough();
321
+
322
+ const gitFileStatus = z.object({
323
+ cwd: z.string().min(1).max(4096),
324
+ }).passthrough();
325
+
286
326
  // Plugin install: mirrors pluginInstall.cjs SLUG_RE + length cap. Defense in
287
327
  // depth — install() re-checks; the schema rejects earlier.
288
328
  const PLUGIN_SLUG_RE = /^[a-z0-9\-/]+$/;
@@ -290,6 +330,20 @@ const pluginsInstall = z.object({
290
330
  slug: z.string().regex(PLUGIN_SLUG_RE).min(1).max(128),
291
331
  }).passthrough();
292
332
 
333
+ // SuperAgent — "boss" run that writes a structured prompt to the active
334
+ // tab's PTY. Bounds match the inline schemas in superagent.cjs; centralizing
335
+ // here so the schema is the boundary fence rather than each handler.
336
+ const superagentStart = z.object({
337
+ tabId: z.string().min(1).max(128),
338
+ prompt: z.string().min(1).max(8 * 1024),
339
+ specialistCount: z.number().int().min(1).max(8),
340
+ depth: z.enum(['quick', 'standard', 'deep']),
341
+ }).strict();
342
+
343
+ const superagentTabId = z.object({
344
+ tabId: z.string().min(1).max(128),
345
+ }).strict();
346
+
293
347
  /**
294
348
  * Wrap an IPC handler with schema validation. Returns a new handler that
295
349
  * parses the payload before calling the original. On invalid payload throws
@@ -342,12 +396,20 @@ module.exports = {
342
396
  voiceSetRecording,
343
397
  appTestFireHook,
344
398
  appGitBranch,
399
+ gitStatus,
400
+ gitFileStatus,
345
401
  pluginsInstall,
402
+ superagentStart,
403
+ superagentTabId,
346
404
  memoryList,
347
405
  memoryRead,
348
406
  memoryWrite,
349
407
  memoryDelete,
350
408
  memoryCreate,
409
+ agentMemoryList,
410
+ agentMemoryGet,
411
+ agentMemorySet,
412
+ agentMemoryDelete,
351
413
  watchersAdd,
352
414
  watchersList,
353
415
  watchersRemove,
@@ -0,0 +1,124 @@
1
+ /**
2
+ * ProjectSkills — per-project skill enable/disable state.
3
+ *
4
+ * Storage: <cwd>/.claude/project-skills.json
5
+ * Format: { skills: Array<{ skillId: string; enabled: boolean }>, schemaVersion: 1 }
6
+ *
7
+ * Reads enumerate all skills under <cwd>/.claude/skills/ and <home>/.claude/skills/
8
+ * and merge their enable state from the project-local config. Skills not listed in
9
+ * the JSON default to `enabled: true` (i.e., opt-out per project).
10
+ *
11
+ * IPC:
12
+ * - project-skills:get(cwd) -> SkillState[]
13
+ * - project-skills:set(cwd, skillId, enabled) -> { ok: boolean }
14
+ *
15
+ * Atomic writes go through config.cjs::writeJson. cwd is validated via
16
+ * validatePath which constrains it to allowedRoots (home + registered project
17
+ * dirs).
18
+ */
19
+
20
+ const { ipcMain } = require('electron');
21
+ const path = require('node:path');
22
+ const { z } = require('zod');
23
+ const { readJson, writeJson, addAllowedRoot } = require('./config.cjs');
24
+
25
+ const SCHEMA_VERSION = 1;
26
+
27
+ function projectSkillsPath(cwd) {
28
+ return path.join(cwd, '.claude', 'project-skills.json');
29
+ }
30
+
31
+ /**
32
+ * Load the project-skills.json record for a cwd. Missing file => empty record.
33
+ * Returns { skills: Array<{skillId, enabled}>, schemaVersion }.
34
+ */
35
+ async function loadRecord(cwd) {
36
+ const filePath = projectSkillsPath(cwd);
37
+ const r = await readJson(filePath);
38
+ if (!r.exists || !r.data || typeof r.data !== 'object') {
39
+ return { skills: [], schemaVersion: SCHEMA_VERSION };
40
+ }
41
+ const data = r.data;
42
+ const skills = Array.isArray(data.skills) ? data.skills : [];
43
+ // Filter for well-formed entries; tolerate corruption silently.
44
+ const clean = [];
45
+ const seen = new Set();
46
+ for (const s of skills) {
47
+ if (!s || typeof s.skillId !== 'string' || typeof s.enabled !== 'boolean') continue;
48
+ if (seen.has(s.skillId)) continue;
49
+ seen.add(s.skillId);
50
+ clean.push({ skillId: s.skillId, enabled: s.enabled });
51
+ }
52
+ return { skills: clean, schemaVersion: SCHEMA_VERSION };
53
+ }
54
+
55
+ async function saveRecord(cwd, record) {
56
+ const filePath = projectSkillsPath(cwd);
57
+ const payload = {
58
+ schemaVersion: SCHEMA_VERSION,
59
+ skills: record.skills,
60
+ savedAt: Date.now(),
61
+ };
62
+ return writeJson(filePath, payload);
63
+ }
64
+
65
+ /** Return the array of skill enable-states for a project cwd. */
66
+ async function getProjectSkills(cwd) {
67
+ // Register cwd so writeJson is permitted under <cwd>/.claude.
68
+ addAllowedRoot(cwd);
69
+ const record = await loadRecord(cwd);
70
+ return record.skills;
71
+ }
72
+
73
+ /** Upsert a single skillId's enabled flag. */
74
+ async function setProjectSkill(cwd, skillId, enabled) {
75
+ addAllowedRoot(cwd);
76
+ const record = await loadRecord(cwd);
77
+ const idx = record.skills.findIndex((s) => s.skillId === skillId);
78
+ if (idx >= 0) {
79
+ record.skills[idx] = { skillId, enabled };
80
+ } else {
81
+ record.skills.push({ skillId, enabled });
82
+ }
83
+ await saveRecord(cwd, record);
84
+ return { ok: true };
85
+ }
86
+
87
+ // ──────────────────────────────────────────── IPC schemas
88
+ const projectSkillsCwd = z.object({
89
+ cwd: z.string().min(1).max(4096),
90
+ });
91
+
92
+ const projectSkillsSet = z.object({
93
+ cwd: z.string().min(1).max(4096),
94
+ skillId: z.string().min(1).max(256),
95
+ enabled: z.boolean(),
96
+ });
97
+
98
+ function validated(schema, handler) {
99
+ return (_event, payload) => {
100
+ const parsed = schema.parse(payload);
101
+ return handler(parsed);
102
+ };
103
+ }
104
+
105
+ function registerProjectSkillsHandlers() {
106
+ ipcMain.handle(
107
+ 'project-skills:get',
108
+ validated(projectSkillsCwd, ({ cwd }) => getProjectSkills(cwd)),
109
+ );
110
+ ipcMain.handle(
111
+ 'project-skills:set',
112
+ validated(projectSkillsSet, ({ cwd, skillId, enabled }) =>
113
+ setProjectSkill(cwd, skillId, enabled),
114
+ ),
115
+ );
116
+ }
117
+
118
+ module.exports = {
119
+ registerProjectSkillsHandlers,
120
+ // Exported for tests / direct use.
121
+ getProjectSkills,
122
+ setProjectSkill,
123
+ projectSkillsPath,
124
+ };