@prohost/cli 0.14.0 → 0.16.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 +83 -0
- package/README.md +265 -1
- package/dist/agent/accounts.d.ts +11 -0
- package/dist/agent/accounts.js +12 -1
- package/dist/agent/api.d.ts +6 -0
- package/dist/agent/api.js +5 -0
- package/dist/agent/claude.d.ts +6 -0
- package/dist/agent/claude.js +2 -2
- package/dist/agent/daemon.d.ts +33 -0
- package/dist/agent/daemon.js +56 -0
- package/dist/agent/prompt.d.ts +12 -0
- package/dist/agent/prompt.js +5 -0
- package/dist/agent/run.d.ts +110 -1
- package/dist/agent/run.js +198 -22
- package/dist/codehost/history.d.ts +80 -0
- package/dist/codehost/history.js +98 -0
- package/dist/codehost/hooks.d.ts +10 -2
- package/dist/codehost/hooks.js +15 -4
- package/dist/codehost/host.d.ts +32 -0
- package/dist/codehost/host.js +26 -1
- package/dist/codehost/host_stats.d.ts +49 -2
- package/dist/codehost/host_stats.js +153 -2
- package/dist/codehost/relay.d.ts +16 -1
- package/dist/codehost/relay.js +69 -3
- package/dist/codehost/sizing.d.ts +22 -4
- package/dist/codehost/sizing.js +46 -6
- package/dist/codehost/terminals.d.ts +33 -2
- package/dist/codehost/terminals.js +92 -30
- package/dist/codehost/tmux.d.ts +6 -1
- package/dist/codehost/tmux.js +3 -0
- package/dist/host/api.d.ts +82 -0
- package/dist/host/api.js +122 -0
- package/dist/host/capacity.d.ts +41 -0
- package/dist/host/capacity.js +48 -0
- package/dist/host/command.d.ts +59 -0
- package/dist/host/command.js +584 -0
- package/dist/host/computer.d.ts +120 -0
- package/dist/host/computer.js +291 -0
- package/dist/host/events.d.ts +24 -0
- package/dist/host/events.js +47 -0
- package/dist/host/install.d.ts +152 -0
- package/dist/host/install.js +755 -0
- package/dist/host/ledger.d.ts +52 -0
- package/dist/host/ledger.js +131 -0
- package/dist/host/legacy.d.ts +38 -0
- package/dist/host/legacy.js +115 -0
- package/dist/host/logins.d.ts +109 -0
- package/dist/host/logins.js +190 -0
- package/dist/host/migrate.d.ts +165 -0
- package/dist/host/migrate.js +364 -0
- package/dist/host/pair.d.ts +62 -0
- package/dist/host/pair.js +219 -0
- package/dist/host/peak_memory.d.ts +44 -0
- package/dist/host/peak_memory.js +139 -0
- package/dist/host/scheduler.d.ts +86 -0
- package/dist/host/scheduler.js +186 -0
- package/dist/host/service.d.ts +114 -0
- package/dist/host/service.js +1027 -0
- package/dist/host/session_move.d.ts +48 -0
- package/dist/host/session_move.js +106 -0
- package/dist/host/skills.d.ts +29 -0
- package/dist/host/skills.js +135 -0
- package/dist/host/status.d.ts +91 -0
- package/dist/host/status.js +130 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +47 -3
- package/dist/version.d.ts +2 -2
- package/dist/version.js +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The host's run ledger, keyed by employee and conversation.
|
|
3
|
+
*
|
|
4
|
+
* Two things outlive a restart here:
|
|
5
|
+
*
|
|
6
|
+
* - **Claimed runs.** Recorded the moment a claim succeeds, before anything
|
|
7
|
+
* runs, so a run is never executed twice. A run claimed but never completed
|
|
8
|
+
* (the service died mid-run) is reported failed on the next start, with its
|
|
9
|
+
* own token — it is never run again: the agent may already have acted.
|
|
10
|
+
* - **Which login each conversation works on** (`employee/conversation` →
|
|
11
|
+
* AI login), so a conversation stays with the login holding its session.
|
|
12
|
+
*
|
|
13
|
+
* The file holds run tokens, so it is written 0600, atomically.
|
|
14
|
+
*/
|
|
15
|
+
export interface LedgerRun {
|
|
16
|
+
run_id: string;
|
|
17
|
+
agent_id: string;
|
|
18
|
+
/** `employee/conversation`, or `run:<id>` for a run with no conversation. */
|
|
19
|
+
lane: string;
|
|
20
|
+
account_key?: string;
|
|
21
|
+
claimed_at: string;
|
|
22
|
+
completed: boolean;
|
|
23
|
+
completion_path?: string;
|
|
24
|
+
/** Kept only until the run is completed. */
|
|
25
|
+
run_token?: string;
|
|
26
|
+
}
|
|
27
|
+
export interface LedgerConversation {
|
|
28
|
+
account_key: string;
|
|
29
|
+
updated_at: string;
|
|
30
|
+
}
|
|
31
|
+
export declare const LEDGER_RUN_LIMIT = 500;
|
|
32
|
+
export declare const LEDGER_CONVERSATION_LIMIT = 2000;
|
|
33
|
+
export declare class HostLedger {
|
|
34
|
+
private readonly file;
|
|
35
|
+
private readonly warn;
|
|
36
|
+
private readonly runs;
|
|
37
|
+
private readonly conversations;
|
|
38
|
+
private constructor();
|
|
39
|
+
static load(env?: NodeJS.ProcessEnv, warn?: (line: string) => void): HostLedger;
|
|
40
|
+
get(runId: string): LedgerRun | undefined;
|
|
41
|
+
/** Runs claimed here and never completed: the service stopped mid-run. */
|
|
42
|
+
unfinished(): LedgerRun[];
|
|
43
|
+
claimed(run: Omit<LedgerRun, 'claimed_at' | 'completed'>): void;
|
|
44
|
+
/** The run's account changed mid-run (it moved to another login). */
|
|
45
|
+
setAccount(runId: string, accountKey: string): void;
|
|
46
|
+
completed(runId: string): void;
|
|
47
|
+
conversationLogin(lane: string): string | undefined;
|
|
48
|
+
setConversationLogin(lane: string, accountKey: string): void;
|
|
49
|
+
/** How many conversations stick to each login. */
|
|
50
|
+
conversationsPerLogin(): Map<string, number>;
|
|
51
|
+
private persist;
|
|
52
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The host's run ledger, keyed by employee and conversation.
|
|
3
|
+
*
|
|
4
|
+
* Two things outlive a restart here:
|
|
5
|
+
*
|
|
6
|
+
* - **Claimed runs.** Recorded the moment a claim succeeds, before anything
|
|
7
|
+
* runs, so a run is never executed twice. A run claimed but never completed
|
|
8
|
+
* (the service died mid-run) is reported failed on the next start, with its
|
|
9
|
+
* own token — it is never run again: the agent may already have acted.
|
|
10
|
+
* - **Which login each conversation works on** (`employee/conversation` →
|
|
11
|
+
* AI login), so a conversation stays with the login holding its session.
|
|
12
|
+
*
|
|
13
|
+
* The file holds run tokens, so it is written 0600, atomically.
|
|
14
|
+
*/
|
|
15
|
+
import { hostLedgerPath, readObject, writePrivateFile } from './computer.js';
|
|
16
|
+
export const LEDGER_RUN_LIMIT = 500;
|
|
17
|
+
export const LEDGER_CONVERSATION_LIMIT = 2_000;
|
|
18
|
+
export class HostLedger {
|
|
19
|
+
file;
|
|
20
|
+
warn;
|
|
21
|
+
runs = new Map();
|
|
22
|
+
conversations = new Map();
|
|
23
|
+
constructor(file, warn) {
|
|
24
|
+
this.file = file;
|
|
25
|
+
this.warn = warn;
|
|
26
|
+
}
|
|
27
|
+
static load(env = process.env, warn = () => { }) {
|
|
28
|
+
const ledger = new HostLedger(hostLedgerPath(env), warn);
|
|
29
|
+
const raw = readObject(ledger.file);
|
|
30
|
+
for (const entry of Array.isArray(raw?.runs) ? raw.runs : []) {
|
|
31
|
+
const run = entry;
|
|
32
|
+
if (run && typeof run.run_id === 'string' && typeof run.agent_id === 'string' && typeof run.lane === 'string') {
|
|
33
|
+
ledger.runs.set(run.run_id, {
|
|
34
|
+
run_id: run.run_id,
|
|
35
|
+
agent_id: run.agent_id,
|
|
36
|
+
lane: run.lane,
|
|
37
|
+
...(typeof run.account_key === 'string' ? { account_key: run.account_key } : {}),
|
|
38
|
+
claimed_at: typeof run.claimed_at === 'string' ? run.claimed_at : '',
|
|
39
|
+
completed: run.completed === true,
|
|
40
|
+
...(typeof run.completion_path === 'string' ? { completion_path: run.completion_path } : {}),
|
|
41
|
+
...(typeof run.run_token === 'string' && run.completed !== true ? { run_token: run.run_token } : {}),
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
const conversations = raw?.conversations;
|
|
46
|
+
if (conversations && typeof conversations === 'object' && !Array.isArray(conversations)) {
|
|
47
|
+
for (const [key, value] of Object.entries(conversations)) {
|
|
48
|
+
const entry = value;
|
|
49
|
+
if (entry && typeof entry.account_key === 'string') {
|
|
50
|
+
ledger.conversations.set(key, {
|
|
51
|
+
account_key: entry.account_key,
|
|
52
|
+
updated_at: typeof entry.updated_at === 'string' ? entry.updated_at : '',
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return ledger;
|
|
58
|
+
}
|
|
59
|
+
get(runId) {
|
|
60
|
+
return this.runs.get(runId);
|
|
61
|
+
}
|
|
62
|
+
/** Runs claimed here and never completed: the service stopped mid-run. */
|
|
63
|
+
unfinished() {
|
|
64
|
+
return [...this.runs.values()].filter((run) => !run.completed);
|
|
65
|
+
}
|
|
66
|
+
claimed(run) {
|
|
67
|
+
this.runs.delete(run.run_id);
|
|
68
|
+
this.runs.set(run.run_id, { ...run, claimed_at: new Date().toISOString(), completed: false });
|
|
69
|
+
while (this.runs.size > LEDGER_RUN_LIMIT) {
|
|
70
|
+
const oldest = this.runs.keys().next().value;
|
|
71
|
+
if (oldest === undefined)
|
|
72
|
+
break;
|
|
73
|
+
this.runs.delete(oldest);
|
|
74
|
+
}
|
|
75
|
+
this.persist();
|
|
76
|
+
}
|
|
77
|
+
/** The run's account changed mid-run (it moved to another login). */
|
|
78
|
+
setAccount(runId, accountKey) {
|
|
79
|
+
const run = this.runs.get(runId);
|
|
80
|
+
if (!run)
|
|
81
|
+
return;
|
|
82
|
+
run.account_key = accountKey;
|
|
83
|
+
this.persist();
|
|
84
|
+
}
|
|
85
|
+
completed(runId) {
|
|
86
|
+
const run = this.runs.get(runId);
|
|
87
|
+
if (!run)
|
|
88
|
+
return;
|
|
89
|
+
run.completed = true;
|
|
90
|
+
delete run.run_token;
|
|
91
|
+
this.persist();
|
|
92
|
+
}
|
|
93
|
+
conversationLogin(lane) {
|
|
94
|
+
return this.conversations.get(lane)?.account_key;
|
|
95
|
+
}
|
|
96
|
+
setConversationLogin(lane, accountKey) {
|
|
97
|
+
if (lane.startsWith('run:'))
|
|
98
|
+
return;
|
|
99
|
+
if (this.conversations.get(lane)?.account_key === accountKey)
|
|
100
|
+
return;
|
|
101
|
+
this.conversations.delete(lane);
|
|
102
|
+
this.conversations.set(lane, { account_key: accountKey, updated_at: new Date().toISOString() });
|
|
103
|
+
while (this.conversations.size > LEDGER_CONVERSATION_LIMIT) {
|
|
104
|
+
const oldest = this.conversations.keys().next().value;
|
|
105
|
+
if (oldest === undefined)
|
|
106
|
+
break;
|
|
107
|
+
this.conversations.delete(oldest);
|
|
108
|
+
}
|
|
109
|
+
this.persist();
|
|
110
|
+
}
|
|
111
|
+
/** How many conversations stick to each login. */
|
|
112
|
+
conversationsPerLogin() {
|
|
113
|
+
const counts = new Map();
|
|
114
|
+
for (const entry of this.conversations.values()) {
|
|
115
|
+
counts.set(entry.account_key, (counts.get(entry.account_key) ?? 0) + 1);
|
|
116
|
+
}
|
|
117
|
+
return counts;
|
|
118
|
+
}
|
|
119
|
+
persist() {
|
|
120
|
+
try {
|
|
121
|
+
writePrivateFile(this.file, {
|
|
122
|
+
version: 1,
|
|
123
|
+
runs: [...this.runs.values()],
|
|
124
|
+
conversations: Object.fromEntries(this.conversations),
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
catch (err) {
|
|
128
|
+
this.warn(`! could not write the run ledger (${err instanceof Error ? err.message : String(err)})`);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Adopted agent homes (`host migrate`) that ProhostAI has not placed on this
|
|
3
|
+
* computer yet, served the way `agent run` served them — with their own
|
|
4
|
+
* credential, in this one process — until the computer takes them over.
|
|
5
|
+
*
|
|
6
|
+
* `sync` is called with the employees the computer serves: an adopted home
|
|
7
|
+
* whose employee is among them is stopped (it drains: runs going finish), any
|
|
8
|
+
* other adopted home with a known `--exec` keeps running. A host-only home
|
|
9
|
+
* runs only while the computer isn't paired: once it is, the host service
|
|
10
|
+
* hosts the terminal sessions itself.
|
|
11
|
+
*/
|
|
12
|
+
import type { AgentRunOptions } from '../agent/run.js';
|
|
13
|
+
import type { AdoptedHome } from './migrate.js';
|
|
14
|
+
export type HarnessRunner = (options: AgentRunOptions) => Promise<unknown>;
|
|
15
|
+
export declare class LegacyHarnesses {
|
|
16
|
+
private readonly homes;
|
|
17
|
+
private readonly options;
|
|
18
|
+
private readonly running;
|
|
19
|
+
/** Stopped, still finishing the runs it had: never started again until done, and awaited on stop. */
|
|
20
|
+
private readonly draining;
|
|
21
|
+
/** What the last `sync` was told, so a home that finishes draining is started again if it is wanted. */
|
|
22
|
+
private last;
|
|
23
|
+
private stopped;
|
|
24
|
+
constructor(homes: readonly AdoptedHome[], options: {
|
|
25
|
+
env?: NodeJS.ProcessEnv;
|
|
26
|
+
log: (line: string) => void;
|
|
27
|
+
machine?: string;
|
|
28
|
+
run?: HarnessRunner;
|
|
29
|
+
});
|
|
30
|
+
/** Which homes run now, given the employees the computer serves and whether it is paired. */
|
|
31
|
+
wanted(served: ReadonlySet<string>, paired: boolean): AdoptedHome[];
|
|
32
|
+
sync(served: ReadonlySet<string>, paired: boolean): void;
|
|
33
|
+
active(): string[];
|
|
34
|
+
/** Homes stopped and still finishing their runs. */
|
|
35
|
+
drainingHomes(): string[];
|
|
36
|
+
stop(): Promise<void>;
|
|
37
|
+
private start;
|
|
38
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Adopted agent homes (`host migrate`) that ProhostAI has not placed on this
|
|
3
|
+
* computer yet, served the way `agent run` served them — with their own
|
|
4
|
+
* credential, in this one process — until the computer takes them over.
|
|
5
|
+
*
|
|
6
|
+
* `sync` is called with the employees the computer serves: an adopted home
|
|
7
|
+
* whose employee is among them is stopped (it drains: runs going finish), any
|
|
8
|
+
* other adopted home with a known `--exec` keeps running. A host-only home
|
|
9
|
+
* runs only while the computer isn't paired: once it is, the host service
|
|
10
|
+
* hosts the terminal sessions itself.
|
|
11
|
+
*/
|
|
12
|
+
import { isClaudeExec } from '../agent/claude.js';
|
|
13
|
+
import { loadCredentials } from '../agent/credentials.js';
|
|
14
|
+
import { claudeTokenEnv } from './computer.js';
|
|
15
|
+
import { runAgentHarness } from '../agent/run.js';
|
|
16
|
+
export class LegacyHarnesses {
|
|
17
|
+
homes;
|
|
18
|
+
options;
|
|
19
|
+
running = new Map();
|
|
20
|
+
/** Stopped, still finishing the runs it had: never started again until done, and awaited on stop. */
|
|
21
|
+
draining = new Map();
|
|
22
|
+
/** What the last `sync` was told, so a home that finishes draining is started again if it is wanted. */
|
|
23
|
+
last;
|
|
24
|
+
stopped = false;
|
|
25
|
+
constructor(homes, options) {
|
|
26
|
+
this.homes = homes;
|
|
27
|
+
this.options = options;
|
|
28
|
+
}
|
|
29
|
+
/** Which homes run now, given the employees the computer serves and whether it is paired. */
|
|
30
|
+
wanted(served, paired) {
|
|
31
|
+
return this.homes.filter((home) => {
|
|
32
|
+
if (home.host_only)
|
|
33
|
+
return !paired;
|
|
34
|
+
return Boolean(home.exec) && !served.has(home.agent_id);
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
sync(served, paired) {
|
|
38
|
+
if (this.stopped)
|
|
39
|
+
return;
|
|
40
|
+
this.last = { served, paired };
|
|
41
|
+
const wanted = new Set(this.wanted(served, paired).map((home) => home.home));
|
|
42
|
+
for (const [home, running] of this.running) {
|
|
43
|
+
if (wanted.has(home))
|
|
44
|
+
continue;
|
|
45
|
+
this.options.log(` ⇢ ${home}: now served by this computer — stopping its own connection`);
|
|
46
|
+
running.controller.abort();
|
|
47
|
+
this.running.delete(home);
|
|
48
|
+
this.draining.set(home, running.done.finally(() => {
|
|
49
|
+
this.draining.delete(home);
|
|
50
|
+
// Wanted again while it was finishing: start it now.
|
|
51
|
+
if (this.last)
|
|
52
|
+
this.sync(this.last.served, this.last.paired);
|
|
53
|
+
}));
|
|
54
|
+
}
|
|
55
|
+
for (const home of this.homes) {
|
|
56
|
+
if (!wanted.has(home.home) || this.running.has(home.home) || this.draining.has(home.home))
|
|
57
|
+
continue;
|
|
58
|
+
this.start(home);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
active() {
|
|
62
|
+
return [...this.running.keys()];
|
|
63
|
+
}
|
|
64
|
+
/** Homes stopped and still finishing their runs. */
|
|
65
|
+
drainingHomes() {
|
|
66
|
+
return [...this.draining.keys()];
|
|
67
|
+
}
|
|
68
|
+
async stop() {
|
|
69
|
+
this.stopped = true;
|
|
70
|
+
const all = [...this.running.values()];
|
|
71
|
+
this.running.clear();
|
|
72
|
+
for (const running of all)
|
|
73
|
+
running.controller.abort();
|
|
74
|
+
await Promise.all([
|
|
75
|
+
...all.map((running) => running.done.catch(() => undefined)),
|
|
76
|
+
...[...this.draining.values()].map((done) => done.catch(() => undefined)),
|
|
77
|
+
]);
|
|
78
|
+
}
|
|
79
|
+
start(home) {
|
|
80
|
+
const env = { ...process.env, ...this.options.env, PROHOST_HOME: home.home };
|
|
81
|
+
let credentials;
|
|
82
|
+
try {
|
|
83
|
+
credentials = loadCredentials(env);
|
|
84
|
+
}
|
|
85
|
+
catch (err) {
|
|
86
|
+
this.options.log(` ! ${home.home}: ${err instanceof Error ? err.message : String(err)}`);
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
const prefix = `[${home.agent_name}] `;
|
|
90
|
+
const controller = new AbortController();
|
|
91
|
+
const options = {
|
|
92
|
+
exec: home.exec ?? '',
|
|
93
|
+
...(home.host_only ? { hostOnly: true } : { codeHost: null }),
|
|
94
|
+
credentials,
|
|
95
|
+
env,
|
|
96
|
+
...(this.options.machine ? { machine: this.options.machine } : {}),
|
|
97
|
+
...(home.workdir ? { cwd: home.workdir } : {}),
|
|
98
|
+
...(home.concurrency !== undefined ? { concurrency: home.concurrency } : {}),
|
|
99
|
+
...(home.repos ? { repos: home.repos } : {}),
|
|
100
|
+
...(home.timeout_seconds !== undefined ? { timeoutMs: home.timeout_seconds * 1000 } : {}),
|
|
101
|
+
...(home.idle_timeout_seconds !== undefined ? { idleTimeoutMs: home.idle_timeout_seconds * 1000 } : {}),
|
|
102
|
+
...(home.safe_tools ? { safeTools: true } : {}),
|
|
103
|
+
// A dedicated install's Claude token (in the host's home), for a Claude harness only; re-read per run.
|
|
104
|
+
...(home.exec && isClaudeExec(home.exec) ? { extraEnv: () => claudeTokenEnv(this.options.env ?? process.env) } : {}),
|
|
105
|
+
signal: controller.signal,
|
|
106
|
+
onLine: (line) => this.options.log(`${prefix}${line}`),
|
|
107
|
+
};
|
|
108
|
+
this.options.log(` ⇢ ${home.home}: serving ${home.agent_name} with its own connection until it is placed here`);
|
|
109
|
+
const run = this.options.run ?? runAgentHarness;
|
|
110
|
+
const done = run(options).catch((err) => {
|
|
111
|
+
this.options.log(`${prefix}✗ stopped: ${err instanceof Error ? err.message : String(err)}`);
|
|
112
|
+
});
|
|
113
|
+
this.running.set(home.home, { controller, done });
|
|
114
|
+
}
|
|
115
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The computer's AI logins, and which one each conversation works on.
|
|
3
|
+
*
|
|
4
|
+
* A login is one Claude Code or Codex sign-in in its own config directory:
|
|
5
|
+
* the CLI's defaults (`~/.claude`, `~/.codex`) and every account in the
|
|
6
|
+
* machine-wide registry (`~/.prohost-accounts`, see `agent/accounts.ts`).
|
|
7
|
+
*
|
|
8
|
+
* What is sent to ProhostAI about a login is its key, label, runtime, plan and
|
|
9
|
+
* sign-in state — never the email. `claude auth status --json` carries the
|
|
10
|
+
* email; it is read only for `prohost host logins` on this machine
|
|
11
|
+
* (`email_local`) and never leaves it.
|
|
12
|
+
*/
|
|
13
|
+
import type { AccountRecord, AccountRuntime, AuthState, CommandRunner } from '../agent/accounts.js';
|
|
14
|
+
import type { QuotaProvider, QuotaWindow } from '../agent/runtime_status.js';
|
|
15
|
+
export interface LoginInfo {
|
|
16
|
+
account_key: string;
|
|
17
|
+
label: string;
|
|
18
|
+
runtime: AccountRuntime;
|
|
19
|
+
/** `max`, `pro`, `plus`, … — lowercased; `null` when the login didn't say. */
|
|
20
|
+
subscription_type: string | null;
|
|
21
|
+
/** `Claude Max`, `ChatGPT Pro`, … */
|
|
22
|
+
plan_label: string | null;
|
|
23
|
+
auth_state: AuthState;
|
|
24
|
+
/** The config directory (`CLAUDE_CONFIG_DIR` / `CODEX_HOME`). Local only. */
|
|
25
|
+
dir: string;
|
|
26
|
+
quota_windows: QuotaWindow[];
|
|
27
|
+
/** Local only: shown by `host logins`, never sent. */
|
|
28
|
+
email_local?: string;
|
|
29
|
+
}
|
|
30
|
+
/** The directory a login's sessions live in: its own, or the CLI's default. */
|
|
31
|
+
export declare function loginDir(record: AccountRecord, env?: NodeJS.ProcessEnv): string;
|
|
32
|
+
/**
|
|
33
|
+
* Every login on this machine: the registry, plus each runtime's default when
|
|
34
|
+
* its config directory exists or its binary is installed (created in the
|
|
35
|
+
* registry on first sight, so it has a stable key).
|
|
36
|
+
*/
|
|
37
|
+
export declare function loginRecords(env?: NodeJS.ProcessEnv, installed?: (binary: string) => boolean): AccountRecord[];
|
|
38
|
+
export interface DetectOptions {
|
|
39
|
+
env?: NodeJS.ProcessEnv;
|
|
40
|
+
run?: CommandRunner;
|
|
41
|
+
/** Usage / plan probe for one login (the CLI's own `get_usage` / rate-limit read). */
|
|
42
|
+
quota?: (record: AccountRecord) => QuotaProvider | undefined;
|
|
43
|
+
/** Read the email for local display (`host logins`). */
|
|
44
|
+
includeEmail?: boolean;
|
|
45
|
+
records?: AccountRecord[];
|
|
46
|
+
}
|
|
47
|
+
/** Ask each login whether it is signed in, on which plan, and how much it has left. */
|
|
48
|
+
export declare function detectLogins(options?: DetectOptions): Promise<LoginInfo[]>;
|
|
49
|
+
/** The environment a run on this login spawns with. */
|
|
50
|
+
export declare function loginEnv(login: Pick<LoginInfo, 'runtime' | 'dir' | 'label'>, env?: NodeJS.ProcessEnv): NodeJS.ProcessEnv;
|
|
51
|
+
/** When an exhausted window lifts: the latest reset among windows at 100 %. */
|
|
52
|
+
export declare function exhaustedUntil(windows: readonly QuotaWindow[], now: number): number | undefined;
|
|
53
|
+
export type LoginPick =
|
|
54
|
+
/** Run on this login. */
|
|
55
|
+
{
|
|
56
|
+
kind: 'use';
|
|
57
|
+
login: LoginInfo;
|
|
58
|
+
}
|
|
59
|
+
/** The conversation's login rests: copy its session to `to` and resume there. */
|
|
60
|
+
| {
|
|
61
|
+
kind: 'move';
|
|
62
|
+
from: LoginInfo;
|
|
63
|
+
to: LoginInfo;
|
|
64
|
+
}
|
|
65
|
+
/** Nothing has room: wait. `until` is the earliest reset, when known. */
|
|
66
|
+
| {
|
|
67
|
+
kind: 'wait';
|
|
68
|
+
until?: number;
|
|
69
|
+
resting?: LoginInfo;
|
|
70
|
+
}
|
|
71
|
+
/** No signed-in login of this kind on the computer. */
|
|
72
|
+
| {
|
|
73
|
+
kind: 'none';
|
|
74
|
+
};
|
|
75
|
+
/** When a login that hit its limit without saying when is tried again. */
|
|
76
|
+
export declare const UNKNOWN_RESET_MS: number;
|
|
77
|
+
/**
|
|
78
|
+
* Logins, their rests, and which conversation sticks to which login.
|
|
79
|
+
*
|
|
80
|
+
* A conversation sticks to the login that holds its session. When that login
|
|
81
|
+
* rests (its usage limit), the conversation moves only if sharing between
|
|
82
|
+
* logins is on for this computer — and only to a login of the same kind:
|
|
83
|
+
* a Claude Code session cannot continue in Codex, or the other way round.
|
|
84
|
+
*/
|
|
85
|
+
export declare class LoginPool {
|
|
86
|
+
private readonly affinity;
|
|
87
|
+
private readonly now;
|
|
88
|
+
private logins;
|
|
89
|
+
private readonly rests;
|
|
90
|
+
constructor(affinity: {
|
|
91
|
+
get(conversation: string): string | undefined;
|
|
92
|
+
set(conversation: string, accountKey: string): void;
|
|
93
|
+
}, now?: () => number);
|
|
94
|
+
update(logins: LoginInfo[]): void;
|
|
95
|
+
all(): readonly LoginInfo[];
|
|
96
|
+
get(accountKey: string): LoginInfo | undefined;
|
|
97
|
+
/** The login hit its usage limit; it rests until `until` (ms), or an hour when unknown. */
|
|
98
|
+
rest(accountKey: string, until?: number): number;
|
|
99
|
+
/** When the login is next usable, if it is resting now. */
|
|
100
|
+
restingUntil(accountKey: string): number | undefined;
|
|
101
|
+
/** The conversation's login, if it has one. */
|
|
102
|
+
stuckTo(conversation: string | undefined): string | undefined;
|
|
103
|
+
stick(conversation: string | undefined, accountKey: string): void;
|
|
104
|
+
/**
|
|
105
|
+
* Which login a run in `conversation` (undefined: no conversation) works on.
|
|
106
|
+
* `busy` counts runs going per login, so new conversations spread out.
|
|
107
|
+
*/
|
|
108
|
+
pick(runtime: AccountRuntime, conversation: string | undefined, share: boolean, busy?: (accountKey: string) => number): LoginPick;
|
|
109
|
+
}
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The computer's AI logins, and which one each conversation works on.
|
|
3
|
+
*
|
|
4
|
+
* A login is one Claude Code or Codex sign-in in its own config directory:
|
|
5
|
+
* the CLI's defaults (`~/.claude`, `~/.codex`) and every account in the
|
|
6
|
+
* machine-wide registry (`~/.prohost-accounts`, see `agent/accounts.ts`).
|
|
7
|
+
*
|
|
8
|
+
* What is sent to ProhostAI about a login is its key, label, runtime, plan and
|
|
9
|
+
* sign-in state — never the email. `claude auth status --json` carries the
|
|
10
|
+
* email; it is read only for `prohost host logins` on this machine
|
|
11
|
+
* (`email_local`) and never leaves it.
|
|
12
|
+
*/
|
|
13
|
+
import { existsSync } from 'node:fs';
|
|
14
|
+
import os from 'node:os';
|
|
15
|
+
import path from 'node:path';
|
|
16
|
+
import { accountEnv, checkAuth, ensureDefaultAccount, loadAccounts, } from '../agent/accounts.js';
|
|
17
|
+
import { claudePlanLabel } from '../agent/runtime_status.js';
|
|
18
|
+
import { resolvesOnPath } from '../codehost/agents.js';
|
|
19
|
+
/** The directory a login's sessions live in: its own, or the CLI's default. */
|
|
20
|
+
export function loginDir(record, env = process.env) {
|
|
21
|
+
if (record.dir)
|
|
22
|
+
return record.dir;
|
|
23
|
+
return path.join(env.HOME || os.homedir(), record.runtime === 'claude_code' ? '.claude' : '.codex');
|
|
24
|
+
}
|
|
25
|
+
const BINARY = { claude_code: 'claude', codex: 'codex' };
|
|
26
|
+
/**
|
|
27
|
+
* Every login on this machine: the registry, plus each runtime's default when
|
|
28
|
+
* its config directory exists or its binary is installed (created in the
|
|
29
|
+
* registry on first sight, so it has a stable key).
|
|
30
|
+
*/
|
|
31
|
+
export function loginRecords(env = process.env, installed = (binary) => resolvesOnPath(binary, env)) {
|
|
32
|
+
for (const runtime of ['claude_code', 'codex']) {
|
|
33
|
+
const dir = path.join(env.HOME || os.homedir(), runtime === 'claude_code' ? '.claude' : '.codex');
|
|
34
|
+
if (existsSync(dir) || installed(BINARY[runtime])) {
|
|
35
|
+
try {
|
|
36
|
+
ensureDefaultAccount(runtime, env);
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
/* a locked or unwritable registry costs the default its key this round */
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return loadAccounts(env);
|
|
44
|
+
}
|
|
45
|
+
/** `ChatGPT Pro` → `pro`. */
|
|
46
|
+
function planKey(label) {
|
|
47
|
+
const key = label?.replace(/^(ChatGPT|Claude)\s+/i, '').trim().toLowerCase();
|
|
48
|
+
return key && /^[a-z0-9_-]{1,32}$/.test(key) ? key : undefined;
|
|
49
|
+
}
|
|
50
|
+
/** Ask each login whether it is signed in, on which plan, and how much it has left. */
|
|
51
|
+
export async function detectLogins(options = {}) {
|
|
52
|
+
const env = options.env ?? process.env;
|
|
53
|
+
const records = options.records ?? loginRecords(env);
|
|
54
|
+
return Promise.all(records.map(async (record) => {
|
|
55
|
+
const auth = await checkAuth(record, { run: options.run, includeEmail: options.includeEmail });
|
|
56
|
+
const observed = auth.state === 'expired' ? undefined : await options.quota?.(record)?.refresh().catch(() => undefined);
|
|
57
|
+
const subscription = auth.subscriptionType ?? planKey(observed?.plan_label);
|
|
58
|
+
const planLabel = observed?.plan_label ?? (record.runtime === 'claude_code' ? claudePlanLabel(auth.subscriptionType) : undefined);
|
|
59
|
+
return {
|
|
60
|
+
account_key: record.key,
|
|
61
|
+
label: record.label,
|
|
62
|
+
runtime: record.runtime,
|
|
63
|
+
subscription_type: subscription ?? null,
|
|
64
|
+
plan_label: planLabel ?? null,
|
|
65
|
+
auth_state: auth.state,
|
|
66
|
+
dir: loginDir(record, env),
|
|
67
|
+
quota_windows: observed?.quota_windows ?? [],
|
|
68
|
+
...(options.includeEmail && auth.email ? { email_local: auth.email } : {}),
|
|
69
|
+
};
|
|
70
|
+
}));
|
|
71
|
+
}
|
|
72
|
+
/** The environment a run on this login spawns with. */
|
|
73
|
+
export function loginEnv(login, env = process.env) {
|
|
74
|
+
const home = env.HOME || os.homedir();
|
|
75
|
+
const isDefault = login.dir === path.join(home, login.runtime === 'claude_code' ? '.claude' : '.codex');
|
|
76
|
+
return isDefault ? {} : accountEnv({ runtime: login.runtime, dir: login.dir });
|
|
77
|
+
}
|
|
78
|
+
/** When an exhausted window lifts: the latest reset among windows at 100 %. */
|
|
79
|
+
export function exhaustedUntil(windows, now) {
|
|
80
|
+
let until;
|
|
81
|
+
for (const window of windows) {
|
|
82
|
+
if (window.used_percent < 100 || !window.resets_at)
|
|
83
|
+
continue;
|
|
84
|
+
const at = Date.parse(window.resets_at);
|
|
85
|
+
if (Number.isFinite(at) && at > now)
|
|
86
|
+
until = Math.max(until ?? 0, at);
|
|
87
|
+
}
|
|
88
|
+
return until;
|
|
89
|
+
}
|
|
90
|
+
/** When a login that hit its limit without saying when is tried again. */
|
|
91
|
+
export const UNKNOWN_RESET_MS = 60 * 60_000;
|
|
92
|
+
/**
|
|
93
|
+
* Logins, their rests, and which conversation sticks to which login.
|
|
94
|
+
*
|
|
95
|
+
* A conversation sticks to the login that holds its session. When that login
|
|
96
|
+
* rests (its usage limit), the conversation moves only if sharing between
|
|
97
|
+
* logins is on for this computer — and only to a login of the same kind:
|
|
98
|
+
* a Claude Code session cannot continue in Codex, or the other way round.
|
|
99
|
+
*/
|
|
100
|
+
export class LoginPool {
|
|
101
|
+
affinity;
|
|
102
|
+
now;
|
|
103
|
+
logins = [];
|
|
104
|
+
rests = new Map();
|
|
105
|
+
constructor(affinity, now = Date.now) {
|
|
106
|
+
this.affinity = affinity;
|
|
107
|
+
this.now = now;
|
|
108
|
+
}
|
|
109
|
+
update(logins) {
|
|
110
|
+
this.logins = logins;
|
|
111
|
+
}
|
|
112
|
+
all() {
|
|
113
|
+
return this.logins;
|
|
114
|
+
}
|
|
115
|
+
get(accountKey) {
|
|
116
|
+
return this.logins.find((login) => login.account_key === accountKey);
|
|
117
|
+
}
|
|
118
|
+
/** The login hit its usage limit; it rests until `until` (ms), or an hour when unknown. */
|
|
119
|
+
rest(accountKey, until) {
|
|
120
|
+
const at = until && until > this.now() ? until : this.now() + UNKNOWN_RESET_MS;
|
|
121
|
+
this.rests.set(accountKey, at);
|
|
122
|
+
return at;
|
|
123
|
+
}
|
|
124
|
+
/** When the login is next usable, if it is resting now. */
|
|
125
|
+
restingUntil(accountKey) {
|
|
126
|
+
const now = this.now();
|
|
127
|
+
const rested = this.rests.get(accountKey);
|
|
128
|
+
const fromRun = rested !== undefined && rested > now ? rested : undefined;
|
|
129
|
+
if (rested !== undefined && rested <= now)
|
|
130
|
+
this.rests.delete(accountKey);
|
|
131
|
+
const login = this.get(accountKey);
|
|
132
|
+
const fromWindows = login ? exhaustedUntil(login.quota_windows, now) : undefined;
|
|
133
|
+
if (fromRun === undefined)
|
|
134
|
+
return fromWindows;
|
|
135
|
+
if (fromWindows === undefined)
|
|
136
|
+
return fromRun;
|
|
137
|
+
return Math.max(fromRun, fromWindows);
|
|
138
|
+
}
|
|
139
|
+
/** The conversation's login, if it has one. */
|
|
140
|
+
stuckTo(conversation) {
|
|
141
|
+
return conversation ? this.affinity.get(conversation) : undefined;
|
|
142
|
+
}
|
|
143
|
+
stick(conversation, accountKey) {
|
|
144
|
+
if (conversation)
|
|
145
|
+
this.affinity.set(conversation, accountKey);
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* Which login a run in `conversation` (undefined: no conversation) works on.
|
|
149
|
+
* `busy` counts runs going per login, so new conversations spread out.
|
|
150
|
+
*/
|
|
151
|
+
pick(runtime, conversation, share, busy = () => 0) {
|
|
152
|
+
const usable = this.logins.filter((login) => login.runtime === runtime && login.auth_state !== 'expired');
|
|
153
|
+
if (usable.length === 0)
|
|
154
|
+
return { kind: 'none' };
|
|
155
|
+
const free = usable
|
|
156
|
+
.filter((login) => this.restingUntil(login.account_key) === undefined)
|
|
157
|
+
.sort((a, b) => busy(a.account_key) - busy(b.account_key));
|
|
158
|
+
const earliest = () => {
|
|
159
|
+
const times = usable.map((login) => this.restingUntil(login.account_key)).filter((t) => t !== undefined);
|
|
160
|
+
return times.length > 0 ? Math.min(...times) : undefined;
|
|
161
|
+
};
|
|
162
|
+
const stuck = this.stuckTo(conversation);
|
|
163
|
+
const signedOut = stuck
|
|
164
|
+
? this.logins.find((login) => login.account_key === stuck && login.runtime === runtime && login.auth_state === 'expired')
|
|
165
|
+
: undefined;
|
|
166
|
+
if (signedOut) {
|
|
167
|
+
// Its session lives in a login that is signed out: it moves only when
|
|
168
|
+
// sharing is on; otherwise the conversation waits for that sign-in.
|
|
169
|
+
const other = share ? free.find((login) => login.account_key !== signedOut.account_key) : undefined;
|
|
170
|
+
return other ? { kind: 'move', from: signedOut, to: other } : { kind: 'wait', resting: signedOut };
|
|
171
|
+
}
|
|
172
|
+
// A login no longer on this computer at all took its sessions with it:
|
|
173
|
+
// nothing to keep, so the conversation starts fresh on one that has room.
|
|
174
|
+
const sticky = stuck ? usable.find((login) => login.account_key === stuck) : undefined;
|
|
175
|
+
if (sticky) {
|
|
176
|
+
const until = this.restingUntil(sticky.account_key);
|
|
177
|
+
if (until === undefined)
|
|
178
|
+
return { kind: 'use', login: sticky };
|
|
179
|
+
const other = share ? free.find((login) => login.account_key !== sticky.account_key) : undefined;
|
|
180
|
+
if (other)
|
|
181
|
+
return { kind: 'move', from: sticky, to: other };
|
|
182
|
+
// With sharing on, the first login of this kind to reset frees the conversation.
|
|
183
|
+
return { kind: 'wait', until: share ? earliest() : until, resting: sticky };
|
|
184
|
+
}
|
|
185
|
+
const first = free[0];
|
|
186
|
+
if (first)
|
|
187
|
+
return { kind: 'use', login: first };
|
|
188
|
+
return { kind: 'wait', until: earliest() };
|
|
189
|
+
}
|
|
190
|
+
}
|