@rikcodes/teamclaude 1.1.13-rik.1

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/src/service.js ADDED
@@ -0,0 +1,241 @@
1
+ // Run the proxy as a user service — `teamclaude service install`.
2
+ //
3
+ // npm has no equivalent of `brew services`, and a postinstall hook is the wrong
4
+ // place for this (it is skipped under --ignore-scripts, surprises CI, and
5
+ // installs system state nobody asked for). So the CLI does it explicitly, the
6
+ // same way `teamclaude alias --install` handles the shell alias.
7
+ //
8
+ // macOS gets a LaunchAgent, Linux a systemd --user unit. Both are per-user: no
9
+ // root, no system-wide daemon, and the proxy runs as the user whose accounts and
10
+ // config it serves.
11
+
12
+ import { writeFile, mkdir, rm, readFile } from 'node:fs/promises';
13
+ import { existsSync, realpathSync } from 'node:fs';
14
+ import { spawnSync } from 'node:child_process';
15
+ import { homedir } from 'node:os';
16
+ import { join, dirname } from 'node:path';
17
+
18
+ export const LABEL = 'com.karpeleslab.teamclaude';
19
+ export const UNIT_NAME = 'teamclaude.service';
20
+
21
+ /** Which service manager to target, or null where we have nothing to offer. */
22
+ export function serviceKind(platform = process.platform) {
23
+ if (platform === 'darwin') return 'launchd';
24
+ if (platform === 'linux') return 'systemd';
25
+ return null;
26
+ }
27
+
28
+ export function launchAgentPath(home = homedir()) {
29
+ return join(home, 'Library', 'LaunchAgents', `${LABEL}.plist`);
30
+ }
31
+
32
+ export function systemdUnitPath(home = homedir(), xdgConfig = process.env.XDG_CONFIG_HOME) {
33
+ return join(xdgConfig || join(home, '.config'), 'systemd', 'user', UNIT_NAME);
34
+ }
35
+
36
+ export function logPath(home = homedir(), platform = process.platform) {
37
+ return platform === 'darwin'
38
+ ? join(home, 'Library', 'Logs', 'teamclaude.log')
39
+ : join(home, '.local', 'state', 'teamclaude.log');
40
+ }
41
+
42
+ /**
43
+ * The interpreter and script to bake into the unit.
44
+ *
45
+ * `process.execPath` is NOT usable as-is: on a Homebrew install it points into
46
+ * the versioned Cellar directory (…/Cellar/node/26.5.0_1/bin/node), which the
47
+ * next `brew upgrade node` deletes — the service would then fail to start with
48
+ * no obvious connection to the upgrade. Prefer a `node` on PATH that resolves to
49
+ * the same binary, since that is the stable symlink maintained across upgrades.
50
+ *
51
+ * The script is `argv[1]` unresolved for the same reason: the bin symlink
52
+ * survives a package reinstall, the versioned path inside node_modules may not.
53
+ */
54
+ export function resolveExec({
55
+ execPath = process.execPath,
56
+ argv1 = process.argv[1],
57
+ pathEnv = process.env.PATH || '',
58
+ realpath = realpathSync,
59
+ exists = existsSync,
60
+ } = {}) {
61
+ const same = (candidate) => {
62
+ try { return realpath(candidate) === realpath(execPath); } catch { return false; }
63
+ };
64
+ let node = execPath;
65
+ for (const dir of pathEnv.split(':')) {
66
+ if (!dir) continue;
67
+ const candidate = join(dir, 'node');
68
+ if (exists(candidate) && same(candidate)) { node = candidate; break; }
69
+ }
70
+ return { node, entry: argv1 };
71
+ }
72
+
73
+ /**
74
+ * PATH for the service. launchd starts with an empty environment, so without
75
+ * this the background self-update cannot find npm, and any child process the
76
+ * proxy spawns inherits nothing. Built from the directories that actually
77
+ * matter rather than copying the interactive shell's PATH, which is full of
78
+ * per-session entries that mean nothing to a daemon.
79
+ */
80
+ export function servicePath({ node, entry }) {
81
+ const dirs = [dirname(node), dirname(entry), '/usr/local/bin', '/usr/bin', '/bin', '/usr/sbin', '/sbin'];
82
+ return [...new Set(dirs.filter(Boolean))].join(':');
83
+ }
84
+
85
+ const xmlEscape = (s) => String(s)
86
+ .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
87
+
88
+ export function renderLaunchAgent({ node, entry, log, path, configPath = null }) {
89
+ const args = [node, entry, 'server', '--headless'];
90
+ const env = [` <key>PATH</key>\n <string>${xmlEscape(path)}</string>`];
91
+ if (configPath) env.push(` <key>TEAMCLAUDE_CONFIG</key>\n <string>${xmlEscape(configPath)}</string>`);
92
+ return `<?xml version="1.0" encoding="UTF-8"?>
93
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
94
+ <plist version="1.0">
95
+ <dict>
96
+ <key>Label</key>
97
+ <string>${LABEL}</string>
98
+ <key>ProgramArguments</key>
99
+ <array>
100
+ ${args.map(a => ` <string>${xmlEscape(a)}</string>`).join('\n')}
101
+ </array>
102
+ <key>RunAtLoad</key>
103
+ <true/>
104
+ <key>KeepAlive</key>
105
+ <true/>
106
+ <key>ProcessType</key>
107
+ <string>Background</string>
108
+ <key>StandardOutPath</key>
109
+ <string>${xmlEscape(log)}</string>
110
+ <key>StandardErrorPath</key>
111
+ <string>${xmlEscape(log)}</string>
112
+ <key>EnvironmentVariables</key>
113
+ <dict>
114
+ ${env.join('\n')}
115
+ </dict>
116
+ </dict>
117
+ </plist>
118
+ `;
119
+ }
120
+
121
+ export function renderSystemdUnit({ node, entry, path, configPath = null }) {
122
+ const environment = [`Environment=PATH=${path}`];
123
+ if (configPath) environment.push(`Environment=TEAMCLAUDE_CONFIG=${configPath}`);
124
+ return `[Unit]
125
+ Description=TeamClaude multi-account Claude proxy
126
+ Documentation=https://github.com/KarpelesLab/teamclaude
127
+ After=network-online.target
128
+
129
+ [Service]
130
+ ExecStart=${node} ${entry} server --headless
131
+ Restart=always
132
+ RestartSec=5
133
+ ${environment.join('\n')}
134
+
135
+ [Install]
136
+ WantedBy=default.target
137
+ `;
138
+ }
139
+
140
+ /** Run a command, returning { code, stdout, stderr }. Injected in tests. */
141
+ function runCommand(cmd, args) {
142
+ const r = spawnSync(cmd, args, { encoding: 'utf8' });
143
+ return { code: r.status ?? 1, stdout: r.stdout || '', stderr: r.stderr || '' };
144
+ }
145
+
146
+ /** `gui/<uid>` — the launchd domain a per-user agent lives in. */
147
+ const guiDomain = (uid = process.getuid?.() ?? 0) => `gui/${uid}`;
148
+
149
+ export async function installService({
150
+ kind = serviceKind(), home = homedir(), platform = process.platform,
151
+ exec = resolveExec(), run = runCommand, configPath = null, log = console.log,
152
+ xdgConfig = process.env.XDG_CONFIG_HOME,
153
+ } = {}) {
154
+ if (!kind) return { ok: false, error: `No service integration for ${platform}` };
155
+ const logFile = logPath(home, platform);
156
+ const path = servicePath(exec);
157
+
158
+ if (kind === 'launchd') {
159
+ const plist = launchAgentPath(home);
160
+ await mkdir(dirname(plist), { recursive: true });
161
+ await mkdir(dirname(logFile), { recursive: true });
162
+ await writeFile(plist, renderLaunchAgent({ ...exec, log: logFile, path, configPath }), { mode: 0o644 });
163
+ // Replace any previous registration first: bootstrap fails outright when the
164
+ // label is already loaded, and an install that reports success while the old
165
+ // definition keeps running is worse than a loud failure.
166
+ run('launchctl', ['bootout', `${guiDomain()}/${LABEL}`]);
167
+ const boot = run('launchctl', ['bootstrap', guiDomain(), plist]);
168
+ if (boot.code !== 0) return { ok: false, error: boot.stderr.trim() || `launchctl bootstrap exited ${boot.code}`, file: plist };
169
+ log(`[TeamClaude] Service installed: ${plist}`);
170
+ log(`[TeamClaude] Logs: ${logFile}`);
171
+ return { ok: true, file: plist, logFile };
172
+ }
173
+
174
+ const unit = systemdUnitPath(home, xdgConfig);
175
+ await mkdir(dirname(unit), { recursive: true });
176
+ await writeFile(unit, renderSystemdUnit({ ...exec, path, configPath }), { mode: 0o644 });
177
+ run('systemctl', ['--user', 'daemon-reload']);
178
+ const enable = run('systemctl', ['--user', 'enable', '--now', UNIT_NAME]);
179
+ if (enable.code !== 0) return { ok: false, error: enable.stderr.trim() || `systemctl exited ${enable.code}`, file: unit };
180
+ log(`[TeamClaude] Service installed: ${unit}`);
181
+ log('[TeamClaude] Logs: journalctl --user --unit teamclaude.service --follow');
182
+ // Without lingering the unit dies with the last login session, which is not
183
+ // what "install a service" is expected to mean.
184
+ log('[TeamClaude] To keep it running with no session open: loginctl enable-linger $USER');
185
+ return { ok: true, file: unit, logFile: null };
186
+ }
187
+
188
+ export async function uninstallService({
189
+ kind = serviceKind(), home = homedir(), run = runCommand, log = console.log,
190
+ xdgConfig = process.env.XDG_CONFIG_HOME,
191
+ } = {}) {
192
+ if (!kind) return { ok: false, error: 'No service integration for this platform' };
193
+ if (kind === 'launchd') {
194
+ const plist = launchAgentPath(home);
195
+ run('launchctl', ['bootout', `${guiDomain()}/${LABEL}`]);
196
+ await rm(plist, { force: true });
197
+ log(`[TeamClaude] Service removed: ${plist}`);
198
+ return { ok: true, file: plist };
199
+ }
200
+ const unit = systemdUnitPath(home, xdgConfig);
201
+ run('systemctl', ['--user', 'disable', '--now', UNIT_NAME]);
202
+ await rm(unit, { force: true });
203
+ run('systemctl', ['--user', 'daemon-reload']);
204
+ log(`[TeamClaude] Service removed: ${unit}`);
205
+ return { ok: true, file: unit };
206
+ }
207
+
208
+ export async function serviceStatus({
209
+ kind = serviceKind(), home = homedir(), run = runCommand,
210
+ xdgConfig = process.env.XDG_CONFIG_HOME,
211
+ } = {}) {
212
+ if (!kind) return { installed: false, running: false, detail: 'unsupported platform' };
213
+ if (kind === 'launchd') {
214
+ const plist = launchAgentPath(home);
215
+ const installed = existsSync(plist);
216
+ const r = run('launchctl', ['print', `${guiDomain()}/${LABEL}`]);
217
+ const pid = /\bpid = (\d+)/.exec(r.stdout)?.[1] || null;
218
+ return { installed, running: r.code === 0 && !!pid, pid, file: plist, detail: r.code === 0 ? 'loaded' : 'not loaded' };
219
+ }
220
+ const unit = systemdUnitPath(home, xdgConfig);
221
+ const r = run('systemctl', ['--user', 'is-active', UNIT_NAME]);
222
+ return { installed: existsSync(unit), running: r.stdout.trim() === 'active', file: unit, detail: r.stdout.trim() || r.stderr.trim() };
223
+ }
224
+
225
+ /** The unit file contents, for `service print` — inspect before installing. */
226
+ export function renderService({ kind = serviceKind(), home = homedir(), platform = process.platform, exec = resolveExec(), configPath = null } = {}) {
227
+ if (!kind) return null;
228
+ const path = servicePath(exec);
229
+ return kind === 'launchd'
230
+ ? renderLaunchAgent({ ...exec, log: logPath(home, platform), path, configPath })
231
+ : renderSystemdUnit({ ...exec, path, configPath });
232
+ }
233
+
234
+ /** Read back what is installed, for diffing against what we would write now. */
235
+ export async function readInstalled({
236
+ kind = serviceKind(), home = homedir(), xdgConfig = process.env.XDG_CONFIG_HOME,
237
+ } = {}) {
238
+ if (!kind) return null;
239
+ const file = kind === 'launchd' ? launchAgentPath(home) : systemdUnitPath(home, xdgConfig);
240
+ return readFile(file, 'utf8').catch(() => null);
241
+ }
@@ -0,0 +1,133 @@
1
+ // Tracks Claude Code sessions by their `x-claude-code-session-id` header so
2
+ // teamclaude can (a) report how many sessions are running and (b) optionally
3
+ // keep each session pinned to one account while spreading NEW sessions across
4
+ // accounts (the opt-in fix for concurrency funnelling — issue #109).
5
+ //
6
+ // Two windows:
7
+ // - KNOWN: a session is remembered until it goes idle for this long, then
8
+ // forgotten. 1h matches the maximum prompt-cache extension window — past
9
+ // that there is no cache left to preserve, so the pin has no value.
10
+ // - ACTIVE: a session counts as "active" (and toward per-account load) if it
11
+ // made a request this recently. Short, so load-balancing reacts to what is
12
+ // actually running now rather than to sessions merely lingering in the hour.
13
+ export const SESSION_KNOWN_TTL_MS = 60 * 60 * 1000; // 1h idle → forgotten
14
+ export const SESSION_ACTIVE_TTL_MS = 2 * 60 * 1000; // 2min idle → no longer "active"
15
+
16
+ const SWEEP_INTERVAL_MS = 60 * 1000; // bound growth without an external timer
17
+
18
+ export class SessionTracker {
19
+ constructor({ knownTtlMs, activeTtlMs, now } = {}) {
20
+ // id -> { accountIndex, firstSeen, lastSeen, count, inFlight }
21
+ this.sessions = new Map();
22
+ this.knownTtlMs = knownTtlMs ?? SESSION_KNOWN_TTL_MS;
23
+ this.activeTtlMs = activeTtlMs ?? SESSION_ACTIVE_TTL_MS;
24
+ this._now = now || (() => Date.now());
25
+ this._lastSweep = 0;
26
+ }
27
+
28
+ // Record that `sessionId` made a request served by `accountIndex`. Refreshes
29
+ // lastSeen (keeping the session "active"/"known") and, when an account is
30
+ // given, (re)pins the session to it. Throttled sweep keeps the map bounded
31
+ // even in a headless server that never renders status.
32
+ touch(sessionId, accountIndex = null, now = this._now()) {
33
+ if (!sessionId) return null;
34
+ const s = this._ensure(sessionId, now);
35
+ s.lastSeen = now;
36
+ s.count += 1;
37
+ if (accountIndex != null) s.accountIndex = accountIndex;
38
+ if (now - this._lastSweep > SWEEP_INTERVAL_MS) this.sweep(now);
39
+ return s;
40
+ }
41
+
42
+ // Mark a request for this session as started. A session with any request in
43
+ // flight counts as active (and non-expirable) for the whole request, however
44
+ // long it streams — a 5-minute completion must not drop out of "active" or the
45
+ // load balancer would under-count that account. Paired with endRequest.
46
+ beginRequest(sessionId, now = this._now()) {
47
+ if (!sessionId) return null;
48
+ const s = this._ensure(sessionId, now);
49
+ s.inFlight += 1;
50
+ s.lastSeen = now;
51
+ return s;
52
+ }
53
+
54
+ // Mark a request as finished (refreshes recency; releases the in-flight hold).
55
+ endRequest(sessionId, now = this._now()) {
56
+ const s = sessionId && this.sessions.get(sessionId);
57
+ if (!s) return;
58
+ s.inFlight = Math.max(0, s.inFlight - 1);
59
+ s.lastSeen = now;
60
+ }
61
+
62
+ _ensure(sessionId, now) {
63
+ let s = this.sessions.get(sessionId);
64
+ if (!s) {
65
+ s = { accountIndex: null, firstSeen: now, lastSeen: now, count: 0, inFlight: 0 };
66
+ this.sessions.set(sessionId, s);
67
+ }
68
+ return s;
69
+ }
70
+
71
+ // Active = a request in flight now, or one seen within the active window.
72
+ _isActive(s, now) {
73
+ return s.inFlight > 0 || now - s.lastSeen <= this.activeTtlMs;
74
+ }
75
+
76
+ // Expired = idle past the known window AND nothing in flight (a long-running
77
+ // request keeps the session alive no matter how old lastSeen is).
78
+ _isExpired(s, now) {
79
+ return s.inFlight === 0 && now - s.lastSeen > this.knownTtlMs;
80
+ }
81
+
82
+ // The account a known (non-expired) session is pinned to, or null if the
83
+ // session is unknown/forgotten. Expired-on-read entries are dropped.
84
+ pinnedAccount(sessionId, now = this._now()) {
85
+ const s = sessionId && this.sessions.get(sessionId);
86
+ if (!s) return null;
87
+ if (this._isExpired(s, now)) {
88
+ this.sessions.delete(sessionId);
89
+ return null;
90
+ }
91
+ return s.accountIndex ?? null;
92
+ }
93
+
94
+ // Active sessions currently pinned to `accountIndex` — the load metric used to
95
+ // spread new sessions across accounts. Counts in-flight sessions regardless of
96
+ // how long their request has been streaming.
97
+ activeCountFor(accountIndex, now = this._now()) {
98
+ let n = 0;
99
+ for (const s of this.sessions.values()) {
100
+ if (s.accountIndex === accountIndex && this._isActive(s, now)) n += 1;
101
+ }
102
+ return n;
103
+ }
104
+
105
+ // Drop sessions idle longer than the known window (but never one still in flight).
106
+ sweep(now = this._now()) {
107
+ this._lastSweep = now;
108
+ for (const [id, s] of this.sessions) {
109
+ if (this._isExpired(s, now)) this.sessions.delete(id);
110
+ }
111
+ }
112
+
113
+ // { known, active, perAccount: { [index]: activeCount } } — for status/TUI.
114
+ // Sweeps as it goes so a long-lived headless server stays bounded.
115
+ stats(now = this._now()) {
116
+ this._lastSweep = now;
117
+ let known = 0;
118
+ let active = 0;
119
+ const perAccount = {};
120
+ for (const [id, s] of this.sessions) {
121
+ if (this._isExpired(s, now)) {
122
+ this.sessions.delete(id);
123
+ continue;
124
+ }
125
+ known += 1;
126
+ if (this._isActive(s, now)) {
127
+ active += 1;
128
+ if (s.accountIndex != null) perAccount[s.accountIndex] = (perAccount[s.accountIndex] || 0) + 1;
129
+ }
130
+ }
131
+ return { known, active, perAccount };
132
+ }
133
+ }