claude-code-session-manager 0.8.1 → 0.8.3
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 +66 -11
- package/dist/assets/{cssMode-DKTELvb6.js → cssMode-DyaNC2Cs.js} +1 -1
- package/dist/assets/{editor.main-Dx55Am4z.js → editor.main-BhSGi_Jw.js} +3 -3
- package/dist/assets/{freemarker2-CBdvn_u-.js → freemarker2-DZH3si5v.js} +1 -1
- package/dist/assets/{handlebars-B67ay2ue.js → handlebars-DvzTd6uL.js} +1 -1
- package/dist/assets/{html-002uK0_M.js → html-C5GmopAN.js} +1 -1
- package/dist/assets/{htmlMode-DsT8oVY_.js → htmlMode-DwnrHwx1.js} +1 -1
- package/dist/assets/index-BGshD4Pw.js +2976 -0
- package/dist/assets/index-DCK87t79.css +32 -0
- package/dist/assets/{javascript-Cfg-gFlu.js → javascript-JqHrxiCa.js} +1 -1
- package/dist/assets/{jsonMode-CCIKxANa.js → jsonMode-8rZcy09i.js} +1 -1
- package/dist/assets/{liquid-DewgYvox.js → liquid-ClpD_v7G.js} +1 -1
- package/dist/assets/{lspLanguageFeatures-BcMPMUo0.js → lspLanguageFeatures-u0WgQBQz.js} +1 -1
- package/dist/assets/{mdx-BGrrIvjV.js → mdx-DtViUgdm.js} +1 -1
- package/dist/assets/{python-CVhAv32T.js → python-CaAvhRGm.js} +1 -1
- package/dist/assets/{razor-DteXtrPO.js → razor-saGNVU7l.js} +1 -1
- package/dist/assets/{tsMode-DKeWRYvl.js → tsMode-HZwWTCj8.js} +1 -1
- package/dist/assets/{typescript-Dl1KPrAp.js → typescript-BInV4PNE.js} +1 -1
- package/dist/assets/{xml-DdyOGE0N.js → xml-tgO806YR.js} +1 -1
- package/dist/assets/{yaml-BwFXDW6t.js → yaml-CHApZArv.js} +1 -1
- package/dist/index.html +2 -2
- package/package.json +1 -1
- package/src/main/config.cjs +93 -19
- package/src/main/index.cjs +163 -31
- package/src/main/ipcSchemas.cjs +59 -2
- package/src/main/lib/cleanEnv.cjs +20 -0
- package/src/main/lib/credentials.cjs +184 -0
- package/src/main/lib/schedulerConfig.cjs +10 -0
- package/src/main/logs.cjs +1 -1
- package/src/main/otelSettings.cjs +1 -1
- package/src/main/pty.cjs +53 -6
- package/src/main/scheduler.cjs +518 -147
- package/src/main/transcripts.cjs +26 -21
- package/src/main/usage.cjs +76 -25
- package/src/main/voiceSettings.cjs +1 -1
- package/src/main/watchers.cjs +69 -11
- package/src/preload/api.d.ts +51 -11
- package/src/preload/index.cjs +13 -0
- package/dist/assets/index-DsC4vT8M.css +0 -32
- package/dist/assets/index-E14-spyd.js +0 -2972
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fsp = require('node:fs/promises');
|
|
4
|
+
const fs = require('node:fs');
|
|
5
|
+
const path = require('node:path');
|
|
6
|
+
const os = require('node:os');
|
|
7
|
+
const { spawn } = require('node:child_process');
|
|
8
|
+
const { cleanChildEnv } = require('./cleanEnv.cjs');
|
|
9
|
+
|
|
10
|
+
const CREDS_PATH = path.join(os.homedir(), '.claude', '.credentials.json');
|
|
11
|
+
const REFRESH_LOG_PATH = path.join(os.homedir(), '.claude', 'session-manager', 'credential-refresh.log');
|
|
12
|
+
const REFRESH_LOG_MAX_BYTES = 100 * 1024;
|
|
13
|
+
|
|
14
|
+
// Standard OAuth 2.0 refresh grant — endpoint discovered from Claude Code CLI behavior.
|
|
15
|
+
// Returns { kind: 'unsupported' } if the endpoint returns 404 or cannot be reached,
|
|
16
|
+
// allowing the caller to fall back gracefully.
|
|
17
|
+
const OAUTH_TOKEN_URL = 'https://claude.ai/api/auth/oauth/token';
|
|
18
|
+
|
|
19
|
+
async function readCredentials() {
|
|
20
|
+
try {
|
|
21
|
+
const raw = await fsp.readFile(CREDS_PATH, 'utf8');
|
|
22
|
+
const data = JSON.parse(raw);
|
|
23
|
+
const oa = data?.claudeAiOauth;
|
|
24
|
+
if (!oa?.accessToken) return { kind: 'config', message: 'missing accessToken in credentials file' };
|
|
25
|
+
return { kind: 'ok', creds: oa, raw: data };
|
|
26
|
+
} catch (e) {
|
|
27
|
+
if (e?.code === 'ENOENT') return { kind: 'config', message: 'credentials file not found' };
|
|
28
|
+
return { kind: 'config', message: `cannot read credentials: ${e.message}` };
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function expiresAtMs(creds) {
|
|
33
|
+
const v = creds.expiresAt;
|
|
34
|
+
if (typeof v === 'number') return v;
|
|
35
|
+
if (typeof v === 'string') {
|
|
36
|
+
const ms = new Date(v).getTime();
|
|
37
|
+
return Number.isNaN(ms) ? null : ms;
|
|
38
|
+
}
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function isExpired(creds) {
|
|
43
|
+
const ms = expiresAtMs(creds);
|
|
44
|
+
return ms !== null && ms < Date.now();
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function isExpiringSoon(creds, withinMs = 5 * 60_000) {
|
|
48
|
+
const ms = expiresAtMs(creds);
|
|
49
|
+
return ms !== null && ms - Date.now() < withinMs;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async function writeCredentials(rawData, freshOauth) {
|
|
53
|
+
const next = { ...rawData, claudeAiOauth: { ...rawData.claudeAiOauth, ...freshOauth } };
|
|
54
|
+
const tmp = `${CREDS_PATH}.${process.pid}.${Date.now()}.tmp`;
|
|
55
|
+
await fsp.writeFile(tmp, JSON.stringify(next, null, 2), { encoding: 'utf8', mode: 0o600 });
|
|
56
|
+
try { await fsp.chmod(tmp, 0o600); } catch { /* umask may have already set it */ }
|
|
57
|
+
await fsp.rename(tmp, CREDS_PATH);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function appendRefreshLog(entry) {
|
|
61
|
+
try {
|
|
62
|
+
const line = JSON.stringify({ ...entry, ts: new Date().toISOString() }) + '\n';
|
|
63
|
+
let size = 0;
|
|
64
|
+
try { size = fs.statSync(REFRESH_LOG_PATH).size; } catch { /* new file */ }
|
|
65
|
+
if (size >= REFRESH_LOG_MAX_BYTES) {
|
|
66
|
+
const rotated = REFRESH_LOG_PATH + '.1';
|
|
67
|
+
try { fs.unlinkSync(rotated); } catch { /* */ }
|
|
68
|
+
try { fs.renameSync(REFRESH_LOG_PATH, rotated); } catch { /* */ }
|
|
69
|
+
}
|
|
70
|
+
fs.mkdirSync(path.dirname(REFRESH_LOG_PATH), { recursive: true });
|
|
71
|
+
fs.appendFileSync(REFRESH_LOG_PATH, line);
|
|
72
|
+
} catch { /* non-fatal; telemetry must not break the main flow */ }
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Stretch: attempt standard OAuth 2.0 refresh token grant.
|
|
76
|
+
async function tryOAuthRefresh(creds) {
|
|
77
|
+
if (!creds.refreshToken) return { kind: 'unsupported', message: 'no refresh token in credentials' };
|
|
78
|
+
try {
|
|
79
|
+
const r = await fetch(OAUTH_TOKEN_URL, {
|
|
80
|
+
method: 'POST',
|
|
81
|
+
headers: { 'Content-Type': 'application/json' },
|
|
82
|
+
body: JSON.stringify({ grant_type: 'refresh_token', refresh_token: creds.refreshToken }),
|
|
83
|
+
signal: AbortSignal.timeout(10_000),
|
|
84
|
+
});
|
|
85
|
+
if (r.status === 404) return { kind: 'unsupported', message: 'refresh endpoint not found (HTTP 404)' };
|
|
86
|
+
if (r.status === 401 || r.status === 403) return { kind: 'auth', message: `refresh rejected: HTTP ${r.status}` };
|
|
87
|
+
if (!r.ok) return { kind: 'transient', message: `HTTP ${r.status}` };
|
|
88
|
+
const j = await r.json();
|
|
89
|
+
if (!j.access_token) return { kind: 'auth', message: 'no access_token in refresh response' };
|
|
90
|
+
return {
|
|
91
|
+
kind: 'ok',
|
|
92
|
+
fresh: {
|
|
93
|
+
accessToken: j.access_token,
|
|
94
|
+
refreshToken: j.refresh_token ?? creds.refreshToken,
|
|
95
|
+
expiresAt: j.expires_at ?? (Date.now() + (j.expires_in ?? 3600) * 1000),
|
|
96
|
+
},
|
|
97
|
+
};
|
|
98
|
+
} catch (e) {
|
|
99
|
+
if (e?.name === 'TimeoutError') return { kind: 'transient', message: 'refresh request timed out' };
|
|
100
|
+
return { kind: 'unsupported', message: `refresh error: ${e?.message}` };
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Stretch fallback: spawning `claude --version` triggers silent token refresh in the CLI binary.
|
|
105
|
+
function tryCliFallback() {
|
|
106
|
+
return new Promise((resolve) => {
|
|
107
|
+
let settled = false;
|
|
108
|
+
const settle = (result) => { if (!settled) { settled = true; resolve(result); } };
|
|
109
|
+
const timer = setTimeout(() => settle({ ok: false, reason: 'timeout' }), 15_000);
|
|
110
|
+
let child;
|
|
111
|
+
try {
|
|
112
|
+
child = spawn('claude', ['--version'], { stdio: 'ignore', env: cleanChildEnv() });
|
|
113
|
+
} catch (e) {
|
|
114
|
+
clearTimeout(timer);
|
|
115
|
+
return settle({ ok: false, reason: e?.message });
|
|
116
|
+
}
|
|
117
|
+
child.on('close', (code) => { clearTimeout(timer); settle({ ok: code === 0 }); });
|
|
118
|
+
child.on('error', (e) => { clearTimeout(timer); settle({ ok: false, reason: e?.message }); });
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Main entry point for callers: check expiry, attempt refresh if needed.
|
|
124
|
+
* Returns:
|
|
125
|
+
* { kind: 'ok', creds } — credentials are fresh and ready to use
|
|
126
|
+
* { kind: 'auth', message, expiredAt } — expired/revoked; user must run `claude`
|
|
127
|
+
* { kind: 'config', message } — cannot read credentials file
|
|
128
|
+
* { kind: 'unsupported', message, creds } — auto-refresh failed; token still valid for now
|
|
129
|
+
*/
|
|
130
|
+
async function refreshIfNeeded(forceRefresh = false) {
|
|
131
|
+
const cr = await readCredentials();
|
|
132
|
+
if (cr.kind !== 'ok') return cr;
|
|
133
|
+
const { creds, raw } = cr;
|
|
134
|
+
|
|
135
|
+
if (!forceRefresh && !isExpiringSoon(creds)) {
|
|
136
|
+
return { kind: 'ok', creds };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const alreadyExpired = isExpired(creds);
|
|
140
|
+
|
|
141
|
+
// Stretch: try OAuth refresh endpoint first.
|
|
142
|
+
const oauthResult = await tryOAuthRefresh(creds);
|
|
143
|
+
appendRefreshLog({ event: `oauth_refresh_${oauthResult.kind}`, message: oauthResult.message ?? null });
|
|
144
|
+
|
|
145
|
+
if (oauthResult.kind === 'ok') {
|
|
146
|
+
try {
|
|
147
|
+
await writeCredentials(raw, oauthResult.fresh);
|
|
148
|
+
const freshCr = await readCredentials();
|
|
149
|
+
if (freshCr.kind === 'ok') {
|
|
150
|
+
appendRefreshLog({ event: 'oauth_refresh_written_ok' });
|
|
151
|
+
return { kind: 'ok', creds: freshCr.creds };
|
|
152
|
+
}
|
|
153
|
+
} catch (e) {
|
|
154
|
+
appendRefreshLog({ event: 'oauth_refresh_write_failed', error: e?.message });
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Stretch: if OAuth didn't explicitly reject the token, try `claude --version` fallback.
|
|
159
|
+
if (oauthResult.kind !== 'auth') {
|
|
160
|
+
const cliResult = await tryCliFallback();
|
|
161
|
+
appendRefreshLog({ event: cliResult.ok ? 'cli_fallback_ok' : 'cli_fallback_failed', reason: cliResult.reason ?? null });
|
|
162
|
+
if (cliResult.ok) {
|
|
163
|
+
const freshCr = await readCredentials();
|
|
164
|
+
if (freshCr.kind === 'ok' && !isExpired(freshCr.creds)) {
|
|
165
|
+
return { kind: 'ok', creds: freshCr.creds };
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
if (alreadyExpired) {
|
|
171
|
+
const ms = expiresAtMs(creds);
|
|
172
|
+
appendRefreshLog({ event: 'auth_failed_expired', expiredAtMs: ms });
|
|
173
|
+
return {
|
|
174
|
+
kind: 'auth',
|
|
175
|
+
message: 'Credentials expired. Run `claude` in a terminal to refresh.',
|
|
176
|
+
expiredAt: ms,
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Token expiring soon but not yet expired — auto-refresh failed; caller may proceed with current token.
|
|
181
|
+
return { kind: 'unsupported', message: 'Auto-refresh failed; token still valid for now', creds };
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
module.exports = { readCredentials, expiresAtMs, isExpired, isExpiringSoon, refreshIfNeeded };
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
module.exports = {
|
|
2
|
+
POLL_INTERVAL_MS: 2 * 60_000,
|
|
3
|
+
POLL_MIN_INTERVAL_MS: 90_000,
|
|
4
|
+
USAGE_REFRESH_INTERVAL_MS: 15_000,
|
|
5
|
+
HTTP_TIMEOUT_MS: 30_000,
|
|
6
|
+
HTTP_RETRY_DELAY_MS: 1_000,
|
|
7
|
+
OFFSET_MINUTES_MAX: 180,
|
|
8
|
+
CONCURRENCY_CAP_MAX: 20,
|
|
9
|
+
MAX_JOB_DURATION_MS: 4 * 60 * 60_000,
|
|
10
|
+
};
|
package/src/main/logs.cjs
CHANGED
|
@@ -44,7 +44,7 @@ function pruneOld() {
|
|
|
44
44
|
// content. Voice features go to logs and the hotkey wrapper already strips
|
|
45
45
|
// these in main, but renderer-side log calls go through this writer too.
|
|
46
46
|
// Defense in depth: drop and replace with `[redacted]` so leaks are obvious.
|
|
47
|
-
const REDACT_KEY = /^(transcript|interim|final|text|content|partial|userText|message)$/i;
|
|
47
|
+
const REDACT_KEY = /^(transcript|interim|final|text|content|partial|userText|message|token|secret|password|authorization|cookie|api[_-]?key|access[_-]?token|refresh[_-]?token)$/i;
|
|
48
48
|
function sanitizeMeta(meta) {
|
|
49
49
|
if (!meta || typeof meta !== 'object') return meta;
|
|
50
50
|
if (Array.isArray(meta)) return meta;
|
|
@@ -78,7 +78,7 @@ async function save(cfg) {
|
|
|
78
78
|
await fsp.mkdir(path.dirname(p), { recursive: true }).catch(() => {});
|
|
79
79
|
const body = JSON.stringify(next, null, 2) + '\n';
|
|
80
80
|
const tmp = `${p}.tmp-${process.pid}-${Date.now()}`;
|
|
81
|
-
await fsp.writeFile(tmp, body, 'utf8',
|
|
81
|
+
await fsp.writeFile(tmp, body, { encoding: 'utf8', mode: 0o600 });
|
|
82
82
|
try { await fsp.chmod(tmp, 0o600); } catch { /* */ }
|
|
83
83
|
await fsp.rename(tmp, p);
|
|
84
84
|
return next;
|
package/src/main/pty.cjs
CHANGED
|
@@ -2,7 +2,9 @@ const pty = require('node-pty');
|
|
|
2
2
|
const { ipcMain } = require('electron');
|
|
3
3
|
const path = require('node:path');
|
|
4
4
|
const os = require('node:os');
|
|
5
|
+
const fs = require('node:fs');
|
|
5
6
|
const { addAllowedRoot } = require('./config.cjs');
|
|
7
|
+
const { cleanChildEnv } = require('./lib/cleanEnv.cjs');
|
|
6
8
|
|
|
7
9
|
/**
|
|
8
10
|
* PtyManager — owns every claude PTY process, keyed by tabId (renderer-generated UUID).
|
|
@@ -21,8 +23,22 @@ class PtyManager {
|
|
|
21
23
|
|
|
22
24
|
spawn({ tabId, cwd, cols = 120, rows = 30 }) {
|
|
23
25
|
console.log('[pty] spawn requested', { tabId });
|
|
24
|
-
|
|
25
|
-
|
|
26
|
+
|
|
27
|
+
// Validate that cwd is inside homedir before widening the allowed-root set.
|
|
28
|
+
if (cwd) {
|
|
29
|
+
const home = os.homedir();
|
|
30
|
+
let realCwd;
|
|
31
|
+
try {
|
|
32
|
+
realCwd = fs.realpathSync(cwd);
|
|
33
|
+
} catch {
|
|
34
|
+
realCwd = path.resolve(cwd);
|
|
35
|
+
}
|
|
36
|
+
if (realCwd !== home && !realCwd.startsWith(home + path.sep)) {
|
|
37
|
+
throw new Error(`pty cwd outside home directory: ${realCwd}`);
|
|
38
|
+
}
|
|
39
|
+
addAllowedRoot(realCwd);
|
|
40
|
+
}
|
|
41
|
+
|
|
26
42
|
// Idempotent reattach: renderer reloads (HMR/Ctrl+R) re-run App.tsx's
|
|
27
43
|
// hydrate path, which calls pty.spawn for each persisted tabId. The PTY
|
|
28
44
|
// from the previous renderer-load is still registered here, so rather
|
|
@@ -41,6 +57,16 @@ class PtyManager {
|
|
|
41
57
|
} catch {
|
|
42
58
|
/* pty may have exited between the check and the resize */
|
|
43
59
|
}
|
|
60
|
+
// If the process has already exited but its session wasn't cleaned up,
|
|
61
|
+
// fire a synthetic exit after the renderer re-registers its onExit handler.
|
|
62
|
+
if (existing.proc.exitCode != null) {
|
|
63
|
+
const exitCode = existing.proc.exitCode;
|
|
64
|
+
setImmediate(() => {
|
|
65
|
+
if (this.window && !this.window.isDestroyed()) {
|
|
66
|
+
this.window.webContents.send(`pty:exit:${tabId}`, { exitCode, signal: undefined });
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
}
|
|
44
70
|
return { pid: existing.proc.pid, cwd: existing.cwd, reattached: true };
|
|
45
71
|
}
|
|
46
72
|
|
|
@@ -53,15 +79,15 @@ class PtyManager {
|
|
|
53
79
|
'/usr/bin',
|
|
54
80
|
'/bin',
|
|
55
81
|
].join(':');
|
|
56
|
-
|
|
57
|
-
|
|
82
|
+
|
|
83
|
+
const env = cleanChildEnv({
|
|
58
84
|
PATH: `${extraPath}:${process.env.PATH || ''}`,
|
|
59
85
|
TERM: 'xterm-256color',
|
|
60
86
|
COLORTERM: 'truecolor',
|
|
61
87
|
FORCE_COLOR: '1',
|
|
62
88
|
// Tag the session so hook server / transcript tail can correlate.
|
|
63
89
|
SESSION_MANAGER_TAB_ID: tabId,
|
|
64
|
-
};
|
|
90
|
+
});
|
|
65
91
|
|
|
66
92
|
const shell = process.env.SHELL || '/bin/bash';
|
|
67
93
|
console.log('[pty] spawning shell', shell);
|
|
@@ -108,7 +134,28 @@ class PtyManager {
|
|
|
108
134
|
|
|
109
135
|
write({ tabId, data }) {
|
|
110
136
|
const s = this.sessions.get(tabId);
|
|
111
|
-
if (s)
|
|
137
|
+
if (!s) {
|
|
138
|
+
// Tab was removed or never existed — tell the renderer so it can surface
|
|
139
|
+
// "skipped" feedback rather than silently dropping the write.
|
|
140
|
+
if (this.window && !this.window.isDestroyed()) {
|
|
141
|
+
this.window.webContents.send('pty:write-error', { tabId, reason: 'no-pty' });
|
|
142
|
+
}
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
try {
|
|
146
|
+
s.proc.write(data);
|
|
147
|
+
} catch (err) {
|
|
148
|
+
// node-pty throws synchronously (or the underlying net.Socket emits an
|
|
149
|
+
// error that node-pty re-throws) when writing to an exited process.
|
|
150
|
+
// Catch here so the uncaught-exception handler never sees it, and notify
|
|
151
|
+
// the renderer to surface "skipped" feedback.
|
|
152
|
+
if (this.window && !this.window.isDestroyed()) {
|
|
153
|
+
this.window.webContents.send('pty:write-error', {
|
|
154
|
+
tabId,
|
|
155
|
+
reason: String(err?.message || 'write-failed'),
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
}
|
|
112
159
|
}
|
|
113
160
|
|
|
114
161
|
resize({ tabId, cols, rows }) {
|