claude-code-session-manager 0.3.2 → 0.5.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.
Files changed (31) hide show
  1. package/dist/assets/{cssMode-DKcHzqs6.js → cssMode-DuceD2Ek.js} +1 -1
  2. package/dist/assets/{editor.main-CKATA8Es.js → editor.main-W7kZjY3Y.js} +3 -3
  3. package/dist/assets/{freemarker2-DW6HspbF.js → freemarker2-BrfVQxqM.js} +1 -1
  4. package/dist/assets/{handlebars-DrHBBsXD.js → handlebars-CEk4GZAW.js} +1 -1
  5. package/dist/assets/{html-sZnU1oHD.js → html-Dsr1hOJo.js} +1 -1
  6. package/dist/assets/{htmlMode-BqHVHLoz.js → htmlMode-DTyxWkAs.js} +1 -1
  7. package/dist/assets/index-DUYNLg5N.js +2973 -0
  8. package/dist/assets/index-QriiiRo1.css +32 -0
  9. package/dist/assets/{javascript-CoPK13FX.js → javascript-DDnXRxuX.js} +1 -1
  10. package/dist/assets/{jsonMode-DLl_bJXa.js → jsonMode-BFDUayfd.js} +1 -1
  11. package/dist/assets/{liquid-C-gTpqe2.js → liquid-BcvXX-ei.js} +1 -1
  12. package/dist/assets/{lspLanguageFeatures-BVR1hoTD.js → lspLanguageFeatures-D6rzws04.js} +1 -1
  13. package/dist/assets/{mdx-BDBDopEV.js → mdx-DnY5OLKT.js} +1 -1
  14. package/dist/assets/{python-BzWMTDid.js → python-BA4bdGM0.js} +1 -1
  15. package/dist/assets/{razor-CZXXc8Yy.js → razor-VjEf8dER.js} +1 -1
  16. package/dist/assets/{tsMode-DBVY2EZ_.js → tsMode-BzXie6uX.js} +1 -1
  17. package/dist/assets/{typescript-Ca_aDCJd.js → typescript-BEjKh90W.js} +1 -1
  18. package/dist/assets/{xml-C7eMpTwW.js → xml-C64Hq61M.js} +1 -1
  19. package/dist/assets/{yaml-BOPrlUSY.js → yaml-BvsE9PT3.js} +1 -1
  20. package/dist/index.html +2 -2
  21. package/package.json +7 -1
  22. package/src/main/index.cjs +142 -1
  23. package/src/main/otel.cjs +248 -0
  24. package/src/main/otelSettings.cjs +119 -0
  25. package/src/main/scheduler.cjs +717 -0
  26. package/src/main/transcripts.cjs +10 -0
  27. package/src/main/watchers.cjs +154 -0
  28. package/src/preload/api.d.ts +167 -0
  29. package/src/preload/index.cjs +39 -0
  30. package/dist/assets/index-BzbwWnyF.css +0 -32
  31. package/dist/assets/index-FnbJBSnE.js +0 -2971
package/dist/index.html CHANGED
@@ -4,8 +4,8 @@
4
4
  <meta charset="UTF-8" />
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
6
  <title>Claude Session Manager</title>
7
- <script type="module" crossorigin src="./assets/index-FnbJBSnE.js"></script>
8
- <link rel="stylesheet" crossorigin href="./assets/index-BzbwWnyF.css">
7
+ <script type="module" crossorigin src="./assets/index-DUYNLg5N.js"></script>
8
+ <link rel="stylesheet" crossorigin href="./assets/index-QriiiRo1.css">
9
9
  </head>
10
10
  <body class="bg-bg text-fg font-mono antialiased">
11
11
  <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.2",
3
+ "version": "0.5.0",
4
4
  "description": "Local cockpit for Claude Code CLI sessions — terminal + full config surface.",
5
5
  "type": "module",
6
6
  "main": "src/main/index.cjs",
@@ -53,6 +53,12 @@
53
53
  "@electron/rebuild": "^3.7.0",
54
54
  "@huggingface/transformers": "^4.1.0",
55
55
  "@monaco-editor/react": "^4.6.0",
56
+ "@opentelemetry/api": "^1.9.0",
57
+ "@opentelemetry/exporter-trace-otlp-http": "^0.57.0",
58
+ "@opentelemetry/resources": "^1.30.0",
59
+ "@opentelemetry/sdk-trace-base": "^1.30.0",
60
+ "@opentelemetry/sdk-trace-node": "^1.30.0",
61
+ "@opentelemetry/semantic-conventions": "^1.28.0",
56
62
  "@ricky0123/vad-web": "^0.0.30",
57
63
  "@xterm/addon-fit": "^0.10.0",
58
64
  "@xterm/addon-web-links": "^0.11.0",
@@ -1,5 +1,5 @@
1
1
  const { app, BrowserWindow, ipcMain, dialog, Menu, session, systemPreferences, globalShortcut } = require('electron');
2
- const { spawn, execFileSync } = require('node:child_process');
2
+ const { spawn, execFile, execFileSync } = require('node:child_process');
3
3
  const path = require('node:path');
4
4
  const fs = require('node:fs');
5
5
  const os = require('node:os');
@@ -11,6 +11,10 @@ const billing = require('./usage.cjs');
11
11
  const logs = require('./logs.cjs');
12
12
  const voiceHotkey = require('./voiceHotkey.cjs');
13
13
  const voiceWizard = require('./voiceWizard.cjs');
14
+ const scheduler = require('./scheduler.cjs');
15
+ const watchers = require('./watchers.cjs');
16
+ const otel = require('./otel.cjs');
17
+ const otelSettings = require('./otelSettings.cjs');
14
18
 
15
19
  let mainWindow = null;
16
20
  let rebooting = false;
@@ -78,6 +82,7 @@ async function rebootApp() {
78
82
  ptyManager.killAll();
79
83
  configMgr.closeAllWatchers();
80
84
  transcripts.closeAll();
85
+ watchers.manager.killAll();
81
86
 
82
87
  // Rewrite persisted tabs with fresh session IDs so the next boot starts
83
88
  // new claude sessions instead of resuming old ones.
@@ -96,6 +101,8 @@ async function rebootApp() {
96
101
  voiceHotkey.init(mainWindow).catch((e) => {
97
102
  logs.writeLine({ scope: 'voice-hotkey', level: 'error', message: 'reinit failed', meta: { error: e?.message } });
98
103
  });
104
+ scheduler.attachWindow(mainWindow);
105
+ watchers.attachWindow(mainWindow);
99
106
  rebooting = false;
100
107
  return;
101
108
  }
@@ -185,6 +192,102 @@ ipcMain.handle('app:pick-directory', async () => {
185
192
 
186
193
  ipcMain.on('app:reboot-app', () => rebootApp());
187
194
 
195
+ // Hooks tab "Test fire": run a hook command with a fake event payload piped
196
+ // to stdin. shell:true is intentional — Claude Code's hook field is a shell
197
+ // string. Timeout is enforced via SIGKILL on a timer because spawn's built-in
198
+ // `timeout` option only sends SIGTERM, which a wedged shell may ignore.
199
+ ipcMain.handle('app:test-fire-hook', async (_e, payload) => {
200
+ const command = typeof payload?.command === 'string' ? payload.command : '';
201
+ const env = payload && typeof payload.env === 'object' && payload.env !== null ? payload.env : null;
202
+ const stdin = typeof payload?.payload === 'string' ? payload.payload : '';
203
+ const requested = Number(payload?.timeoutMs);
204
+ const timeoutMs = Number.isFinite(requested) && requested > 0
205
+ ? Math.min(requested, 30_000)
206
+ : 5_000;
207
+
208
+ if (!command.trim()) {
209
+ return { exitCode: -1, stdout: '', stderr: 'empty command', durationMs: 0 };
210
+ }
211
+
212
+ return await new Promise((resolve) => {
213
+ const startedAt = Date.now();
214
+ let child;
215
+ try {
216
+ child = spawn(command, {
217
+ shell: true,
218
+ env: { ...process.env, ...(env ?? {}) },
219
+ stdio: ['pipe', 'pipe', 'pipe'],
220
+ });
221
+ } catch (err) {
222
+ resolve({
223
+ exitCode: -1,
224
+ stdout: '',
225
+ stderr: `spawn failed: ${err?.message ?? String(err)}`,
226
+ durationMs: Date.now() - startedAt,
227
+ });
228
+ return;
229
+ }
230
+
231
+ const stdoutChunks = [];
232
+ const stderrChunks = [];
233
+ let timedOut = false;
234
+
235
+ const killTimer = setTimeout(() => {
236
+ timedOut = true;
237
+ try { child.kill('SIGKILL'); } catch { /* already dead */ }
238
+ }, timeoutMs);
239
+
240
+ child.stdout.on('data', (b) => stdoutChunks.push(b));
241
+ child.stderr.on('data', (b) => stderrChunks.push(b));
242
+
243
+ child.on('error', (err) => {
244
+ clearTimeout(killTimer);
245
+ resolve({
246
+ exitCode: -1,
247
+ stdout: Buffer.concat(stdoutChunks).toString('utf8'),
248
+ stderr: `spawn error: ${err?.message ?? String(err)}`,
249
+ durationMs: Date.now() - startedAt,
250
+ });
251
+ });
252
+
253
+ child.on('close', (code, signal) => {
254
+ clearTimeout(killTimer);
255
+ const stderr = Buffer.concat(stderrChunks).toString('utf8');
256
+ const tail = timedOut
257
+ ? `${stderr}${stderr && !stderr.endsWith('\n') ? '\n' : ''}[killed: timeout after ${timeoutMs}ms]`
258
+ : stderr;
259
+ resolve({
260
+ exitCode: typeof code === 'number' ? code : (signal ? -1 : 0),
261
+ stdout: Buffer.concat(stdoutChunks).toString('utf8'),
262
+ stderr: tail,
263
+ durationMs: Date.now() - startedAt,
264
+ });
265
+ });
266
+
267
+ try {
268
+ child.stdin.end(stdin);
269
+ } catch {
270
+ // child may have already exited; close handler will resolve.
271
+ }
272
+ });
273
+ });
274
+
275
+ // StatusBar: best-effort current branch for the active tab's cwd. Returns null
276
+ // for non-git dirs, detached HEAD, missing git, or timeouts so callers can
277
+ // render `—` without branching on error shape. 1s ceiling keeps a wedged git
278
+ // (network filesystem, hung index lock) from blocking the renderer.
279
+ ipcMain.handle('app:git-branch', async (_e, payload) => {
280
+ const cwd = payload && typeof payload.cwd === 'string' ? payload.cwd : null;
281
+ if (!cwd) return null;
282
+ return await new Promise((resolve) => {
283
+ execFile('git', ['branch', '--show-current'], { cwd, timeout: 1000, windowsHide: true }, (err, stdout) => {
284
+ if (err) { resolve(null); return; }
285
+ const out = String(stdout).trim();
286
+ resolve(out.length ? out : null);
287
+ });
288
+ });
289
+ });
290
+
188
291
  registerPtyHandlers();
189
292
  configMgr.registerConfigHandlers();
190
293
  transcripts.registerTranscriptHandlers();
@@ -193,6 +296,21 @@ billing.registerBillingHandlers();
193
296
  logs.registerLogHandlers();
194
297
  voiceHotkey.registerHotkeyHandlers();
195
298
  voiceWizard.registerWizardHandlers();
299
+ scheduler.registerScheduleHandlers();
300
+ watchers.registerWatcherHandlers();
301
+
302
+ // OTEL telemetry export (opt-in via ~/.config/session-manager/otel.json).
303
+ ipcMain.handle('otel:get-config', async () => otelSettings.load());
304
+ ipcMain.handle('otel:set-config', async (_e, cfg) => {
305
+ if (!otelSettings.isValid(cfg)) {
306
+ return { ok: false, error: 'invalid config' };
307
+ }
308
+ const saved = await otelSettings.save(cfg);
309
+ const result = await otel.applyConfig(saved);
310
+ return { ok: !!result?.ok, error: result?.error ?? null, config: saved, status: otel.status() };
311
+ });
312
+ ipcMain.handle('otel:status', () => otel.status());
313
+ ipcMain.handle('otel:config-path', () => otelSettings.storePath());
196
314
 
197
315
  // --- App lifecycle ---
198
316
 
@@ -311,6 +429,24 @@ app.whenReady().then(async () => {
311
429
  voiceHotkey.init(mainWindow).catch((e) => {
312
430
  logs.writeLine({ scope: 'voice-hotkey', level: 'error', message: 'init failed', meta: { error: e?.message } });
313
431
  });
432
+ scheduler.attachWindow(mainWindow);
433
+ watchers.attachWindow(mainWindow);
434
+ scheduler.init().catch((e) => {
435
+ logs.writeLine({ scope: 'scheduler', level: 'error', message: 'init failed', meta: { error: e?.message } });
436
+ });
437
+
438
+ // OTEL: load persisted config and start the exporter only if `enabled`.
439
+ // Failures are non-fatal — the app must keep working without telemetry.
440
+ otelSettings.load()
441
+ .then((cfg) => otel.applyConfig(cfg))
442
+ .then((res) => {
443
+ if (res && !res.ok && res.error) {
444
+ logs.writeLine({ scope: 'otel', level: 'warn', message: 'init failed', meta: { error: res.error } });
445
+ }
446
+ })
447
+ .catch((e) => {
448
+ logs.writeLine({ scope: 'otel', level: 'error', message: 'init failed', meta: { error: e?.message } });
449
+ });
314
450
  });
315
451
 
316
452
  app.on('will-quit', () => {
@@ -324,6 +460,7 @@ app.on('window-all-closed', () => {
324
460
  ptyManager.killAll();
325
461
  configMgr.closeAllWatchers();
326
462
  transcripts.closeAll();
463
+ watchers.manager.killAll();
327
464
  if (process.platform !== 'darwin') app.quit();
328
465
  });
329
466
 
@@ -335,4 +472,8 @@ app.on('before-quit', () => {
335
472
  ptyManager.killAll();
336
473
  configMgr.closeAllWatchers();
337
474
  transcripts.closeAll();
475
+ watchers.manager.killAll();
476
+ // Best-effort flush of any pending OTEL spans. shutdown() has its own 2s
477
+ // ceiling so a wedged exporter can't hold quit.
478
+ otel.shutdown().catch(() => {});
338
479
  });
@@ -0,0 +1,248 @@
1
+ /**
2
+ * otel — mirrors transcripts.cjs classified events as OpenTelemetry spans.
3
+ *
4
+ * Spans are 0-duration "events": one span per classified transcript line,
5
+ * named `transcript.<kind>`, with structural attributes only. Tool inputs,
6
+ * plan text, and agent prompts are excluded unless `includeContent` is on
7
+ * (matches upstream OTEL_LOG_USER_PROMPTS opt-in).
8
+ *
9
+ * The OTEL SDK is required lazily — if any of the @opentelemetry/* packages
10
+ * fail to load, the module is left in an inert state and `recordTranscriptEvent`
11
+ * becomes a no-op. The app must keep working without telemetry.
12
+ *
13
+ * Threading: the BatchSpanProcessor batches/sends spans on its own timer; we
14
+ * never block the transcript flush.
15
+ */
16
+
17
+ const otelSettings = require('./otelSettings.cjs');
18
+
19
+ let provider = null;
20
+ let tracer = null;
21
+ let enabled = false;
22
+ let includeContent = false;
23
+ let lastError = null;
24
+ let initialized = false;
25
+
26
+ /**
27
+ * Lazy-require all @opentelemetry/* packages. Returns null if any are missing.
28
+ * We catch the per-require so a partial install doesn't half-init.
29
+ */
30
+ function loadDeps() {
31
+ try {
32
+ const api = require('@opentelemetry/api');
33
+ const { NodeTracerProvider } = require('@opentelemetry/sdk-trace-node');
34
+ const { BatchSpanProcessor } = require('@opentelemetry/sdk-trace-base');
35
+ const { OTLPTraceExporter } = require('@opentelemetry/exporter-trace-otlp-http');
36
+ const { Resource } = require('@opentelemetry/resources');
37
+ const { SemanticResourceAttributes } = require('@opentelemetry/semantic-conventions');
38
+ return { api, NodeTracerProvider, BatchSpanProcessor, OTLPTraceExporter, Resource, SemanticResourceAttributes };
39
+ } catch (err) {
40
+ return { error: err };
41
+ }
42
+ }
43
+
44
+ /**
45
+ * Tear down any existing provider. Best-effort — flushes pending spans on a
46
+ * 2s ceiling so a wedged exporter doesn't block app shutdown / config reload.
47
+ */
48
+ async function shutdown() {
49
+ if (!provider) {
50
+ enabled = false;
51
+ tracer = null;
52
+ return;
53
+ }
54
+ const p = provider;
55
+ provider = null;
56
+ tracer = null;
57
+ enabled = false;
58
+ try {
59
+ await Promise.race([
60
+ p.shutdown(),
61
+ new Promise((resolve) => setTimeout(resolve, 2000)),
62
+ ]);
63
+ } catch (err) {
64
+ console.warn('[otel] shutdown failed:', err?.message);
65
+ }
66
+ }
67
+
68
+ /**
69
+ * Configure the tracer provider. Tears down any prior provider first so a
70
+ * settings change reliably re-points at the new endpoint.
71
+ *
72
+ * Returns { ok, error?: string } — the renderer surfaces `error` in the UI.
73
+ */
74
+ async function init({ endpoint, headers, serviceName, includeContent: ic } = {}) {
75
+ await shutdown();
76
+ initialized = true;
77
+
78
+ const deps = loadDeps();
79
+ if (deps.error) {
80
+ lastError = `OTEL packages unavailable: ${deps.error.message}`;
81
+ return { ok: false, error: lastError };
82
+ }
83
+
84
+ const { NodeTracerProvider, BatchSpanProcessor, OTLPTraceExporter, Resource, SemanticResourceAttributes } = deps;
85
+
86
+ try {
87
+ const exporter = new OTLPTraceExporter({
88
+ url: String(endpoint || 'http://localhost:4318/v1/traces'),
89
+ headers: typeof headers === 'object' && headers ? headers : {},
90
+ });
91
+ const tp = new NodeTracerProvider({
92
+ resource: new Resource({
93
+ [SemanticResourceAttributes.SERVICE_NAME]: String(serviceName || 'session-manager'),
94
+ }),
95
+ });
96
+ tp.addSpanProcessor(new BatchSpanProcessor(exporter));
97
+ // Note: we deliberately do NOT call tp.register() — that installs a global
98
+ // tracer provider, which would intercept any other OTEL user in the
99
+ // process. We only want to emit our own spans, so we use the provider
100
+ // directly via getTracer().
101
+ provider = tp;
102
+ tracer = tp.getTracer('session-manager-transcripts');
103
+ includeContent = !!ic;
104
+ enabled = true;
105
+ lastError = null;
106
+ return { ok: true };
107
+ } catch (err) {
108
+ lastError = err?.message || String(err);
109
+ enabled = false;
110
+ return { ok: false, error: lastError };
111
+ }
112
+ }
113
+
114
+ /**
115
+ * Apply current persisted config: shut down if disabled, init if enabled.
116
+ * Called on app boot and after every settings save.
117
+ */
118
+ async function applyConfig(cfg) {
119
+ if (!cfg || !cfg.enabled) {
120
+ await shutdown();
121
+ return { ok: true };
122
+ }
123
+ return await init({
124
+ endpoint: cfg.endpoint,
125
+ headers: otelSettings.parseHeaders(cfg.headers),
126
+ serviceName: cfg.serviceName,
127
+ includeContent: cfg.includeContent,
128
+ });
129
+ }
130
+
131
+ /**
132
+ * Build the kind-specific attribute subset. Keep PII fields gated behind
133
+ * `includeContent`. Truncate any free-form string at 4096 chars to bound the
134
+ * span payload — most exporters reject spans larger than ~64KiB.
135
+ */
136
+ function attrsFor(kind, data) {
137
+ const cap = (s) => (typeof s === 'string' ? s.slice(0, 4096) : s);
138
+ switch (kind) {
139
+ case 'tool_use': {
140
+ const a = {
141
+ 'tool.name': data?.name,
142
+ 'tool.id': data?.id,
143
+ };
144
+ if (includeContent && data?.input != null) {
145
+ try { a['tool.input'] = cap(JSON.stringify(data.input)); } catch { /* */ }
146
+ }
147
+ return a;
148
+ }
149
+ case 'todo_write': {
150
+ const todos = Array.isArray(data) ? data : [];
151
+ let completed = 0, inProgress = 0;
152
+ for (const t of todos) {
153
+ if (t?.status === 'completed') completed++;
154
+ else if (t?.status === 'in_progress') inProgress++;
155
+ }
156
+ return {
157
+ 'todo.count': todos.length,
158
+ 'todo.completed': completed,
159
+ 'todo.in_progress': inProgress,
160
+ };
161
+ }
162
+ case 'plan': {
163
+ const text = typeof data?.plan === 'string' ? data.plan : (typeof data === 'string' ? data : '');
164
+ const a = { 'plan.chars': text.length };
165
+ if (includeContent && text) a['plan.text'] = cap(text);
166
+ return a;
167
+ }
168
+ case 'usage': {
169
+ return {
170
+ 'usage.input_tokens': Number(data?.input_tokens || 0),
171
+ 'usage.output_tokens': Number(data?.output_tokens || 0),
172
+ 'usage.cache_creation_input_tokens': Number(data?.cache_creation_input_tokens || 0),
173
+ 'usage.cache_read_input_tokens': Number(data?.cache_read_input_tokens || 0),
174
+ };
175
+ }
176
+ case 'agent_spawn': {
177
+ const a = {
178
+ 'agent.subtype': data?.subagent_type,
179
+ 'agent.description': data?.description, // task description is structural metadata
180
+ };
181
+ if (includeContent && typeof data?.prompt === 'string') {
182
+ a['agent.prompt'] = cap(data.prompt);
183
+ }
184
+ return a;
185
+ }
186
+ default:
187
+ return {};
188
+ }
189
+ }
190
+
191
+ /**
192
+ * Drop attributes whose value is undefined/null so the exporter doesn't
193
+ * complain about unsupported attribute types.
194
+ */
195
+ function pruneAttrs(obj) {
196
+ const out = {};
197
+ for (const [k, v] of Object.entries(obj)) {
198
+ if (v === undefined || v === null) continue;
199
+ out[k] = v;
200
+ }
201
+ return out;
202
+ }
203
+
204
+ /**
205
+ * Emit a 0-duration span for one classified transcript event.
206
+ * Synchronous + non-blocking: the BatchSpanProcessor handles network I/O
207
+ * on its own schedule.
208
+ */
209
+ function recordTranscriptEvent({ tabId, tabCwd, kind, data, ts }) {
210
+ if (!enabled || !tracer) return;
211
+ try {
212
+ const startTime = typeof ts === 'number' ? ts : Date.now();
213
+ const attrs = pruneAttrs({
214
+ 'tab.id': tabId,
215
+ 'tab.cwd': tabCwd,
216
+ kind,
217
+ ...attrsFor(kind, data),
218
+ });
219
+ const span = tracer.startSpan(`transcript.${kind}`, {
220
+ startTime,
221
+ attributes: attrs,
222
+ });
223
+ span.end(startTime);
224
+ } catch (err) {
225
+ // A failure here must never break transcript ingestion. Log once-ish.
226
+ if (!recordTranscriptEvent._warned) {
227
+ recordTranscriptEvent._warned = true;
228
+ console.warn('[otel] recordTranscriptEvent failed:', err?.message);
229
+ }
230
+ }
231
+ }
232
+
233
+ function status() {
234
+ return {
235
+ enabled,
236
+ initialized,
237
+ error: lastError,
238
+ includeContent,
239
+ };
240
+ }
241
+
242
+ module.exports = {
243
+ init,
244
+ applyConfig,
245
+ shutdown,
246
+ recordTranscriptEvent,
247
+ status,
248
+ };
@@ -0,0 +1,119 @@
1
+ /**
2
+ * otelSettings — persists OTEL telemetry export configuration.
3
+ *
4
+ * Storage: ~/.config/session-manager/otel.json
5
+ * Shape: {
6
+ * enabled: boolean,
7
+ * endpoint: string, // OTLP/HTTP traces endpoint
8
+ * headers: string, // newline-separated "Key: Value" pairs
9
+ * serviceName: string,
10
+ * includeContent: boolean, // mirrors upstream OTEL_LOG_USER_PROMPTS — opt-in PII
11
+ * schemaVersion: 1
12
+ * }
13
+ *
14
+ * Atomic write pattern (tmp + rename) mirrors voiceSettings.cjs.
15
+ */
16
+
17
+ const fsp = require('node:fs/promises');
18
+ const path = require('node:path');
19
+ const os = require('node:os');
20
+
21
+ const SCHEMA_VERSION = 1;
22
+
23
+ const DEFAULTS = Object.freeze({
24
+ enabled: false,
25
+ endpoint: 'http://localhost:4318/v1/traces',
26
+ headers: '',
27
+ serviceName: 'session-manager',
28
+ includeContent: false,
29
+ schemaVersion: SCHEMA_VERSION,
30
+ });
31
+
32
+ function storePath() {
33
+ return path.join(os.homedir(), '.config', 'session-manager', 'otel.json');
34
+ }
35
+
36
+ function isValid(cfg) {
37
+ if (!cfg || typeof cfg !== 'object') return false;
38
+ if (typeof cfg.enabled !== 'boolean') return false;
39
+ if (typeof cfg.endpoint !== 'string') return false;
40
+ if (typeof cfg.headers !== 'string') return false;
41
+ if (typeof cfg.serviceName !== 'string' || !cfg.serviceName.trim()) return false;
42
+ if (typeof cfg.includeContent !== 'boolean') return false;
43
+ return true;
44
+ }
45
+
46
+ function normalize(cfg) {
47
+ return {
48
+ enabled: !!cfg.enabled,
49
+ endpoint: typeof cfg.endpoint === 'string' && cfg.endpoint.trim() ? cfg.endpoint.trim() : DEFAULTS.endpoint,
50
+ headers: typeof cfg.headers === 'string' ? cfg.headers : '',
51
+ serviceName: typeof cfg.serviceName === 'string' && cfg.serviceName.trim() ? cfg.serviceName.trim() : DEFAULTS.serviceName,
52
+ includeContent: !!cfg.includeContent,
53
+ schemaVersion: SCHEMA_VERSION,
54
+ };
55
+ }
56
+
57
+ async function load() {
58
+ try {
59
+ const raw = await fsp.readFile(storePath(), 'utf8');
60
+ const data = JSON.parse(raw);
61
+ if (data && typeof data === 'object') {
62
+ return normalize({ ...DEFAULTS, ...data });
63
+ }
64
+ } catch (e) {
65
+ if (e.code !== 'ENOENT') {
66
+ console.warn('[otelSettings] load failed:', e.message);
67
+ }
68
+ }
69
+ return { ...DEFAULTS };
70
+ }
71
+
72
+ let writeQueue = Promise.resolve();
73
+ async function save(cfg) {
74
+ if (!isValid(cfg)) throw new Error('Invalid OTEL config');
75
+ const next = normalize(cfg);
76
+ const run = async () => {
77
+ const p = storePath();
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, 'utf8', { mode: 0o600 });
82
+ try { await fsp.chmod(tmp, 0o600); } catch { /* */ }
83
+ await fsp.rename(tmp, p);
84
+ return next;
85
+ };
86
+ const tail = writeQueue.then(run, run);
87
+ writeQueue = tail.catch(() => {});
88
+ return tail;
89
+ }
90
+
91
+ /**
92
+ * Parse "Key: Value\nKey2: Value2" → { Key: 'Value', Key2: 'Value2' }.
93
+ * Empty / whitespace-only lines and lines without ':' are silently dropped.
94
+ * Complexity: O(n) where n is the string length.
95
+ */
96
+ function parseHeaders(text) {
97
+ const out = {};
98
+ if (typeof text !== 'string' || !text.trim()) return out;
99
+ for (const line of text.split(/\r?\n/)) {
100
+ const trimmed = line.trim();
101
+ if (!trimmed) continue;
102
+ const idx = trimmed.indexOf(':');
103
+ if (idx <= 0) continue;
104
+ const k = trimmed.slice(0, idx).trim();
105
+ const v = trimmed.slice(idx + 1).trim();
106
+ if (k) out[k] = v;
107
+ }
108
+ return out;
109
+ }
110
+
111
+ module.exports = {
112
+ load,
113
+ save,
114
+ storePath,
115
+ parseHeaders,
116
+ isValid,
117
+ DEFAULTS,
118
+ SCHEMA_VERSION,
119
+ };