@prohost/cli 0.8.3 → 0.10.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 +65 -0
- package/README.md +119 -2
- package/dist/agent/account_runtime.d.ts +19 -3
- package/dist/agent/account_runtime.js +28 -6
- package/dist/agent/accounts.d.ts +17 -1
- package/dist/agent/accounts.js +43 -2
- package/dist/agent/agent_commands.js +6 -3
- package/dist/agent/api.d.ts +6 -0
- package/dist/agent/api.js +10 -5
- package/dist/agent/claude.js +9 -3
- package/dist/agent/command.d.ts +9 -0
- package/dist/agent/command.js +64 -2
- package/dist/agent/daemon.d.ts +4 -0
- package/dist/agent/daemon.js +4 -0
- package/dist/agent/prompt.d.ts +27 -0
- package/dist/agent/prompt.js +21 -1
- package/dist/agent/run.d.ts +28 -3
- package/dist/agent/run.js +90 -18
- package/dist/agent/scheduler.d.ts +44 -0
- package/dist/agent/scheduler.js +93 -0
- package/dist/agent/worktrees.d.ts +136 -0
- package/dist/agent/worktrees.js +317 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +29 -2
- package/dist/usage/backoff.d.ts +42 -0
- package/dist/usage/backoff.js +85 -0
- package/dist/usage/command.d.ts +31 -0
- package/dist/usage/command.js +269 -0
- package/dist/usage/config.d.ts +38 -0
- package/dist/usage/config.js +75 -0
- package/dist/usage/discovery.d.ts +63 -0
- package/dist/usage/discovery.js +187 -0
- package/dist/usage/quota.d.ts +96 -0
- package/dist/usage/quota.js +145 -0
- package/dist/usage/scanner.d.ts +117 -0
- package/dist/usage/scanner.js +438 -0
- package/dist/usage/service.d.ts +122 -0
- package/dist/usage/service.js +299 -0
- package/dist/version.d.ts +2 -2
- package/dist/version.js +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A private checkout of each `--repos` repository, per conversation.
|
|
3
|
+
*
|
|
4
|
+
* A coding agent that opens pull requests does it in a git checkout, and until
|
|
5
|
+
* `--concurrency` there was only ever one run touching it. Two runs sharing one
|
|
6
|
+
* checkout switch each other's branch mid-edit and commit each other's
|
|
7
|
+
* half-finished files — the reason runs were serialized in the first place. So
|
|
8
|
+
* when the operator names the repositories the agent works on, each
|
|
9
|
+
* conversation gets its own `git worktree` of every one of them:
|
|
10
|
+
*
|
|
11
|
+
* $PROHOST_HOME/worktrees/<conversation>/<repo-name>
|
|
12
|
+
*
|
|
13
|
+
* Per *conversation*, not per run, for the same reason sessions are: a
|
|
14
|
+
* follow-up in the thread ("address the review comments") resumes the session
|
|
15
|
+
* that made the branch, and must find that branch where it left it. Runs of one
|
|
16
|
+
* conversation never overlap (see `scheduler.ts`), so one checkout per
|
|
17
|
+
* conversation is exactly as isolated as one per run and far cheaper.
|
|
18
|
+
*
|
|
19
|
+
* The harness makes the worktree; the agent never needs `git worktree add`.
|
|
20
|
+
* The agent command's working directory does not change — it stays the
|
|
21
|
+
* workspace, because that is where its `.claude/` configuration lives and what
|
|
22
|
+
* its sessions are keyed on. The checkouts are named in the prompt and in
|
|
23
|
+
* `$PROHOST_RUN_REPOS`.
|
|
24
|
+
*
|
|
25
|
+
* Worktrees share the source repository's object store, so a branch committed
|
|
26
|
+
* in one survives the worktree being removed. That is what makes pruning safe:
|
|
27
|
+
* `git worktree remove` (never `--force`) refuses a checkout with uncommitted
|
|
28
|
+
* or untracked files, and those are the only things a worktree holds alone.
|
|
29
|
+
*/
|
|
30
|
+
/** Child-process env var naming the directory that holds this run's checkouts. */
|
|
31
|
+
export declare const RUN_REPOS_ENV_VAR = "PROHOST_RUN_REPOS";
|
|
32
|
+
/** A conversation's checkouts idle this long are removed, if clean. */
|
|
33
|
+
export declare const WORKTREE_RETENTION_MS: number;
|
|
34
|
+
export interface RunCheckout {
|
|
35
|
+
/** Directory name of the repository, e.g. `backend-service`. */
|
|
36
|
+
name: string;
|
|
37
|
+
/** This conversation's private worktree of it. */
|
|
38
|
+
path: string;
|
|
39
|
+
/** The operator's checkout it was made from — the one the agent must leave alone. */
|
|
40
|
+
source: string;
|
|
41
|
+
}
|
|
42
|
+
export interface CheckoutFailure {
|
|
43
|
+
source: string;
|
|
44
|
+
reason: string;
|
|
45
|
+
}
|
|
46
|
+
export interface PreparedCheckouts {
|
|
47
|
+
/** Directory holding the checkouts; exported as {@link RUN_REPOS_ENV_VAR}. */
|
|
48
|
+
dir: string;
|
|
49
|
+
checkouts: RunCheckout[];
|
|
50
|
+
failures: CheckoutFailure[];
|
|
51
|
+
}
|
|
52
|
+
export declare class ReposFlagError extends Error {
|
|
53
|
+
}
|
|
54
|
+
export declare function worktreesRoot(env?: NodeJS.ProcessEnv): string;
|
|
55
|
+
/**
|
|
56
|
+
* Directory name for a lane (see `sessionKeyFor`).
|
|
57
|
+
*
|
|
58
|
+
* Readable enough to recognise in `ls`, and suffixed with a digest of the whole
|
|
59
|
+
* key because the readable part is lossy: two keys that differ only in
|
|
60
|
+
* punctuation, or in the agent they belong to, must not share a checkout.
|
|
61
|
+
*/
|
|
62
|
+
export declare function laneDirName(lane: string): string;
|
|
63
|
+
/**
|
|
64
|
+
* Read `--repos a,b` into absolute paths, refusing what can never work.
|
|
65
|
+
*
|
|
66
|
+
* An empty list and two checkouts with one directory name are always errors.
|
|
67
|
+
* A path that is not a git checkout is an error only when `requireCheckouts`
|
|
68
|
+
* is set — which `install-daemon` does and `agent run` does not. At install
|
|
69
|
+
* time a mistyped path should stop the install; at run time the same check
|
|
70
|
+
* would make a daemon whose repository was moved, deleted or is on an
|
|
71
|
+
* unmounted volume exit before connecting, and the service manager restart it
|
|
72
|
+
* forever. A running harness instead reports the checkout it could not make on
|
|
73
|
+
* each run, and keeps answering.
|
|
74
|
+
*
|
|
75
|
+
* @throws ReposFlagError naming the entry that is wrong.
|
|
76
|
+
*/
|
|
77
|
+
export declare function parseReposFlag(raw: string, options?: {
|
|
78
|
+
requireCheckouts?: boolean;
|
|
79
|
+
}): string[];
|
|
80
|
+
/** Whether `repo` is the top of a git checkout (a clone, or a worktree of one). */
|
|
81
|
+
export declare function isCheckout(repo: string): boolean;
|
|
82
|
+
export interface WorktreeManagerOptions {
|
|
83
|
+
/** Absolute paths of the operator's checkouts. Empty disables the feature. */
|
|
84
|
+
repos: string[];
|
|
85
|
+
env?: NodeJS.ProcessEnv;
|
|
86
|
+
log?: (line: string) => void;
|
|
87
|
+
/** Clock seam for tests. */
|
|
88
|
+
now?: () => number;
|
|
89
|
+
retentionMs?: number;
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Makes, reuses and prunes per-conversation worktrees for one harness.
|
|
93
|
+
*
|
|
94
|
+
* Every git mutation goes through one in-process queue. Lanes run side by
|
|
95
|
+
* side, and two `git worktree add` calls against the same repository race for
|
|
96
|
+
* the same administrative name; pruning, meanwhile, must never remove the
|
|
97
|
+
* directory a run arriving this instant is about to use.
|
|
98
|
+
*/
|
|
99
|
+
export declare class WorktreeManager {
|
|
100
|
+
private readonly repos;
|
|
101
|
+
private readonly env;
|
|
102
|
+
private readonly log;
|
|
103
|
+
private readonly now;
|
|
104
|
+
private readonly retentionMs;
|
|
105
|
+
private chain;
|
|
106
|
+
private lastPrunedAt;
|
|
107
|
+
constructor(options: WorktreeManagerOptions);
|
|
108
|
+
get enabled(): boolean;
|
|
109
|
+
private exclusive;
|
|
110
|
+
/**
|
|
111
|
+
* Ensure this lane has a worktree of every repository, and say where.
|
|
112
|
+
*
|
|
113
|
+
* Never rejects and never fails the run: most runs are questions that touch
|
|
114
|
+
* no repository at all, so a checkout that could not be made is reported in
|
|
115
|
+
* {@link PreparedCheckouts.failures} for the prompt to tell the agent about.
|
|
116
|
+
*/
|
|
117
|
+
prepare(lane: string): Promise<PreparedCheckouts>;
|
|
118
|
+
/** The repository a checkout's history lives in, as one comparable path. */
|
|
119
|
+
private commonDir;
|
|
120
|
+
/**
|
|
121
|
+
* Keep an existing checkout — once it is proven to be a worktree of `source`.
|
|
122
|
+
*
|
|
123
|
+
* The directory is named after the repository's basename, so pointing
|
|
124
|
+
* `--repos` from `/old/app` to `/new/app` leaves every conversation with an
|
|
125
|
+
* `app` checkout of the *old* repository. Handing that to the agent as a
|
|
126
|
+
* checkout of the new one would have it commit to the wrong repository.
|
|
127
|
+
*/
|
|
128
|
+
private reuse;
|
|
129
|
+
/** Returns why the worktree could not be made, or `undefined` when it was. */
|
|
130
|
+
private add;
|
|
131
|
+
private touch;
|
|
132
|
+
private pruneIfDue;
|
|
133
|
+
/** Remove idle conversations' checkouts, never the one about to be used. */
|
|
134
|
+
private prune;
|
|
135
|
+
private removeLane;
|
|
136
|
+
}
|
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A private checkout of each `--repos` repository, per conversation.
|
|
3
|
+
*
|
|
4
|
+
* A coding agent that opens pull requests does it in a git checkout, and until
|
|
5
|
+
* `--concurrency` there was only ever one run touching it. Two runs sharing one
|
|
6
|
+
* checkout switch each other's branch mid-edit and commit each other's
|
|
7
|
+
* half-finished files — the reason runs were serialized in the first place. So
|
|
8
|
+
* when the operator names the repositories the agent works on, each
|
|
9
|
+
* conversation gets its own `git worktree` of every one of them:
|
|
10
|
+
*
|
|
11
|
+
* $PROHOST_HOME/worktrees/<conversation>/<repo-name>
|
|
12
|
+
*
|
|
13
|
+
* Per *conversation*, not per run, for the same reason sessions are: a
|
|
14
|
+
* follow-up in the thread ("address the review comments") resumes the session
|
|
15
|
+
* that made the branch, and must find that branch where it left it. Runs of one
|
|
16
|
+
* conversation never overlap (see `scheduler.ts`), so one checkout per
|
|
17
|
+
* conversation is exactly as isolated as one per run and far cheaper.
|
|
18
|
+
*
|
|
19
|
+
* The harness makes the worktree; the agent never needs `git worktree add`.
|
|
20
|
+
* The agent command's working directory does not change — it stays the
|
|
21
|
+
* workspace, because that is where its `.claude/` configuration lives and what
|
|
22
|
+
* its sessions are keyed on. The checkouts are named in the prompt and in
|
|
23
|
+
* `$PROHOST_RUN_REPOS`.
|
|
24
|
+
*
|
|
25
|
+
* Worktrees share the source repository's object store, so a branch committed
|
|
26
|
+
* in one survives the worktree being removed. That is what makes pruning safe:
|
|
27
|
+
* `git worktree remove` (never `--force`) refuses a checkout with uncommitted
|
|
28
|
+
* or untracked files, and those are the only things a worktree holds alone.
|
|
29
|
+
*/
|
|
30
|
+
import { execFile } from 'node:child_process';
|
|
31
|
+
import { createHash } from 'node:crypto';
|
|
32
|
+
import { existsSync, mkdirSync, readdirSync, rmdirSync, rmSync, statSync, utimesSync, writeFileSync } from 'node:fs';
|
|
33
|
+
import path from 'node:path';
|
|
34
|
+
import { prohostHome } from './credentials.js';
|
|
35
|
+
/** Child-process env var naming the directory that holds this run's checkouts. */
|
|
36
|
+
export const RUN_REPOS_ENV_VAR = 'PROHOST_RUN_REPOS';
|
|
37
|
+
/** Touched on every run of a conversation; its mtime is "last used". */
|
|
38
|
+
const MARKER_FILENAME = '.last-used';
|
|
39
|
+
/** A conversation's checkouts idle this long are removed, if clean. */
|
|
40
|
+
export const WORKTREE_RETENTION_MS = 14 * 24 * 3_600_000;
|
|
41
|
+
/** Pruning walks every conversation directory, so it is not done per run. */
|
|
42
|
+
const PRUNE_INTERVAL_MS = 24 * 3_600_000;
|
|
43
|
+
/** Checking out a large repository takes a while; a wedged git must still end. */
|
|
44
|
+
const GIT_TIMEOUT_MS = 10 * 60_000;
|
|
45
|
+
export class ReposFlagError extends Error {
|
|
46
|
+
}
|
|
47
|
+
export function worktreesRoot(env = process.env) {
|
|
48
|
+
return path.join(prohostHome(env), 'worktrees');
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Directory name for a lane (see `sessionKeyFor`).
|
|
52
|
+
*
|
|
53
|
+
* Readable enough to recognise in `ls`, and suffixed with a digest of the whole
|
|
54
|
+
* key because the readable part is lossy: two keys that differ only in
|
|
55
|
+
* punctuation, or in the agent they belong to, must not share a checkout.
|
|
56
|
+
*/
|
|
57
|
+
export function laneDirName(lane) {
|
|
58
|
+
const surface = lane.includes('/') ? lane.slice(lane.indexOf('/') + 1) : lane;
|
|
59
|
+
const readable = surface.replace(/[^A-Za-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 60);
|
|
60
|
+
const digest = createHash('sha256').update(lane).digest('hex').slice(0, 8);
|
|
61
|
+
return readable ? `${readable}-${digest}` : digest;
|
|
62
|
+
}
|
|
63
|
+
function git(args, env) {
|
|
64
|
+
return new Promise((resolve) => {
|
|
65
|
+
execFile('git', args, { env: { ...process.env, ...env }, timeout: GIT_TIMEOUT_MS, maxBuffer: 4 * 1024 * 1024 }, (error, stdout, stderr) => {
|
|
66
|
+
resolve({ ok: error === null, stdout: String(stdout), stderr: String(stderr) || (error?.message ?? '') });
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
function lastLine(text) {
|
|
71
|
+
const lines = text.trim().split('\n');
|
|
72
|
+
return (lines[lines.length - 1] ?? '').trim();
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Read `--repos a,b` into absolute paths, refusing what can never work.
|
|
76
|
+
*
|
|
77
|
+
* An empty list and two checkouts with one directory name are always errors.
|
|
78
|
+
* A path that is not a git checkout is an error only when `requireCheckouts`
|
|
79
|
+
* is set — which `install-daemon` does and `agent run` does not. At install
|
|
80
|
+
* time a mistyped path should stop the install; at run time the same check
|
|
81
|
+
* would make a daemon whose repository was moved, deleted or is on an
|
|
82
|
+
* unmounted volume exit before connecting, and the service manager restart it
|
|
83
|
+
* forever. A running harness instead reports the checkout it could not make on
|
|
84
|
+
* each run, and keeps answering.
|
|
85
|
+
*
|
|
86
|
+
* @throws ReposFlagError naming the entry that is wrong.
|
|
87
|
+
*/
|
|
88
|
+
export function parseReposFlag(raw, options = {}) {
|
|
89
|
+
const entries = raw
|
|
90
|
+
.split(',')
|
|
91
|
+
.map((entry) => entry.trim())
|
|
92
|
+
.filter((entry) => entry.length > 0);
|
|
93
|
+
if (entries.length === 0)
|
|
94
|
+
throw new ReposFlagError('--repos needs at least one path to a git checkout.');
|
|
95
|
+
const repos = [];
|
|
96
|
+
const names = new Map();
|
|
97
|
+
for (const entry of entries) {
|
|
98
|
+
const repo = path.resolve(entry);
|
|
99
|
+
if (options.requireCheckouts && !isCheckout(repo)) {
|
|
100
|
+
throw new ReposFlagError(`--repos: ${repo} is not the top of a git checkout (no .git there).`);
|
|
101
|
+
}
|
|
102
|
+
const name = path.basename(repo);
|
|
103
|
+
const clash = names.get(name);
|
|
104
|
+
if (clash && clash !== repo) {
|
|
105
|
+
throw new ReposFlagError(`--repos: ${clash} and ${repo} are both named "${name}"; their checkouts would land in the same directory.`);
|
|
106
|
+
}
|
|
107
|
+
if (!clash)
|
|
108
|
+
repos.push(repo);
|
|
109
|
+
names.set(name, repo);
|
|
110
|
+
}
|
|
111
|
+
return repos;
|
|
112
|
+
}
|
|
113
|
+
/** Whether `repo` is the top of a git checkout (a clone, or a worktree of one). */
|
|
114
|
+
export function isCheckout(repo) {
|
|
115
|
+
return existsSync(path.join(repo, '.git'));
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Makes, reuses and prunes per-conversation worktrees for one harness.
|
|
119
|
+
*
|
|
120
|
+
* Every git mutation goes through one in-process queue. Lanes run side by
|
|
121
|
+
* side, and two `git worktree add` calls against the same repository race for
|
|
122
|
+
* the same administrative name; pruning, meanwhile, must never remove the
|
|
123
|
+
* directory a run arriving this instant is about to use.
|
|
124
|
+
*/
|
|
125
|
+
export class WorktreeManager {
|
|
126
|
+
repos;
|
|
127
|
+
env;
|
|
128
|
+
log;
|
|
129
|
+
now;
|
|
130
|
+
retentionMs;
|
|
131
|
+
chain = Promise.resolve();
|
|
132
|
+
lastPrunedAt = 0;
|
|
133
|
+
constructor(options) {
|
|
134
|
+
this.repos = options.repos;
|
|
135
|
+
this.env = options.env ?? process.env;
|
|
136
|
+
this.log = options.log ?? (() => { });
|
|
137
|
+
this.now = options.now ?? Date.now;
|
|
138
|
+
this.retentionMs = options.retentionMs ?? WORKTREE_RETENTION_MS;
|
|
139
|
+
}
|
|
140
|
+
get enabled() {
|
|
141
|
+
return this.repos.length > 0;
|
|
142
|
+
}
|
|
143
|
+
exclusive(work) {
|
|
144
|
+
const next = this.chain.then(work, work);
|
|
145
|
+
this.chain = next.catch(() => { });
|
|
146
|
+
return next;
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* Ensure this lane has a worktree of every repository, and say where.
|
|
150
|
+
*
|
|
151
|
+
* Never rejects and never fails the run: most runs are questions that touch
|
|
152
|
+
* no repository at all, so a checkout that could not be made is reported in
|
|
153
|
+
* {@link PreparedCheckouts.failures} for the prompt to tell the agent about.
|
|
154
|
+
*/
|
|
155
|
+
prepare(lane) {
|
|
156
|
+
return this.exclusive(async () => {
|
|
157
|
+
const dir = path.join(worktreesRoot(this.env), laneDirName(lane));
|
|
158
|
+
const prepared = { dir, checkouts: [], failures: [] };
|
|
159
|
+
try {
|
|
160
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
161
|
+
this.touch(dir);
|
|
162
|
+
}
|
|
163
|
+
catch (err) {
|
|
164
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
165
|
+
prepared.failures = this.repos.map((source) => ({ source, reason }));
|
|
166
|
+
return prepared;
|
|
167
|
+
}
|
|
168
|
+
for (const source of this.repos) {
|
|
169
|
+
const name = path.basename(source);
|
|
170
|
+
const target = path.join(dir, name);
|
|
171
|
+
let failure;
|
|
172
|
+
try {
|
|
173
|
+
failure = isCheckout(target) ? await this.reuse(source, target) : await this.add(source, target);
|
|
174
|
+
}
|
|
175
|
+
catch (err) {
|
|
176
|
+
failure = err instanceof Error ? err.message : String(err);
|
|
177
|
+
}
|
|
178
|
+
if (failure)
|
|
179
|
+
prepared.failures.push({ source, reason: failure });
|
|
180
|
+
else
|
|
181
|
+
prepared.checkouts.push({ name, path: target, source });
|
|
182
|
+
}
|
|
183
|
+
// Housekeeping for other conversations must not cost this one its run.
|
|
184
|
+
await this.pruneIfDue(dir).catch((err) => {
|
|
185
|
+
this.log(` ! could not prune idle checkouts (${err instanceof Error ? err.message : String(err)})`);
|
|
186
|
+
});
|
|
187
|
+
return prepared;
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
/** The repository a checkout's history lives in, as one comparable path. */
|
|
191
|
+
async commonDir(checkout) {
|
|
192
|
+
const result = await git(['-C', checkout, 'rev-parse', '--path-format=absolute', '--git-common-dir'], this.env);
|
|
193
|
+
const dir = result.stdout.trim();
|
|
194
|
+
return result.ok && dir ? dir : undefined;
|
|
195
|
+
}
|
|
196
|
+
/**
|
|
197
|
+
* Keep an existing checkout — once it is proven to be a worktree of `source`.
|
|
198
|
+
*
|
|
199
|
+
* The directory is named after the repository's basename, so pointing
|
|
200
|
+
* `--repos` from `/old/app` to `/new/app` leaves every conversation with an
|
|
201
|
+
* `app` checkout of the *old* repository. Handing that to the agent as a
|
|
202
|
+
* checkout of the new one would have it commit to the wrong repository.
|
|
203
|
+
*/
|
|
204
|
+
async reuse(source, target) {
|
|
205
|
+
const [mine, theirs] = await Promise.all([this.commonDir(target), this.commonDir(source)]);
|
|
206
|
+
if (theirs === undefined)
|
|
207
|
+
return `${source} is not a git checkout`;
|
|
208
|
+
if (mine === theirs)
|
|
209
|
+
return undefined;
|
|
210
|
+
// Not ours to throw away: without `--force`, git removes it only if it
|
|
211
|
+
// holds nothing uncommitted.
|
|
212
|
+
const removed = mine === undefined
|
|
213
|
+
? { ok: false, stderr: 'it is not a usable git checkout' }
|
|
214
|
+
: await git(['--git-dir', mine, 'worktree', 'remove', target], this.env);
|
|
215
|
+
if (!removed.ok) {
|
|
216
|
+
return (`${target} is a checkout of a different repository and could not be replaced ` +
|
|
217
|
+
`(${lastLine(removed.stderr) || 'git would not remove it'})`);
|
|
218
|
+
}
|
|
219
|
+
this.log(` ⚙ replaced ${target}: it was a checkout of ${mine}, not of ${source}`);
|
|
220
|
+
return this.add(source, target);
|
|
221
|
+
}
|
|
222
|
+
/** Returns why the worktree could not be made, or `undefined` when it was. */
|
|
223
|
+
async add(source, target) {
|
|
224
|
+
// A worktree whose directory was deleted by hand is still registered, and
|
|
225
|
+
// git refuses to add another at that path until the record is pruned.
|
|
226
|
+
await git(['-C', source, 'worktree', 'prune'], this.env);
|
|
227
|
+
// Detached at the remote's default branch: the agent cuts its own branch,
|
|
228
|
+
// and a detached HEAD cannot collide with a branch checked out elsewhere.
|
|
229
|
+
let base = 'HEAD';
|
|
230
|
+
for (const candidate of ['origin/HEAD', 'origin/main', 'origin/master']) {
|
|
231
|
+
const known = await git(['-C', source, 'rev-parse', '--verify', '--quiet', `${candidate}^{commit}`], this.env);
|
|
232
|
+
if (known.ok) {
|
|
233
|
+
base = candidate;
|
|
234
|
+
break;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
const added = await git(['-C', source, 'worktree', 'add', '--detach', target, base], this.env);
|
|
238
|
+
if (added.ok) {
|
|
239
|
+
this.log(` ⚙ checkout ready: ${target} (worktree of ${source} at ${base})`);
|
|
240
|
+
return undefined;
|
|
241
|
+
}
|
|
242
|
+
// A half-made directory would be mistaken for a checkout on the next run.
|
|
243
|
+
rmSync(target, { recursive: true, force: true });
|
|
244
|
+
return lastLine(added.stderr) || 'git worktree add failed';
|
|
245
|
+
}
|
|
246
|
+
touch(dir) {
|
|
247
|
+
const marker = path.join(dir, MARKER_FILENAME);
|
|
248
|
+
const stamp = new Date(this.now());
|
|
249
|
+
if (existsSync(marker))
|
|
250
|
+
utimesSync(marker, stamp, stamp);
|
|
251
|
+
else {
|
|
252
|
+
writeFileSync(marker, '');
|
|
253
|
+
utimesSync(marker, stamp, stamp);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
async pruneIfDue(keep) {
|
|
257
|
+
if (this.now() - this.lastPrunedAt < PRUNE_INTERVAL_MS)
|
|
258
|
+
return;
|
|
259
|
+
this.lastPrunedAt = this.now();
|
|
260
|
+
await this.prune(keep);
|
|
261
|
+
}
|
|
262
|
+
/** Remove idle conversations' checkouts, never the one about to be used. */
|
|
263
|
+
async prune(keep) {
|
|
264
|
+
const root = worktreesRoot(this.env);
|
|
265
|
+
let lanes;
|
|
266
|
+
try {
|
|
267
|
+
lanes = readdirSync(root);
|
|
268
|
+
}
|
|
269
|
+
catch {
|
|
270
|
+
return; // nothing has ever been checked out
|
|
271
|
+
}
|
|
272
|
+
for (const lane of lanes) {
|
|
273
|
+
const dir = path.join(root, lane);
|
|
274
|
+
if (dir === keep)
|
|
275
|
+
continue;
|
|
276
|
+
try {
|
|
277
|
+
if (!statSync(dir).isDirectory())
|
|
278
|
+
continue;
|
|
279
|
+
const marker = path.join(dir, MARKER_FILENAME);
|
|
280
|
+
const usedAt = (existsSync(marker) ? statSync(marker) : statSync(dir)).mtimeMs;
|
|
281
|
+
if (this.now() - usedAt < this.retentionMs)
|
|
282
|
+
continue;
|
|
283
|
+
await this.removeLane(dir);
|
|
284
|
+
}
|
|
285
|
+
catch (err) {
|
|
286
|
+
this.log(` ! could not prune ${dir} (${err instanceof Error ? err.message : String(err)})`);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
async removeLane(dir) {
|
|
291
|
+
let kept = 0;
|
|
292
|
+
for (const name of readdirSync(dir)) {
|
|
293
|
+
if (name === MARKER_FILENAME)
|
|
294
|
+
continue;
|
|
295
|
+
const target = path.join(dir, name);
|
|
296
|
+
if (!existsSync(path.join(target, '.git'))) {
|
|
297
|
+
kept += 1;
|
|
298
|
+
continue;
|
|
299
|
+
}
|
|
300
|
+
// Asked of the worktree itself, so pruning still works for a repository
|
|
301
|
+
// the operator has since dropped from `--repos`.
|
|
302
|
+
const common = await git(['-C', target, 'rev-parse', '--path-format=absolute', '--git-common-dir'], this.env);
|
|
303
|
+
const removed = common.ok
|
|
304
|
+
? await git(['--git-dir', common.stdout.trim(), 'worktree', 'remove', target], this.env)
|
|
305
|
+
: common;
|
|
306
|
+
if (!removed.ok) {
|
|
307
|
+
kept += 1;
|
|
308
|
+
this.log(` ! kept idle checkout ${target} — ${lastLine(removed.stderr) || 'git would not remove it'}`);
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
if (kept === 0) {
|
|
312
|
+
rmSync(path.join(dir, MARKER_FILENAME), { force: true });
|
|
313
|
+
rmdirSync(dir);
|
|
314
|
+
this.log(` ⚙ removed idle checkouts in ${dir}`);
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
* npx @prohost/cli agent pair --code <pairing-code>
|
|
9
9
|
* npx @prohost/cli agent run --exec 'claude -p'
|
|
10
10
|
* npx @prohost/cli agent install-daemon --exec 'claude -p'
|
|
11
|
+
* npx @prohost/cli usage connect --key <api-key>
|
|
11
12
|
*
|
|
12
13
|
* Parses arguments with a zero-dep, zero-magic flag parser so the published
|
|
13
14
|
* binary stays small and starts fast.
|
package/dist/index.js
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
* npx @prohost/cli agent pair --code <pairing-code>
|
|
9
9
|
* npx @prohost/cli agent run --exec 'claude -p'
|
|
10
10
|
* npx @prohost/cli agent install-daemon --exec 'claude -p'
|
|
11
|
+
* npx @prohost/cli usage connect --key <api-key>
|
|
11
12
|
*
|
|
12
13
|
* Parses arguments with a zero-dep, zero-magic flag parser so the published
|
|
13
14
|
* binary stays small and starts fast.
|
|
@@ -17,6 +18,7 @@ import process from 'node:process';
|
|
|
17
18
|
import { fileURLToPath } from 'node:url';
|
|
18
19
|
import { runAgent } from './agent/command.js';
|
|
19
20
|
import { DEFAULT_WS_URL, runListen } from './listen.js';
|
|
21
|
+
import { runUsage } from './usage/command.js';
|
|
20
22
|
import { CLI_VERSION } from './version.js';
|
|
21
23
|
function printHelp() {
|
|
22
24
|
const lines = [
|
|
@@ -25,13 +27,17 @@ function printHelp() {
|
|
|
25
27
|
'Usage:',
|
|
26
28
|
' prohost listen --forward-to <local-url> [--api-key <key>] [--url <ws-url>] [--machine <name>] [--subscription-id <id>]',
|
|
27
29
|
' prohost agent pair --code <pairing-code> [--account <label>] [--webhook-url <https-url>] [--base-url <url>]',
|
|
28
|
-
" prohost agent run --exec '<command>' [--
|
|
30
|
+
" prohost agent run --exec '<command>' [--concurrency <n>] [--repos <checkout,…>] [--idle-timeout <seconds>]",
|
|
31
|
+
' [--timeout <seconds>] [--dry-run] [--workdir <dir>]',
|
|
29
32
|
" prohost agent install-daemon --exec '<command>' [--workdir <dir>] [--account <label>] Keep the agent running",
|
|
30
33
|
' prohost agent uninstall-daemon',
|
|
31
34
|
' prohost agent account add <label> --runtime claude|codex [--billing subscription|api_key]',
|
|
32
35
|
' prohost agent account list | remove <label>',
|
|
33
36
|
' prohost agent list Every paired agent on this machine, and its accounts',
|
|
34
37
|
' prohost agent workspace init --developer --repo <checkout> [--workdir <dir>]',
|
|
38
|
+
' prohost usage connect [--key <api-key>] [--base-url <url>] Report AI usage without pairing an agent',
|
|
39
|
+
' prohost usage discover [--dry-run] | start [--once] [--machine <name>] | status',
|
|
40
|
+
' prohost usage install-daemon [--machine <name>] | uninstall-daemon',
|
|
35
41
|
'',
|
|
36
42
|
'listen flags:',
|
|
37
43
|
' --forward-to Local URL to POST each webhook payload to (required)',
|
|
@@ -53,6 +59,14 @@ function printHelp() {
|
|
|
53
59
|
' --timeout Seconds one run may take before it is killed, however busy it is.',
|
|
54
60
|
' Defaults to 0 — no wall-clock limit, because a long run is not a stuck',
|
|
55
61
|
' one. Set it if you want a hard ceiling as well as the stall watchdog.',
|
|
62
|
+
' --concurrency How many runs may execute at once, 1 to 16. Defaults to 1. Runs in the same',
|
|
63
|
+
' conversation always take turns, so only different conversations overlap.',
|
|
64
|
+
' Every run spends the same login\'s quota, and they share this machine —',
|
|
65
|
+
' pair it with --repos if the agent edits code.',
|
|
66
|
+
' --repos Comma-separated git checkouts the agent works on. Each conversation gets',
|
|
67
|
+
' its own `git worktree` of every one under $PROHOST_HOME/worktrees, kept for',
|
|
68
|
+
' the life of the thread and removed after 14 idle days if it is clean. The',
|
|
69
|
+
' agent is told to work there and to leave the originals alone.',
|
|
56
70
|
' --dry-run Print the reply that would be posted; write nothing.',
|
|
57
71
|
' --workdir Working directory for the agent command. Defaults to $PROHOST_HOME/workspace.',
|
|
58
72
|
' Avoid pointing it at a code checkout: agents read CLAUDE.md, .claude/settings.json',
|
|
@@ -90,9 +104,19 @@ function printHelp() {
|
|
|
90
104
|
' Adds the ProhostAI PR-protocol skill and the `gh pr create` gate hook from --repo (a',
|
|
91
105
|
' backend-service checkout) to the agent workspace\'s .claude/. Opt-in; nothing else changes.',
|
|
92
106
|
'',
|
|
107
|
+
'Usage reporting (observer mode):',
|
|
108
|
+
' `usage connect` stores an API key with the usage:write scope ($PROHOST_HOME/usage.json, owner-only;',
|
|
109
|
+
' a machine with a paired agent can reuse its credential) and registers every Claude Code and Codex',
|
|
110
|
+
' login it finds: ~/.claude*, ~/.codex*, $CLAUDE_CONFIG_DIR, $CODEX_HOME (`~/.claude-work` → "work").',
|
|
111
|
+
' `usage start` then reports, every 5 minutes, each login\'s remaining capacity — asked of the',
|
|
112
|
+
' unmodified CLI, at most every 15 minutes per login — and daily token totals per model and project',
|
|
113
|
+
' folder, read from the local session logs (last 30 days, then incrementally). Never prompt text.',
|
|
114
|
+
' `--once` runs one pass and exits. `usage status` shows what was last sent.',
|
|
115
|
+
'',
|
|
93
116
|
'agent install-daemon flags: (macOS: launchd; Linux: prints a systemd-user unit)',
|
|
94
117
|
' --exec The same command you would pass to `agent run` (required).',
|
|
95
|
-
' --workdir, --idle-timeout, --timeout, --machine, --url,
|
|
118
|
+
' --workdir, --concurrency, --repos, --idle-timeout, --timeout, --machine, --url,',
|
|
119
|
+
' --allow-unverified, --safe-tools',
|
|
96
120
|
' — passed to `agent run`.',
|
|
97
121
|
'',
|
|
98
122
|
'Global flags:',
|
|
@@ -172,6 +196,9 @@ async function main(argv) {
|
|
|
172
196
|
if (command === 'agent') {
|
|
173
197
|
return runAgent(subcommand, flags, printHelp, args);
|
|
174
198
|
}
|
|
199
|
+
if (command === 'usage') {
|
|
200
|
+
return runUsage(subcommand, flags, printHelp);
|
|
201
|
+
}
|
|
175
202
|
if (command !== 'listen') {
|
|
176
203
|
process.stderr.write(`Unknown command: ${command}\n\n`);
|
|
177
204
|
printHelp();
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* When the usage reporter may try an endpoint again, and what it says about it.
|
|
3
|
+
*
|
|
4
|
+
* The reporter runs for weeks under launchd, so it must never crash and never
|
|
5
|
+
* fill a log. Three kinds of failure, treated differently:
|
|
6
|
+
*
|
|
7
|
+
* - **Gated** — 401, 403, 404. The routes are not deployed yet, the workspace
|
|
8
|
+
* doesn't have the feature, or the key lacks `usage:write`. None of that
|
|
9
|
+
* changes in minutes, so wait {@link GATED_BACKOFF_MS} and say so once —
|
|
10
|
+
* not on every retry.
|
|
11
|
+
* - **Transient** — no response, 408, 429, 5xx. Retry with exponential backoff
|
|
12
|
+
* from {@link BASE_BACKOFF_MS} up to {@link MAX_BACKOFF_MS}; logged once
|
|
13
|
+
* per streak.
|
|
14
|
+
* - **Rejected** — any other 4xx. A body this build got wrong; retrying soon
|
|
15
|
+
* won't fix it, so it waits like a gated one and logs the server's reason.
|
|
16
|
+
*
|
|
17
|
+
* A success after any of these says "recovered" once and resets everything.
|
|
18
|
+
*/
|
|
19
|
+
import type { ApiResult } from '../agent/api.js';
|
|
20
|
+
export declare const BASE_BACKOFF_MS = 60000;
|
|
21
|
+
export declare const MAX_BACKOFF_MS: number;
|
|
22
|
+
export declare const GATED_BACKOFF_MS: number;
|
|
23
|
+
export type FailureKind = 'gated' | 'transient' | 'rejected';
|
|
24
|
+
export declare function classifyFailure(result: ApiResult): FailureKind;
|
|
25
|
+
export declare class Backoff {
|
|
26
|
+
private readonly name;
|
|
27
|
+
private readonly now;
|
|
28
|
+
private nextAt;
|
|
29
|
+
private failures;
|
|
30
|
+
private lastKind;
|
|
31
|
+
private lastStatus;
|
|
32
|
+
constructor(name: string, now?: () => number);
|
|
33
|
+
/** Whether a request may be made now. */
|
|
34
|
+
ready(): boolean;
|
|
35
|
+
/** When the next attempt is allowed (epoch ms; 0 = now). */
|
|
36
|
+
retryAt(): number;
|
|
37
|
+
/**
|
|
38
|
+
* Record an attempt's outcome. Returns a line to log, or `undefined` when
|
|
39
|
+
* this outcome was already reported.
|
|
40
|
+
*/
|
|
41
|
+
record(result: ApiResult): string | undefined;
|
|
42
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* When the usage reporter may try an endpoint again, and what it says about it.
|
|
3
|
+
*
|
|
4
|
+
* The reporter runs for weeks under launchd, so it must never crash and never
|
|
5
|
+
* fill a log. Three kinds of failure, treated differently:
|
|
6
|
+
*
|
|
7
|
+
* - **Gated** — 401, 403, 404. The routes are not deployed yet, the workspace
|
|
8
|
+
* doesn't have the feature, or the key lacks `usage:write`. None of that
|
|
9
|
+
* changes in minutes, so wait {@link GATED_BACKOFF_MS} and say so once —
|
|
10
|
+
* not on every retry.
|
|
11
|
+
* - **Transient** — no response, 408, 429, 5xx. Retry with exponential backoff
|
|
12
|
+
* from {@link BASE_BACKOFF_MS} up to {@link MAX_BACKOFF_MS}; logged once
|
|
13
|
+
* per streak.
|
|
14
|
+
* - **Rejected** — any other 4xx. A body this build got wrong; retrying soon
|
|
15
|
+
* won't fix it, so it waits like a gated one and logs the server's reason.
|
|
16
|
+
*
|
|
17
|
+
* A success after any of these says "recovered" once and resets everything.
|
|
18
|
+
*/
|
|
19
|
+
import { describeFailure } from '../agent/api.js';
|
|
20
|
+
export const BASE_BACKOFF_MS = 60_000;
|
|
21
|
+
export const MAX_BACKOFF_MS = 30 * 60_000;
|
|
22
|
+
export const GATED_BACKOFF_MS = 30 * 60_000;
|
|
23
|
+
export function classifyFailure(result) {
|
|
24
|
+
const { status } = result;
|
|
25
|
+
if (status === 401 || status === 403 || status === 404)
|
|
26
|
+
return 'gated';
|
|
27
|
+
if (status === 0 || status === 408 || status === 429 || status >= 500)
|
|
28
|
+
return 'transient';
|
|
29
|
+
return 'rejected';
|
|
30
|
+
}
|
|
31
|
+
export class Backoff {
|
|
32
|
+
name;
|
|
33
|
+
now;
|
|
34
|
+
nextAt = 0;
|
|
35
|
+
failures = 0;
|
|
36
|
+
lastKind;
|
|
37
|
+
lastStatus;
|
|
38
|
+
constructor(name, now = Date.now) {
|
|
39
|
+
this.name = name;
|
|
40
|
+
this.now = now;
|
|
41
|
+
}
|
|
42
|
+
/** Whether a request may be made now. */
|
|
43
|
+
ready() {
|
|
44
|
+
return this.now() >= this.nextAt;
|
|
45
|
+
}
|
|
46
|
+
/** When the next attempt is allowed (epoch ms; 0 = now). */
|
|
47
|
+
retryAt() {
|
|
48
|
+
return this.nextAt;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Record an attempt's outcome. Returns a line to log, or `undefined` when
|
|
52
|
+
* this outcome was already reported.
|
|
53
|
+
*/
|
|
54
|
+
record(result) {
|
|
55
|
+
if (result.ok) {
|
|
56
|
+
const wasFailing = this.failures > 0;
|
|
57
|
+
this.failures = 0;
|
|
58
|
+
this.nextAt = 0;
|
|
59
|
+
this.lastKind = undefined;
|
|
60
|
+
this.lastStatus = undefined;
|
|
61
|
+
return wasFailing ? `${this.name}: recovered` : undefined;
|
|
62
|
+
}
|
|
63
|
+
const kind = classifyFailure(result);
|
|
64
|
+
this.failures += 1;
|
|
65
|
+
const delay = kind === 'transient'
|
|
66
|
+
? Math.min(MAX_BACKOFF_MS, BASE_BACKOFF_MS * 2 ** Math.min(this.failures - 1, 16))
|
|
67
|
+
: GATED_BACKOFF_MS;
|
|
68
|
+
this.nextAt = this.now() + delay;
|
|
69
|
+
const repeat = this.lastKind === kind && (kind === 'transient' || this.lastStatus === result.status);
|
|
70
|
+
this.lastKind = kind;
|
|
71
|
+
this.lastStatus = result.status;
|
|
72
|
+
if (repeat)
|
|
73
|
+
return undefined;
|
|
74
|
+
const minutes = Math.round(delay / 60_000);
|
|
75
|
+
if (kind === 'gated') {
|
|
76
|
+
return (`${this.name}: ${describeFailure(result)} — usage reporting is not available for this key yet ` +
|
|
77
|
+
`(not deployed, not enabled for the workspace, or the key lacks usage:write). ` +
|
|
78
|
+
`Retrying every ${minutes} min.`);
|
|
79
|
+
}
|
|
80
|
+
if (kind === 'rejected') {
|
|
81
|
+
return `${this.name}: ${describeFailure(result)} rejected${result.error ? ` (${result.error.slice(0, 200)})` : ''}. Retrying in ${minutes} min.`;
|
|
82
|
+
}
|
|
83
|
+
return `${this.name}: ${describeFailure(result)}. Retrying with backoff (next in ${minutes} min).`;
|
|
84
|
+
}
|
|
85
|
+
}
|