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/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-DtQ4LzuV.js"></script>
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-Dwb94Uxm.css">
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.28.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",
@@ -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
  });
@@ -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
- 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 };
81
+ return parseCredsRaw(raw, 'file');
26
82
  } 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}` };
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 = { readCredentials, expiresAtMs, isExpired, isExpiringSoon, refreshIfNeeded };
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 install({ slug }) {
45
- if (typeof slug !== 'string' || !SLUG_RE.test(slug) || slug.length > 128) {
46
- return Promise.resolve({ ok: false, exitCode: -1, error: 'invalid slug' });
47
- }
48
- if (inFlight.has(slug)) {
49
- return Promise.resolve({ ok: false, exitCode: -1, error: 'install already in progress' });
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, ['plugin', 'install', slug], {
86
+ proc = pty.spawn(claudeBin, args, {
71
87
  name: 'xterm-256color',
72
88
  cols: 120,
73
89
  rows: 30,
74
- cwd: home,
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
- inFlight.set(slug, proc);
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-release the inFlight lock so the slug isn't permanently stuck.
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
- inFlight.delete(slug);
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
- inFlight.delete(slug);
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 };
@@ -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
- const cur = await readQueue();
1732
+ let cur = await readQueue();
1705
1733
  const clearSrc = pollRecoveryClearSource(cur.paused?.reason ?? null, !!cachedNextReset);
1706
- if (clearSrc) await clearPause(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 };
@@ -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 };
@@ -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
- install: (payload: { slug: string }) => Promise<PluginInstallResult>;
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: {