@ours.network/fleet 1.2.0-nightly.21 → 1.2.0-nightly.23
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/application/conversation-history.d.ts +19 -0
- package/dist/application/conversation-history.js +125 -1
- package/dist/application/session-control.d.ts +10 -0
- package/dist/application/session-control.js +35 -2
- package/dist/build-info.json +4 -4
- package/dist/isolation/policy.js +2 -0
- package/dist/isolation/types.d.ts +5 -0
- package/dist/runner.js +8 -1
- package/dist/session/acp.js +7 -0
- package/dist/session/control.js +1 -0
- package/dist/subscriptions/cli.d.ts +78 -0
- package/dist/subscriptions/cli.js +186 -0
- package/dist/subscriptions/launch.d.ts +37 -0
- package/dist/subscriptions/launch.js +93 -0
- package/dist/subscriptions/login.d.ts +75 -0
- package/dist/subscriptions/login.js +182 -0
- package/dist/subscriptions/observed.d.ts +7 -0
- package/dist/subscriptions/observed.js +44 -0
- package/dist/subscriptions/probe.d.ts +10 -0
- package/dist/subscriptions/probe.js +73 -0
- package/dist/subscriptions/service.d.ts +102 -0
- package/dist/subscriptions/service.js +364 -0
- package/dist/subscriptions/store.d.ts +71 -0
- package/dist/subscriptions/store.js +140 -0
- package/dist/web/runtime.js +7 -0
- package/dist/web/server.d.ts +2 -0
- package/dist/web/server.js +95 -5
- package/package.json +1 -1
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { FleetError } from '../application/errors.js';
|
|
4
|
+
import { DEFAULT_PROFILE_ID, profileHome, readSubscriptionState, writePin, } from './store.js';
|
|
5
|
+
/** The subscription provider a harness authenticates with, if Fleet manages it. */
|
|
6
|
+
export function providerForHarness(harness) {
|
|
7
|
+
if (harness === 'claude-code')
|
|
8
|
+
return 'claude';
|
|
9
|
+
if (harness === 'codex')
|
|
10
|
+
return 'codex';
|
|
11
|
+
return undefined;
|
|
12
|
+
}
|
|
13
|
+
/** Env var that selects the CLI home for each provider. */
|
|
14
|
+
export const HOME_ENV = {
|
|
15
|
+
claude: 'CLAUDE_CONFIG_DIR',
|
|
16
|
+
codex: 'CODEX_HOME',
|
|
17
|
+
};
|
|
18
|
+
/**
|
|
19
|
+
* Credential sources that would silently outrank the fleet-wide active profile
|
|
20
|
+
* (see Claude Code's documented authentication precedence and Codex's
|
|
21
|
+
* API-key login). While a non-default profile is active they are refused.
|
|
22
|
+
*/
|
|
23
|
+
export const CONFLICTING_AUTH_ENV = {
|
|
24
|
+
claude: [
|
|
25
|
+
'CLAUDE_CONFIG_DIR', 'ANTHROPIC_API_KEY', 'ANTHROPIC_AUTH_TOKEN', 'CLAUDE_CODE_OAUTH_TOKEN',
|
|
26
|
+
'CLAUDE_CODE_OAUTH_REFRESH_TOKEN', 'CLAUDE_CODE_OAUTH_TOKEN_FILE_DESCRIPTOR',
|
|
27
|
+
'CLAUDE_CODE_USE_BEDROCK', 'CLAUDE_CODE_USE_VERTEX', 'CLAUDE_CODE_USE_FOUNDRY', 'ANTHROPIC_PROFILE',
|
|
28
|
+
'ANTHROPIC_FEDERATION_RULE_ID', 'ANTHROPIC_ORGANIZATION_ID', 'ANTHROPIC_IDENTITY_TOKEN_FILE',
|
|
29
|
+
],
|
|
30
|
+
codex: ['CODEX_HOME', 'OPENAI_API_KEY', 'CODEX_API_KEY'],
|
|
31
|
+
};
|
|
32
|
+
/** Claude managed-settings file (Linux), which outranks user settings. */
|
|
33
|
+
export const CLAUDE_MANAGED_SETTINGS = '/etc/claude-code/managed-settings.json';
|
|
34
|
+
/**
|
|
35
|
+
* Competing Claude credential sources declared in settings files: an `env`
|
|
36
|
+
* block entry from CONFLICTING_AUTH_ENV, or an `apiKeyHelper`. Settings are
|
|
37
|
+
* shared into profiles, so they can silently outrank a profile's login.
|
|
38
|
+
*/
|
|
39
|
+
export function claudeSettingsAuthSources(home, managed = CLAUDE_MANAGED_SETTINGS) {
|
|
40
|
+
const found = [];
|
|
41
|
+
for (const file of [join(home, 'settings.json'), managed]) {
|
|
42
|
+
let settings;
|
|
43
|
+
try {
|
|
44
|
+
settings = JSON.parse(readFileSync(file, 'utf8'));
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
for (const key of CONFLICTING_AUTH_ENV.claude)
|
|
50
|
+
if (settings?.env && typeof settings.env === 'object' && key in settings.env)
|
|
51
|
+
found.push(`${key} (${file})`);
|
|
52
|
+
if (settings?.apiKeyHelper)
|
|
53
|
+
found.push(`apiKeyHelper (${file})`);
|
|
54
|
+
}
|
|
55
|
+
return found;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Resolve the provider's active profile for a launch and record it in the
|
|
59
|
+
* agent's state dir. Resolution happens once, here: a later switch never
|
|
60
|
+
* changes what this session uses.
|
|
61
|
+
*/
|
|
62
|
+
export function pinSubscriptionForLaunch(role, stateDir, inherited = process.env, now = () => new Date()) {
|
|
63
|
+
const provider = providerForHarness(role.harness);
|
|
64
|
+
// A loopback auth proxy supplies credentials itself; it is not managed here.
|
|
65
|
+
if (!provider || role.auth_proxy)
|
|
66
|
+
return undefined;
|
|
67
|
+
const profileId = readSubscriptionState().providers[provider].activeProfileId;
|
|
68
|
+
const pin = { provider, profileId, pinnedAt: now().toISOString() };
|
|
69
|
+
if (profileId === DEFAULT_PROFILE_ID) {
|
|
70
|
+
// The adopted default launches exactly as before; credential env that points
|
|
71
|
+
// elsewhere is recorded so the panel can say this session is not managed.
|
|
72
|
+
const unmanaged = CONFLICTING_AUTH_ENV[provider].filter(key => role.env?.[key] !== undefined || inherited[key]);
|
|
73
|
+
if (unmanaged.length)
|
|
74
|
+
pin.unmanaged = unmanaged;
|
|
75
|
+
writePin(stateDir, pin);
|
|
76
|
+
return { pin, env: {} };
|
|
77
|
+
}
|
|
78
|
+
const conflicts = CONFLICTING_AUTH_ENV[provider].filter(key => role.env?.[key] !== undefined || (key !== HOME_ENV[provider] && inherited[key]));
|
|
79
|
+
if (conflicts.length) {
|
|
80
|
+
throw new FleetError('invalid_request', `role '${role.name}': ${conflicts.join(', ')} would bypass the active ${provider} subscription profile; ` +
|
|
81
|
+
'remove it from the role env / supervisor environment or switch back to the default profile');
|
|
82
|
+
}
|
|
83
|
+
const home = profileHome(provider, profileId);
|
|
84
|
+
if (provider === 'claude') {
|
|
85
|
+
const settings = claudeSettingsAuthSources(home);
|
|
86
|
+
if (settings.length) {
|
|
87
|
+
throw new FleetError('invalid_request', `role '${role.name}': Claude settings declare ${settings.join(', ')}, which would bypass the active subscription profile`);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
pin.home = home;
|
|
91
|
+
writePin(stateDir, pin);
|
|
92
|
+
return { pin, env: { [HOME_ENV[provider]]: home }, writablePath: home };
|
|
93
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { type SubscriptionBinaries } from './cli.js';
|
|
2
|
+
import type { SubscriptionProvider } from './store.js';
|
|
3
|
+
/**
|
|
4
|
+
* A short-lived, browser-owned login. The URL comes from the official CLI and
|
|
5
|
+
* is shown only if its origin is on the provider's allowlist; the user's code
|
|
6
|
+
* goes to the CLI's stdin exactly once and is never stored.
|
|
7
|
+
*/
|
|
8
|
+
export type LoginState = 'starting' | 'awaiting_user' | 'verifying' | 'succeeded' | 'failed' | 'cancelled' | 'expired';
|
|
9
|
+
export interface LoginView {
|
|
10
|
+
loginId: string;
|
|
11
|
+
provider: SubscriptionProvider;
|
|
12
|
+
kind: 'paste_code' | 'device_code';
|
|
13
|
+
state: LoginState;
|
|
14
|
+
url?: string;
|
|
15
|
+
/** Device code to type at `url`; only exposed while the user still needs it. */
|
|
16
|
+
userCode?: string;
|
|
17
|
+
expiresAt: string;
|
|
18
|
+
error?: string;
|
|
19
|
+
}
|
|
20
|
+
export declare const LOGIN_TTL_MS: number;
|
|
21
|
+
export declare const LOGIN_URL_ALLOWLIST: Record<SubscriptionProvider, string[]>;
|
|
22
|
+
export declare function allowedLoginUrl(provider: SubscriptionProvider, raw: string): string | undefined;
|
|
23
|
+
export interface LoginHandle {
|
|
24
|
+
view(): LoginView;
|
|
25
|
+
/** Browser session that started the login; only it may act on it. */
|
|
26
|
+
readonly owner: string;
|
|
27
|
+
submitCode(code: string): void;
|
|
28
|
+
cancel(): void;
|
|
29
|
+
/** Resolves when the CLI has finished (success or not). */
|
|
30
|
+
readonly done: Promise<boolean>;
|
|
31
|
+
}
|
|
32
|
+
declare abstract class BaseLogin implements LoginHandle {
|
|
33
|
+
readonly provider: SubscriptionProvider;
|
|
34
|
+
readonly owner: string;
|
|
35
|
+
readonly kind: LoginView['kind'];
|
|
36
|
+
readonly loginId: string;
|
|
37
|
+
readonly expiresAt: number;
|
|
38
|
+
state: LoginState;
|
|
39
|
+
url?: string;
|
|
40
|
+
userCode?: string;
|
|
41
|
+
error?: string;
|
|
42
|
+
done: Promise<boolean>;
|
|
43
|
+
protected finish: (ok: boolean) => void;
|
|
44
|
+
private readonly timer;
|
|
45
|
+
constructor(provider: SubscriptionProvider, owner: string, kind: LoginView['kind'], now?: number);
|
|
46
|
+
view(): LoginView;
|
|
47
|
+
abstract submitCode(code: string): void;
|
|
48
|
+
protected abstract stop(): void;
|
|
49
|
+
cancel(): void;
|
|
50
|
+
protected end(state: LoginState, error?: string): void;
|
|
51
|
+
/** Called by the service after verifying the new home. */
|
|
52
|
+
complete(ok: boolean, error?: string): void;
|
|
53
|
+
}
|
|
54
|
+
/** `claude auth login` inside script(1): prints an authorize URL, then reads a paste code. */
|
|
55
|
+
export declare class ClaudeLogin extends BaseLogin {
|
|
56
|
+
private child?;
|
|
57
|
+
private output;
|
|
58
|
+
private submitted;
|
|
59
|
+
/** Resolves with the CLI exit status once it exits. */
|
|
60
|
+
exited: Promise<number | null>;
|
|
61
|
+
constructor(owner: string, bins: SubscriptionBinaries, home: string, profileId: string);
|
|
62
|
+
submitCode(code: string): void;
|
|
63
|
+
protected stop(): void;
|
|
64
|
+
}
|
|
65
|
+
/** Codex device-code login through the app-server's supported account API. */
|
|
66
|
+
export declare class CodexDeviceLogin extends BaseLogin {
|
|
67
|
+
private server?;
|
|
68
|
+
private codexLoginId?;
|
|
69
|
+
/** Resolves true when the app-server reports a successful login. */
|
|
70
|
+
completed: Promise<boolean>;
|
|
71
|
+
constructor(owner: string, bins: SubscriptionBinaries, home: string, profileId: string);
|
|
72
|
+
submitCode(): void;
|
|
73
|
+
protected stop(): void;
|
|
74
|
+
}
|
|
75
|
+
export {};
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { randomBytes } from 'node:crypto';
|
|
3
|
+
import { FleetError } from '../application/errors.js';
|
|
4
|
+
import { CodexAppServer, profileEnv } from './cli.js';
|
|
5
|
+
export const LOGIN_TTL_MS = 10 * 60_000;
|
|
6
|
+
export const LOGIN_URL_ALLOWLIST = {
|
|
7
|
+
claude: ['claude.com', 'claude.ai', 'platform.claude.com', 'console.anthropic.com'],
|
|
8
|
+
codex: ['auth.openai.com'],
|
|
9
|
+
};
|
|
10
|
+
export function allowedLoginUrl(provider, raw) {
|
|
11
|
+
let url;
|
|
12
|
+
try {
|
|
13
|
+
url = new URL(raw);
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
return undefined;
|
|
17
|
+
}
|
|
18
|
+
if (url.protocol !== 'https:' || url.username || url.password || url.port)
|
|
19
|
+
return undefined;
|
|
20
|
+
return LOGIN_URL_ALLOWLIST[provider].includes(url.hostname) ? url.toString() : undefined;
|
|
21
|
+
}
|
|
22
|
+
/** Claude paste codes are opaque `code#state` strings; refuse anything else. */
|
|
23
|
+
const PASTE_CODE_RE = /^[A-Za-z0-9._~#-]{8,512}$/;
|
|
24
|
+
const URL_RE = /https:\/\/[^\s\x07\x1b"'<>\\]+/;
|
|
25
|
+
class BaseLogin {
|
|
26
|
+
provider;
|
|
27
|
+
owner;
|
|
28
|
+
kind;
|
|
29
|
+
loginId = randomBytes(16).toString('hex');
|
|
30
|
+
expiresAt;
|
|
31
|
+
state = 'starting';
|
|
32
|
+
url;
|
|
33
|
+
userCode;
|
|
34
|
+
error;
|
|
35
|
+
done;
|
|
36
|
+
finish;
|
|
37
|
+
timer;
|
|
38
|
+
constructor(provider, owner, kind, now = Date.now()) {
|
|
39
|
+
this.provider = provider;
|
|
40
|
+
this.owner = owner;
|
|
41
|
+
this.kind = kind;
|
|
42
|
+
this.expiresAt = now + LOGIN_TTL_MS;
|
|
43
|
+
this.done = new Promise(resolve => { this.finish = resolve; });
|
|
44
|
+
this.timer = setTimeout(() => this.end('expired', 'login timed out'), LOGIN_TTL_MS);
|
|
45
|
+
this.timer.unref?.();
|
|
46
|
+
}
|
|
47
|
+
view() {
|
|
48
|
+
const live = this.state === 'starting' || this.state === 'awaiting_user';
|
|
49
|
+
return {
|
|
50
|
+
loginId: this.loginId, provider: this.provider, kind: this.kind, state: this.state,
|
|
51
|
+
url: live ? this.url : undefined,
|
|
52
|
+
userCode: this.state === 'awaiting_user' ? this.userCode : undefined,
|
|
53
|
+
expiresAt: new Date(this.expiresAt).toISOString(),
|
|
54
|
+
error: this.error,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
cancel() { this.end('cancelled'); }
|
|
58
|
+
end(state, error) {
|
|
59
|
+
if (['succeeded', 'failed', 'cancelled', 'expired'].includes(this.state))
|
|
60
|
+
return;
|
|
61
|
+
this.state = state;
|
|
62
|
+
this.error = error?.slice(0, 200);
|
|
63
|
+
this.userCode = undefined;
|
|
64
|
+
clearTimeout(this.timer);
|
|
65
|
+
this.stop();
|
|
66
|
+
this.finish(state === 'succeeded');
|
|
67
|
+
}
|
|
68
|
+
/** Called by the service after verifying the new home. */
|
|
69
|
+
complete(ok, error) { this.end(ok ? 'succeeded' : 'failed', error); }
|
|
70
|
+
}
|
|
71
|
+
/** `claude auth login` inside script(1): prints an authorize URL, then reads a paste code. */
|
|
72
|
+
export class ClaudeLogin extends BaseLogin {
|
|
73
|
+
child;
|
|
74
|
+
output = '';
|
|
75
|
+
submitted = false;
|
|
76
|
+
/** Resolves with the CLI exit status once it exits. */
|
|
77
|
+
exited;
|
|
78
|
+
constructor(owner, bins, home, profileId) {
|
|
79
|
+
super('claude', owner, 'paste_code');
|
|
80
|
+
const child = spawn(bins.script, ['-qefc', `${shellQuote(bins.claude)} auth login`, '/dev/null'], {
|
|
81
|
+
env: profileEnv('CLAUDE_CONFIG_DIR', profileId, home), stdio: ['pipe', 'pipe', 'pipe'],
|
|
82
|
+
});
|
|
83
|
+
this.child = child;
|
|
84
|
+
child.stdin.on('error', () => { });
|
|
85
|
+
const onData = (chunk) => {
|
|
86
|
+
if (this.output.length < 64 * 1024)
|
|
87
|
+
this.output += chunk.toString('utf8');
|
|
88
|
+
if (!this.url) {
|
|
89
|
+
const match = URL_RE.exec(this.output);
|
|
90
|
+
if (match) {
|
|
91
|
+
const url = allowedLoginUrl('claude', match[0]);
|
|
92
|
+
if (!url) {
|
|
93
|
+
this.end('failed', 'the CLI returned an unexpected sign-in address');
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
this.url = url;
|
|
97
|
+
this.state = 'awaiting_user';
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
// Claude asks for Enter after success; the answer carries no secret.
|
|
101
|
+
if (this.submitted && /press enter/i.test(this.output))
|
|
102
|
+
child.stdin.write('\r');
|
|
103
|
+
};
|
|
104
|
+
child.stdout.on('data', onData);
|
|
105
|
+
child.stderr.on('data', onData);
|
|
106
|
+
this.exited = new Promise(resolve => {
|
|
107
|
+
child.once('error', () => { this.end('failed', 'could not start the sign-in'); resolve(null); });
|
|
108
|
+
child.once('close', code => resolve(code));
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
submitCode(code) {
|
|
112
|
+
if (this.state !== 'awaiting_user' || this.submitted)
|
|
113
|
+
throw new FleetError('conflict', 'this login is not waiting for a code');
|
|
114
|
+
if (!PASTE_CODE_RE.test(code))
|
|
115
|
+
throw new FleetError('invalid_request', 'that does not look like a sign-in code');
|
|
116
|
+
this.submitted = true;
|
|
117
|
+
this.state = 'verifying';
|
|
118
|
+
this.output = '';
|
|
119
|
+
this.child?.stdin.write(`${code}\r`);
|
|
120
|
+
}
|
|
121
|
+
stop() { this.child?.kill('SIGTERM'); }
|
|
122
|
+
}
|
|
123
|
+
/** Codex device-code login through the app-server's supported account API. */
|
|
124
|
+
export class CodexDeviceLogin extends BaseLogin {
|
|
125
|
+
server;
|
|
126
|
+
codexLoginId;
|
|
127
|
+
/** Resolves true when the app-server reports a successful login. */
|
|
128
|
+
completed;
|
|
129
|
+
constructor(owner, bins, home, profileId) {
|
|
130
|
+
super('codex', owner, 'device_code');
|
|
131
|
+
this.completed = (async () => {
|
|
132
|
+
try {
|
|
133
|
+
this.server = await CodexAppServer.start(bins.codex, profileEnv('CODEX_HOME', profileId, home));
|
|
134
|
+
if (this.state !== 'starting') {
|
|
135
|
+
this.server.close();
|
|
136
|
+
return false;
|
|
137
|
+
}
|
|
138
|
+
const completion = new Promise(resolve => {
|
|
139
|
+
this.server.onNotification((method, params) => {
|
|
140
|
+
const p = params;
|
|
141
|
+
if (method === 'account/login/completed' && (!p.loginId || p.loginId === this.codexLoginId)) {
|
|
142
|
+
if (!p.success)
|
|
143
|
+
this.error = 'sign-in was not completed';
|
|
144
|
+
resolve(Boolean(p.success));
|
|
145
|
+
}
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
const started = await this.server.call('account/login/start', { type: 'chatgptDeviceCode' });
|
|
149
|
+
if (this.state !== 'starting') {
|
|
150
|
+
this.server.close();
|
|
151
|
+
return false;
|
|
152
|
+
}
|
|
153
|
+
const url = started.verificationUrl && allowedLoginUrl('codex', started.verificationUrl);
|
|
154
|
+
if (!url || !started.userCode) {
|
|
155
|
+
this.end('failed', 'the CLI returned an unexpected sign-in address');
|
|
156
|
+
return false;
|
|
157
|
+
}
|
|
158
|
+
this.codexLoginId = started.loginId;
|
|
159
|
+
this.url = url;
|
|
160
|
+
this.userCode = String(started.userCode).slice(0, 32);
|
|
161
|
+
this.state = 'awaiting_user';
|
|
162
|
+
const ok = await Promise.race([completion, this.done.then(() => false)]);
|
|
163
|
+
if (this.state === 'awaiting_user')
|
|
164
|
+
this.state = 'verifying';
|
|
165
|
+
return ok;
|
|
166
|
+
}
|
|
167
|
+
catch {
|
|
168
|
+
this.end('failed', 'could not start the sign-in');
|
|
169
|
+
return false;
|
|
170
|
+
}
|
|
171
|
+
})();
|
|
172
|
+
}
|
|
173
|
+
submitCode() { throw new FleetError('invalid_request', 'device-code logins need no code from Fleet'); }
|
|
174
|
+
stop() {
|
|
175
|
+
if (this.server && this.codexLoginId && this.state === 'cancelled')
|
|
176
|
+
void this.server.call('account/login/cancel', { loginId: this.codexLoginId }, 3_000).catch(() => { });
|
|
177
|
+
setTimeout(() => this.server?.close(), 500).unref?.();
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
function shellQuote(value) {
|
|
181
|
+
return `'${value.replace(/'/g, `'\\''`)}'`;
|
|
182
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Persist the Claude subscription rate-limit snapshot an agent session just
|
|
3
|
+
* received (SDK `rate_limit_event`, forwarded by claude-agent-acp as
|
|
4
|
+
* `usage_update._meta["_claude/rateLimit"]`). Only numbers and window names
|
|
5
|
+
* are kept. The web backend attributes it to the agent's pinned profile.
|
|
6
|
+
*/
|
|
7
|
+
export declare function recordClaudeRateLimit(stateDir: string, info: unknown, now?: Date): boolean;
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { join } from 'node:path';
|
|
2
|
+
import { replaceFileAtomically } from '../atomic-file.js';
|
|
3
|
+
import { OBSERVED_USAGE_FILE } from './store.js';
|
|
4
|
+
const WINDOW_LABELS = {
|
|
5
|
+
five_hour: { label: '5h', mins: 300 },
|
|
6
|
+
seven_day: { label: 'weekly', mins: 10_080 },
|
|
7
|
+
seven_day_opus: { label: 'weekly · Opus', mins: 10_080 },
|
|
8
|
+
seven_day_sonnet: { label: 'weekly · Sonnet', mins: 10_080 },
|
|
9
|
+
};
|
|
10
|
+
/**
|
|
11
|
+
* Persist the Claude subscription rate-limit snapshot an agent session just
|
|
12
|
+
* received (SDK `rate_limit_event`, forwarded by claude-agent-acp as
|
|
13
|
+
* `usage_update._meta["_claude/rateLimit"]`). Only numbers and window names
|
|
14
|
+
* are kept. The web backend attributes it to the agent's pinned profile.
|
|
15
|
+
*/
|
|
16
|
+
export function recordClaudeRateLimit(stateDir, info, now = new Date()) {
|
|
17
|
+
if (!info || typeof info !== 'object')
|
|
18
|
+
return false;
|
|
19
|
+
const raw = info;
|
|
20
|
+
const entries = raw.unifiedWindows && typeof raw.unifiedWindows === 'object'
|
|
21
|
+
? Object.entries(raw.unifiedWindows)
|
|
22
|
+
: typeof raw.rateLimitType === 'string' ? [[raw.rateLimitType, raw]] : [];
|
|
23
|
+
const windows = [];
|
|
24
|
+
for (const [id, w] of entries) {
|
|
25
|
+
if (!/^[a-z0-9_]{1,40}$/.test(id) || !w || typeof w.utilization !== 'number' || !Number.isFinite(w.utilization))
|
|
26
|
+
continue;
|
|
27
|
+
const known = WINDOW_LABELS[id];
|
|
28
|
+
windows.push({
|
|
29
|
+
id, label: known?.label ?? id.replace(/_/g, ' '),
|
|
30
|
+
usedPercent: Math.round(Math.max(0, w.utilization) * 1000) / 10,
|
|
31
|
+
windowMins: known?.mins,
|
|
32
|
+
resetsAt: typeof w.resetsAt === 'number' ? new Date(w.resetsAt * 1000).toISOString() : undefined,
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
if (!windows.length)
|
|
36
|
+
return false;
|
|
37
|
+
try {
|
|
38
|
+
replaceFileAtomically(join(stateDir, OBSERVED_USAGE_FILE), `${JSON.stringify({ windows, observedAt: now.toISOString(), source: 'agent' })}\n`, 0o600);
|
|
39
|
+
return true;
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
return false;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export interface ClaudeProbeResult {
|
|
2
|
+
rateLimit?: unknown;
|
|
3
|
+
exhausted: boolean;
|
|
4
|
+
}
|
|
5
|
+
/**
|
|
6
|
+
* Ask Haiku for one short reply in the selected Claude home. The JSONL stream
|
|
7
|
+
* can contain a rate_limit_event; prompts, replies and CLI stderr are discarded.
|
|
8
|
+
* This runs only after an explicit UI action and has a time and spend bound.
|
|
9
|
+
*/
|
|
10
|
+
export declare function probeClaudeRateLimit(bin: string, profileId: string, timeoutMs?: number): Promise<ClaudeProbeResult | undefined>;
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { mkdirSync } from 'node:fs';
|
|
3
|
+
import { profileEnv } from './cli.js';
|
|
4
|
+
import { profileHome, subscriptionsRoot } from './store.js';
|
|
5
|
+
const LIMIT_ERROR = /\b(?:hit (?:your |the )?limit|reached (?:your |the )?(?:usage )?limit|usage limits? (?:reached|exceeded)|rate limits? (?:reached|exceeded)|rate_limit_error|quota exceeded|out of (?:usage|credits)|too many requests|429)\b/i;
|
|
6
|
+
/**
|
|
7
|
+
* Ask Haiku for one short reply in the selected Claude home. The JSONL stream
|
|
8
|
+
* can contain a rate_limit_event; prompts, replies and CLI stderr are discarded.
|
|
9
|
+
* This runs only after an explicit UI action and has a time and spend bound.
|
|
10
|
+
*/
|
|
11
|
+
export function probeClaudeRateLimit(bin, profileId, timeoutMs = 75_000) {
|
|
12
|
+
const cwd = subscriptionsRoot();
|
|
13
|
+
mkdirSync(cwd, { recursive: true, mode: 0o700 });
|
|
14
|
+
return new Promise(resolve => {
|
|
15
|
+
const child = spawn(bin, [
|
|
16
|
+
'-p', 'Reply OK.', '--model', 'haiku', '--output-format', 'stream-json', '--verbose',
|
|
17
|
+
'--no-session-persistence', '--strict-mcp-config', '--tools', '',
|
|
18
|
+
'--system-prompt', 'Reply OK.', '--max-budget-usd', '0.05',
|
|
19
|
+
], {
|
|
20
|
+
cwd, env: profileEnv('CLAUDE_CONFIG_DIR', profileId, profileHome('claude', profileId)),
|
|
21
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
22
|
+
});
|
|
23
|
+
let pending = '';
|
|
24
|
+
let rateLimit;
|
|
25
|
+
let exhausted = false;
|
|
26
|
+
let done = false;
|
|
27
|
+
const finish = (value) => {
|
|
28
|
+
if (done)
|
|
29
|
+
return;
|
|
30
|
+
done = true;
|
|
31
|
+
clearTimeout(timer);
|
|
32
|
+
resolve(value);
|
|
33
|
+
};
|
|
34
|
+
const timer = setTimeout(() => { child.kill('SIGKILL'); finish(); }, timeoutMs);
|
|
35
|
+
child.stdout.on('data', chunk => {
|
|
36
|
+
pending += String(chunk);
|
|
37
|
+
if (pending.length > 64 * 1024 && !pending.includes('\n')) {
|
|
38
|
+
child.kill('SIGKILL');
|
|
39
|
+
finish();
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
for (let end = pending.indexOf('\n'); end >= 0; end = pending.indexOf('\n')) {
|
|
43
|
+
const line = pending.slice(0, end);
|
|
44
|
+
pending = pending.slice(end + 1);
|
|
45
|
+
if (line.length > 64 * 1024)
|
|
46
|
+
continue;
|
|
47
|
+
try {
|
|
48
|
+
const event = JSON.parse(line);
|
|
49
|
+
if (event.type === 'rate_limit_event')
|
|
50
|
+
rateLimit = event.rate_limit_info ?? event.rateLimitInfo ?? event.rate_limit ?? event.rateLimit ?? event;
|
|
51
|
+
if ((event.type === 'result' && event.is_error === true) || event.type === 'error') {
|
|
52
|
+
const error = event.error && typeof event.error === 'object' ? event.error : undefined;
|
|
53
|
+
const message = typeof event.result === 'string' ? event.result
|
|
54
|
+
: typeof event.error === 'string' ? event.error : error?.message;
|
|
55
|
+
if (typeof message === 'string' && LIMIT_ERROR.test(message.slice(0, 4000)))
|
|
56
|
+
exhausted = true;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
catch { /* Other CLI output is never retained. */ }
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
// Some CLI failures are reported only on stderr. Inspect a small rolling
|
|
63
|
+
// fragment for a limit signal; never retain or return the raw text.
|
|
64
|
+
let stderrTail = '';
|
|
65
|
+
child.stderr.on('data', chunk => {
|
|
66
|
+
stderrTail = (stderrTail + String(chunk)).slice(-4000);
|
|
67
|
+
if (LIMIT_ERROR.test(stderrTail))
|
|
68
|
+
exhausted = true;
|
|
69
|
+
});
|
|
70
|
+
child.once('error', () => finish());
|
|
71
|
+
child.once('close', () => finish(rateLimit !== undefined || exhausted ? { rateLimit, exhausted } : undefined));
|
|
72
|
+
});
|
|
73
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { type SubscriptionBinaries, type UsageWindow } from './cli.js';
|
|
2
|
+
import { type LoginHandle, type LoginView } from './login.js';
|
|
3
|
+
import { type SubscriptionProvider } from './store.js';
|
|
4
|
+
export type HealthState = 'ok' | 'expiring' | 'expired' | 'signed_out' | 'error' | 'unknown';
|
|
5
|
+
export interface ProfileHealth {
|
|
6
|
+
state: HealthState;
|
|
7
|
+
checkedAt?: string;
|
|
8
|
+
detail?: string;
|
|
9
|
+
/** When the stored login stops refreshing (Claude, observed field). */
|
|
10
|
+
loginExpiresAt?: string;
|
|
11
|
+
}
|
|
12
|
+
export interface ProfileUsage {
|
|
13
|
+
windows: UsageWindow[];
|
|
14
|
+
observedAt?: string;
|
|
15
|
+
/** `pull`: asked the provider. `agent`: last value an agent session reported. */
|
|
16
|
+
source: 'pull' | 'agent' | 'probe' | 'none';
|
|
17
|
+
/** A probe received an explicit limit error, even if no percentages were sent. */
|
|
18
|
+
exhausted?: boolean;
|
|
19
|
+
}
|
|
20
|
+
export interface ProfileView {
|
|
21
|
+
id: string;
|
|
22
|
+
label: string;
|
|
23
|
+
active: boolean;
|
|
24
|
+
account?: {
|
|
25
|
+
email?: string;
|
|
26
|
+
plan?: string;
|
|
27
|
+
org?: string;
|
|
28
|
+
};
|
|
29
|
+
health: ProfileHealth;
|
|
30
|
+
usage: ProfileUsage;
|
|
31
|
+
/** Running agents whose session is pinned to this profile. */
|
|
32
|
+
agents: string[];
|
|
33
|
+
}
|
|
34
|
+
export interface ProviderView {
|
|
35
|
+
provider: SubscriptionProvider;
|
|
36
|
+
activeProfileId: string;
|
|
37
|
+
profiles: ProfileView[];
|
|
38
|
+
/** Running agents still on a profile other than the active one: restart to switch. */
|
|
39
|
+
staleAgents: Array<{
|
|
40
|
+
roleId: string;
|
|
41
|
+
profileId: string;
|
|
42
|
+
}>;
|
|
43
|
+
/** Running agents whose credentials come from env, not a Fleet profile. */
|
|
44
|
+
unmanagedAgents: Array<{
|
|
45
|
+
roleId: string;
|
|
46
|
+
via: string[];
|
|
47
|
+
}>;
|
|
48
|
+
}
|
|
49
|
+
export interface AgentRef {
|
|
50
|
+
roleId: string;
|
|
51
|
+
stateDir?: string;
|
|
52
|
+
running: boolean;
|
|
53
|
+
}
|
|
54
|
+
export interface SubscriptionServiceOptions {
|
|
55
|
+
agents(): Promise<AgentRef[]>;
|
|
56
|
+
binaries?: SubscriptionBinaries;
|
|
57
|
+
now?: () => number;
|
|
58
|
+
onChange?: () => void;
|
|
59
|
+
}
|
|
60
|
+
interface CheckResult {
|
|
61
|
+
health: ProfileHealth;
|
|
62
|
+
account?: ProfileView['account'];
|
|
63
|
+
usage?: ProfileUsage;
|
|
64
|
+
}
|
|
65
|
+
export declare class SubscriptionService {
|
|
66
|
+
private readonly options;
|
|
67
|
+
private readonly bins;
|
|
68
|
+
private readonly now;
|
|
69
|
+
private readonly checks;
|
|
70
|
+
private readonly running;
|
|
71
|
+
private readonly logins;
|
|
72
|
+
private readonly probes;
|
|
73
|
+
constructor(options: SubscriptionServiceOptions);
|
|
74
|
+
list(): Promise<ProviderView[]>;
|
|
75
|
+
/** Health + account + (Codex) usage for one profile. Concurrent calls share one run. */
|
|
76
|
+
check(provider: SubscriptionProvider, profileId: string): Promise<CheckResult>;
|
|
77
|
+
private checkClaude;
|
|
78
|
+
private checkCodex;
|
|
79
|
+
startLogin(provider: SubscriptionProvider, owner: string): Promise<LoginView>;
|
|
80
|
+
private completeLogin;
|
|
81
|
+
/** Query the official CLI for each existing profile, including the adopted default. */
|
|
82
|
+
private accountAlreadyAdded;
|
|
83
|
+
login(loginId: string, owner: string): LoginHandle;
|
|
84
|
+
setActive(provider: SubscriptionProvider, profileId: string): Promise<ProviderView>;
|
|
85
|
+
rename(provider: SubscriptionProvider, profileId: string, label: string): Promise<void>;
|
|
86
|
+
/**
|
|
87
|
+
* Remove a profile and its login. Refused while it is active or while any
|
|
88
|
+
* running agent is still pinned to it; the adopted default is never removed.
|
|
89
|
+
*/
|
|
90
|
+
remove(provider: SubscriptionProvider, profileId: string): Promise<void>;
|
|
91
|
+
private profile;
|
|
92
|
+
/** An explicit, bounded Haiku request can refresh Claude usage for an idle profile. */
|
|
93
|
+
probeClaudeUsage(profileId: string): Promise<ProfileUsage>;
|
|
94
|
+
private probeUsageDir;
|
|
95
|
+
private probedUsage;
|
|
96
|
+
private pins;
|
|
97
|
+
/** Latest Claude rate-limit snapshot any agent on this profile reported. */
|
|
98
|
+
private observedUsage;
|
|
99
|
+
}
|
|
100
|
+
/** Read what `recordClaudeRateLimit` stored, dropping windows that already reset. */
|
|
101
|
+
export declare function readObservedClaudeUsage(stateDir: string, now?: number): ProfileUsage | undefined;
|
|
102
|
+
export {};
|