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.
- package/dist/assets/{TiptapBody-BroECZ_z.js → TiptapBody-CRmtVlnv.js} +1 -1
- package/dist/assets/{cssMode-Crq-Rykh.js → cssMode-jKB9FuI1.js} +1 -1
- package/dist/assets/{freemarker2-B6CC21Ql.js → freemarker2-BPjwx9LW.js} +1 -1
- package/dist/assets/{handlebars-BLgR-12n.js → handlebars-BLoV3dqv.js} +1 -1
- package/dist/assets/{html-CiQkt_KY.js → html-D-6YItp_.js} +1 -1
- package/dist/assets/{htmlMode-Cy8mc91p.js → htmlMode-Dtc92cqk.js} +1 -1
- package/dist/assets/{index-DW-tvyin.css → index-4x5C_duH.css} +1 -1
- package/dist/assets/{index-DU-o-LEm.js → index-DG1rozxP.js} +1286 -1255
- package/dist/assets/{javascript-CHNCN8qj.js → javascript-BQcvFYdn.js} +1 -1
- package/dist/assets/{jsonMode-BSN7mvBT.js → jsonMode-CnFmKTUr.js} +1 -1
- package/dist/assets/{liquid-B0kmZauA.js → liquid-ogD2CQU1.js} +1 -1
- package/dist/assets/{lspLanguageFeatures-DI_RToRa.js → lspLanguageFeatures-DKVkL-Ro.js} +1 -1
- package/dist/assets/{mdx-BSF-fsyJ.js → mdx-BHbTIp7G.js} +1 -1
- package/dist/assets/{python-DUl3Fmgk.js → python-fa-8uToX.js} +1 -1
- package/dist/assets/{razor-Df7WxBjo.js → razor-D4OJJrW7.js} +1 -1
- package/dist/assets/{tsMode-qccVs0_G.js → tsMode-J9ZwPyiC.js} +1 -1
- package/dist/assets/{typescript-BEwM5qbq.js → typescript-1H3_b_hk.js} +1 -1
- package/dist/assets/{xml-CCtx-_Kw.js → xml-Djys1Yq_.js} +1 -1
- package/dist/assets/{yaml-B66nOkCW.js → yaml-CAZ0iMcZ.js} +1 -1
- package/dist/index.html +2 -2
- package/package.json +1 -1
- package/src/main/agentMemory.cjs +267 -0
- package/src/main/files.cjs +346 -0
- package/src/main/git.cjs +333 -0
- package/src/main/historyAggregator.cjs +70 -0
- package/src/main/index.cjs +12 -0
- package/src/main/ipcSchemas.cjs +62 -0
- package/src/main/projectSkills.cjs +124 -0
- package/src/main/superagent.cjs +202 -0
- package/src/preload/api.d.ts +203 -0
- package/src/preload/index.cjs +47 -0
package/src/main/git.cjs
ADDED
|
@@ -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 };
|
package/src/main/index.cjs
CHANGED
|
@@ -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
|
});
|
package/src/main/ipcSchemas.cjs
CHANGED
|
@@ -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
|
+
};
|