claude-code-session-manager 0.28.0 → 0.30.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-Py59G5JF.js} +1 -1
- package/dist/assets/{index-DtQ4LzuV.js → index-BBlKMDCL.js} +346 -345
- package/dist/assets/index-DMIi9YZH.css +32 -0
- 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/kg.cjs +41 -0
- package/src/main/lib/cleanEnv.cjs +32 -1
- package/src/main/lib/credentials.cjs +104 -13
- package/src/main/pluginInstall.cjs +107 -33
- package/src/main/pty.cjs +5 -12
- package/src/main/scheduler.cjs +38 -5
- 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
|
@@ -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
|
|
23
|
+
const fs = require('node:fs');
|
|
24
|
+
const { cleanChildEnv, pathWithUserBins } = 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,43 @@ 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
|
+
return cleanChildEnv({
|
|
62
|
+
PATH: pathWithUserBins(),
|
|
63
|
+
TERM: 'xterm-256color',
|
|
64
|
+
FORCE_COLOR: '0', // strip ANSI so the renderer doesn't have to.
|
|
65
|
+
});
|
|
66
|
+
}
|
|
51
67
|
|
|
68
|
+
/**
|
|
69
|
+
* Run one `claude plugin …` pty step, streaming output under `slug` and
|
|
70
|
+
* resolving with the exit code. `register`/`unregister` thread the live proc
|
|
71
|
+
* into the inFlight map so plugins:abort can kill whichever step is current.
|
|
72
|
+
*/
|
|
73
|
+
function runStep({ slug, args, register, unregister }) {
|
|
52
74
|
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
75
|
const claudeBin = resolveClaudeBin();
|
|
68
76
|
let proc;
|
|
69
77
|
try {
|
|
70
|
-
proc = pty.spawn(claudeBin,
|
|
78
|
+
proc = pty.spawn(claudeBin, args, {
|
|
71
79
|
name: 'xterm-256color',
|
|
72
80
|
cols: 120,
|
|
73
81
|
rows: 30,
|
|
74
|
-
cwd:
|
|
75
|
-
env,
|
|
82
|
+
cwd: os.homedir(),
|
|
83
|
+
env: childEnv(),
|
|
76
84
|
});
|
|
77
85
|
} catch (err) {
|
|
78
86
|
resolve({ ok: false, exitCode: -1, error: `spawn failed: ${err?.message ?? String(err)}` });
|
|
79
87
|
return;
|
|
80
88
|
}
|
|
81
89
|
|
|
82
|
-
|
|
90
|
+
register(proc);
|
|
83
91
|
|
|
84
92
|
let lineBuf = '';
|
|
85
93
|
let settled = false;
|
|
86
94
|
|
|
87
95
|
const killTimer = setTimeout(() => {
|
|
88
96
|
try { proc.kill('SIGTERM'); } catch { /* */ }
|
|
89
|
-
// Escalate to SIGKILL after KILL_GRACE_MS if the pty hasn't exited.
|
|
90
97
|
const escalate = setTimeout(() => {
|
|
91
98
|
try { proc.kill('SIGKILL'); } catch { /* already dead */ }
|
|
92
99
|
}, KILL_GRACE_MS);
|
|
@@ -96,11 +103,11 @@ function install({ slug }) {
|
|
|
96
103
|
|
|
97
104
|
// Belt-and-suspenders: if onExit never fires (broken pty event path after
|
|
98
105
|
// SIGKILL — analogous to anthropics/claude-code #61735's unreachable pts),
|
|
99
|
-
// force-
|
|
106
|
+
// force-resolve so the step (and its inFlight lock) isn't permanently stuck.
|
|
100
107
|
const deadman = setTimeout(() => {
|
|
101
108
|
if (settled) return;
|
|
102
109
|
settled = true;
|
|
103
|
-
|
|
110
|
+
unregister();
|
|
104
111
|
resolve({ ok: false, exitCode: -1, error: 'install hung — pty onExit never fired' });
|
|
105
112
|
}, KILL_AFTER_MS + KILL_GRACE_MS + 30_000);
|
|
106
113
|
if (deadman.unref) deadman.unref();
|
|
@@ -113,7 +120,6 @@ function install({ slug }) {
|
|
|
113
120
|
lineBuf = lineBuf.slice(nl + 1);
|
|
114
121
|
send('plugins:install-progress', { slug, line });
|
|
115
122
|
}
|
|
116
|
-
// Guard runaway buffers without newlines.
|
|
117
123
|
if (lineBuf.length > MAX_LINE_BYTES) {
|
|
118
124
|
send('plugins:install-progress', { slug, line: lineBuf });
|
|
119
125
|
lineBuf = '';
|
|
@@ -129,13 +135,81 @@ function install({ slug }) {
|
|
|
129
135
|
send('plugins:install-progress', { slug, line: lineBuf });
|
|
130
136
|
lineBuf = '';
|
|
131
137
|
}
|
|
132
|
-
|
|
138
|
+
unregister();
|
|
133
139
|
const code = typeof exitCode === 'number' ? exitCode : -1;
|
|
134
140
|
resolve({ ok: code === 0, exitCode: code });
|
|
135
141
|
});
|
|
136
142
|
});
|
|
137
143
|
}
|
|
138
144
|
|
|
145
|
+
/**
|
|
146
|
+
* Install a plugin. Without `marketplace`, runs `claude plugin install <slug>`
|
|
147
|
+
* (official catalog is pre-registered). With `marketplace: { add, name }`,
|
|
148
|
+
* first runs `claude plugin marketplace add <add>` to register the source, then
|
|
149
|
+
* installs `<slug>@<name>` — this is what a non-official plugin (e.g. the
|
|
150
|
+
* bundled session-manager-dev) needs on a fresh machine.
|
|
151
|
+
*/
|
|
152
|
+
async function install({ slug, marketplace }) {
|
|
153
|
+
if (typeof slug !== 'string' || !SLUG_RE.test(slug) || slug.length > 128) {
|
|
154
|
+
return { ok: false, exitCode: -1, error: 'invalid slug' };
|
|
155
|
+
}
|
|
156
|
+
let mkt = null;
|
|
157
|
+
if (marketplace != null) {
|
|
158
|
+
const { add, name } = marketplace;
|
|
159
|
+
const addValid = typeof add === 'string' && add.length <= 200 &&
|
|
160
|
+
(add === BUNDLED_ADD || MKT_ADD_RE.test(add));
|
|
161
|
+
if (!addValid || typeof name !== 'string' || !MKT_NAME_RE.test(name) || name.length > 128) {
|
|
162
|
+
return { ok: false, exitCode: -1, error: 'invalid marketplace' };
|
|
163
|
+
}
|
|
164
|
+
// Resolve the `bundled` sentinel to the app's own packaged marketplace dir.
|
|
165
|
+
let addArg = add;
|
|
166
|
+
if (add === BUNDLED_ADD) {
|
|
167
|
+
const dir = bundledMarketplaceDir();
|
|
168
|
+
if (!fs.existsSync(path.join(dir, '.claude-plugin', 'marketplace.json'))) {
|
|
169
|
+
return { ok: false, exitCode: -1, error: 'bundled marketplace not found' };
|
|
170
|
+
}
|
|
171
|
+
addArg = dir;
|
|
172
|
+
}
|
|
173
|
+
mkt = { add: addArg, name };
|
|
174
|
+
}
|
|
175
|
+
if (inFlight.has(slug)) {
|
|
176
|
+
return { ok: false, exitCode: -1, error: 'install already in progress' };
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Reserve the slug for the whole sequence; each step swaps in its live proc.
|
|
180
|
+
inFlight.set(slug, null);
|
|
181
|
+
const register = (proc) => inFlight.set(slug, proc);
|
|
182
|
+
const release = () => inFlight.delete(slug);
|
|
183
|
+
// Between steps the slug stays reserved (set to null) so a concurrent call
|
|
184
|
+
// can't slip in; runStep's unregister only clears the per-step proc handle.
|
|
185
|
+
const unregisterStep = () => inFlight.set(slug, null);
|
|
186
|
+
|
|
187
|
+
try {
|
|
188
|
+
if (mkt) {
|
|
189
|
+
const added = await runStep({
|
|
190
|
+
slug,
|
|
191
|
+
args: ['plugin', 'marketplace', 'add', mkt.add],
|
|
192
|
+
register,
|
|
193
|
+
unregister: unregisterStep,
|
|
194
|
+
});
|
|
195
|
+
// Tolerate "already added" — the add command exits non-zero when the
|
|
196
|
+
// marketplace is already registered; fall through to install regardless.
|
|
197
|
+
if (!added.ok) {
|
|
198
|
+
send('plugins:install-progress', { slug, line: `[marketplace add exit ${added.exitCode} — continuing to install]` });
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
const installTarget = mkt ? `${slug}@${mkt.name}` : slug;
|
|
202
|
+
return await runStep({
|
|
203
|
+
slug,
|
|
204
|
+
args: ['plugin', 'install', installTarget],
|
|
205
|
+
register,
|
|
206
|
+
unregister: unregisterStep,
|
|
207
|
+
});
|
|
208
|
+
} finally {
|
|
209
|
+
release();
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
139
213
|
function registerPluginInstallHandlers() {
|
|
140
214
|
ipcMain.handle('plugins:install', async (_e, payload) => {
|
|
141
215
|
// safeParse to preserve the existing `{ ok:false, exitCode:-1, error }`
|
|
@@ -145,7 +219,7 @@ function registerPluginInstallHandlers() {
|
|
|
145
219
|
if (!parsed.success) {
|
|
146
220
|
return { ok: false, exitCode: -1, error: 'invalid slug' };
|
|
147
221
|
}
|
|
148
|
-
return install({ slug: parsed.data.slug });
|
|
222
|
+
return install({ slug: parsed.data.slug, marketplace: parsed.data.marketplace });
|
|
149
223
|
});
|
|
150
224
|
|
|
151
225
|
// plugins:abort — send SIGKILL to a stuck install and release the inFlight
|
|
@@ -164,4 +238,4 @@ function registerPluginInstallHandlers() {
|
|
|
164
238
|
});
|
|
165
239
|
}
|
|
166
240
|
|
|
167
|
-
module.exports = { registerPluginInstallHandlers, attachWindow };
|
|
241
|
+
module.exports = { registerPluginInstallHandlers, attachWindow, install, bundledMarketplaceDir };
|
package/src/main/pty.cjs
CHANGED
|
@@ -4,7 +4,7 @@ const path = require('node:path');
|
|
|
4
4
|
const os = require('node:os');
|
|
5
5
|
const fs = require('node:fs');
|
|
6
6
|
const { addAllowedRoot } = require('./config.cjs');
|
|
7
|
-
const { cleanChildEnv } = require('./lib/cleanEnv.cjs');
|
|
7
|
+
const { cleanChildEnv, pathWithUserBins } = require('./lib/cleanEnv.cjs');
|
|
8
8
|
const { checkInsideHome } = require('./lib/insideHome.cjs');
|
|
9
9
|
const { sendIfAlive } = require('./lib/sendToRenderer.cjs');
|
|
10
10
|
|
|
@@ -62,18 +62,11 @@ class PtyManager {
|
|
|
62
62
|
return { pid: existing.proc.pid, cwd: existing.cwd, reattached: true };
|
|
63
63
|
}
|
|
64
64
|
|
|
65
|
-
// Prefer `claude` on PATH; extend PATH with common user bin dirs
|
|
66
|
-
// Electron can launch with a stripped environment
|
|
67
|
-
|
|
68
|
-
path.join(os.homedir(), '.local', 'bin'),
|
|
69
|
-
path.join(os.homedir(), '.npm-global', 'bin'),
|
|
70
|
-
'/usr/local/bin',
|
|
71
|
-
'/usr/bin',
|
|
72
|
-
'/bin',
|
|
73
|
-
].join(':');
|
|
74
|
-
|
|
65
|
+
// Prefer `claude` on PATH; extend PATH with common user + Homebrew bin dirs
|
|
66
|
+
// since Electron can launch with a stripped environment (Apple Silicon
|
|
67
|
+
// Homebrew is /opt/homebrew, absent from the default Electron PATH).
|
|
75
68
|
const env = cleanChildEnv({
|
|
76
|
-
PATH:
|
|
69
|
+
PATH: pathWithUserBins(),
|
|
77
70
|
TERM: 'xterm-256color',
|
|
78
71
|
COLORTERM: 'truecolor',
|
|
79
72
|
FORCE_COLOR: '1',
|
package/src/main/scheduler.cjs
CHANGED
|
@@ -48,7 +48,7 @@ const { randomUUID } = require('node:crypto');
|
|
|
48
48
|
const { execFile } = require('node:child_process');
|
|
49
49
|
const { ipcMain } = require('electron');
|
|
50
50
|
const billing = require('./usage.cjs');
|
|
51
|
-
const { cleanChildEnv } = require('./lib/cleanEnv.cjs');
|
|
51
|
+
const { cleanChildEnv, pathWithUserBins } = require('./lib/cleanEnv.cjs');
|
|
52
52
|
const supervisor = require('./supervisor.cjs');
|
|
53
53
|
const { resolveClaudeBin } = require('./lib/claudeBin.cjs');
|
|
54
54
|
const { readTail } = require('./lib/fileTail.cjs');
|
|
@@ -921,7 +921,9 @@ async function executeJob(job, runDir, defaultCwd, onPid) {
|
|
|
921
921
|
// Strip Claude Code env and secrets that leak in when session-manager is
|
|
922
922
|
// launched from a `claude` shell. CLAUDE_EFFORT=xhigh forces Opus and
|
|
923
923
|
// overrides `--model sonnet`, so scheduled jobs burn Opus credits silently.
|
|
924
|
-
|
|
924
|
+
// PATH must include Homebrew/user bins or the job's node/git children ENOENT
|
|
925
|
+
// when Electron was launched from Finder/Dock on macOS (stripped PATH).
|
|
926
|
+
const childEnv = cleanChildEnv({ PATH: pathWithUserBins() });
|
|
925
927
|
|
|
926
928
|
// Track whether the agent has emitted a `result` event in its JSONL stream.
|
|
927
929
|
// null until seen; then one of "success" | "error_max_turns" | … per the
|
|
@@ -1230,7 +1232,7 @@ DO NOT attempt the fix. ONLY write the file. When the file exists, exit immediat
|
|
|
1230
1232
|
}
|
|
1231
1233
|
|
|
1232
1234
|
const claudeBin = resolveClaudeBin();
|
|
1233
|
-
const childEnv = cleanChildEnv();
|
|
1235
|
+
const childEnv = cleanChildEnv({ PATH: pathWithUserBins() }); // Homebrew/user bins for macOS
|
|
1234
1236
|
|
|
1235
1237
|
// Investigation needs only a deadman watchdog — no idle-tail or result-tail
|
|
1236
1238
|
// since investigations are short-running Opus probes with a hard ceiling.
|
|
@@ -1685,6 +1687,34 @@ function pollRecoveryClearSource(pauseReason, hasCachedReset) {
|
|
|
1685
1687
|
async function pollLoop() {
|
|
1686
1688
|
try {
|
|
1687
1689
|
await reapDeadRunningJobs().catch(() => {});
|
|
1690
|
+
|
|
1691
|
+
// Enterprise auth (Bedrock / Vertex / API-key / corporate gateway): there is
|
|
1692
|
+
// no consumer 5-hour usage meter to poll. Don't hit an endpoint that will
|
|
1693
|
+
// 404/time-out and eventually pause the queue on 'network' — treat usage as
|
|
1694
|
+
// wide-open and fire on pending + memory alone. (Blackrock-style machines.)
|
|
1695
|
+
if (!billing.usageMeterApplicable()) {
|
|
1696
|
+
cachedUtilization = 0;
|
|
1697
|
+
consecutiveFailures = 0;
|
|
1698
|
+
backoffMs = 0;
|
|
1699
|
+
backoffNextAt = null;
|
|
1700
|
+
firstFailureAt = null;
|
|
1701
|
+
firstNon429FailureAt = null;
|
|
1702
|
+
lastFailureKind = null;
|
|
1703
|
+
lastPollAt = Date.now();
|
|
1704
|
+
lastPollOk = true;
|
|
1705
|
+
persistSchedulerState();
|
|
1706
|
+
let cur = await readQueue();
|
|
1707
|
+
// Clear a stale auth/network pause inherited from a prior consumer-auth
|
|
1708
|
+
// session so the queue isn't wedged. Never clears rate_limit/manual.
|
|
1709
|
+
if (cur.paused && (cur.paused.reason === 'auth' || cur.paused.reason === 'network')) {
|
|
1710
|
+
await clearPause('enterprise-auth');
|
|
1711
|
+
cur = await readQueue(); // re-read so the cleared pause is visible to launch THIS cycle
|
|
1712
|
+
}
|
|
1713
|
+
await maybeLaunchWhenAvailable(cur);
|
|
1714
|
+
await broadcast();
|
|
1715
|
+
return; // finally re-arms the timer
|
|
1716
|
+
}
|
|
1717
|
+
|
|
1688
1718
|
const r = await billing.fetchUsage();
|
|
1689
1719
|
|
|
1690
1720
|
if (r.kind === 'ok') {
|
|
@@ -1701,9 +1731,12 @@ async function pollLoop() {
|
|
|
1701
1731
|
persistSchedulerState();
|
|
1702
1732
|
|
|
1703
1733
|
// Clear any pause that was waiting for a successful billing read.
|
|
1704
|
-
|
|
1734
|
+
let cur = await readQueue();
|
|
1705
1735
|
const clearSrc = pollRecoveryClearSource(cur.paused?.reason ?? null, !!cachedNextReset);
|
|
1706
|
-
if (clearSrc)
|
|
1736
|
+
if (clearSrc) {
|
|
1737
|
+
await clearPause(clearSrc);
|
|
1738
|
+
cur = await readQueue(); // re-read so the cleared pause launches work THIS cycle
|
|
1739
|
+
}
|
|
1707
1740
|
|
|
1708
1741
|
await maybeLaunchWhenAvailable(cur);
|
|
1709
1742
|
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: {
|