bullswarm 0.14.1 → 0.15.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/CHANGELOG.md CHANGED
@@ -1,5 +1,13 @@
1
1
  # bullswarm changelog
2
2
 
3
+ ## 0.15.0 — extra Claude Code logins as separate pools
4
+
5
+ - Claude Code extra logins (`~/.claude-<slug>` / `$CLAUDE_CONFIG_DIR`) become
6
+ their own pools (`claude-code:<slug>`), metered and spawned with
7
+ `CLAUDE_CONFIG_DIR` set. Discovery is dynamic from the filesystem; there
8
+ is no hardcoded extra-profile list. The spawn command for each profile is
9
+ `CLAUDE_CONFIG_DIR=<dir> claude`.
10
+
3
11
  ## 0.14.1 — the TUI survives its writer; steering lands or expires truthfully
4
12
 
5
13
  Proven on a goal-3 re-run (`d7xyg2`): 1 planner turn / 269 s (baseline 0.13.1: 1 / 294 s), planner context 6.2 k chars (from 32.7 k), auto-completed, deliverable verified, zero observation crashes — `docs/experiments/2026-08-29-dogfood-bullswarm-builds-bullswarm.md`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bullswarm",
3
- "version": "0.14.1",
3
+ "version": "0.15.0",
4
4
  "description": "Route work across coding-agent CLI subscriptions — paced by live quota meters, verified by content, never trusting exit codes.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,202 @@
1
+ // Discover Claude Code logins on this machine.
2
+ //
3
+ // One account = one config dir (`~/.claude` by default, or CLAUDE_CONFIG_DIR).
4
+ // Extra homes live next to it as `~/.claude-<slug>`. Credentials:
5
+ // macOS Keychain `Claude Code-credentials` for ~/.claude
6
+ // `Claude Code-credentials-<sha256(absPath)[:8]>` for any other home
7
+ // plus `$dir/.credentials.json` on every platform.
8
+
9
+ import { createHash } from 'node:crypto';
10
+ import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
11
+ import { homedir, platform } from 'node:os';
12
+ import { basename, join, resolve } from 'node:path';
13
+ import {
14
+ DEFAULT_KEYCHAIN_SERVICE,
15
+ extractCredentials,
16
+ isUsable,
17
+ readFromMacKeychain,
18
+ } from '../meters/claude.js';
19
+
20
+ const HOME_MARKERS = ['.credentials.json', '.claude.json', 'settings.json', 'projects'];
21
+
22
+ export function defaultClaudeHome(homeDir = homedir()) {
23
+ return resolve(join(homeDir, '.claude'));
24
+ }
25
+
26
+ export function keychainServiceForConfigDir(configDir, homeDir = homedir()) {
27
+ const resolved = resolve(configDir);
28
+ if (resolved === defaultClaudeHome(homeDir)) return DEFAULT_KEYCHAIN_SERVICE;
29
+ const hash = createHash('sha256').update(resolved).digest('hex').slice(0, 8);
30
+ return `${DEFAULT_KEYCHAIN_SERVICE}-${hash}`;
31
+ }
32
+
33
+ export function accountSlugForConfigDir(configDir, homeDir = homedir()) {
34
+ const resolved = resolve(configDir);
35
+ if (resolved === defaultClaudeHome(homeDir)) return null;
36
+ const base = basename(resolved);
37
+ if (base.startsWith('.claude-')) {
38
+ const slug = base.slice('.claude-'.length);
39
+ return slug.length > 0 ? slug : 'alt';
40
+ }
41
+ if (base.startsWith('.claude')) {
42
+ const rest = base.slice('.claude'.length).replace(/^-+/, '');
43
+ return rest.length > 0 ? rest : 'alt';
44
+ }
45
+ return base || 'alt';
46
+ }
47
+
48
+ export function poolNameForSlug(slug) {
49
+ return slug ? `claude-code:${slug}` : 'claude-code';
50
+ }
51
+
52
+ export function looksLikeClaudeHome(dir) {
53
+ try {
54
+ if (!statSync(dir).isDirectory()) return false;
55
+ } catch {
56
+ return false;
57
+ }
58
+ return HOME_MARKERS.some((name) => existsSync(join(dir, name)));
59
+ }
60
+
61
+ export function discoverClaudeConfigDirs(opts = {}) {
62
+ const homeDir = opts.homeDir ?? homedir();
63
+ const found = [];
64
+ const seen = new Set();
65
+ const add = (dir) => {
66
+ const resolved = resolve(dir);
67
+ if (seen.has(resolved)) return;
68
+ if (!looksLikeClaudeHome(resolved)) return;
69
+ seen.add(resolved);
70
+ found.push(resolved);
71
+ };
72
+ add(join(homeDir, '.claude'));
73
+ try {
74
+ for (const name of readdirSync(homeDir)) {
75
+ if (!name.startsWith('.claude-')) continue;
76
+ add(join(homeDir, name));
77
+ }
78
+ } catch { /* unreadable home */ }
79
+ const envDir = opts.envConfigDir ?? process.env.CLAUDE_CONFIG_DIR;
80
+ if (envDir && String(envDir).trim()) add(String(envDir).trim());
81
+ return found;
82
+ }
83
+
84
+ function readCredentialsFile(path) {
85
+ try {
86
+ return extractCredentials(readFileSync(path, 'utf8'));
87
+ } catch {
88
+ return null;
89
+ }
90
+ }
91
+
92
+ export function readAccountCredentials(configDir, opts = {}) {
93
+ const homeDir = opts.homeDir ?? homedir();
94
+ const nowMs = opts.nowMs ?? Date.now();
95
+ const os = opts.platform ?? platform();
96
+ const keychainRead = opts.readKeychain ?? readFromMacKeychain;
97
+ const usableOnly = opts.usableOnly !== false;
98
+
99
+ const fileCreds = readCredentialsFile(join(configDir, '.credentials.json'));
100
+ let keychainCreds = null;
101
+ if (os === 'darwin') {
102
+ keychainCreds = keychainRead(keychainServiceForConfigDir(configDir, homeDir));
103
+ }
104
+ const extraFile = resolve(configDir) === defaultClaudeHome(homeDir)
105
+ ? readCredentialsFile(join(homeDir, '.config', 'claude', 'credentials.json'))
106
+ : null;
107
+
108
+ const candidates = [];
109
+ if (keychainCreds) candidates.push({ creds: keychainCreds, source: 'keychain' });
110
+ if (fileCreds) candidates.push({ creds: fileCreds, source: 'file' });
111
+ if (extraFile) candidates.push({ creds: extraFile, source: 'file' });
112
+ for (const c of candidates) {
113
+ if (!usableOnly || isUsable(c.creds, nowMs)) return c;
114
+ }
115
+ return null;
116
+ }
117
+
118
+ export function profileCommand(configDir, bin = 'claude') {
119
+ return `CLAUDE_CONFIG_DIR=${configDir} ${bin}`;
120
+ }
121
+
122
+ export function discoverClaudeAccounts(opts = {}) {
123
+ const homeDir = opts.homeDir ?? homedir();
124
+ const dirs = discoverClaudeConfigDirs({
125
+ homeDir,
126
+ envConfigDir: opts.envConfigDir,
127
+ });
128
+ const accounts = [];
129
+ const seenTokens = new Set();
130
+ for (const configDir of dirs) {
131
+ const got = readAccountCredentials(configDir, opts);
132
+ if (!got) continue;
133
+ if (seenTokens.has(got.creds.accessToken)) continue;
134
+ seenTokens.add(got.creds.accessToken);
135
+ const slug = accountSlugForConfigDir(configDir, homeDir);
136
+ accounts.push({
137
+ configDir,
138
+ slug,
139
+ pool: poolNameForSlug(slug),
140
+ command: profileCommand(configDir, opts.bin ?? 'claude'),
141
+ creds: got.creds,
142
+ source: got.source,
143
+ });
144
+ }
145
+ accounts.sort((a, b) => {
146
+ if (a.slug === null) return -1;
147
+ if (b.slug === null) return 1;
148
+ return a.slug.localeCompare(b.slug);
149
+ });
150
+ return accounts;
151
+ }
152
+
153
+ /**
154
+ * Clone the packaged `claude-code` connector once per extra login.
155
+ * The default home keeps the historical pool name `claude-code`.
156
+ * Extra homes become `claude-code:<slug>` with CLAUDE_CONFIG_DIR in env
157
+ * so spawn bills that seat. Discovery is filesystem-driven — never a
158
+ * hardcoded profile list.
159
+ */
160
+ export function expandClaudeAccountConnectors(connectors, opts = {}) {
161
+ if (opts.disabled === true) return connectors;
162
+ if (process.env.BULLSWARM_DISABLE_CLAUDE_PROFILES === '1') return connectors;
163
+ // node:test (and MCP children it spawns) inherit NODE_TEST_CONTEXT. Do not
164
+ // scan the operator's real extra Claude homes unless the test injects
165
+ // `accounts` or `homeDir`.
166
+ if (process.env.NODE_TEST_CONTEXT && opts.accounts == null && opts.homeDir == null) {
167
+ return connectors;
168
+ }
169
+ const base = connectors['claude-code'];
170
+ if (!base) return connectors;
171
+ const accounts = opts.accounts ?? discoverClaudeAccounts({
172
+ homeDir: opts.homeDir,
173
+ envConfigDir: opts.envConfigDir,
174
+ bin: base.bin ?? 'claude',
175
+ });
176
+ const defaultDir = accounts.find((a) => a.slug == null)?.configDir
177
+ ?? defaultClaudeHome(opts.homeDir);
178
+ const bin = base.bin ?? 'claude';
179
+ base.env = { ...(base.env ?? {}), CLAUDE_CONFIG_DIR: defaultDir };
180
+ base.profile = {
181
+ slug: null,
182
+ configDir: defaultDir,
183
+ command: profileCommand(defaultDir, bin),
184
+ };
185
+ for (const account of accounts) {
186
+ if (!account.slug) continue;
187
+ const name = account.pool;
188
+ if (connectors[name]) continue;
189
+ const clone = structuredClone(base);
190
+ clone.name = name;
191
+ clone.env = { ...(base.env ?? {}), CLAUDE_CONFIG_DIR: account.configDir };
192
+ clone.configDirs = [account.configDir];
193
+ clone.flags = { ...(base.flags ?? {}), isCaller: false };
194
+ clone.profile = {
195
+ slug: account.slug,
196
+ configDir: account.configDir,
197
+ command: account.command,
198
+ };
199
+ connectors[name] = clone;
200
+ }
201
+ return connectors;
202
+ }
package/src/lib/config.js CHANGED
@@ -13,8 +13,9 @@ import { readFileSync, readdirSync, existsSync } from 'node:fs';
13
13
  import { join } from 'node:path';
14
14
  import { loadState } from './state.js';
15
15
  import { paceScore, isQuarantined } from './route.js';
16
+ import { expandClaudeAccountConnectors } from './claude-accounts.js';
16
17
 
17
- export function loadConnectors(bullswarmDir) {
18
+ export function loadConnectors(bullswarmDir, opts = {}) {
18
19
  const dir = join(bullswarmDir, 'connectors');
19
20
  if (!existsSync(dir)) return {};
20
21
  const out = {};
@@ -28,6 +29,7 @@ export function loadConnectors(bullswarmDir) {
28
29
  // never crash a run
29
30
  }
30
31
  }
32
+ expandClaudeAccountConnectors(out, opts);
31
33
  return out;
32
34
  }
33
35
 
package/src/lib/watch.js CHANGED
@@ -78,7 +78,12 @@ export function runDelegate(connector, taskFile, targetDir, opts = {}) {
78
78
  // hazard). Caller-supplied opts.env takes precedence over
79
79
  // process.env so the runtime can inject BULLSWARM_DEPTH (recursion
80
80
  // guard) and other core-owned env contracts.
81
- env: { ...process.env, ...(opts.env ?? {}), PWD: resolvedDir },
81
+ env: {
82
+ ...process.env,
83
+ ...(connector.env ?? {}),
84
+ ...(opts.env ?? {}),
85
+ PWD: resolvedDir,
86
+ },
82
87
  stdio: ['ignore', 'pipe', 'pipe'],
83
88
  });
84
89
  opts.onSpawn?.(child.pid);
@@ -19,18 +19,24 @@ export class ClaudeMeterError extends Error {
19
19
  }
20
20
  }
21
21
 
22
+ export const DEFAULT_KEYCHAIN_SERVICE = 'Claude Code-credentials';
23
+
24
+ export function isUsable(creds, now = Date.now(), skewMs = EXPIRY_SKEW_MS) {
25
+ return Boolean(creds) && creds.expiresAt - skewMs > now;
26
+ }
27
+
22
28
  export function readOAuthCredentials() {
23
29
  if (platform() === 'darwin') {
24
- return readFromMacKeychain() ?? readFromCredentialsFile();
30
+ return readFromMacKeychain(DEFAULT_KEYCHAIN_SERVICE) ?? readFromCredentialsFile();
25
31
  }
26
32
  return readFromCredentialsFile();
27
33
  }
28
34
 
29
- function readFromMacKeychain() {
35
+ export function readFromMacKeychain(service = DEFAULT_KEYCHAIN_SERVICE) {
30
36
  try {
31
37
  const blob = execFileSync(
32
38
  'security',
33
- ['find-generic-password', '-s', 'Claude Code-credentials', '-w'],
39
+ ['find-generic-password', '-s', service, '-w'],
34
40
  { stdio: ['ignore', 'pipe', 'ignore'], encoding: 'utf8' },
35
41
  );
36
42
  return extractCredentials(blob);
@@ -73,13 +79,13 @@ function normalizeWindow(raw) {
73
79
  };
74
80
  }
75
81
 
76
- export function parseClaudeUsage(body) {
82
+ export function parseClaudeUsage(body, pool = 'claude-code') {
77
83
  if (!body || typeof body !== 'object') {
78
84
  throw new ClaudeMeterError('Claude usage response missing body', 'parse');
79
85
  }
80
86
  return {
81
87
  captured_at: new Date().toISOString(),
82
- pool: 'claude-code',
88
+ pool,
83
89
  five_hour: normalizeWindow(body.five_hour) ?? { utilization: null, resets_at: null },
84
90
  seven_day: normalizeWindow(body.seven_day) ?? { utilization: null, resets_at: null },
85
91
  monthly: null,
@@ -89,12 +95,11 @@ export function parseClaudeUsage(body) {
89
95
  };
90
96
  }
91
97
 
92
- export async function fetchClaudeUsage() {
93
- const creds = readOAuthCredentials();
98
+ export async function fetchClaudeUsageWithCredentials(creds, pool = 'claude-code') {
94
99
  if (!creds) {
95
100
  throw new ClaudeMeterError('No Claude Code OAuth token. Run `claude` to log in.', 'no_token');
96
101
  }
97
- if (creds.expiresAt - EXPIRY_SKEW_MS <= Date.now()) {
102
+ if (!isUsable(creds)) {
98
103
  throw new ClaudeMeterError(
99
104
  'Claude OAuth token expired; open Claude Code to refresh it.',
100
105
  'expired',
@@ -122,5 +127,9 @@ export async function fetchClaudeUsage() {
122
127
  } catch (err) {
123
128
  throw new ClaudeMeterError(`Failed to parse usage response: ${err.message}`, 'parse');
124
129
  }
125
- return parseClaudeUsage(body);
130
+ return parseClaudeUsage(body, pool);
131
+ }
132
+
133
+ export async function fetchClaudeUsage() {
134
+ return fetchClaudeUsageWithCredentials(readOAuthCredentials(), 'claude-code');
126
135
  }
@@ -6,7 +6,8 @@ import { MeterCache, paceSnapshot, FRESH_MS, STALE_MS } from './framework.js';
6
6
  import { fetchCodexUsage, CodexMeterError } from './codex.js';
7
7
  import { fetchGrokUsage, GrokMeterError } from './grok.js';
8
8
  import { fetchCommandCodeUsage, CommandCodeMeterError } from './command-code.js';
9
- import { fetchClaudeUsage, ClaudeMeterError } from './claude.js';
9
+ import { fetchClaudeUsage, fetchClaudeUsageWithCredentials, ClaudeMeterError } from './claude.js';
10
+ import { discoverClaudeAccounts, poolNameForSlug } from '../lib/claude-accounts.js';
10
11
 
11
12
  export const METERS_DIR = () =>
12
13
  process.env.BULLSWARM_HOME?.trim() || join(homedir(), '.bullswarm');
@@ -19,7 +20,26 @@ const READERS = {
19
20
  claude: fetchClaudeUsage,
20
21
  };
21
22
 
23
+ function claudeReaderFor(pool) {
24
+ return async () => {
25
+ const accounts = discoverClaudeAccounts();
26
+ const slug = pool.startsWith('claude-code:') ? pool.slice('claude-code:'.length) : null;
27
+ const account = accounts.find((a) => poolNameForSlug(a.slug) === pool)
28
+ ?? accounts.find((a) => a.slug === slug);
29
+ if (!account) {
30
+ throw new ClaudeMeterError(
31
+ `No Claude Code OAuth token for pool ${pool}. Log in with CLAUDE_CONFIG_DIR pointing at that home.`,
32
+ 'no_token',
33
+ );
34
+ }
35
+ return fetchClaudeUsageWithCredentials(account.creds, pool);
36
+ };
37
+ }
38
+
22
39
  export function readerFor(pool) {
40
+ if (pool === 'claude-code' || pool === 'claude' || pool.startsWith('claude-code:')) {
41
+ return claudeReaderFor(pool);
42
+ }
23
43
  return READERS[pool] ?? null;
24
44
  }
25
45
 
@@ -457,6 +457,8 @@ async function wfCapabilities(opts) {
457
457
  enabled: p.enabled !== false,
458
458
  lanes: p.lanes ?? p.connector?.lanes ?? [],
459
459
  capabilities: p.capabilities ?? p.connector?.capabilities ?? [],
460
+ command: p.connector?.profile?.command ?? p.connector?.spawn?.cmd?.[0] ?? null,
461
+ configDir: p.connector?.profile?.configDir ?? p.connector?.env?.CLAUDE_CONFIG_DIR ?? null,
460
462
  model: (() => {
461
463
  const cmd = p.connector?.spawn?.cmd ?? [];
462
464
  const i = cmd.indexOf('--model');