@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/LICENSE +21 -0
- package/README.md +122 -0
- package/package.json +43 -0
- package/src/account-manager.js +1459 -0
- package/src/account-uuid-rewrite.js +115 -0
- package/src/alias.js +125 -0
- package/src/claude-env.js +65 -0
- package/src/config.js +146 -0
- package/src/crash-log.js +27 -0
- package/src/egress-guard.js +132 -0
- package/src/identity.js +96 -0
- package/src/index.js +1873 -0
- package/src/json-format-stream.js +63 -0
- package/src/mitm.js +336 -0
- package/src/model.js +276 -0
- package/src/oauth.js +459 -0
- package/src/prober.js +158 -0
- package/src/request-log.js +32 -0
- package/src/resolve-accounts.js +43 -0
- package/src/server.js +1319 -0
- package/src/service.js +241 -0
- package/src/session-tracker.js +133 -0
- package/src/status-renderer.js +316 -0
- package/src/sx.js +218 -0
- package/src/terminal-title.js +31 -0
- package/src/tool-pair-sanitize.js +193 -0
- package/src/tui-remote.js +274 -0
- package/src/tui.js +1634 -0
- package/src/updater.js +177 -0
- package/src/upstream-fetch.js +267 -0
- package/src/upstream-proxy.js +214 -0
- package/src/warmer.js +237 -0
- package/src/x509.js +166 -0
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, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
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
|
+
}
|