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.
- package/dist/assets/{cssMode-DKcHzqs6.js → cssMode-DuceD2Ek.js} +1 -1
- package/dist/assets/{editor.main-CKATA8Es.js → editor.main-W7kZjY3Y.js} +3 -3
- package/dist/assets/{freemarker2-DW6HspbF.js → freemarker2-BrfVQxqM.js} +1 -1
- package/dist/assets/{handlebars-DrHBBsXD.js → handlebars-CEk4GZAW.js} +1 -1
- package/dist/assets/{html-sZnU1oHD.js → html-Dsr1hOJo.js} +1 -1
- package/dist/assets/{htmlMode-BqHVHLoz.js → htmlMode-DTyxWkAs.js} +1 -1
- package/dist/assets/index-DUYNLg5N.js +2973 -0
- package/dist/assets/index-QriiiRo1.css +32 -0
- package/dist/assets/{javascript-CoPK13FX.js → javascript-DDnXRxuX.js} +1 -1
- package/dist/assets/{jsonMode-DLl_bJXa.js → jsonMode-BFDUayfd.js} +1 -1
- package/dist/assets/{liquid-C-gTpqe2.js → liquid-BcvXX-ei.js} +1 -1
- package/dist/assets/{lspLanguageFeatures-BVR1hoTD.js → lspLanguageFeatures-D6rzws04.js} +1 -1
- package/dist/assets/{mdx-BDBDopEV.js → mdx-DnY5OLKT.js} +1 -1
- package/dist/assets/{python-BzWMTDid.js → python-BA4bdGM0.js} +1 -1
- package/dist/assets/{razor-CZXXc8Yy.js → razor-VjEf8dER.js} +1 -1
- package/dist/assets/{tsMode-DBVY2EZ_.js → tsMode-BzXie6uX.js} +1 -1
- package/dist/assets/{typescript-Ca_aDCJd.js → typescript-BEjKh90W.js} +1 -1
- package/dist/assets/{xml-C7eMpTwW.js → xml-C64Hq61M.js} +1 -1
- package/dist/assets/{yaml-BOPrlUSY.js → yaml-BvsE9PT3.js} +1 -1
- package/dist/index.html +2 -2
- package/package.json +7 -1
- package/src/main/index.cjs +142 -1
- package/src/main/otel.cjs +248 -0
- package/src/main/otelSettings.cjs +119 -0
- package/src/main/scheduler.cjs +717 -0
- package/src/main/transcripts.cjs +10 -0
- package/src/main/watchers.cjs +154 -0
- package/src/preload/api.d.ts +167 -0
- package/src/preload/index.cjs +39 -0
- package/dist/assets/index-BzbwWnyF.css +0 -32
- 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-
|
|
8
|
-
<link rel="stylesheet" crossorigin href="./assets/index-
|
|
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
|
+
"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",
|
package/src/main/index.cjs
CHANGED
|
@@ -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
|
+
};
|