@prohost/cli 0.8.3 → 0.9.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 +26 -0
- package/README.md +46 -1
- package/dist/agent/account_runtime.d.ts +19 -3
- package/dist/agent/account_runtime.js +28 -6
- 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.js +12 -2
- package/dist/version.d.ts +2 -2
- package/dist/version.js +1 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,32 @@ Versions follow [semver](https://semver.org/). Publishing is automated: merging
|
|
|
4
4
|
a version bump to `main` triggers `.github/workflows/npm-publish-cli.yml`, which
|
|
5
5
|
builds via `prepack`, runs the suite, publishes, and tags `cli-v<version>`.
|
|
6
6
|
|
|
7
|
+
## 0.9.0
|
|
8
|
+
|
|
9
|
+
A quick question no longer waits behind a long job in another thread.
|
|
10
|
+
|
|
11
|
+
- **`agent run --concurrency <n>`** (1–16, default 1) lets runs in different
|
|
12
|
+
conversations execute at once. Runs in the same conversation still take
|
|
13
|
+
turns in arrival order, because each turn resumes the session the previous
|
|
14
|
+
one left. Without the flag nothing changes: one run at a time, in order.
|
|
15
|
+
Queued runs are heartbeated and can be stopped exactly as before.
|
|
16
|
+
- **`agent run --repos <checkout,…>`** gives every conversation its own
|
|
17
|
+
`git worktree` of each named repository under `$PROHOST_HOME/worktrees`,
|
|
18
|
+
so two runs never share a branch or a half-edited file. The prompt names
|
|
19
|
+
the checkouts and tells the agent to leave the originals alone, and the
|
|
20
|
+
directory is exported as `PROHOST_RUN_REPOS`. A conversation keeps its
|
|
21
|
+
checkouts between runs; they are removed after 14 idle days unless they
|
|
22
|
+
hold uncommitted or untracked files. A checkout that cannot be made does
|
|
23
|
+
not fail the run — the agent is told that repository is read-only for it.
|
|
24
|
+
- **`install-daemon` passes both flags through**, and validates them first so
|
|
25
|
+
a typo cannot crash-loop the daemon.
|
|
26
|
+
- **`PROHOST_PAIRED_RUN=1`** is set for every agent command the harness
|
|
27
|
+
starts. ProhostAI's own PR protocol uses it to end a run once the pull
|
|
28
|
+
request is open instead of holding the slot while CI runs.
|
|
29
|
+
- With `--concurrency` above 1 the agent is told other runs may be executing
|
|
30
|
+
beside it. The strict Claude MCP config is now written atomically, since
|
|
31
|
+
another run's `claude` may be reading it at that moment.
|
|
32
|
+
|
|
7
33
|
## 0.8.3
|
|
8
34
|
|
|
9
35
|
A paired agent's shell steps read as what they do, not "Running a command".
|
package/README.md
CHANGED
|
@@ -430,7 +430,8 @@ on the machine via `ps` and a process environment is not. Literal `headers`,
|
|
|
430
430
|
treatment: the CLI translates them to Codex's environment-indirection fields,
|
|
431
431
|
so their values do not appear in process arguments.
|
|
432
432
|
|
|
433
|
-
Runs are handled one at a time
|
|
433
|
+
Runs are handled one at a time unless you raise `--concurrency` (see "Running
|
|
434
|
+
several conversations at once"), and each run executes **at most once on this
|
|
434
435
|
machine** — even across restarts. Started runs are recorded in
|
|
435
436
|
`~/.prohost/runs.json` before the command launches, so a webhook redelivery
|
|
436
437
|
that arrives after a crash or restart will close the run out rather than run
|
|
@@ -483,6 +484,48 @@ prohost agent list # every agent here, its ac
|
|
|
483
484
|
Only one `agent run` can serve an agent home at a time: a second one exits,
|
|
484
485
|
naming the pid that holds `$PROHOST_HOME/run.lock`.
|
|
485
486
|
|
|
487
|
+
### Running several conversations at once
|
|
488
|
+
|
|
489
|
+
By default a second request waits for the first, however unrelated they are —
|
|
490
|
+
a one-line question in one thread sits behind a two-hour job in another.
|
|
491
|
+
`--concurrency <n>` (1 to 16) lets up to `n` runs execute at once:
|
|
492
|
+
|
|
493
|
+
```bash
|
|
494
|
+
prohost agent run --exec 'claude -p' --concurrency 3 --repos ~/code/app,~/code/site
|
|
495
|
+
```
|
|
496
|
+
|
|
497
|
+
- **Runs in the same conversation still take turns, in order.** Each turn
|
|
498
|
+
resumes the session the last one left, so two turns of one thread never
|
|
499
|
+
overlap. Only different conversations run side by side.
|
|
500
|
+
- **Every run spends the same login's quota.** Three runs at once use a
|
|
501
|
+
subscription's 5-hour window about three times as fast. Start small.
|
|
502
|
+
- **They share the machine.** The agent is told other runs may be executing
|
|
503
|
+
beside it. If it edits code, also pass `--repos`.
|
|
504
|
+
|
|
505
|
+
`--repos` takes a comma-separated list of git checkouts the agent works on.
|
|
506
|
+
Each conversation then gets its own `git worktree` of every one, at
|
|
507
|
+
`~/.prohost/worktrees/<conversation>/<repo-name>`, made on that conversation's
|
|
508
|
+
first run. The agent is told to do its repository work there and to leave your
|
|
509
|
+
originals alone; the directory is also exported to the command as
|
|
510
|
+
`$PROHOST_RUN_REPOS`. Its working directory does not change — it is still the
|
|
511
|
+
workspace, which is where its configuration and its sessions live.
|
|
512
|
+
|
|
513
|
+
A conversation keeps its checkouts between runs, so a follow-up finds the
|
|
514
|
+
branch the last run left. After 14 days without a run they are removed with
|
|
515
|
+
`git worktree remove`, which refuses a checkout holding uncommitted or
|
|
516
|
+
untracked files — those are kept and logged. Committed branches live in your
|
|
517
|
+
original repository and are never touched. Each worktree is a full working
|
|
518
|
+
copy, and dependencies installed inside it are not shared, so budget disk for
|
|
519
|
+
it.
|
|
520
|
+
|
|
521
|
+
`--repos` works without `--concurrency` too. If a checkout cannot be made (a
|
|
522
|
+
full disk, a path that is no longer a repository), the run still happens: the
|
|
523
|
+
agent is told to treat that repository as read-only for the run.
|
|
524
|
+
|
|
525
|
+
Every command the harness starts has `PROHOST_PAIRED_RUN=1` in its
|
|
526
|
+
environment, so instructions your agent reads from disk can tell a paired run
|
|
527
|
+
from any other use of the same files.
|
|
528
|
+
|
|
486
529
|
### A developer workspace
|
|
487
530
|
|
|
488
531
|
For an agent that works on the ProhostAI codebase, `prohost agent workspace
|
|
@@ -533,6 +576,8 @@ same judgement.
|
|
|
533
576
|
| `--exec` | *(required)* | Command to run for each requested run. Prompt arrives on stdin. |
|
|
534
577
|
| `--idle-timeout` | `900` | Seconds a run may produce **no output at all** before it is treated as stalled and killed. Any byte on stdout or stderr resets it. `0` disables it — see "Long runs". |
|
|
535
578
|
| `--timeout` | `0` | Seconds one run may take before the command is killed, however busy it is. `0` — the default — means no wall-clock limit. Set it if you want a hard ceiling as well. |
|
|
579
|
+
| `--concurrency` | `1` | How many runs may execute at once (1–16). Runs in one conversation always take turns. See "Running several conversations at once". |
|
|
580
|
+
| `--repos` | — | Comma-separated git checkouts the agent works on. Each conversation gets its own worktree of every one. |
|
|
536
581
|
| `--dry-run` | off | Print the reply that would be posted; write nothing. |
|
|
537
582
|
| `--workdir` | `~/.prohost/workspace` | Working directory for the command. See "Where your agent runs". |
|
|
538
583
|
| `--cwd` | — | Deprecated alias for `--workdir`. |
|
|
@@ -24,6 +24,15 @@ export declare class HarnessAccount {
|
|
|
24
24
|
private readonly initialLabel;
|
|
25
25
|
/** Label the most recent run was spawned with — what a run in flight is using. */
|
|
26
26
|
private spawnedLabel;
|
|
27
|
+
/**
|
|
28
|
+
* Labels of the runs executing right now, oldest first.
|
|
29
|
+
*
|
|
30
|
+
* One value was enough while runs were serialized. With `--concurrency`, a
|
|
31
|
+
* pick can land while one run is on the old account and the next run then
|
|
32
|
+
* starts on the new one — and a single field would let the second overwrite
|
|
33
|
+
* what the first is using.
|
|
34
|
+
*/
|
|
35
|
+
private readonly inFlight;
|
|
27
36
|
/** `requested_at` of the newest pick applied, so a late older one is dropped. */
|
|
28
37
|
private appliedRequestedAt;
|
|
29
38
|
constructor(options: {
|
|
@@ -45,8 +54,13 @@ export declare class HarnessAccount {
|
|
|
45
54
|
resolve(): AccountResolution;
|
|
46
55
|
/** {@link resolve} for a run about to be spawned; remembers which account it got. */
|
|
47
56
|
resolveForSpawn(): AccountResolution;
|
|
48
|
-
/**
|
|
49
|
-
|
|
57
|
+
/** The run spawned on `label` has ended. Pairs with {@link resolveForSpawn}. */
|
|
58
|
+
finishSpawn(label: string): void;
|
|
59
|
+
/**
|
|
60
|
+
* Whether the chosen account differs from the one a run was spawned with —
|
|
61
|
+
* the given run's, or the most recent run's when none is named.
|
|
62
|
+
*/
|
|
63
|
+
changedSinceSpawn(spawned?: string | undefined): boolean;
|
|
50
64
|
/** Spawn environment for the current account; empty when it can't be resolved. */
|
|
51
65
|
spawnEnv(): NodeJS.ProcessEnv;
|
|
52
66
|
/**
|
|
@@ -67,7 +81,9 @@ export declare class HarnessAccount {
|
|
|
67
81
|
*
|
|
68
82
|
* While a run executes, "this agent's" is the account it was spawned with,
|
|
69
83
|
* not a newer pick — the server would otherwise mark the pick fulfilled and
|
|
70
|
-
* credit that run's capacity to an account it isn't using.
|
|
84
|
+
* credit that run's capacity to an account it isn't using. With several runs
|
|
85
|
+
* executing it is the oldest one's: the pick is not fulfilled until the last
|
|
86
|
+
* run on the previous account has ended.
|
|
71
87
|
*/
|
|
72
88
|
snapshot(busy?: boolean): Promise<AccountsSnapshot>;
|
|
73
89
|
/** The command that signs the current account in, for a log line. */
|
|
@@ -17,6 +17,15 @@ export class HarnessAccount {
|
|
|
17
17
|
initialLabel;
|
|
18
18
|
/** Label the most recent run was spawned with — what a run in flight is using. */
|
|
19
19
|
spawnedLabel;
|
|
20
|
+
/**
|
|
21
|
+
* Labels of the runs executing right now, oldest first.
|
|
22
|
+
*
|
|
23
|
+
* One value was enough while runs were serialized. With `--concurrency`, a
|
|
24
|
+
* pick can land while one run is on the old account and the next run then
|
|
25
|
+
* starts on the new one — and a single field would let the second overwrite
|
|
26
|
+
* what the first is using.
|
|
27
|
+
*/
|
|
28
|
+
inFlight = [];
|
|
20
29
|
/** `requested_at` of the newest pick applied, so a late older one is dropped. */
|
|
21
30
|
appliedRequestedAt;
|
|
22
31
|
constructor(options) {
|
|
@@ -50,13 +59,24 @@ export class HarnessAccount {
|
|
|
50
59
|
/** {@link resolve} for a run about to be spawned; remembers which account it got. */
|
|
51
60
|
resolveForSpawn() {
|
|
52
61
|
const resolved = this.resolve();
|
|
53
|
-
if (resolved.ok)
|
|
62
|
+
if (resolved.ok) {
|
|
54
63
|
this.spawnedLabel = resolved.record.label;
|
|
64
|
+
this.inFlight.push(resolved.record.label);
|
|
65
|
+
}
|
|
55
66
|
return resolved;
|
|
56
67
|
}
|
|
57
|
-
/**
|
|
58
|
-
|
|
59
|
-
|
|
68
|
+
/** The run spawned on `label` has ended. Pairs with {@link resolveForSpawn}. */
|
|
69
|
+
finishSpawn(label) {
|
|
70
|
+
const index = this.inFlight.indexOf(label);
|
|
71
|
+
if (index !== -1)
|
|
72
|
+
this.inFlight.splice(index, 1);
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Whether the chosen account differs from the one a run was spawned with —
|
|
76
|
+
* the given run's, or the most recent run's when none is named.
|
|
77
|
+
*/
|
|
78
|
+
changedSinceSpawn(spawned = this.spawnedLabel) {
|
|
79
|
+
return spawned !== undefined && spawned !== this.label();
|
|
60
80
|
}
|
|
61
81
|
/** Spawn environment for the current account; empty when it can't be resolved. */
|
|
62
82
|
spawnEnv() {
|
|
@@ -96,10 +116,12 @@ export class HarnessAccount {
|
|
|
96
116
|
*
|
|
97
117
|
* While a run executes, "this agent's" is the account it was spawned with,
|
|
98
118
|
* not a newer pick — the server would otherwise mark the pick fulfilled and
|
|
99
|
-
* credit that run's capacity to an account it isn't using.
|
|
119
|
+
* credit that run's capacity to an account it isn't using. With several runs
|
|
120
|
+
* executing it is the oldest one's: the pick is not fulfilled until the last
|
|
121
|
+
* run on the previous account has ended.
|
|
100
122
|
*/
|
|
101
123
|
async snapshot(busy = false) {
|
|
102
|
-
const label = busy
|
|
124
|
+
const label = busy ? (this.inFlight[0] ?? this.spawnedLabel ?? this.label()) : this.label();
|
|
103
125
|
const record = resolveAgentAccount(label, this.runtime, this.env);
|
|
104
126
|
const resolved = record ? { ok: true, record } : { ok: false, error: '' };
|
|
105
127
|
const records = loadAccounts(this.env);
|
package/dist/agent/claude.js
CHANGED
|
@@ -14,7 +14,8 @@
|
|
|
14
14
|
* session is unresumable, if the JSON is not what we expect — the run still
|
|
15
15
|
* happens and still completes. These are enhancements that must fail soft.
|
|
16
16
|
*/
|
|
17
|
-
import {
|
|
17
|
+
import { randomBytes } from 'node:crypto';
|
|
18
|
+
import { chmodSync, mkdirSync, renameSync, writeFileSync } from 'node:fs';
|
|
18
19
|
import path from 'node:path';
|
|
19
20
|
import { prohostHome } from './credentials.js';
|
|
20
21
|
import { MCP_SERVER_NAME } from './mcp.js';
|
|
@@ -697,7 +698,12 @@ export function writeMcpConfig(options) {
|
|
|
697
698
|
const file = mcpConfigPath(env);
|
|
698
699
|
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
699
700
|
chmodSync(dir, 0o700);
|
|
700
|
-
|
|
701
|
-
|
|
701
|
+
// Written beside the target and renamed over it. Every run rewrites this
|
|
702
|
+
// file just before spawning, and with `--concurrency` another run's `claude`
|
|
703
|
+
// may be reading it at that instant — a plain write would let it read half.
|
|
704
|
+
const staged = `${file}.${process.pid}.${randomBytes(4).toString('hex')}.tmp`;
|
|
705
|
+
writeFileSync(staged, `${JSON.stringify(buildMcpConfig(options), null, 2)}\n`, { mode: 0o600 });
|
|
706
|
+
chmodSync(staged, 0o600);
|
|
707
|
+
renameSync(staged, file);
|
|
702
708
|
return file;
|
|
703
709
|
}
|
package/dist/agent/command.d.ts
CHANGED
|
@@ -29,6 +29,15 @@ export declare const INVALID_DURATION: unique symbol;
|
|
|
29
29
|
* had not.
|
|
30
30
|
*/
|
|
31
31
|
export declare function parseSeconds(raw: string | boolean | undefined): number | undefined | typeof INVALID_DURATION;
|
|
32
|
+
/** Returned by {@link parseConcurrency} for a value that isn't a run count. */
|
|
33
|
+
export declare const INVALID_CONCURRENCY: unique symbol;
|
|
34
|
+
/**
|
|
35
|
+
* Read `--concurrency`. Pure, like {@link parseSeconds}, and strict for the
|
|
36
|
+
* same reason: `--concurrency` with nothing after it arrives as `true`, and
|
|
37
|
+
* treating that as absent would leave someone believing their agent runs four
|
|
38
|
+
* things at once while it runs one.
|
|
39
|
+
*/
|
|
40
|
+
export declare function parseConcurrency(raw: string | boolean | undefined): number | undefined | typeof INVALID_CONCURRENCY;
|
|
32
41
|
/**
|
|
33
42
|
* Environment the daemon must be given explicitly.
|
|
34
43
|
*
|
package/dist/agent/command.js
CHANGED
|
@@ -16,6 +16,8 @@ import { acquireRunLock } from './lock.js';
|
|
|
16
16
|
import { PairError, runPair } from './pair.js';
|
|
17
17
|
import { CredentialsMissingError, ensureWorkspace, loadCredentials, prohostHome, tryLoadCredentials, } from './credentials.js';
|
|
18
18
|
import { DEFAULT_EXEC_TIMEOUT_MS, DEFAULT_IDLE_TIMEOUT_MS, runAgentHarness } from './run.js';
|
|
19
|
+
import { MAX_CONCURRENCY } from './scheduler.js';
|
|
20
|
+
import { ReposFlagError, isCheckout, parseReposFlag } from './worktrees.js';
|
|
19
21
|
import { announceUpgradeIfAvailable } from '../upgrade.js';
|
|
20
22
|
function flagString(flags, name) {
|
|
21
23
|
const value = flags[name];
|
|
@@ -119,6 +121,55 @@ function parseIdleTimeoutSeconds(flags) {
|
|
|
119
121
|
}
|
|
120
122
|
return parsed;
|
|
121
123
|
}
|
|
124
|
+
/** Returned by {@link parseConcurrency} for a value that isn't a run count. */
|
|
125
|
+
export const INVALID_CONCURRENCY = Symbol('invalid-concurrency');
|
|
126
|
+
/**
|
|
127
|
+
* Read `--concurrency`. Pure, like {@link parseSeconds}, and strict for the
|
|
128
|
+
* same reason: `--concurrency` with nothing after it arrives as `true`, and
|
|
129
|
+
* treating that as absent would leave someone believing their agent runs four
|
|
130
|
+
* things at once while it runs one.
|
|
131
|
+
*/
|
|
132
|
+
export function parseConcurrency(raw) {
|
|
133
|
+
if (raw === undefined)
|
|
134
|
+
return undefined;
|
|
135
|
+
if (typeof raw !== 'string' || !/^\d+$/.test(raw.trim()))
|
|
136
|
+
return INVALID_CONCURRENCY;
|
|
137
|
+
const count = Number(raw);
|
|
138
|
+
return count >= 1 && count <= MAX_CONCURRENCY ? count : INVALID_CONCURRENCY;
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* What `--concurrency` and `--repos` asked for, or `null` after explaining why not.
|
|
142
|
+
*
|
|
143
|
+
* `installing` decides how a `--repos` path that is not a git checkout is
|
|
144
|
+
* treated — see {@link parseReposFlag}. `agent run` only warns about it.
|
|
145
|
+
*/
|
|
146
|
+
function parseParallelFlags(flags, installing) {
|
|
147
|
+
const concurrency = parseConcurrency(flags.concurrency);
|
|
148
|
+
if (concurrency === INVALID_CONCURRENCY) {
|
|
149
|
+
process.stderr.write(`--concurrency must be a whole number from 1 to ${MAX_CONCURRENCY}.\n`);
|
|
150
|
+
return null;
|
|
151
|
+
}
|
|
152
|
+
if (flags.repos === undefined)
|
|
153
|
+
return { concurrency };
|
|
154
|
+
const raw = flagString(flags, 'repos');
|
|
155
|
+
if (!raw) {
|
|
156
|
+
process.stderr.write('--repos needs a comma-separated list of git checkouts, e.g. --repos ~/code/app\n');
|
|
157
|
+
return null;
|
|
158
|
+
}
|
|
159
|
+
try {
|
|
160
|
+
const repos = parseReposFlag(raw, { requireCheckouts: installing });
|
|
161
|
+
for (const repo of repos.filter((entry) => !isCheckout(entry))) {
|
|
162
|
+
process.stderr.write(`! --repos: ${repo} is not a git checkout right now. Runs will be told it is read-only until it is.\n`);
|
|
163
|
+
}
|
|
164
|
+
return { concurrency, repos };
|
|
165
|
+
}
|
|
166
|
+
catch (err) {
|
|
167
|
+
if (!(err instanceof ReposFlagError))
|
|
168
|
+
throw err;
|
|
169
|
+
process.stderr.write(`✗ ${err.message}\n`);
|
|
170
|
+
return null;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
122
173
|
/**
|
|
123
174
|
* Environment the daemon must be given explicitly.
|
|
124
175
|
*
|
|
@@ -210,6 +261,10 @@ async function installDaemonCommand(flags) {
|
|
|
210
261
|
const idleTimeoutSeconds = parseIdleTimeoutSeconds(flags);
|
|
211
262
|
if (idleTimeoutSeconds === INVALID_DURATION)
|
|
212
263
|
return 1;
|
|
264
|
+
// Same reasoning: a bad value here would crash-loop the daemon, not print.
|
|
265
|
+
const parallel = parseParallelFlags(flags, true);
|
|
266
|
+
if (!parallel)
|
|
267
|
+
return 1;
|
|
213
268
|
// Absolute, and created now. A supervisor does not inherit the installing
|
|
214
269
|
// shell's current directory, so a relative `--workdir .` copied verbatim into
|
|
215
270
|
// the plist names a directory that either doesn't exist or isn't the one the
|
|
@@ -235,6 +290,8 @@ async function installDaemonCommand(flags) {
|
|
|
235
290
|
url: flagString(flags, 'url'),
|
|
236
291
|
allowUnverified: flags['allow-unverified'] === true,
|
|
237
292
|
safeTools: flags['safe-tools'] === true || flags['no-skip-permissions'] === true,
|
|
293
|
+
concurrency: parallel.concurrency,
|
|
294
|
+
repos: parallel.repos,
|
|
238
295
|
};
|
|
239
296
|
const spec = {
|
|
240
297
|
label: launchdLabel(prohostHome()),
|
|
@@ -332,6 +389,9 @@ async function runCommand(flags) {
|
|
|
332
389
|
const parsedIdleTimeout = parseIdleTimeoutSeconds(flags);
|
|
333
390
|
if (parsedIdleTimeout === INVALID_DURATION)
|
|
334
391
|
return 1;
|
|
392
|
+
const parallel = parseParallelFlags(flags, false);
|
|
393
|
+
if (!parallel)
|
|
394
|
+
return 1;
|
|
335
395
|
// One harness per home: two would both execute every run.
|
|
336
396
|
const lock = acquireRunLock();
|
|
337
397
|
if (!lock.ok) {
|
|
@@ -340,8 +400,8 @@ async function runCommand(flags) {
|
|
|
340
400
|
'`prohost agent uninstall-daemon` — or give this agent its own $PROHOST_HOME.\n');
|
|
341
401
|
return 1;
|
|
342
402
|
}
|
|
343
|
-
// First signal stops accepting work and lets the in-flight
|
|
344
|
-
// reporting
|
|
403
|
+
// First signal stops accepting work and lets the in-flight runs finish
|
|
404
|
+
// reporting themselves (so the server doesn't wait on a run we abandoned); a
|
|
345
405
|
// second one means the operator wants out now.
|
|
346
406
|
const controller = new AbortController();
|
|
347
407
|
const onSig = () => {
|
|
@@ -364,6 +424,8 @@ async function runCommand(flags) {
|
|
|
364
424
|
url: flagString(flags, 'url') ?? process.env.PROHOST_WS_URL,
|
|
365
425
|
machine: flagString(flags, 'machine'),
|
|
366
426
|
cwd: requestedWorkdir(flags),
|
|
427
|
+
concurrency: parallel.concurrency,
|
|
428
|
+
repos: parallel.repos,
|
|
367
429
|
timeoutMs: parsedTimeout === undefined ? DEFAULT_EXEC_TIMEOUT_MS : parsedTimeout * 1000,
|
|
368
430
|
idleTimeoutMs: parsedIdleTimeout === undefined ? DEFAULT_IDLE_TIMEOUT_MS : parsedIdleTimeout * 1000,
|
|
369
431
|
dryRun: flags['dry-run'] === true,
|
package/dist/agent/daemon.d.ts
CHANGED
|
@@ -90,6 +90,10 @@ export interface HarnessInvocation {
|
|
|
90
90
|
allowUnverified?: boolean;
|
|
91
91
|
/** Gate tools behind permission prompts (the `--safe-tools` opt-out). */
|
|
92
92
|
safeTools?: boolean;
|
|
93
|
+
/** Runs allowed at once; omitted for the default of one. */
|
|
94
|
+
concurrency?: number;
|
|
95
|
+
/** Absolute checkouts each conversation gets its own worktree of. */
|
|
96
|
+
repos?: string[];
|
|
93
97
|
}
|
|
94
98
|
export declare function harnessArguments(invocation: HarnessInvocation): string[];
|
|
95
99
|
/** Default log destination — alongside the credential and the run ledger. */
|
package/dist/agent/daemon.js
CHANGED
|
@@ -192,6 +192,10 @@ export function harnessArguments(invocation) {
|
|
|
192
192
|
args.push('--allow-unverified');
|
|
193
193
|
if (invocation.safeTools)
|
|
194
194
|
args.push('--safe-tools');
|
|
195
|
+
if (invocation.concurrency !== undefined)
|
|
196
|
+
args.push('--concurrency', String(invocation.concurrency));
|
|
197
|
+
if (invocation.repos && invocation.repos.length > 0)
|
|
198
|
+
args.push('--repos', invocation.repos.join(','));
|
|
195
199
|
return args;
|
|
196
200
|
}
|
|
197
201
|
/** Default log destination — alongside the credential and the run ledger. */
|
package/dist/agent/prompt.d.ts
CHANGED
|
@@ -72,6 +72,33 @@ export interface PromptContext {
|
|
|
72
72
|
* hour" is advice about how to work, not a request to hurry.
|
|
73
73
|
*/
|
|
74
74
|
idleTimeoutMs?: number;
|
|
75
|
+
/**
|
|
76
|
+
* Other runs may be executing on this machine at the same moment
|
|
77
|
+
* (`--concurrency` above 1).
|
|
78
|
+
*
|
|
79
|
+
* Said because it changes what is safe: an agent that believes it has the
|
|
80
|
+
* machine to itself switches branches in a shared checkout and writes
|
|
81
|
+
* `scratch.json` into the working directory, and the run beside it does the
|
|
82
|
+
* same. Unset, the prompt is byte-for-byte what it was.
|
|
83
|
+
*/
|
|
84
|
+
parallel?: boolean;
|
|
85
|
+
/**
|
|
86
|
+
* Git checkouts private to this run's conversation (`--repos`).
|
|
87
|
+
*
|
|
88
|
+
* Named with the checkout they were made from, because the agent's own
|
|
89
|
+
* standing instructions usually name *that* path — and the point of this
|
|
90
|
+
* block is to move it off it.
|
|
91
|
+
*/
|
|
92
|
+
checkouts?: Array<{
|
|
93
|
+
name: string;
|
|
94
|
+
path: string;
|
|
95
|
+
source: string;
|
|
96
|
+
}>;
|
|
97
|
+
/** `--repos` entries no private checkout could be made of, and why. */
|
|
98
|
+
checkoutFailures?: Array<{
|
|
99
|
+
source: string;
|
|
100
|
+
reason: string;
|
|
101
|
+
}>;
|
|
75
102
|
}
|
|
76
103
|
/** Build the stdin prompt for one run. */
|
|
77
104
|
export declare function buildPrompt(run: AgentRunRequest, context: PromptContext): string;
|
package/dist/agent/prompt.js
CHANGED
|
@@ -7,6 +7,26 @@
|
|
|
7
7
|
* agent that would otherwise narrate its reasoning returns something sendable.
|
|
8
8
|
*/
|
|
9
9
|
import { NO_REPLY_TOKEN } from './contract.js';
|
|
10
|
+
/**
|
|
11
|
+
* What the agent is told about sharing the machine and where its checkouts are.
|
|
12
|
+
*
|
|
13
|
+
* Empty when neither `--concurrency` nor `--repos` is in play.
|
|
14
|
+
*/
|
|
15
|
+
function isolationLines(context) {
|
|
16
|
+
const lines = [];
|
|
17
|
+
const checkouts = context.checkouts ?? [];
|
|
18
|
+
const failures = context.checkoutFailures ?? [];
|
|
19
|
+
if (context.parallel) {
|
|
20
|
+
lines.push('- Other conversations may be running on this machine right now, as other', ' processes of you, in this same working directory. Give any scratch file', ' a name unique to this run, and do not assume a file or a git branch', ' you did not create in this conversation is yours to change.');
|
|
21
|
+
}
|
|
22
|
+
if (checkouts.length > 0) {
|
|
23
|
+
lines.push('- This conversation has its own git checkouts. Do all repository work', ' in them, and nowhere else:', ...checkouts.map((checkout) => ` ${checkout.name}: ${checkout.path}`), ' They are worktrees private to this conversation and they persist', ' between your runs here, so a branch or an uncommitted change you find', ' in one is your own earlier work in this thread. Wherever your other', ' instructions name one of these as the place to work, use the path', ' above instead:', ...checkouts.map((checkout) => ` ${checkout.source}`), ' Never switch branches, commit, or edit files in those originals —', ' other runs read them. Do not run `git worktree add` yourself.');
|
|
24
|
+
}
|
|
25
|
+
if (failures.length > 0) {
|
|
26
|
+
lines.push('- A private checkout could not be prepared for:', ...failures.map((failure) => ` ${failure.source} (${failure.reason})`), ' Treat that repository as read-only for this run: do not switch', ' branches, commit, or edit files in it, and say so if the request', ' needed a change there.');
|
|
27
|
+
}
|
|
28
|
+
return lines;
|
|
29
|
+
}
|
|
10
30
|
/**
|
|
11
31
|
* Say the run's budget in a unit a reader thinks in.
|
|
12
32
|
*
|
|
@@ -259,7 +279,7 @@ export function buildPrompt(run, context) {
|
|
|
259
279
|
// not buried under the tool advice: how long the agent has changes how it
|
|
260
280
|
// should approach the work, so it has to be read before any of the "go and
|
|
261
281
|
// look" bullets below.
|
|
262
|
-
...timeBudgetLines(context.timeoutMs, context.idleTimeoutMs),
|
|
282
|
+
...timeBudgetLines(context.timeoutMs, context.idleTimeoutMs), ...isolationLines(context),
|
|
263
283
|
// Claude Code lazy-loads part of its catalog — WebSearch/WebFetch among
|
|
264
284
|
// them — so a run that inspects its loaded tools concludes it cannot
|
|
265
285
|
// browse and says so to a customer, when one ToolSearch call away it
|
package/dist/agent/run.d.ts
CHANGED
|
@@ -10,9 +10,12 @@
|
|
|
10
10
|
*
|
|
11
11
|
* Two deliberate safety properties:
|
|
12
12
|
*
|
|
13
|
-
* * **Runs are serialized.** One local agent, one run at a time;
|
|
14
|
-
* frames queue. Agents hold working directories and rate limits,
|
|
15
|
-
* several at once is a good way to corrupt both.
|
|
13
|
+
* * **Runs are serialized by default.** One local agent, one run at a time;
|
|
14
|
+
* concurrent frames queue. Agents hold working directories and rate limits,
|
|
15
|
+
* and running several at once is a good way to corrupt both. `--concurrency`
|
|
16
|
+
* raises the limit for runs in *different* conversations only — two turns of
|
|
17
|
+
* one conversation never overlap (see `scheduler.ts`) — and `--repos` gives
|
|
18
|
+
* each conversation its own git checkouts to do it in (see `worktrees.ts`).
|
|
16
19
|
* * **Runs are deduped on ``run_id``.** Webhook deliveries retry, and each
|
|
17
20
|
* attempt is mirrored to this socket. Without dedupe a retry would execute
|
|
18
21
|
* the command a second time and post a second reply.
|
|
@@ -59,6 +62,15 @@ export declare const DEFAULT_EXEC_TIMEOUT_MS = 0;
|
|
|
59
62
|
* tokens and their agent's availability indefinitely.
|
|
60
63
|
*/
|
|
61
64
|
export declare const DEFAULT_IDLE_TIMEOUT_MS = 900000;
|
|
65
|
+
/**
|
|
66
|
+
* Set to `1` in the environment of every agent command this harness starts.
|
|
67
|
+
*
|
|
68
|
+
* Lets instructions the agent reads from disk tell a paired run — someone is
|
|
69
|
+
* waiting in a thread, and the run holds one of this agent's few slots — from
|
|
70
|
+
* the same file being read anywhere else. ProhostAI's own pull-request protocol
|
|
71
|
+
* uses it to stop a run from sitting on CI after the PR is open.
|
|
72
|
+
*/
|
|
73
|
+
export declare const PAIRED_RUN_ENV_VAR = "PROHOST_PAIRED_RUN";
|
|
62
74
|
export interface AgentRunOptions {
|
|
63
75
|
/** Shell command that runs the agent, e.g. ``claude -p``. */
|
|
64
76
|
exec: string;
|
|
@@ -95,6 +107,17 @@ export interface AgentRunOptions {
|
|
|
95
107
|
* ``$PROHOST_HOME/workspace`` — see {@link workspacePath} for why not "here".
|
|
96
108
|
*/
|
|
97
109
|
cwd?: string;
|
|
110
|
+
/**
|
|
111
|
+
* How many runs may execute at once. Defaults to 1 — one run at a time, as
|
|
112
|
+
* before. Above 1, runs in different conversations overlap; runs in the same
|
|
113
|
+
* conversation still wait for each other.
|
|
114
|
+
*/
|
|
115
|
+
concurrency?: number;
|
|
116
|
+
/**
|
|
117
|
+
* Git checkouts the agent works on (absolute paths). When set, every
|
|
118
|
+
* conversation gets a private worktree of each — see `worktrees.ts`.
|
|
119
|
+
*/
|
|
120
|
+
repos?: string[];
|
|
98
121
|
onLine?: (line: string) => void;
|
|
99
122
|
webSocketCtor?: typeof WebSocket;
|
|
100
123
|
fetchImpl?: typeof fetch;
|
|
@@ -186,6 +209,8 @@ interface PreparedMcp {
|
|
|
186
209
|
mcpWired: boolean;
|
|
187
210
|
/** A fail-closed preparation error. When set, the agent is never spawned. */
|
|
188
211
|
error?: string;
|
|
212
|
+
/** The account this run was resolved onto; hand it back when the run ends. */
|
|
213
|
+
accountLabel?: string;
|
|
189
214
|
}
|
|
190
215
|
/**
|
|
191
216
|
* Prepare a fresh Codex MCP override immediately before each invocation.
|