claude-code-session-manager 0.8.6 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +95 -65
- package/dist/assets/{cssMode-DBg6nxUL.js → cssMode-DWlBzlpW.js} +1 -1
- package/dist/assets/{freemarker2-CyjUGY3f.js → freemarker2-Cgg83m-Z.js} +1 -1
- package/dist/assets/{handlebars-lhtCWqlB.js → handlebars-C4r4LOI9.js} +1 -1
- package/dist/assets/{html-egptHwbZ.js → html-DaxRI5sW.js} +1 -1
- package/dist/assets/htmlMode-Bu_8jtXo.js +1 -0
- package/dist/assets/{index-DjeqNwqn.js → index-C_tgFedf.js} +1115 -1081
- package/dist/assets/{index-DnLtSCQS.css → index-Dj3Db4OA.css} +1 -1
- package/dist/assets/{javascript-tZbiID3O.js → javascript-D5Ztx-Ej.js} +1 -1
- package/dist/assets/{jsonMode-BGtPN-L-.js → jsonMode-tfsgezVc.js} +1 -1
- package/dist/assets/{liquid-DvTeXhev.js → liquid-F2cD9OL0.js} +1 -1
- package/dist/assets/{lspLanguageFeatures-D9xoxVlV.js → lspLanguageFeatures-Bz_Eih8F.js} +2 -2
- package/dist/assets/{mdx-BQ3Ja4wM.js → mdx-BPlD1clX.js} +1 -1
- package/dist/assets/{ort-wasm-simd-threaded.asyncify-CtKKja6V.wasm → ort-wasm-simd-threaded.asyncify-DMmc6YqF.wasm} +0 -0
- package/dist/assets/{python-C71RWXaP.js → python-B4gUOWNI.js} +1 -1
- package/dist/assets/{razor-w__Mkyns.js → razor-B6pMxVp1.js} +1 -1
- package/dist/assets/{tsMode-DOQLQDB3.js → tsMode-C9nq6cHi.js} +1 -1
- package/dist/assets/{typescript-DEiub2Jt.js → typescript-Do5Vtwxu.js} +1 -1
- package/dist/assets/{whisperWorker-QfIS0sPF.js → whisperWorker-CcsPqZUS.js} +19 -19
- package/dist/assets/{xml-RXkLQscS.js → xml-C0mTbVRp.js} +1 -1
- package/dist/assets/{yaml-C8HIpJku.js → yaml-D3sePJfA.js} +1 -1
- package/dist/index.html +2 -2
- package/package.json +18 -10
- package/screenshots/.gitkeep +0 -0
- package/screenshots/README-screenshots.md +13 -0
- package/src/main/config.cjs +47 -9
- package/src/main/historyAggregator.cjs +10 -5
- package/src/main/index.cjs +85 -14
- package/src/main/ipcSchemas.cjs +165 -3
- package/src/main/lib/claudeBin.cjs +39 -0
- package/src/main/lib/encodeCwd.cjs +19 -0
- package/src/main/lib/fileTail.cjs +35 -0
- package/src/main/lib/insideHome.cjs +38 -0
- package/src/main/lib/prdFrontmatter.cjs +51 -0
- package/src/main/lib/sendToRenderer.cjs +21 -0
- package/src/main/memoryTool.cjs +203 -0
- package/src/main/otelSettings.cjs +2 -7
- package/src/main/pluginInstall.cjs +129 -0
- package/src/main/pty.cjs +13 -29
- package/src/main/queueOps.cjs +404 -0
- package/src/main/scheduler/prdParser.cjs +135 -0
- package/src/main/scheduler.cjs +291 -250
- package/src/main/sessionsStore.cjs +2 -6
- package/src/main/supervisor.cjs +3 -35
- package/src/main/teams.cjs +95 -0
- package/src/main/transcripts.cjs +5 -7
- package/src/main/usage.cjs +8 -0
- package/src/main/voiceHotkey.cjs +13 -9
- package/src/main/voiceSettings.cjs +2 -9
- package/src/main/voiceWizard.cjs +4 -11
- package/src/main/watchers.cjs +18 -42
- package/src/preload/api.d.ts +153 -1
- package/src/preload/index.cjs +29 -0
- package/dist/assets/htmlMode-tPDeHGOB.js +0 -1
package/src/main/ipcSchemas.cjs
CHANGED
|
@@ -20,9 +20,12 @@ const ptySpawn = z.object({
|
|
|
20
20
|
|
|
21
21
|
const ptyTabId = z.object({ tabId: z.string().min(1).max(128) });
|
|
22
22
|
|
|
23
|
+
// 64 KiB cap per pty:write — typewriter input is bounded; a renderer firing
|
|
24
|
+
// megabytes per call is either a bug or an attack. Block it at the boundary.
|
|
25
|
+
const PTY_WRITE_MAX_BYTES = 64 * 1024;
|
|
23
26
|
const ptyWrite = z.object({
|
|
24
27
|
tabId: z.string().min(1).max(128),
|
|
25
|
-
data: z.string(),
|
|
28
|
+
data: z.string().max(PTY_WRITE_MAX_BYTES),
|
|
26
29
|
});
|
|
27
30
|
|
|
28
31
|
const ptyResize = z.object({
|
|
@@ -96,6 +99,34 @@ const scheduleReadLog = z.object({
|
|
|
96
99
|
runId: z.string().regex(SCHEDULE_RUN_ID_RE),
|
|
97
100
|
});
|
|
98
101
|
|
|
102
|
+
// PRD write: slug + body (≤256 KiB, matches PRD_WRITE_MAX_BYTES in scheduler.cjs).
|
|
103
|
+
const PRD_WRITE_MAX_BYTES = 256 * 1024;
|
|
104
|
+
const scheduleWritePrd = z.object({
|
|
105
|
+
slug: z.string().regex(SCHEDULE_SLUG_RE),
|
|
106
|
+
body: z.string().refine(
|
|
107
|
+
(s) => Buffer.byteLength(s, 'utf8') <= PRD_WRITE_MAX_BYTES,
|
|
108
|
+
`body must be ≤ ${PRD_WRITE_MAX_BYTES} bytes`,
|
|
109
|
+
),
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
// Bulk archive: slug list, capped to limit unbounded retag/archive payloads.
|
|
113
|
+
const scheduleArchivePrd = z.object({
|
|
114
|
+
slugs: z.array(z.string().regex(SCHEDULE_SLUG_RE)).min(1).max(500),
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
const scheduleRetagItem = z.object({
|
|
118
|
+
slug: z.string().regex(SCHEDULE_SLUG_RE),
|
|
119
|
+
parallelGroup: z.number().int().min(0).max(999).optional(),
|
|
120
|
+
estimateMinutes: z.number().int().min(1).max(100000).optional(),
|
|
121
|
+
}).refine(
|
|
122
|
+
(it) => it.parallelGroup !== undefined || it.estimateMinutes !== undefined,
|
|
123
|
+
'at least one of parallelGroup or estimateMinutes is required',
|
|
124
|
+
);
|
|
125
|
+
|
|
126
|
+
const scheduleRetagPrd = z.object({
|
|
127
|
+
items: z.array(scheduleRetagItem).min(1).max(500),
|
|
128
|
+
});
|
|
129
|
+
|
|
99
130
|
// ──────────────────────────────────────────── Projects
|
|
100
131
|
const ENCODED_SLUG_RE = /^[A-Za-z0-9._-]+$/;
|
|
101
132
|
|
|
@@ -135,17 +166,125 @@ const setConfigSchema = z.object({
|
|
|
135
166
|
}).optional(),
|
|
136
167
|
}).strict();
|
|
137
168
|
|
|
169
|
+
// ──────────────────────────────────────────── Memory tool (Bundle C, cycle 3)
|
|
170
|
+
// Workspace-scoped markdown store at ~/.claude/session-manager/memories/<ws>/.
|
|
171
|
+
// Slug regex must match memoryTool.cjs SLUG_RE; workspace regex matches its
|
|
172
|
+
// encodeWorkspace() output (alphanumeric + dash) plus 'default'.
|
|
173
|
+
const MEMORY_WORKSPACE_RE = /^[a-zA-Z0-9-_]{1,256}$/;
|
|
174
|
+
const MEMORY_SLUG_RE = /^[a-z0-9-_]+\.md$/;
|
|
175
|
+
// 1 MiB hard cap — matches MAX_FILE_BYTES in memoryTool.cjs.
|
|
176
|
+
const MEMORY_MAX_BYTES = 1024 * 1024;
|
|
177
|
+
|
|
178
|
+
const memoryList = z.object({
|
|
179
|
+
workspace: z.string().regex(MEMORY_WORKSPACE_RE).optional(),
|
|
180
|
+
}).strict();
|
|
181
|
+
|
|
182
|
+
const memoryRead = z.object({
|
|
183
|
+
workspace: z.string().regex(MEMORY_WORKSPACE_RE).optional(),
|
|
184
|
+
name: z.string().regex(MEMORY_SLUG_RE),
|
|
185
|
+
}).strict();
|
|
186
|
+
|
|
187
|
+
const memoryWrite = z.object({
|
|
188
|
+
workspace: z.string().regex(MEMORY_WORKSPACE_RE).optional(),
|
|
189
|
+
name: z.string().regex(MEMORY_SLUG_RE),
|
|
190
|
+
content: z.string().max(MEMORY_MAX_BYTES),
|
|
191
|
+
}).strict();
|
|
192
|
+
|
|
193
|
+
const memoryDelete = z.object({
|
|
194
|
+
workspace: z.string().regex(MEMORY_WORKSPACE_RE).optional(),
|
|
195
|
+
name: z.string().regex(MEMORY_SLUG_RE),
|
|
196
|
+
}).strict();
|
|
197
|
+
|
|
198
|
+
const memoryCreate = z.object({
|
|
199
|
+
workspace: z.string().regex(MEMORY_WORKSPACE_RE).optional(),
|
|
200
|
+
name: z.string().regex(MEMORY_SLUG_RE),
|
|
201
|
+
description: z.string().max(2048).optional(),
|
|
202
|
+
}).strict();
|
|
203
|
+
|
|
138
204
|
// ──────────────────────────────────────────── History
|
|
139
205
|
const DATE_YYYY_MM_DD = /^\d{4}-\d{2}-\d{2}$/;
|
|
140
206
|
|
|
141
207
|
const historyAggregate = z.object({
|
|
142
208
|
fromDate: z.string().regex(DATE_YYYY_MM_DD).optional(),
|
|
143
209
|
toDate: z.string().regex(DATE_YYYY_MM_DD).optional(),
|
|
144
|
-
}).
|
|
210
|
+
}).nullish();
|
|
211
|
+
|
|
212
|
+
// ──────────────────────────────────────────── Voice (F1/F5/F7/F8)
|
|
213
|
+
// Mirrors voiceSettings.cjs isValidConfig / isValidDevicePref / isValid…
|
|
214
|
+
// validators (ad-hoc on disk). Schemas here gate the IPC boundary so a
|
|
215
|
+
// malformed renderer payload can never reach the file writer.
|
|
216
|
+
const VOICE_ACCELERATOR_RE = /^(CommandOrControl|CmdOrCtrl|Cmd|Command|Ctrl|Control|Alt|Option|Shift|Super|Meta)(\+(CommandOrControl|CmdOrCtrl|Cmd|Command|Ctrl|Control|Alt|Option|Shift|Super|Meta))*\+([A-Z]|[0-9]|F([1-9]|1[0-9]|2[0-4])|Space|Tab|Enter|Backspace|Delete|Escape|Esc)$/;
|
|
217
|
+
|
|
218
|
+
const voiceSetHotkey = z.object({
|
|
219
|
+
accelerator: z.string().regex(VOICE_ACCELERATOR_RE),
|
|
220
|
+
mode: z.enum(['hold', 'toggle']),
|
|
221
|
+
global: z.boolean(),
|
|
222
|
+
schemaVersion: z.number().int().optional(),
|
|
223
|
+
}).passthrough();
|
|
224
|
+
|
|
225
|
+
const voiceSetDevicePref = z.object({
|
|
226
|
+
selectedDeviceId: z.string().max(256).nullable(),
|
|
227
|
+
selectedLabel: z.string().max(256).nullable(),
|
|
228
|
+
schemaVersion: z.number().int().optional(),
|
|
229
|
+
}).passthrough();
|
|
230
|
+
|
|
231
|
+
const voiceSetTurnDetector = z.object({
|
|
232
|
+
enabled: z.boolean(),
|
|
233
|
+
mode: z.enum(['audio', 'text', 'off']),
|
|
234
|
+
schemaVersion: z.number().int().optional(),
|
|
235
|
+
}).passthrough();
|
|
236
|
+
|
|
237
|
+
const voiceSetRecording = z.boolean();
|
|
238
|
+
|
|
239
|
+
// ──────────────────────────────────────────── Watchers
|
|
240
|
+
// watchers:add runs `spawn(command, { shell: true })` — second-highest blast
|
|
241
|
+
// radius after app:test-fire-hook. The 8 KiB cap on `command` is the same
|
|
242
|
+
// cap watchers.cjs uses internally; centralizing here so the schema is the
|
|
243
|
+
// injection fence rather than relying on the inline schemas in watchers.cjs.
|
|
244
|
+
const watchersAdd = z.object({
|
|
245
|
+
tabId: z.string().min(1).max(128),
|
|
246
|
+
label: z.string().max(256).optional().default(''),
|
|
247
|
+
command: z.string().min(1).max(8192),
|
|
248
|
+
cwd: z.string().max(4096).optional().nullable(),
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
const watchersList = z.object({ tabId: z.string().min(1).max(128) });
|
|
252
|
+
const watchersRemove = z.object({ watcherId: z.string().min(1).max(128) });
|
|
253
|
+
const watchersKillTab = z.object({ tabId: z.string().min(1).max(128) });
|
|
254
|
+
|
|
255
|
+
// ──────────────────────────────────────────── Hooks / git / plugins
|
|
256
|
+
// Free-form env: keys must be safe identifier shape (no '=' / NUL / weird
|
|
257
|
+
// unicode), values must be plain strings, both length-capped. We don't
|
|
258
|
+
// restrict the key set — Hooks can legitimately need any env name — but we
|
|
259
|
+
// do refuse anything that wouldn't survive a child_process env-block round
|
|
260
|
+
// trip.
|
|
261
|
+
const ENV_KEY_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
262
|
+
const appTestFireHook = z.object({
|
|
263
|
+
command: z.string().min(1).max(16 * 1024),
|
|
264
|
+
env: z.record(z.string().regex(ENV_KEY_RE).max(256), z.string().max(8 * 1024))
|
|
265
|
+
.nullable()
|
|
266
|
+
.optional(),
|
|
267
|
+
payload: z.string().max(1 * 1024 * 1024).optional(),
|
|
268
|
+
timeoutMs: z.number().int().min(0).max(30_000).optional(),
|
|
269
|
+
}).passthrough();
|
|
270
|
+
|
|
271
|
+
const appGitBranch = z.object({
|
|
272
|
+
cwd: z.string().min(1).max(4096),
|
|
273
|
+
}).passthrough();
|
|
274
|
+
|
|
275
|
+
// Plugin install: mirrors pluginInstall.cjs SLUG_RE + length cap. Defense in
|
|
276
|
+
// depth — install() re-checks; the schema rejects earlier.
|
|
277
|
+
const PLUGIN_SLUG_RE = /^[a-z0-9\-/]+$/;
|
|
278
|
+
const pluginsInstall = z.object({
|
|
279
|
+
slug: z.string().regex(PLUGIN_SLUG_RE).min(1).max(128),
|
|
280
|
+
}).passthrough();
|
|
145
281
|
|
|
146
282
|
/**
|
|
147
283
|
* Wrap an IPC handler with schema validation. Returns a new handler that
|
|
148
|
-
* parses the payload before calling the original.
|
|
284
|
+
* parses the payload before calling the original. On invalid payload throws
|
|
285
|
+
* a ZodError (caught by Electron's IPC harness → rejected promise). Existing
|
|
286
|
+
* call sites already rely on throw semantics for malformed input, so we keep
|
|
287
|
+
* that behavior for backwards compatibility.
|
|
149
288
|
*/
|
|
150
289
|
function validated(schema, handler) {
|
|
151
290
|
return (_event, payload) => {
|
|
@@ -155,6 +294,10 @@ function validated(schema, handler) {
|
|
|
155
294
|
}
|
|
156
295
|
|
|
157
296
|
module.exports = {
|
|
297
|
+
// Centralized slug regex — used by scheduler.cjs and queueOps.cjs for
|
|
298
|
+
// direct test()/match() containment checks alongside the zod parses.
|
|
299
|
+
SCHEDULE_SLUG_RE,
|
|
300
|
+
SCHEDULE_RUN_ID_RE,
|
|
158
301
|
schemas: {
|
|
159
302
|
ptySpawn,
|
|
160
303
|
ptyTabId,
|
|
@@ -171,12 +314,31 @@ module.exports = {
|
|
|
171
314
|
sessionsPayload,
|
|
172
315
|
scheduleSlug,
|
|
173
316
|
scheduleReadLog,
|
|
317
|
+
scheduleWritePrd,
|
|
318
|
+
scheduleArchivePrd,
|
|
319
|
+
scheduleRetagPrd,
|
|
174
320
|
setConfigSchema,
|
|
175
321
|
openInEditor,
|
|
176
322
|
openInFinder,
|
|
177
323
|
openInTerminal,
|
|
178
324
|
archiveProject,
|
|
179
325
|
historyAggregate,
|
|
326
|
+
voiceSetHotkey,
|
|
327
|
+
voiceSetDevicePref,
|
|
328
|
+
voiceSetTurnDetector,
|
|
329
|
+
voiceSetRecording,
|
|
330
|
+
appTestFireHook,
|
|
331
|
+
appGitBranch,
|
|
332
|
+
pluginsInstall,
|
|
333
|
+
memoryList,
|
|
334
|
+
memoryRead,
|
|
335
|
+
memoryWrite,
|
|
336
|
+
memoryDelete,
|
|
337
|
+
memoryCreate,
|
|
338
|
+
watchersAdd,
|
|
339
|
+
watchersList,
|
|
340
|
+
watchersRemove,
|
|
341
|
+
watchersKillTab,
|
|
180
342
|
},
|
|
181
343
|
validated,
|
|
182
344
|
};
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* claudeBin.cjs — shared resolver for the `claude` executable.
|
|
3
|
+
*
|
|
4
|
+
* Both scheduler.cjs and supervisor.cjs need to spawn `claude -p` jobs.
|
|
5
|
+
* Both used to duplicate this list. Single source of truth here.
|
|
6
|
+
*
|
|
7
|
+
* Returns the first executable candidate found, falling back to bare
|
|
8
|
+
* "claude" so spawn() can still try PATH lookup.
|
|
9
|
+
*
|
|
10
|
+
* Cached after first successful resolution. Process-lifetime.
|
|
11
|
+
*/
|
|
12
|
+
'use strict';
|
|
13
|
+
|
|
14
|
+
const fs = require('node:fs');
|
|
15
|
+
const path = require('node:path');
|
|
16
|
+
const os = require('node:os');
|
|
17
|
+
|
|
18
|
+
let cached = null;
|
|
19
|
+
|
|
20
|
+
function resolveClaudeBin() {
|
|
21
|
+
if (cached) return cached;
|
|
22
|
+
// Merged candidate list — was forked in scheduler vs pluginInstall before.
|
|
23
|
+
const home = os.homedir();
|
|
24
|
+
const candidates = [
|
|
25
|
+
path.join(home, '.claude', 'local', 'claude'), // Claude Code bundled install
|
|
26
|
+
path.join(home, '.local', 'bin', 'claude'), // user pip-style install
|
|
27
|
+
path.join(home, '.npm-global', 'bin', 'claude'), // user npm-global
|
|
28
|
+
'/usr/local/bin/claude',
|
|
29
|
+
'/opt/homebrew/bin/claude',
|
|
30
|
+
'/usr/bin/claude',
|
|
31
|
+
];
|
|
32
|
+
for (const c of candidates) {
|
|
33
|
+
try { fs.accessSync(c, fs.constants.X_OK); cached = c; return c; } catch { /* */ }
|
|
34
|
+
}
|
|
35
|
+
cached = 'claude';
|
|
36
|
+
return cached;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
module.exports = { resolveClaudeBin };
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* encodeCwd.cjs — canonical "cwd to directory-safe slug" encoder.
|
|
3
|
+
*
|
|
4
|
+
* Mirrors Claude Code's transcript directory naming convention: replace every
|
|
5
|
+
* non-alphanumeric character with '-'. Used by:
|
|
6
|
+
* - transcripts.cjs to locate `~/.claude/projects/<encoded>/<sessionUuid>.jsonl`
|
|
7
|
+
* - memoryTool.cjs to derive the workspace label for memory storage
|
|
8
|
+
* - the renderer's Memory tab (purely advisory; main process is authoritative)
|
|
9
|
+
*
|
|
10
|
+
* Falls back to 'default' on null/blank input so callers always get a valid slug.
|
|
11
|
+
*/
|
|
12
|
+
'use strict';
|
|
13
|
+
|
|
14
|
+
function encodeCwd(cwd) {
|
|
15
|
+
if (!cwd || typeof cwd !== 'string' || !cwd.trim()) return 'default';
|
|
16
|
+
return cwd.replace(/[^a-zA-Z0-9]/g, '-');
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
module.exports = { encodeCwd };
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* fileTail.cjs — read the last N bytes of a file as UTF-8.
|
|
3
|
+
*
|
|
4
|
+
* Three near-identical copies existed before: scheduler.cjs readTail (1011),
|
|
5
|
+
* supervisor.cjs readTailBytes (104), and an inline copy in scheduler's
|
|
6
|
+
* post-result watchdog (~792). Unified here. Callers MUST be in a synchronous
|
|
7
|
+
* context; intentionally sync because callers are inside polling intervals or
|
|
8
|
+
* exit handlers where async would add an unnecessary event-loop hop.
|
|
9
|
+
*
|
|
10
|
+
* Returns '' on any I/O error (missing file, permission denied, etc.) — the
|
|
11
|
+
* tail readers are best-effort log scanners, not data paths.
|
|
12
|
+
*/
|
|
13
|
+
'use strict';
|
|
14
|
+
|
|
15
|
+
const fs = require('node:fs');
|
|
16
|
+
|
|
17
|
+
function readTail(filePath, bytes) {
|
|
18
|
+
try {
|
|
19
|
+
const stat = fs.statSync(filePath);
|
|
20
|
+
const n = Math.min(stat.size, bytes);
|
|
21
|
+
if (n <= 0) return '';
|
|
22
|
+
const fd = fs.openSync(filePath, 'r');
|
|
23
|
+
try {
|
|
24
|
+
const buf = Buffer.alloc(n);
|
|
25
|
+
fs.readSync(fd, buf, 0, n, stat.size - n);
|
|
26
|
+
return buf.toString('utf8');
|
|
27
|
+
} finally {
|
|
28
|
+
fs.closeSync(fd);
|
|
29
|
+
}
|
|
30
|
+
} catch {
|
|
31
|
+
return '';
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
module.exports = { readTail };
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* insideHome.cjs — security invariant: every renderer-controlled cwd must
|
|
3
|
+
* resolve (after realpath, symlink-safe) to a path inside the user's home
|
|
4
|
+
* directory. Four call sites used to reimplement this: pty.cjs spawn,
|
|
5
|
+
* watchers.cjs add, index.cjs checkInsideHome, ipcSchemas.cjs setConfigSchema
|
|
6
|
+
* defaultCwd refine. The check is a single chokepoint here.
|
|
7
|
+
*
|
|
8
|
+
* Returns { ok: true, realCwd } on success or { ok: false, error } on rejection.
|
|
9
|
+
* Realpath of the cwd is returned so callers can use it without recomputing.
|
|
10
|
+
*
|
|
11
|
+
* NOTE: schemas can't easily call this (zod refines are inline); they retain
|
|
12
|
+
* a simpler `startsWith` check that does NOT symlink-resolve. The runtime
|
|
13
|
+
* resolution here is the authoritative one — schemas are belt-and-suspenders.
|
|
14
|
+
*/
|
|
15
|
+
'use strict';
|
|
16
|
+
|
|
17
|
+
const fs = require('node:fs');
|
|
18
|
+
const path = require('node:path');
|
|
19
|
+
const os = require('node:os');
|
|
20
|
+
|
|
21
|
+
function assertCwdInsideHome(cwd) {
|
|
22
|
+
if (typeof cwd !== 'string' || !cwd) {
|
|
23
|
+
return { ok: false, error: 'cwd must be a non-empty string' };
|
|
24
|
+
}
|
|
25
|
+
const home = os.homedir();
|
|
26
|
+
let realCwd;
|
|
27
|
+
try {
|
|
28
|
+
realCwd = fs.realpathSync(cwd);
|
|
29
|
+
} catch {
|
|
30
|
+
realCwd = path.resolve(cwd);
|
|
31
|
+
}
|
|
32
|
+
if (realCwd !== home && !realCwd.startsWith(home + path.sep)) {
|
|
33
|
+
return { ok: false, error: `cwd outside home directory: ${realCwd}` };
|
|
34
|
+
}
|
|
35
|
+
return { ok: true, realCwd };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
module.exports = { assertCwdInsideHome };
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* prdFrontmatter.cjs — minimal YAML frontmatter parser for scheduled PRD files.
|
|
3
|
+
*
|
|
4
|
+
* PRDs at `~/.claude/session-manager/scheduled-plans/prds/<NN>-<slug>.md` use
|
|
5
|
+
* a small documented frontmatter subset (title, cwd, estimateMinutes,
|
|
6
|
+
* parallelGroup, …). Two callers used to roll their own parser:
|
|
7
|
+
* - scheduler.cjs::parsePrdRaw — typed extraction of the known keys
|
|
8
|
+
* - queueOps.cjs::splitFrontmatter — linting; needs the raw map plus the
|
|
9
|
+
* line-count of the frontmatter region so warnings point at the right line
|
|
10
|
+
*
|
|
11
|
+
* This module owns the parse. Callers transform the raw map into whatever
|
|
12
|
+
* typed shape they need.
|
|
13
|
+
*
|
|
14
|
+
* Format:
|
|
15
|
+
* ---
|
|
16
|
+
* key: value
|
|
17
|
+
* ---
|
|
18
|
+
* body…
|
|
19
|
+
*
|
|
20
|
+
* - Keys must match /^[A-Za-z][A-Za-z0-9_]*$/ (alpha first; alphanum + _).
|
|
21
|
+
* - Values are read as strings; surrounding ' or " quotes are stripped.
|
|
22
|
+
* - Missing or malformed frontmatter → { fm: {}, body: raw, fmLineCount: 0 }.
|
|
23
|
+
*/
|
|
24
|
+
'use strict';
|
|
25
|
+
|
|
26
|
+
function splitFrontmatter(raw) {
|
|
27
|
+
if (typeof raw !== 'string' || !raw.startsWith('---\n')) {
|
|
28
|
+
return { fm: {}, body: raw, fmLineCount: 0 };
|
|
29
|
+
}
|
|
30
|
+
const end = raw.indexOf('\n---', 4);
|
|
31
|
+
if (end === -1) return { fm: {}, body: raw, fmLineCount: 0 };
|
|
32
|
+
const fmRaw = raw.slice(4, end);
|
|
33
|
+
const fm = {};
|
|
34
|
+
for (const line of fmRaw.split('\n')) {
|
|
35
|
+
const m = line.match(/^([A-Za-z][A-Za-z0-9_]*)\s*:\s*(.*)\s*$/);
|
|
36
|
+
if (!m) continue;
|
|
37
|
+
let v = m[2];
|
|
38
|
+
if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) {
|
|
39
|
+
v = v.slice(1, -1);
|
|
40
|
+
}
|
|
41
|
+
fm[m[1]] = v;
|
|
42
|
+
}
|
|
43
|
+
return {
|
|
44
|
+
fm,
|
|
45
|
+
body: raw.slice(end + 4).replace(/^\n/, ''),
|
|
46
|
+
// +2 for the two `---` fences themselves
|
|
47
|
+
fmLineCount: fmRaw.split('\n').length + 2,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
module.exports = { splitFrontmatter };
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* sendToRenderer.cjs — `webContents.send` with a destroyed-window guard.
|
|
3
|
+
*
|
|
4
|
+
* Every IPC-broadcasting module was open-coding `if (window && !window.isDestroyed())`
|
|
5
|
+
* before invoking `webContents.send`. Forgetting the guard produces a hard
|
|
6
|
+
* crash when a broadcast lands during teardown. Centralized here so new
|
|
7
|
+
* broadcasters can't reintroduce the footgun.
|
|
8
|
+
*/
|
|
9
|
+
'use strict';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Send a payload on a channel iff the BrowserWindow is alive.
|
|
13
|
+
* No-ops on null/destroyed windows so callers don't need their own guards.
|
|
14
|
+
*/
|
|
15
|
+
function sendIfAlive(window, channel, payload) {
|
|
16
|
+
if (window && !window.isDestroyed()) {
|
|
17
|
+
window.webContents.send(channel, payload);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
module.exports = { sendIfAlive };
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* memoryTool.cjs — Memory tab backend (cycle 3, Bundle C).
|
|
3
|
+
*
|
|
4
|
+
* Workspace-scoped markdown store at
|
|
5
|
+
* ~/.claude/session-manager/memories/<workspace>/
|
|
6
|
+
* where <workspace> is a derived encoding of an active cwd (mirrors the
|
|
7
|
+
* Claude-Code transcript-dir convention). The Memory tab is a list+detail
|
|
8
|
+
* view over these files. Files are plain markdown with optional frontmatter:
|
|
9
|
+
*
|
|
10
|
+
* ---
|
|
11
|
+
* name: short label
|
|
12
|
+
* description: one-line summary
|
|
13
|
+
* ---
|
|
14
|
+
* <body>
|
|
15
|
+
*
|
|
16
|
+
* Hard caps:
|
|
17
|
+
* - 1 MB per file
|
|
18
|
+
* - 1000 entries per workspace
|
|
19
|
+
* - slugs MUST match /^[a-z0-9-_]+\.md$/
|
|
20
|
+
*
|
|
21
|
+
* Every read/write/delete is routed through config.cjs's atomic helpers,
|
|
22
|
+
* which in turn pin every absolute path inside the home directory via
|
|
23
|
+
* validatePath (with validateWrite layered on top for mutations).
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
const { ipcMain } = require('electron');
|
|
27
|
+
const fs = require('node:fs');
|
|
28
|
+
const fsp = require('node:fs/promises');
|
|
29
|
+
const path = require('node:path');
|
|
30
|
+
const os = require('node:os');
|
|
31
|
+
const config = require('./config.cjs');
|
|
32
|
+
|
|
33
|
+
const MAX_FILE_BYTES = 1024 * 1024; // 1 MiB
|
|
34
|
+
const MAX_ENTRIES = 1000;
|
|
35
|
+
const SLUG_RE = /^[a-z0-9-_]+\.md$/;
|
|
36
|
+
|
|
37
|
+
const { encodeCwd: encodeWorkspace } = require('./lib/encodeCwd.cjs');
|
|
38
|
+
|
|
39
|
+
function memoryRoot() {
|
|
40
|
+
return path.join(os.homedir(), '.claude', 'session-manager', 'memories');
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function workspaceDir(workspace) {
|
|
44
|
+
return path.join(memoryRoot(), workspace);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Reject NUL chars, '..' segments, absolute paths, or anything that would
|
|
49
|
+
* resolve outside the workspace dir. Returns the resolved absolute path.
|
|
50
|
+
* The downstream config.cjs helpers add a second layer of validation, but
|
|
51
|
+
* this lets us return a clean error message before we get there.
|
|
52
|
+
*/
|
|
53
|
+
function resolveEntryPath(workspace, name) {
|
|
54
|
+
if (typeof name !== 'string' || !name) {
|
|
55
|
+
throw new Error('invalid entry name');
|
|
56
|
+
}
|
|
57
|
+
if (name.includes('\0')) throw new Error('NUL in name');
|
|
58
|
+
if (!SLUG_RE.test(name)) {
|
|
59
|
+
throw new Error(`invalid slug (must match ${SLUG_RE.source}): ${name}`);
|
|
60
|
+
}
|
|
61
|
+
const dir = workspaceDir(workspace);
|
|
62
|
+
const full = path.resolve(dir, name);
|
|
63
|
+
// path.resolve normalizes; verify the result is still under dir to guard
|
|
64
|
+
// against bizarre name inputs.
|
|
65
|
+
if (full !== path.join(dir, name)) {
|
|
66
|
+
throw new Error('path escapes workspace');
|
|
67
|
+
}
|
|
68
|
+
const rel = path.relative(dir, full);
|
|
69
|
+
if (rel.startsWith('..') || path.isAbsolute(rel)) {
|
|
70
|
+
throw new Error('path escapes workspace');
|
|
71
|
+
}
|
|
72
|
+
return full;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function validWorkspaceName(name) {
|
|
76
|
+
return typeof name === 'string' && /^[a-zA-Z0-9-_]{1,256}$/.test(name);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async function list({ workspace }) {
|
|
80
|
+
const ws = validWorkspaceName(workspace) ? workspace : 'default';
|
|
81
|
+
const dir = workspaceDir(ws);
|
|
82
|
+
const r = await config.listDir(dir, { filesOnly: true });
|
|
83
|
+
if (!r.ok) {
|
|
84
|
+
return { entries: [], workspace: ws, error: r.error };
|
|
85
|
+
}
|
|
86
|
+
const entries = r.entries
|
|
87
|
+
.filter((e) => e.name.endsWith('.md'))
|
|
88
|
+
.map((e) => ({
|
|
89
|
+
name: e.name,
|
|
90
|
+
path: e.path,
|
|
91
|
+
mtimeMs: e.mtimeMs,
|
|
92
|
+
bytes: e.size,
|
|
93
|
+
}))
|
|
94
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
95
|
+
return { entries, workspace: ws, error: null };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async function read({ workspace, name }) {
|
|
99
|
+
const ws = validWorkspaceName(workspace) ? workspace : 'default';
|
|
100
|
+
const abs = resolveEntryPath(ws, name);
|
|
101
|
+
const r = await config.readText(abs);
|
|
102
|
+
if (!r.exists) {
|
|
103
|
+
return { content: '', exists: false, mtimeMs: 0, bytes: 0, error: r.error };
|
|
104
|
+
}
|
|
105
|
+
return {
|
|
106
|
+
content: r.text,
|
|
107
|
+
exists: true,
|
|
108
|
+
mtimeMs: r.mtimeMs,
|
|
109
|
+
bytes: Buffer.byteLength(r.text, 'utf8'),
|
|
110
|
+
error: null,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async function write({ workspace, name, content }) {
|
|
115
|
+
const ws = validWorkspaceName(workspace) ? workspace : 'default';
|
|
116
|
+
if (typeof content !== 'string') {
|
|
117
|
+
return { ok: false, error: 'content must be a string' };
|
|
118
|
+
}
|
|
119
|
+
const bytes = Buffer.byteLength(content, 'utf8');
|
|
120
|
+
if (bytes > MAX_FILE_BYTES) {
|
|
121
|
+
return { ok: false, error: `file exceeds 1 MiB cap (${bytes} bytes)` };
|
|
122
|
+
}
|
|
123
|
+
const abs = resolveEntryPath(ws, name);
|
|
124
|
+
// Enforce per-workspace entry cap on creates (writes to existing files are
|
|
125
|
+
// always allowed regardless of count). Cheap fs.stat check rather than a
|
|
126
|
+
// full directory enumeration where possible.
|
|
127
|
+
let exists = true;
|
|
128
|
+
try { await fsp.access(abs, fs.constants.F_OK); }
|
|
129
|
+
catch { exists = false; }
|
|
130
|
+
if (!exists) {
|
|
131
|
+
const l = await list({ workspace: ws });
|
|
132
|
+
if (l.entries.length >= MAX_ENTRIES) {
|
|
133
|
+
return { ok: false, error: `workspace at ${MAX_ENTRIES}-entry cap` };
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
try {
|
|
137
|
+
await config.writeTextAtomic(abs, content);
|
|
138
|
+
return { ok: true, error: null };
|
|
139
|
+
} catch (e) {
|
|
140
|
+
return { ok: false, error: e.message };
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async function deleteEntry({ workspace, name }) {
|
|
145
|
+
const ws = validWorkspaceName(workspace) ? workspace : 'default';
|
|
146
|
+
const abs = resolveEntryPath(ws, name);
|
|
147
|
+
// Route through config.cjs::validatePath (home-boundary pin, symlink-safe)
|
|
148
|
+
// plus validateWrite (write-prefix pin) — same contract every other
|
|
149
|
+
// mutating call in the codebase follows. Complexity: O(1) per call.
|
|
150
|
+
let real;
|
|
151
|
+
try {
|
|
152
|
+
real = config.validatePath(abs);
|
|
153
|
+
config.validateWrite(real);
|
|
154
|
+
} catch (e) {
|
|
155
|
+
return { ok: false, error: e.message };
|
|
156
|
+
}
|
|
157
|
+
try {
|
|
158
|
+
await fsp.unlink(real);
|
|
159
|
+
return { ok: true, error: null };
|
|
160
|
+
} catch (e) {
|
|
161
|
+
if (e.code === 'ENOENT') return { ok: true, error: null };
|
|
162
|
+
return { ok: false, error: e.message };
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
async function create({ workspace, name, description }) {
|
|
167
|
+
const ws = validWorkspaceName(workspace) ? workspace : 'default';
|
|
168
|
+
const abs = resolveEntryPath(ws, name);
|
|
169
|
+
// Refuse to overwrite — create is strictly "new file".
|
|
170
|
+
try {
|
|
171
|
+
await fsp.access(abs, fs.constants.F_OK);
|
|
172
|
+
return { ok: false, error: 'memory already exists' };
|
|
173
|
+
} catch { /* expected */ }
|
|
174
|
+
const stem = name.replace(/\.md$/, '');
|
|
175
|
+
const desc = typeof description === 'string' ? description.trim() : '';
|
|
176
|
+
const fm = [
|
|
177
|
+
'---',
|
|
178
|
+
`name: ${stem}`,
|
|
179
|
+
...(desc ? [`description: ${desc.replace(/\n/g, ' ')}`] : []),
|
|
180
|
+
'---',
|
|
181
|
+
'',
|
|
182
|
+
desc || `# ${stem}`,
|
|
183
|
+
'',
|
|
184
|
+
].join('\n');
|
|
185
|
+
return await write({ workspace: ws, name, content: fm });
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function registerMemoryHandlers() {
|
|
189
|
+
const { schemas: s, validated: v } = require('./ipcSchemas.cjs');
|
|
190
|
+
ipcMain.handle('memory:list', v(s.memoryList, list));
|
|
191
|
+
ipcMain.handle('memory:read', v(s.memoryRead, read));
|
|
192
|
+
ipcMain.handle('memory:write', v(s.memoryWrite, write));
|
|
193
|
+
ipcMain.handle('memory:delete', v(s.memoryDelete, deleteEntry));
|
|
194
|
+
ipcMain.handle('memory:create', v(s.memoryCreate, create));
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
module.exports = {
|
|
198
|
+
registerMemoryHandlers,
|
|
199
|
+
encodeWorkspace,
|
|
200
|
+
// exported for tests
|
|
201
|
+
memoryRoot,
|
|
202
|
+
workspaceDir,
|
|
203
|
+
};
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
const fsp = require('node:fs/promises');
|
|
18
18
|
const path = require('node:path');
|
|
19
19
|
const os = require('node:os');
|
|
20
|
+
const config = require('./config.cjs');
|
|
20
21
|
|
|
21
22
|
const SCHEMA_VERSION = 1;
|
|
22
23
|
|
|
@@ -74,13 +75,7 @@ async function save(cfg) {
|
|
|
74
75
|
if (!isValid(cfg)) throw new Error('Invalid OTEL config');
|
|
75
76
|
const next = normalize(cfg);
|
|
76
77
|
const run = async () => {
|
|
77
|
-
|
|
78
|
-
await fsp.mkdir(path.dirname(p), { recursive: true }).catch(() => {});
|
|
79
|
-
const body = JSON.stringify(next, null, 2) + '\n';
|
|
80
|
-
const tmp = `${p}.tmp-${process.pid}-${Date.now()}`;
|
|
81
|
-
await fsp.writeFile(tmp, body, { encoding: 'utf8', mode: 0o600 });
|
|
82
|
-
try { await fsp.chmod(tmp, 0o600); } catch { /* */ }
|
|
83
|
-
await fsp.rename(tmp, p);
|
|
78
|
+
await config.writeTextAtomic(storePath(), JSON.stringify(next, null, 2) + '\n', { mode: 0o600 });
|
|
84
79
|
return next;
|
|
85
80
|
};
|
|
86
81
|
const tail = writeQueue.then(run, run);
|