claude-code-session-manager 0.28.0 → 0.29.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/{TiptapBody-CJ6GK5CM.js → TiptapBody-DyiZVN0v.js} +1 -1
- package/dist/assets/index-DMIi9YZH.css +32 -0
- package/dist/assets/{index-DtQ4LzuV.js → index-qS7GdCsL.js} +346 -345
- package/dist/index.html +2 -2
- package/package.json +1 -1
- package/src/main/index.cjs +7 -0
- package/src/main/ipcSchemas.cjs +10 -0
- package/src/main/lib/credentials.cjs +96 -11
- package/src/main/pluginInstall.cjs +114 -32
- package/src/main/scheduler.cjs +33 -2
- package/src/main/seedDevPlugin.cjs +95 -0
- package/src/main/usage.cjs +34 -1
- package/src/preload/api.d.ts +8 -2
- package/dist/assets/index-Dwb94Uxm.css +0 -32
package/dist/index.html
CHANGED
|
@@ -7,10 +7,10 @@
|
|
|
7
7
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
8
8
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
9
9
|
<link href="https://fonts.googleapis.com/css2?family=Newsreader:ital,opsz,wght@0,6..72,400;0,6..72,500;0,6..72,600;0,6..72,700;1,6..72,400&family=Geist:wght@300;400;500;600;700&family=IBM+Plex+Mono:wght@400;500;600&display=swap" rel="stylesheet">
|
|
10
|
-
<script type="module" crossorigin src="./assets/index-
|
|
10
|
+
<script type="module" crossorigin src="./assets/index-qS7GdCsL.js"></script>
|
|
11
11
|
<link rel="modulepreload" crossorigin href="./assets/monaco-editor-BW5C4Iv1.js">
|
|
12
12
|
<link rel="stylesheet" crossorigin href="./assets/monaco-editor-BTnBOi8r.css">
|
|
13
|
-
<link rel="stylesheet" crossorigin href="./assets/index-
|
|
13
|
+
<link rel="stylesheet" crossorigin href="./assets/index-DMIi9YZH.css">
|
|
14
14
|
</head>
|
|
15
15
|
<body class="bg-bg text-fg font-sans antialiased">
|
|
16
16
|
<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.29.0",
|
|
4
4
|
"description": "Local cockpit for the Claude Code CLI — multi-tab terminal, full config surface, scheduler, voice dictation, and live observability.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/main/index.cjs",
|
package/src/main/index.cjs
CHANGED
|
@@ -26,6 +26,7 @@ const watchers = require('./watchers.cjs');
|
|
|
26
26
|
const teams = require('./teams.cjs');
|
|
27
27
|
const queueOps = require('./queueOps.cjs');
|
|
28
28
|
const pluginInstall = require('./pluginInstall.cjs');
|
|
29
|
+
const { seedDevPlugin } = require('./seedDevPlugin.cjs');
|
|
29
30
|
const otel = require('./otel.cjs');
|
|
30
31
|
const otelSettings = require('./otelSettings.cjs');
|
|
31
32
|
const { registerHistoryAggregatorHandlers } = require('./historyAggregator.cjs');
|
|
@@ -948,6 +949,12 @@ app.whenReady().then(async () => {
|
|
|
948
949
|
scheduler.init().catch((e) => {
|
|
949
950
|
logs.writeLine({ scope: 'scheduler', level: 'error', message: 'init failed', meta: { error: e?.message } });
|
|
950
951
|
});
|
|
952
|
+
// First-boot default: install the bundled session-manager-dev plugin (its 10
|
|
953
|
+
// dev skills) from the app's own marketplace. One-shot + idempotent; never
|
|
954
|
+
// throws. SM_SEED_DEV_PLUGIN_DISABLE=1 to opt out.
|
|
955
|
+
seedDevPlugin({ logger: console }).catch((e) => {
|
|
956
|
+
logs.writeLine({ scope: 'seed-dev-plugin', level: 'error', message: 'seed failed', meta: { error: e?.message } });
|
|
957
|
+
});
|
|
951
958
|
webRemote.init().catch((e) => {
|
|
952
959
|
logs.writeLine({ scope: 'webRemote', level: 'error', message: 'init failed', meta: { error: e?.message } });
|
|
953
960
|
});
|
package/src/main/ipcSchemas.cjs
CHANGED
|
@@ -358,8 +358,18 @@ const repoAnalyze = z.object({
|
|
|
358
358
|
// Plugin install: mirrors pluginInstall.cjs SLUG_RE + length cap. Defense in
|
|
359
359
|
// depth — install() re-checks; the schema rejects earlier.
|
|
360
360
|
const PLUGIN_SLUG_RE = /^[a-z0-9\-/]+$/;
|
|
361
|
+
const PLUGIN_MKT_ADD_RE = /^[A-Za-z0-9][A-Za-z0-9._\-]*\/[A-Za-z0-9._\-]+$/;
|
|
361
362
|
const pluginsInstall = z.object({
|
|
362
363
|
slug: z.string().regex(PLUGIN_SLUG_RE).min(1).max(128),
|
|
364
|
+
// Optional non-official marketplace: registered via `plugin marketplace add`
|
|
365
|
+
// before install. pluginInstall.cjs re-validates (defense in depth).
|
|
366
|
+
marketplace: z.object({
|
|
367
|
+
// `owner/repo`, or the literal `bundled` sentinel (app's own packaged
|
|
368
|
+
// marketplace — pluginInstall.cjs resolves it to an absolute path).
|
|
369
|
+
add: z.string().min(1).max(200)
|
|
370
|
+
.refine((v) => v === 'bundled' || PLUGIN_MKT_ADD_RE.test(v), 'invalid marketplace source'),
|
|
371
|
+
name: z.string().regex(PLUGIN_SLUG_RE).min(1).max(128),
|
|
372
|
+
}).optional(),
|
|
363
373
|
}).passthrough();
|
|
364
374
|
const pluginsAbort = z.object({
|
|
365
375
|
slug: z.string().regex(PLUGIN_SLUG_RE).min(1).max(128),
|
|
@@ -4,10 +4,55 @@ const fsp = require('node:fs/promises');
|
|
|
4
4
|
const fs = require('node:fs');
|
|
5
5
|
const path = require('node:path');
|
|
6
6
|
const os = require('node:os');
|
|
7
|
-
const { spawn } = require('node:child_process');
|
|
7
|
+
const { spawn, execFileSync } = require('node:child_process');
|
|
8
8
|
const { cleanChildEnv } = require('./cleanEnv.cjs');
|
|
9
9
|
|
|
10
10
|
const CREDS_PATH = path.join(os.homedir(), '.claude', '.credentials.json');
|
|
11
|
+
|
|
12
|
+
// macOS stores Claude Code credentials in the login Keychain, not on disk —
|
|
13
|
+
// there is no ~/.claude/.credentials.json there. The Keychain item is a
|
|
14
|
+
// generic password under this service whose secret is the same JSON blob
|
|
15
|
+
// ({ claudeAiOauth: { accessToken, … } }) the Linux/WSL file holds.
|
|
16
|
+
const KEYCHAIN_SERVICE = 'Claude Code-credentials';
|
|
17
|
+
|
|
18
|
+
/** Read the raw credential JSON string from the macOS Keychain, or null. */
|
|
19
|
+
function readKeychainRaw() {
|
|
20
|
+
if (process.platform !== 'darwin') return null;
|
|
21
|
+
try {
|
|
22
|
+
const out = execFileSync(
|
|
23
|
+
'security',
|
|
24
|
+
['find-generic-password', '-s', KEYCHAIN_SERVICE, '-w'],
|
|
25
|
+
{ encoding: 'utf8', timeout: 10_000, stdio: ['ignore', 'pipe', 'ignore'] },
|
|
26
|
+
);
|
|
27
|
+
const trimmed = out.trim();
|
|
28
|
+
return trimmed.length ? trimmed : null;
|
|
29
|
+
} catch {
|
|
30
|
+
return null; // not found / locked — caller treats as "no creds here"
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Discover the account the Keychain item is stored under (for write-back). */
|
|
35
|
+
function keychainAccount() {
|
|
36
|
+
try {
|
|
37
|
+
const out = execFileSync(
|
|
38
|
+
'security',
|
|
39
|
+
['find-generic-password', '-s', KEYCHAIN_SERVICE],
|
|
40
|
+
{ encoding: 'utf8', timeout: 10_000, stdio: ['ignore', 'pipe', 'ignore'] },
|
|
41
|
+
);
|
|
42
|
+
const m = out.match(/"acct"<blob>="([^"]*)"/);
|
|
43
|
+
if (m && m[1]) return m[1];
|
|
44
|
+
} catch { /* fall through to login user */ }
|
|
45
|
+
return os.userInfo().username;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Write the credential JSON back into the Keychain (-U upserts in place). */
|
|
49
|
+
function writeKeychainRaw(value) {
|
|
50
|
+
execFileSync(
|
|
51
|
+
'security',
|
|
52
|
+
['add-generic-password', '-U', '-s', KEYCHAIN_SERVICE, '-a', keychainAccount(), '-w', value],
|
|
53
|
+
{ timeout: 10_000, stdio: 'ignore' },
|
|
54
|
+
);
|
|
55
|
+
}
|
|
11
56
|
const REFRESH_LOG_PATH = path.join(os.homedir(), '.claude', 'session-manager', 'credential-refresh.log');
|
|
12
57
|
const REFRESH_LOG_MAX_BYTES = 100 * 1024;
|
|
13
58
|
|
|
@@ -16,17 +61,42 @@ const REFRESH_LOG_MAX_BYTES = 100 * 1024;
|
|
|
16
61
|
// allowing the caller to fall back gracefully.
|
|
17
62
|
const OAUTH_TOKEN_URL = 'https://claude.ai/api/auth/oauth/token';
|
|
18
63
|
|
|
64
|
+
/** Parse a raw credential JSON blob (from file or Keychain) into a result. */
|
|
65
|
+
function parseCredsRaw(raw, source) {
|
|
66
|
+
let data;
|
|
67
|
+
try {
|
|
68
|
+
data = JSON.parse(raw);
|
|
69
|
+
} catch (e) {
|
|
70
|
+
return { kind: 'config', message: `cannot parse ${source} credentials: ${e.message}` };
|
|
71
|
+
}
|
|
72
|
+
const oa = data?.claudeAiOauth;
|
|
73
|
+
if (!oa?.accessToken) return { kind: 'config', message: `missing accessToken in ${source} credentials` };
|
|
74
|
+
return { kind: 'ok', creds: oa, raw: data, source };
|
|
75
|
+
}
|
|
76
|
+
|
|
19
77
|
async function readCredentials() {
|
|
78
|
+
// 1) File — Linux / WSL (and macOS in the rare case a file exists).
|
|
20
79
|
try {
|
|
21
80
|
const raw = await fsp.readFile(CREDS_PATH, 'utf8');
|
|
22
|
-
|
|
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 };
|
|
81
|
+
return parseCredsRaw(raw, 'file');
|
|
26
82
|
} catch (e) {
|
|
27
|
-
if (e?.code
|
|
28
|
-
|
|
83
|
+
if (e?.code !== 'ENOENT') {
|
|
84
|
+
return { kind: 'config', message: `cannot read credentials: ${e.message}` };
|
|
85
|
+
}
|
|
86
|
+
// ENOENT — fall through to the macOS Keychain before giving up.
|
|
29
87
|
}
|
|
88
|
+
|
|
89
|
+
// 2) macOS Keychain — the canonical store on darwin (no file there).
|
|
90
|
+
if (process.platform === 'darwin') {
|
|
91
|
+
const kc = readKeychainRaw();
|
|
92
|
+
if (kc) return parseCredsRaw(kc, 'keychain');
|
|
93
|
+
return {
|
|
94
|
+
kind: 'config',
|
|
95
|
+
message: `credentials not found (no ${CREDS_PATH}; no Keychain item "${KEYCHAIN_SERVICE}" — run \`claude\` to log in)`,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return { kind: 'config', message: 'credentials file not found' };
|
|
30
100
|
}
|
|
31
101
|
|
|
32
102
|
function expiresAtMs(creds) {
|
|
@@ -49,8 +119,14 @@ function isExpiringSoon(creds, withinMs = 5 * 60_000) {
|
|
|
49
119
|
return ms !== null && ms - Date.now() < withinMs;
|
|
50
120
|
}
|
|
51
121
|
|
|
52
|
-
async function writeCredentials(rawData, freshOauth) {
|
|
122
|
+
async function writeCredentials(rawData, freshOauth, source = 'file') {
|
|
53
123
|
const next = { ...rawData, claudeAiOauth: { ...rawData.claudeAiOauth, ...freshOauth } };
|
|
124
|
+
if (source === 'keychain') {
|
|
125
|
+
// macOS: upsert back into the Keychain. Sync + may throw — caller catches
|
|
126
|
+
// and falls back to the `claude --version` CLI refresh path.
|
|
127
|
+
writeKeychainRaw(JSON.stringify(next));
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
54
130
|
const tmp = `${CREDS_PATH}.${process.pid}.${Date.now()}.tmp`;
|
|
55
131
|
await fsp.writeFile(tmp, JSON.stringify(next, null, 2), { encoding: 'utf8', mode: 0o600 });
|
|
56
132
|
try { await fsp.chmod(tmp, 0o600); } catch { /* umask may have already set it */ }
|
|
@@ -130,7 +206,7 @@ function tryCliFallback() {
|
|
|
130
206
|
async function refreshIfNeeded(forceRefresh = false) {
|
|
131
207
|
const cr = await readCredentials();
|
|
132
208
|
if (cr.kind !== 'ok') return cr;
|
|
133
|
-
const { creds, raw } = cr;
|
|
209
|
+
const { creds, raw, source } = cr;
|
|
134
210
|
|
|
135
211
|
if (!forceRefresh && !isExpiringSoon(creds)) {
|
|
136
212
|
return { kind: 'ok', creds };
|
|
@@ -144,7 +220,7 @@ async function refreshIfNeeded(forceRefresh = false) {
|
|
|
144
220
|
|
|
145
221
|
if (oauthResult.kind === 'ok') {
|
|
146
222
|
try {
|
|
147
|
-
await writeCredentials(raw, oauthResult.fresh);
|
|
223
|
+
await writeCredentials(raw, oauthResult.fresh, source);
|
|
148
224
|
const freshCr = await readCredentials();
|
|
149
225
|
if (freshCr.kind === 'ok') {
|
|
150
226
|
appendRefreshLog({ event: 'oauth_refresh_written_ok' });
|
|
@@ -188,4 +264,13 @@ async function refreshIfNeeded(forceRefresh = false) {
|
|
|
188
264
|
return { kind: 'unsupported', message: 'Auto-refresh failed; token still valid for now', creds };
|
|
189
265
|
}
|
|
190
266
|
|
|
191
|
-
module.exports = {
|
|
267
|
+
module.exports = {
|
|
268
|
+
readCredentials,
|
|
269
|
+
expiresAtMs,
|
|
270
|
+
isExpired,
|
|
271
|
+
isExpiringSoon,
|
|
272
|
+
refreshIfNeeded,
|
|
273
|
+
parseCredsRaw,
|
|
274
|
+
KEYCHAIN_SERVICE,
|
|
275
|
+
CREDS_PATH,
|
|
276
|
+
};
|
|
@@ -20,13 +20,29 @@ const { ipcMain } = require('electron');
|
|
|
20
20
|
const pty = require('node-pty');
|
|
21
21
|
const path = require('node:path');
|
|
22
22
|
const os = require('node:os');
|
|
23
|
+
const fs = require('node:fs');
|
|
23
24
|
const { cleanChildEnv } = require('./lib/cleanEnv.cjs');
|
|
24
25
|
const { resolveClaudeBin } = require('./lib/claudeBin.cjs');
|
|
25
26
|
const { sendIfAlive } = require('./lib/sendToRenderer.cjs');
|
|
26
27
|
const { schemas } = require('./ipcSchemas.cjs');
|
|
27
28
|
|
|
28
29
|
const SLUG_RE = /^[a-z0-9\-/]+$/;
|
|
30
|
+
// Marketplace name: same shape as a plugin slug. Marketplace source (`add`):
|
|
31
|
+
// a GitHub `owner/repo` (case-sensitive) or a dotted/hyphenated path segment.
|
|
32
|
+
// Deliberately excludes whitespace, flags (leading `-`), and shell metachars.
|
|
33
|
+
const MKT_NAME_RE = /^[a-z0-9\-/]+$/;
|
|
34
|
+
const MKT_ADD_RE = /^[A-Za-z0-9][A-Za-z0-9._\-]*\/[A-Za-z0-9._\-]+$/;
|
|
35
|
+
// Sentinel: register the marketplace that ships inside this app's own files
|
|
36
|
+
// (the npx distribution). Resolved to an absolute path in-process so the
|
|
37
|
+
// renderer never supplies a filesystem path. Works offline — no GitHub/npm.
|
|
38
|
+
const BUNDLED_ADD = 'bundled';
|
|
29
39
|
const MAX_LINE_BYTES = 16 * 1024;
|
|
40
|
+
|
|
41
|
+
/** Absolute path to the packaged marketplace root (package dir holding
|
|
42
|
+
* `.claude-plugin/marketplace.json`). src/main/ → ../../ */
|
|
43
|
+
function bundledMarketplaceDir() {
|
|
44
|
+
return path.join(__dirname, '..', '..');
|
|
45
|
+
}
|
|
30
46
|
const KILL_AFTER_MS = 5 * 60 * 1000; // 5 min hard ceiling per install
|
|
31
47
|
const KILL_GRACE_MS = 5_000; // SIGTERM → SIGKILL escalation window
|
|
32
48
|
|
|
@@ -41,52 +57,51 @@ function send(channel, payload) {
|
|
|
41
57
|
sendIfAlive(mainWindow, channel, payload);
|
|
42
58
|
}
|
|
43
59
|
|
|
44
|
-
function
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
60
|
+
function childEnv() {
|
|
61
|
+
const home = os.homedir();
|
|
62
|
+
const extraPath = [
|
|
63
|
+
path.join(home, '.local', 'bin'),
|
|
64
|
+
path.join(home, '.npm-global', 'bin'),
|
|
65
|
+
'/usr/local/bin',
|
|
66
|
+
'/usr/bin',
|
|
67
|
+
'/bin',
|
|
68
|
+
].join(':');
|
|
69
|
+
return cleanChildEnv({
|
|
70
|
+
PATH: `${extraPath}:${process.env.PATH || ''}`,
|
|
71
|
+
TERM: 'xterm-256color',
|
|
72
|
+
FORCE_COLOR: '0', // strip ANSI so the renderer doesn't have to.
|
|
73
|
+
});
|
|
74
|
+
}
|
|
51
75
|
|
|
76
|
+
/**
|
|
77
|
+
* Run one `claude plugin …` pty step, streaming output under `slug` and
|
|
78
|
+
* resolving with the exit code. `register`/`unregister` thread the live proc
|
|
79
|
+
* into the inFlight map so plugins:abort can kill whichever step is current.
|
|
80
|
+
*/
|
|
81
|
+
function runStep({ slug, args, register, unregister }) {
|
|
52
82
|
return new Promise((resolve) => {
|
|
53
|
-
const home = os.homedir();
|
|
54
|
-
const extraPath = [
|
|
55
|
-
path.join(home, '.local', 'bin'),
|
|
56
|
-
path.join(home, '.npm-global', 'bin'),
|
|
57
|
-
'/usr/local/bin',
|
|
58
|
-
'/usr/bin',
|
|
59
|
-
'/bin',
|
|
60
|
-
].join(':');
|
|
61
|
-
const env = cleanChildEnv({
|
|
62
|
-
PATH: `${extraPath}:${process.env.PATH || ''}`,
|
|
63
|
-
TERM: 'xterm-256color',
|
|
64
|
-
FORCE_COLOR: '0', // strip ANSI so the renderer doesn't have to.
|
|
65
|
-
});
|
|
66
|
-
|
|
67
83
|
const claudeBin = resolveClaudeBin();
|
|
68
84
|
let proc;
|
|
69
85
|
try {
|
|
70
|
-
proc = pty.spawn(claudeBin,
|
|
86
|
+
proc = pty.spawn(claudeBin, args, {
|
|
71
87
|
name: 'xterm-256color',
|
|
72
88
|
cols: 120,
|
|
73
89
|
rows: 30,
|
|
74
|
-
cwd:
|
|
75
|
-
env,
|
|
90
|
+
cwd: os.homedir(),
|
|
91
|
+
env: childEnv(),
|
|
76
92
|
});
|
|
77
93
|
} catch (err) {
|
|
78
94
|
resolve({ ok: false, exitCode: -1, error: `spawn failed: ${err?.message ?? String(err)}` });
|
|
79
95
|
return;
|
|
80
96
|
}
|
|
81
97
|
|
|
82
|
-
|
|
98
|
+
register(proc);
|
|
83
99
|
|
|
84
100
|
let lineBuf = '';
|
|
85
101
|
let settled = false;
|
|
86
102
|
|
|
87
103
|
const killTimer = setTimeout(() => {
|
|
88
104
|
try { proc.kill('SIGTERM'); } catch { /* */ }
|
|
89
|
-
// Escalate to SIGKILL after KILL_GRACE_MS if the pty hasn't exited.
|
|
90
105
|
const escalate = setTimeout(() => {
|
|
91
106
|
try { proc.kill('SIGKILL'); } catch { /* already dead */ }
|
|
92
107
|
}, KILL_GRACE_MS);
|
|
@@ -96,11 +111,11 @@ function install({ slug }) {
|
|
|
96
111
|
|
|
97
112
|
// Belt-and-suspenders: if onExit never fires (broken pty event path after
|
|
98
113
|
// SIGKILL — analogous to anthropics/claude-code #61735's unreachable pts),
|
|
99
|
-
// force-
|
|
114
|
+
// force-resolve so the step (and its inFlight lock) isn't permanently stuck.
|
|
100
115
|
const deadman = setTimeout(() => {
|
|
101
116
|
if (settled) return;
|
|
102
117
|
settled = true;
|
|
103
|
-
|
|
118
|
+
unregister();
|
|
104
119
|
resolve({ ok: false, exitCode: -1, error: 'install hung — pty onExit never fired' });
|
|
105
120
|
}, KILL_AFTER_MS + KILL_GRACE_MS + 30_000);
|
|
106
121
|
if (deadman.unref) deadman.unref();
|
|
@@ -113,7 +128,6 @@ function install({ slug }) {
|
|
|
113
128
|
lineBuf = lineBuf.slice(nl + 1);
|
|
114
129
|
send('plugins:install-progress', { slug, line });
|
|
115
130
|
}
|
|
116
|
-
// Guard runaway buffers without newlines.
|
|
117
131
|
if (lineBuf.length > MAX_LINE_BYTES) {
|
|
118
132
|
send('plugins:install-progress', { slug, line: lineBuf });
|
|
119
133
|
lineBuf = '';
|
|
@@ -129,13 +143,81 @@ function install({ slug }) {
|
|
|
129
143
|
send('plugins:install-progress', { slug, line: lineBuf });
|
|
130
144
|
lineBuf = '';
|
|
131
145
|
}
|
|
132
|
-
|
|
146
|
+
unregister();
|
|
133
147
|
const code = typeof exitCode === 'number' ? exitCode : -1;
|
|
134
148
|
resolve({ ok: code === 0, exitCode: code });
|
|
135
149
|
});
|
|
136
150
|
});
|
|
137
151
|
}
|
|
138
152
|
|
|
153
|
+
/**
|
|
154
|
+
* Install a plugin. Without `marketplace`, runs `claude plugin install <slug>`
|
|
155
|
+
* (official catalog is pre-registered). With `marketplace: { add, name }`,
|
|
156
|
+
* first runs `claude plugin marketplace add <add>` to register the source, then
|
|
157
|
+
* installs `<slug>@<name>` — this is what a non-official plugin (e.g. the
|
|
158
|
+
* bundled session-manager-dev) needs on a fresh machine.
|
|
159
|
+
*/
|
|
160
|
+
async function install({ slug, marketplace }) {
|
|
161
|
+
if (typeof slug !== 'string' || !SLUG_RE.test(slug) || slug.length > 128) {
|
|
162
|
+
return { ok: false, exitCode: -1, error: 'invalid slug' };
|
|
163
|
+
}
|
|
164
|
+
let mkt = null;
|
|
165
|
+
if (marketplace != null) {
|
|
166
|
+
const { add, name } = marketplace;
|
|
167
|
+
const addValid = typeof add === 'string' && add.length <= 200 &&
|
|
168
|
+
(add === BUNDLED_ADD || MKT_ADD_RE.test(add));
|
|
169
|
+
if (!addValid || typeof name !== 'string' || !MKT_NAME_RE.test(name) || name.length > 128) {
|
|
170
|
+
return { ok: false, exitCode: -1, error: 'invalid marketplace' };
|
|
171
|
+
}
|
|
172
|
+
// Resolve the `bundled` sentinel to the app's own packaged marketplace dir.
|
|
173
|
+
let addArg = add;
|
|
174
|
+
if (add === BUNDLED_ADD) {
|
|
175
|
+
const dir = bundledMarketplaceDir();
|
|
176
|
+
if (!fs.existsSync(path.join(dir, '.claude-plugin', 'marketplace.json'))) {
|
|
177
|
+
return { ok: false, exitCode: -1, error: 'bundled marketplace not found' };
|
|
178
|
+
}
|
|
179
|
+
addArg = dir;
|
|
180
|
+
}
|
|
181
|
+
mkt = { add: addArg, name };
|
|
182
|
+
}
|
|
183
|
+
if (inFlight.has(slug)) {
|
|
184
|
+
return { ok: false, exitCode: -1, error: 'install already in progress' };
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Reserve the slug for the whole sequence; each step swaps in its live proc.
|
|
188
|
+
inFlight.set(slug, null);
|
|
189
|
+
const register = (proc) => inFlight.set(slug, proc);
|
|
190
|
+
const release = () => inFlight.delete(slug);
|
|
191
|
+
// Between steps the slug stays reserved (set to null) so a concurrent call
|
|
192
|
+
// can't slip in; runStep's unregister only clears the per-step proc handle.
|
|
193
|
+
const unregisterStep = () => inFlight.set(slug, null);
|
|
194
|
+
|
|
195
|
+
try {
|
|
196
|
+
if (mkt) {
|
|
197
|
+
const added = await runStep({
|
|
198
|
+
slug,
|
|
199
|
+
args: ['plugin', 'marketplace', 'add', mkt.add],
|
|
200
|
+
register,
|
|
201
|
+
unregister: unregisterStep,
|
|
202
|
+
});
|
|
203
|
+
// Tolerate "already added" — the add command exits non-zero when the
|
|
204
|
+
// marketplace is already registered; fall through to install regardless.
|
|
205
|
+
if (!added.ok) {
|
|
206
|
+
send('plugins:install-progress', { slug, line: `[marketplace add exit ${added.exitCode} — continuing to install]` });
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
const installTarget = mkt ? `${slug}@${mkt.name}` : slug;
|
|
210
|
+
return await runStep({
|
|
211
|
+
slug,
|
|
212
|
+
args: ['plugin', 'install', installTarget],
|
|
213
|
+
register,
|
|
214
|
+
unregister: unregisterStep,
|
|
215
|
+
});
|
|
216
|
+
} finally {
|
|
217
|
+
release();
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
139
221
|
function registerPluginInstallHandlers() {
|
|
140
222
|
ipcMain.handle('plugins:install', async (_e, payload) => {
|
|
141
223
|
// safeParse to preserve the existing `{ ok:false, exitCode:-1, error }`
|
|
@@ -145,7 +227,7 @@ function registerPluginInstallHandlers() {
|
|
|
145
227
|
if (!parsed.success) {
|
|
146
228
|
return { ok: false, exitCode: -1, error: 'invalid slug' };
|
|
147
229
|
}
|
|
148
|
-
return install({ slug: parsed.data.slug });
|
|
230
|
+
return install({ slug: parsed.data.slug, marketplace: parsed.data.marketplace });
|
|
149
231
|
});
|
|
150
232
|
|
|
151
233
|
// plugins:abort — send SIGKILL to a stuck install and release the inFlight
|
|
@@ -164,4 +246,4 @@ function registerPluginInstallHandlers() {
|
|
|
164
246
|
});
|
|
165
247
|
}
|
|
166
248
|
|
|
167
|
-
module.exports = { registerPluginInstallHandlers, attachWindow };
|
|
249
|
+
module.exports = { registerPluginInstallHandlers, attachWindow, install, bundledMarketplaceDir };
|
package/src/main/scheduler.cjs
CHANGED
|
@@ -1685,6 +1685,34 @@ function pollRecoveryClearSource(pauseReason, hasCachedReset) {
|
|
|
1685
1685
|
async function pollLoop() {
|
|
1686
1686
|
try {
|
|
1687
1687
|
await reapDeadRunningJobs().catch(() => {});
|
|
1688
|
+
|
|
1689
|
+
// Enterprise auth (Bedrock / Vertex / API-key / corporate gateway): there is
|
|
1690
|
+
// no consumer 5-hour usage meter to poll. Don't hit an endpoint that will
|
|
1691
|
+
// 404/time-out and eventually pause the queue on 'network' — treat usage as
|
|
1692
|
+
// wide-open and fire on pending + memory alone. (Blackrock-style machines.)
|
|
1693
|
+
if (!billing.usageMeterApplicable()) {
|
|
1694
|
+
cachedUtilization = 0;
|
|
1695
|
+
consecutiveFailures = 0;
|
|
1696
|
+
backoffMs = 0;
|
|
1697
|
+
backoffNextAt = null;
|
|
1698
|
+
firstFailureAt = null;
|
|
1699
|
+
firstNon429FailureAt = null;
|
|
1700
|
+
lastFailureKind = null;
|
|
1701
|
+
lastPollAt = Date.now();
|
|
1702
|
+
lastPollOk = true;
|
|
1703
|
+
persistSchedulerState();
|
|
1704
|
+
let cur = await readQueue();
|
|
1705
|
+
// Clear a stale auth/network pause inherited from a prior consumer-auth
|
|
1706
|
+
// session so the queue isn't wedged. Never clears rate_limit/manual.
|
|
1707
|
+
if (cur.paused && (cur.paused.reason === 'auth' || cur.paused.reason === 'network')) {
|
|
1708
|
+
await clearPause('enterprise-auth');
|
|
1709
|
+
cur = await readQueue(); // re-read so the cleared pause is visible to launch THIS cycle
|
|
1710
|
+
}
|
|
1711
|
+
await maybeLaunchWhenAvailable(cur);
|
|
1712
|
+
await broadcast();
|
|
1713
|
+
return; // finally re-arms the timer
|
|
1714
|
+
}
|
|
1715
|
+
|
|
1688
1716
|
const r = await billing.fetchUsage();
|
|
1689
1717
|
|
|
1690
1718
|
if (r.kind === 'ok') {
|
|
@@ -1701,9 +1729,12 @@ async function pollLoop() {
|
|
|
1701
1729
|
persistSchedulerState();
|
|
1702
1730
|
|
|
1703
1731
|
// Clear any pause that was waiting for a successful billing read.
|
|
1704
|
-
|
|
1732
|
+
let cur = await readQueue();
|
|
1705
1733
|
const clearSrc = pollRecoveryClearSource(cur.paused?.reason ?? null, !!cachedNextReset);
|
|
1706
|
-
if (clearSrc)
|
|
1734
|
+
if (clearSrc) {
|
|
1735
|
+
await clearPause(clearSrc);
|
|
1736
|
+
cur = await readQueue(); // re-read so the cleared pause launches work THIS cycle
|
|
1737
|
+
}
|
|
1707
1738
|
|
|
1708
1739
|
await maybeLaunchWhenAvailable(cur);
|
|
1709
1740
|
await broadcast();
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* First-boot seeder for the bundled `session-manager-dev` plugin.
|
|
3
|
+
*
|
|
4
|
+
* The plugin (its 10 dev skills) ships inside the npx distribution. To make it
|
|
5
|
+
* a true default, the app installs it on first launch from its own bundled
|
|
6
|
+
* marketplace (offline — no GitHub/registry). Idempotent two ways:
|
|
7
|
+
*
|
|
8
|
+
* 1. A marker file (`~/.claude/session-manager/.dev-plugin-seeded`) records
|
|
9
|
+
* seed state. Once the install SUCCEEDS we write `done` and never touch it
|
|
10
|
+
* again, so a deliberate uninstall stays uninstalled. A FAILED attempt only
|
|
11
|
+
* bumps an attempt counter and retries on the next few boots — a transient
|
|
12
|
+
* first-boot failure (e.g. `claude` not yet on PATH, briefly offline) must
|
|
13
|
+
* not permanently skip the default plugin. After MAX_ATTEMPTS we give up
|
|
14
|
+
* (manual install via the Plugins library button is always available).
|
|
15
|
+
* 2. Before attempting, we check settings.json `enabledPlugins` — if the
|
|
16
|
+
* plugin is already enabled (e.g. installed by hand), we just write the
|
|
17
|
+
* marker and skip the install.
|
|
18
|
+
*
|
|
19
|
+
* Fire-and-forget: called post-window from index.cjs. Errors are logged, never
|
|
20
|
+
* thrown. Kill-switch: SM_SEED_DEV_PLUGIN_DISABLE=1.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
const fs = require('node:fs');
|
|
24
|
+
const path = require('node:path');
|
|
25
|
+
const os = require('node:os');
|
|
26
|
+
const { install } = require('./pluginInstall.cjs');
|
|
27
|
+
|
|
28
|
+
const PLUGIN_SLUG = 'session-manager-dev';
|
|
29
|
+
const MARKETPLACE = { add: 'bundled', name: 'session-manager' };
|
|
30
|
+
const ENABLED_KEY = `${PLUGIN_SLUG}@${MARKETPLACE.name}`;
|
|
31
|
+
const MAX_ATTEMPTS = 3; // give a transient first-boot failure a few chances
|
|
32
|
+
|
|
33
|
+
function markerPath() {
|
|
34
|
+
return path.join(os.homedir(), '.claude', 'session-manager', '.dev-plugin-seeded');
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Read the marker → { done:boolean, attempts:number }. Absent = fresh. */
|
|
38
|
+
function readMarker() {
|
|
39
|
+
try {
|
|
40
|
+
const raw = fs.readFileSync(markerPath(), 'utf8').trim();
|
|
41
|
+
const m = JSON.parse(raw);
|
|
42
|
+
return { done: !!m.done, attempts: Number(m.attempts) || 0 };
|
|
43
|
+
} catch {
|
|
44
|
+
return { done: false, attempts: 0 };
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function alreadyEnabled() {
|
|
49
|
+
try {
|
|
50
|
+
const raw = fs.readFileSync(path.join(os.homedir(), '.claude', 'settings.json'), 'utf8');
|
|
51
|
+
const enabled = JSON.parse(raw)?.enabledPlugins;
|
|
52
|
+
return !!(enabled && enabled[ENABLED_KEY]);
|
|
53
|
+
} catch {
|
|
54
|
+
return false; // settings absent/unparseable — treat as not-enabled.
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function writeMarker(state) {
|
|
59
|
+
try {
|
|
60
|
+
const p = markerPath();
|
|
61
|
+
fs.mkdirSync(path.dirname(p), { recursive: true });
|
|
62
|
+
fs.writeFileSync(p, JSON.stringify({ ...state, ts: new Date().toISOString() }) + '\n');
|
|
63
|
+
} catch (err) {
|
|
64
|
+
console.warn('[seedDevPlugin] could not write marker:', err?.message ?? err);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function seedDevPlugin({ logger = console } = {}) {
|
|
69
|
+
if (process.env.SM_SEED_DEV_PLUGIN_DISABLE === '1') return;
|
|
70
|
+
const marker = readMarker();
|
|
71
|
+
if (marker.done) return; // succeeded before — leave it alone.
|
|
72
|
+
if (marker.attempts >= MAX_ATTEMPTS) return; // gave up — manual install only.
|
|
73
|
+
|
|
74
|
+
try {
|
|
75
|
+
if (alreadyEnabled()) {
|
|
76
|
+
writeMarker({ done: true, attempts: marker.attempts });
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
logger.log?.('[seedDevPlugin] installing bundled session-manager-dev plugin…');
|
|
80
|
+
const r = await install({ slug: PLUGIN_SLUG, marketplace: MARKETPLACE });
|
|
81
|
+
if (r.ok) {
|
|
82
|
+
logger.log?.('[seedDevPlugin] installed session-manager-dev@session-manager');
|
|
83
|
+
writeMarker({ done: true, attempts: marker.attempts });
|
|
84
|
+
} else {
|
|
85
|
+
// Failure: bump the attempt counter so the next boot can retry (bounded).
|
|
86
|
+
logger.warn?.(`[seedDevPlugin] install failed (exit ${r.exitCode})${r.error ? ` — ${r.error}` : ''}; attempt ${marker.attempts + 1}/${MAX_ATTEMPTS}`);
|
|
87
|
+
writeMarker({ done: false, attempts: marker.attempts + 1 });
|
|
88
|
+
}
|
|
89
|
+
} catch (err) {
|
|
90
|
+
logger.warn?.('[seedDevPlugin] error:', err?.message ?? err);
|
|
91
|
+
writeMarker({ done: false, attempts: marker.attempts + 1 });
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
module.exports = { seedDevPlugin };
|
package/src/main/usage.cjs
CHANGED
|
@@ -24,6 +24,39 @@ const { refreshIfNeeded, expiresAtMs } = require('./lib/credentials.cjs');
|
|
|
24
24
|
const { writeJson } = require('./config.cjs');
|
|
25
25
|
|
|
26
26
|
const USAGE_URL = 'https://api.anthropic.com/api/oauth/usage';
|
|
27
|
+
|
|
28
|
+
/** A non-empty env value that isn't an explicit falsey string. */
|
|
29
|
+
function envEnabled(v) {
|
|
30
|
+
return v != null && v !== '' && v !== '0' && String(v).toLowerCase() !== 'false';
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Is the consumer 5-hour usage meter (/api/oauth/usage) even applicable here?
|
|
35
|
+
*
|
|
36
|
+
* That endpoint only exists for OAuth/subscription auth against
|
|
37
|
+
* api.anthropic.com. Enterprise auth modes have no such meter, so polling it
|
|
38
|
+
* just 404s/times-out — and the scheduler must NOT gate on (or pause for) it.
|
|
39
|
+
* Detected modes: Amazon Bedrock, Google Vertex, raw API-key, a custom auth
|
|
40
|
+
* token, or a non-Anthropic base URL (corporate gateway/proxy).
|
|
41
|
+
*
|
|
42
|
+
* Returns false → caller should treat usage as unavailable-by-design and fire
|
|
43
|
+
* work on its own (pending + memory) instead of waiting on a meter.
|
|
44
|
+
*/
|
|
45
|
+
function usageMeterApplicable(env = process.env) {
|
|
46
|
+
if (envEnabled(env.CLAUDE_CODE_USE_BEDROCK)) return false;
|
|
47
|
+
if (envEnabled(env.CLAUDE_CODE_USE_VERTEX)) return false;
|
|
48
|
+
if (env.ANTHROPIC_API_KEY) return false;
|
|
49
|
+
if (env.ANTHROPIC_AUTH_TOKEN) return false;
|
|
50
|
+
if (env.ANTHROPIC_BASE_URL) {
|
|
51
|
+
// Parse the host rather than substring-match, so a deceptive gateway like
|
|
52
|
+
// https://anthropic.com.attacker.example is correctly treated as enterprise.
|
|
53
|
+
let host;
|
|
54
|
+
try { host = new URL(env.ANTHROPIC_BASE_URL).hostname.toLowerCase(); }
|
|
55
|
+
catch { return false; } // unparseable custom URL → treat as a gateway
|
|
56
|
+
if (host !== 'anthropic.com' && !host.endsWith('.anthropic.com')) return false;
|
|
57
|
+
}
|
|
58
|
+
return true;
|
|
59
|
+
}
|
|
27
60
|
const CACHE_PATH = path.join(os.homedir(), '.claude', 'session-manager', 'billing-cache.json');
|
|
28
61
|
// Coalesce the 4 renderer pollers (Overview/AppStatusBar/StatusBar/Usage). A
|
|
29
62
|
// fresh ok-cache is served directly without touching the network. Auth/
|
|
@@ -164,4 +197,4 @@ function registerBillingHandlers() {
|
|
|
164
197
|
});
|
|
165
198
|
}
|
|
166
199
|
|
|
167
|
-
module.exports = { registerBillingHandlers, fetchUsage, classifyUsageResponse };
|
|
200
|
+
module.exports = { registerBillingHandlers, fetchUsage, classifyUsageResponse, usageMeterApplicable };
|
package/src/preload/api.d.ts
CHANGED
|
@@ -1025,8 +1025,14 @@ export interface SessionManagerAPI {
|
|
|
1025
1025
|
};
|
|
1026
1026
|
plugins: {
|
|
1027
1027
|
/** Run `claude plugin install <slug>` in a hidden pty. Streams output
|
|
1028
|
-
* via `onInstallProgress`. Returns { ok, exitCode } on exit.
|
|
1029
|
-
|
|
1028
|
+
* via `onInstallProgress`. Returns { ok, exitCode } on exit.
|
|
1029
|
+
* Pass `marketplace` for a non-official plugin: the source is registered
|
|
1030
|
+
* via `claude plugin marketplace add <add>` first, then `<slug>@<name>`
|
|
1031
|
+
* is installed. */
|
|
1032
|
+
install: (payload: {
|
|
1033
|
+
slug: string;
|
|
1034
|
+
marketplace?: { add: string; name: string };
|
|
1035
|
+
}) => Promise<PluginInstallResult>;
|
|
1030
1036
|
onInstallProgress: (handler: (ev: PluginInstallProgressEvent) => void) => () => void;
|
|
1031
1037
|
};
|
|
1032
1038
|
clipboard: {
|