@prohost/cli 0.10.4 → 0.12.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 +67 -0
- package/README.md +101 -11
- package/dist/agent/command.js +58 -29
- package/dist/agent/contract.d.ts +12 -0
- package/dist/agent/contract.js +12 -0
- package/dist/agent/credentials.d.ts +20 -0
- package/dist/agent/daemon.d.ts +4 -1
- package/dist/agent/list.d.ts +3 -0
- package/dist/agent/list.js +5 -1
- package/dist/agent/pair.d.ts +36 -0
- package/dist/agent/pair.js +107 -14
- package/dist/agent/pair_result.d.ts +73 -0
- package/dist/agent/pair_result.js +99 -0
- package/dist/agent/prompt.d.ts +1 -1
- package/dist/agent/prompt.js +7 -1
- package/dist/agent/run.d.ts +11 -9
- package/dist/agent/run.js +10 -9
- package/dist/agent/scheduler.d.ts +11 -3
- package/dist/agent/scheduler.js +11 -3
- package/dist/agent/worktrees.d.ts +6 -0
- package/dist/agent/worktrees.js +12 -1
- package/dist/index.js +11 -3
- 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,73 @@ 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.12.0
|
|
8
|
+
|
|
9
|
+
- **`agent run` handles up to three conversations at once by default.**
|
|
10
|
+
`--concurrency` used to default to 1, so a one-line question in one thread
|
|
11
|
+
waited behind a long job in another. It now defaults to 3. Runs in the same
|
|
12
|
+
conversation still take turns, in order; only different conversations
|
|
13
|
+
overlap.
|
|
14
|
+
- **This changes existing pairings on upgrade.** `install-daemon` records
|
|
15
|
+
`--concurrency` only when it was given, so a daemon installed without it
|
|
16
|
+
goes from one run at a time to three once it restarts on 0.12.0. Two things
|
|
17
|
+
follow:
|
|
18
|
+
- every run spends the same login's quota, so at busy times a subscription's
|
|
19
|
+
window is used up to three times as fast;
|
|
20
|
+
- without `--repos`, overlapping runs share every checkout on the machine.
|
|
21
|
+
Each run is told so, and told not to switch a shared checkout's branch.
|
|
22
|
+
Pass `--repos` to give each conversation its own.
|
|
23
|
+
- **`--concurrency 1` is the old behaviour**, exactly: one run at a time, in
|
|
24
|
+
arrival order, and a prompt that says nothing about sharing the machine. For
|
|
25
|
+
a daemon, run `install-daemon` again with `--concurrency 1`.
|
|
26
|
+
- **`--repos ~/app,~/site` means what it says.** A shell expands only the `~`
|
|
27
|
+
that starts the word, so every entry after the first used to resolve to a
|
|
28
|
+
directory named `~` under the current one. The CLI now reads a leading `~`
|
|
29
|
+
or `~/` on each entry as the home directory.
|
|
30
|
+
- The startup line that reports the limit says when it is the default and how
|
|
31
|
+
to turn it off.
|
|
32
|
+
|
|
33
|
+
## 0.11.0
|
|
34
|
+
|
|
35
|
+
- **Pair only with the workspace you expect.** `prohost agent pair --code <c>
|
|
36
|
+
--expect-account <workspace-id>` (or `$PROHOST_EXPECT_ACCOUNT`) redeems the
|
|
37
|
+
code only if it belongs to that workspace. A pairing code doesn't say whose
|
|
38
|
+
it is, and redeeming one makes this computer that workspace's runtime, so a
|
|
39
|
+
code from somewhere unexpected could pair a computer with the wrong
|
|
40
|
+
workspace. With an expectation the CLI calls the new
|
|
41
|
+
`POST /v1/agent-pairing/redeem-bound` and nothing else:
|
|
42
|
+
- a code for another workspace is refused and left unspent, and an existing
|
|
43
|
+
pairing keeps working;
|
|
44
|
+
- a refusal is never retried on the plain endpoint. Its message covers every
|
|
45
|
+
reading of the server's one generic 404: an invalid, expired or used code,
|
|
46
|
+
a code for a different workspace, or a server without bound pairing;
|
|
47
|
+
- a value that isn't a workspace id, a bare flag, or an environment variable
|
|
48
|
+
that is set but empty stops the pairing before the code is spent.
|
|
49
|
+
- **Pairing says which workspace it paired in**, and records it in `agent.json`
|
|
50
|
+
as `prohost_account: { id, name }` when the server reports it, with or
|
|
51
|
+
without `--expect-account`. `agent list` shows the name. Files written by an
|
|
52
|
+
older CLI load as before.
|
|
53
|
+
- Without `--expect-account` the pairing request is byte-for-byte what 0.10.4
|
|
54
|
+
sent.
|
|
55
|
+
- **A structured result for programs that run `agent pair`.** Set
|
|
56
|
+
`$PROHOST_PAIR_RESULT_FILE` to an absolute path and the CLI writes
|
|
57
|
+
`{ "version": 1, "outcome", "revoked_previous_credential", "message" }` there
|
|
58
|
+
on every exit it controls. The printed output includes names the server
|
|
59
|
+
chose, which can be worded as anything, so a program that classified a
|
|
60
|
+
pairing by searching that output could be steered by them; `outcome` and
|
|
61
|
+
`revoked_previous_credential` are set by what the CLI did instead. Outcomes:
|
|
62
|
+
`paired`, `code_rejected`, `bound_rejected`, `workspace_unconfirmed`,
|
|
63
|
+
`rate_limited`, `unreachable`, `no_subscription`, `invalid_arguments`,
|
|
64
|
+
`failed`. The file holds no pairing code, API key or signing secret and
|
|
65
|
+
nothing from `agent.json`; it is written atomically, `chmod 600`. A path
|
|
66
|
+
that isn't absolute or can't be written is a one-line warning and never
|
|
67
|
+
fails a pairing. Unset, nothing changes.
|
|
68
|
+
|
|
69
|
+
To tell whether a CLI supports this without pairing: `prohost --version` is
|
|
70
|
+
`0.11.0` or later, or `prohost --help` mentions `--expect-account`. An older
|
|
71
|
+
CLI silently ignores the flag and the environment variable and pairs without
|
|
72
|
+
the check.
|
|
73
|
+
|
|
7
74
|
## 0.10.4
|
|
8
75
|
|
|
9
76
|
- **A day's rebuilt totals wait for its 0.10.1 rows to land.** If the server
|
package/README.md
CHANGED
|
@@ -74,6 +74,84 @@ required to receive runs.
|
|
|
74
74
|
|
|
75
75
|
You can also pass the code as `$PROHOST_PAIRING_CODE`.
|
|
76
76
|
|
|
77
|
+
#### Pairing only with the workspace you expect
|
|
78
|
+
|
|
79
|
+
A pairing code doesn't say which workspace it belongs to, and redeeming one
|
|
80
|
+
makes this computer that workspace's runtime. If you know which workspace you
|
|
81
|
+
mean to pair with, say so, and a code for any other workspace is refused:
|
|
82
|
+
|
|
83
|
+
```bash
|
|
84
|
+
npx @prohost/cli agent pair --code <pairing-code> --expect-account <workspace-id>
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
`<workspace-id>` is the workspace's id (a UUID). You can pass it as
|
|
88
|
+
`$PROHOST_EXPECT_ACCOUNT` instead. The ProhostAI desktop app does this for you
|
|
89
|
+
with the workspace it is signed in to.
|
|
90
|
+
|
|
91
|
+
- A code for another workspace is **refused and left unspent**: it stays valid
|
|
92
|
+
for whoever it belongs to, and if this computer was already paired, that
|
|
93
|
+
pairing keeps working.
|
|
94
|
+
- A refusal is never retried without the check. The server answers one generic
|
|
95
|
+
404 whether the code is invalid, expired, already used, or simply for a
|
|
96
|
+
different workspace — or if it is a server that doesn't support this yet — so
|
|
97
|
+
the CLI tells you all of those and stops.
|
|
98
|
+
- A value that isn't a workspace id stops the pairing before the code is spent.
|
|
99
|
+
- On success the CLI prints the workspace it paired in and records it in
|
|
100
|
+
`agent.json` as `prohost_account` (`{ "id", "name" }`). Pairing without
|
|
101
|
+
`--expect-account` records it too, when the server reports it.
|
|
102
|
+
|
|
103
|
+
`--expect-account` is unrelated to `--account`, which picks the Claude Code or
|
|
104
|
+
Codex login an agent runs on.
|
|
105
|
+
|
|
106
|
+
Needs CLI 0.11.0 or later. An older CLI ignores a flag it doesn't know and
|
|
107
|
+
pairs without the check, so a script that depends on it should confirm the
|
|
108
|
+
version first (`prohost --version`), or that `prohost --help` mentions
|
|
109
|
+
`--expect-account`.
|
|
110
|
+
|
|
111
|
+
#### If a program runs `agent pair` for you
|
|
112
|
+
|
|
113
|
+
What `agent pair` prints is written for a person. It includes text the server
|
|
114
|
+
chose — the agent's name, the workspace's name — and those can be anything,
|
|
115
|
+
including a sentence this CLI prints itself. So a program must not decide what
|
|
116
|
+
happened by searching the output. Set `$PROHOST_PAIR_RESULT_FILE` to an
|
|
117
|
+
absolute path and read the JSON the CLI writes there instead:
|
|
118
|
+
|
|
119
|
+
```json
|
|
120
|
+
{ "version": 1, "outcome": "paired", "revoked_previous_credential": false, "message": "Paired." }
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
| `outcome` | What happened |
|
|
124
|
+
| --- | --- |
|
|
125
|
+
| `paired` | The code was redeemed and the credential stored. |
|
|
126
|
+
| `code_rejected` | Pairing without `--expect-account` got a 404: the code is invalid, expired or already used. |
|
|
127
|
+
| `bound_rejected` | Pairing with `--expect-account` got a 404: any of the above, or the code is for another workspace, or the server has no workspace-bound pairing. Not retried without the check. |
|
|
128
|
+
| `workspace_unconfirmed` | Pairing with `--expect-account` succeeded, but the answer did not name the expected workspace. The code is spent; nothing was saved. |
|
|
129
|
+
| `rate_limited` | Too many attempts from this IP. |
|
|
130
|
+
| `unreachable` | The request got no answer. |
|
|
131
|
+
| `no_subscription` | Redeemed, but no event subscription was provisioned. The code is spent; nothing was saved. |
|
|
132
|
+
| `invalid_arguments` | Stopped before the code was spent: a missing code, a flag with no value, an expectation that isn't a workspace id (or is set but empty), a refused URL, an unknown `--account`. |
|
|
133
|
+
| `failed` | Anything else. |
|
|
134
|
+
|
|
135
|
+
- `outcome` and `revoked_previous_credential` are set by what the CLI did, never
|
|
136
|
+
worked out from any text.
|
|
137
|
+
- `revoked_previous_credential` is `true` exactly when this run found the
|
|
138
|
+
credential stored for the same agent already revoked by the redeem and
|
|
139
|
+
removed it from disk.
|
|
140
|
+
- `message` is what the CLI said about the outcome, for display only. It can
|
|
141
|
+
contain server-chosen text; don't parse it. On `paired` it is the fixed
|
|
142
|
+
string `Paired.`
|
|
143
|
+
- The file never contains the pairing code, the API key (not even its masked
|
|
144
|
+
prefix), the signing secret, or anything from `agent.json`.
|
|
145
|
+
- It is written on every exit `agent pair` controls, atomically (a temp file
|
|
146
|
+
beside it, then a rename) and `chmod 600`, replacing whatever was at the
|
|
147
|
+
path. The directory must already exist.
|
|
148
|
+
- It never changes what pairing does. A path that isn't absolute, or one that
|
|
149
|
+
can't be written, is a one-line warning on stderr and nothing more. Without
|
|
150
|
+
the variable, nothing is written and the output is unchanged.
|
|
151
|
+
|
|
152
|
+
The names live in `src/agent/pair_result.ts` (`PAIR_RESULT_FILE_ENV`,
|
|
153
|
+
`PAIR_RESULT_VERSION`, `PAIR_OUTCOMES`).
|
|
154
|
+
|
|
77
155
|
### 3. Run
|
|
78
156
|
|
|
79
157
|
```bash
|
|
@@ -435,9 +513,9 @@ on the machine via `ps` and a process environment is not. Literal `headers`,
|
|
|
435
513
|
treatment: the CLI translates them to Codex's environment-indirection fields,
|
|
436
514
|
so their values do not appear in process arguments.
|
|
437
515
|
|
|
438
|
-
|
|
439
|
-
several conversations at once"
|
|
440
|
-
machine** — even across restarts. Started runs are recorded in
|
|
516
|
+
Up to three runs are handled at once, in different conversations (see "Running
|
|
517
|
+
several conversations at once"; `--concurrency 1` is one at a time), and each
|
|
518
|
+
run executes **at most once on this machine** — even across restarts. Started runs are recorded in
|
|
441
519
|
`~/.prohost/runs.json` before the command launches, so a webhook redelivery
|
|
442
520
|
that arrives after a crash or restart will close the run out rather than run
|
|
443
521
|
your agent a second time. That matters because your agent holds a credential:
|
|
@@ -491,21 +569,30 @@ naming the pid that holds `$PROHOST_HOME/run.lock`.
|
|
|
491
569
|
|
|
492
570
|
### Running several conversations at once
|
|
493
571
|
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
572
|
+
Up to three runs execute at once, so a one-line question in one thread does
|
|
573
|
+
not sit behind a two-hour job in another. `--concurrency <n>` (1 to 16) sets
|
|
574
|
+
the limit:
|
|
497
575
|
|
|
498
576
|
```bash
|
|
499
|
-
prohost agent run --exec 'claude -p' --concurrency
|
|
577
|
+
prohost agent run --exec 'claude -p' --concurrency 5 --repos ~/code/app,~/code/site
|
|
500
578
|
```
|
|
501
579
|
|
|
580
|
+
`--concurrency 1` is one run at a time, in the order they arrived — what every
|
|
581
|
+
version before 0.12.0 did unless told otherwise.
|
|
582
|
+
|
|
502
583
|
- **Runs in the same conversation still take turns, in order.** Each turn
|
|
503
584
|
resumes the session the last one left, so two turns of one thread never
|
|
504
585
|
overlap. Only different conversations run side by side.
|
|
505
586
|
- **Every run spends the same login's quota.** Three runs at once use a
|
|
506
|
-
subscription's 5-hour window about three times as fast.
|
|
587
|
+
subscription's 5-hour window about three times as fast. Lower the limit if
|
|
588
|
+
the window runs out at busy times.
|
|
507
589
|
- **They share the machine.** The agent is told other runs may be executing
|
|
508
|
-
beside it
|
|
590
|
+
beside it and, without `--repos`, that no git checkout is its own. If it
|
|
591
|
+
edits code, pass `--repos`.
|
|
592
|
+
- **A daemon follows the default.** `install-daemon` records `--concurrency`
|
|
593
|
+
only when you give it, so a daemon installed without it runs three at once
|
|
594
|
+
after upgrading to 0.12.0 and restarting. Reinstall with `--concurrency 1`
|
|
595
|
+
to keep it at one.
|
|
509
596
|
|
|
510
597
|
`--repos` takes a comma-separated list of git checkouts the agent works on.
|
|
511
598
|
Each conversation then gets its own `git worktree` of every one, at
|
|
@@ -523,7 +610,7 @@ original repository and are never touched. Each worktree is a full working
|
|
|
523
610
|
copy, and dependencies installed inside it are not shared, so budget disk for
|
|
524
611
|
it.
|
|
525
612
|
|
|
526
|
-
`--repos` works
|
|
613
|
+
`--repos` works at any `--concurrency`, 1 included. If a checkout cannot be made (a
|
|
527
614
|
full disk, a path that is no longer a repository), the run still happens: the
|
|
528
615
|
agent is told to treat that repository as read-only for the run.
|
|
529
616
|
|
|
@@ -581,7 +668,7 @@ same judgement.
|
|
|
581
668
|
| `--exec` | *(required)* | Command to run for each requested run. Prompt arrives on stdin. |
|
|
582
669
|
| `--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". |
|
|
583
670
|
| `--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. |
|
|
584
|
-
| `--concurrency` | `
|
|
671
|
+
| `--concurrency` | `3` | How many runs may execute at once (1–16); `1` is one at a time. Runs in one conversation always take turns. See "Running several conversations at once". |
|
|
585
672
|
| `--repos` | — | Comma-separated git checkouts the agent works on. Each conversation gets its own worktree of every one. |
|
|
586
673
|
| `--dry-run` | off | Print the reply that would be posted; write nothing. |
|
|
587
674
|
| `--workdir` | `~/.prohost/workspace` | Working directory for the command. See "Where your agent runs". |
|
|
@@ -682,6 +769,7 @@ decision to make.
|
|
|
682
769
|
| Flag | Default | What it does |
|
|
683
770
|
| --- | --- | --- |
|
|
684
771
|
| `--code` | `$PROHOST_PAIRING_CODE` | The one-time pairing code. |
|
|
772
|
+
| `--expect-account` | `$PROHOST_EXPECT_ACCOUNT` | Pair only if the code belongs to this workspace (its id). A code for any other workspace is refused and left unspent, and the pairing is never retried without the check. |
|
|
685
773
|
| `--account` | the home's current account, else `default` | Which Claude Code / Codex login this agent runs on (see Accounts). |
|
|
686
774
|
| `--webhook-url` | none | Also push events to this HTTPS endpoint. Omit for stream-only, which is the normal laptop setup. |
|
|
687
775
|
| `--base-url` | `https://connect.prohost.ai` | Public API base. Must be `https://`; `http://` only for `localhost`, `127.0.0.1` and `[::1]`. `agent run` refuses an `agent.json` whose API, MCP or stream URL is plain http/ws off loopback. |
|
|
@@ -702,6 +790,8 @@ has real credentials.
|
|
|
702
790
|
| `No paired agent found` | Run `prohost agent pair --code <code>` first. |
|
|
703
791
|
| `no event subscription was provisioned` | Pairing half-failed server-side and nothing was saved. The code is spent — generate a fresh one and pair again. |
|
|
704
792
|
| `Pairing code is invalid, expired, or already used` | Codes are single-use with a 15-minute life. Generate a fresh one. |
|
|
793
|
+
| `Pairing was refused, and nothing was paired` | You paired with `--expect-account` (or the desktop app did). The code is invalid, expired or used; or it belongs to a different workspace than the one expected; or the server doesn't support workspace-bound pairing yet. Generate a fresh code in the workspace you mean to pair with. Don't retry without the check unless you trust where the code came from. |
|
|
794
|
+
| `must be a workspace id` | `--expect-account` / `$PROHOST_EXPECT_ACCOUNT` was given something that isn't a workspace id (a UUID), or was set but empty. Nothing was sent. |
|
|
705
795
|
| `auth_error: invalid_api_key` | The connection was revoked in ProhostAI. Re-pair. |
|
|
706
796
|
| `auth_error: insufficient_scope` | The credential isn't allowed to stream events — ask your ProhostAI admin to re-issue the pairing. |
|
|
707
797
|
| `auth_error: stream_subscription_unavailable` | The connection has no event subscription to stream. Re-pair. |
|
package/dist/agent/command.js
CHANGED
|
@@ -15,6 +15,7 @@ import { AccountError, loadAccounts, setAgentAccount } from './accounts.js';
|
|
|
15
15
|
import { storedCredentialsProblem } from './base-url.js';
|
|
16
16
|
import { acquireRunLock } from './lock.js';
|
|
17
17
|
import { PairError, runPair } from './pair.js';
|
|
18
|
+
import { PAIR_RESULT_VERSION, withoutCode, writePairResult } from './pair_result.js';
|
|
18
19
|
import { CredentialsMissingError, ensureWorkspace, loadCredentials, prohostHome, streamUrlFor, tryLoadCredentials, } from './credentials.js';
|
|
19
20
|
import { DEFAULT_EXEC_TIMEOUT_MS, DEFAULT_IDLE_TIMEOUT_MS, runAgentHarness } from './run.js';
|
|
20
21
|
import { MAX_CONCURRENCY } from './scheduler.js';
|
|
@@ -345,49 +346,78 @@ async function uninstallDaemonCommand() {
|
|
|
345
346
|
}
|
|
346
347
|
async function pairCommand(flags) {
|
|
347
348
|
const code = flagString(flags, 'code') ?? process.env.PROHOST_PAIRING_CODE;
|
|
348
|
-
|
|
349
|
-
|
|
349
|
+
// For a program that launched this: what happened, as a fact set by the
|
|
350
|
+
// branch that ran — written to `$PROHOST_PAIR_RESULT_FILE` when that is set,
|
|
351
|
+
// and nothing at all when it is not. See `pair_result.ts`.
|
|
352
|
+
const report = (outcome, message, revokedPreviousCredential = false) => writePairResult({
|
|
353
|
+
version: PAIR_RESULT_VERSION,
|
|
354
|
+
outcome,
|
|
355
|
+
revoked_previous_credential: revokedPreviousCredential,
|
|
356
|
+
message: withoutCode(message, code),
|
|
357
|
+
});
|
|
358
|
+
/** Stop before the code is spent: say why, record it, exit 1. */
|
|
359
|
+
const refuse = (line) => {
|
|
360
|
+
process.stderr.write(`${line}\n`);
|
|
361
|
+
report('invalid_arguments', line.replace(/^✗\s*/, ''));
|
|
350
362
|
return 1;
|
|
351
|
-
}
|
|
363
|
+
};
|
|
364
|
+
if (!code)
|
|
365
|
+
return refuse('Missing pairing code. Pass --code or set $PROHOST_PAIRING_CODE.');
|
|
352
366
|
// Checked before the code is spent: a typo here must not cost a pairing.
|
|
353
367
|
const account = flagString(flags, 'account');
|
|
354
|
-
if (flags.account === true)
|
|
355
|
-
|
|
356
|
-
return 1;
|
|
357
|
-
}
|
|
368
|
+
if (flags.account === true)
|
|
369
|
+
return refuse('--account needs a label, e.g. --account work');
|
|
358
370
|
// A re-pair keeps the account the home already runs on unless told otherwise.
|
|
359
371
|
const previousAccount = tryLoadCredentials()?.account;
|
|
360
|
-
|
|
361
|
-
|
|
372
|
+
const accountProblem = account ? unknownAccountProblem(account) : undefined;
|
|
373
|
+
if (accountProblem)
|
|
374
|
+
return refuse(accountProblem);
|
|
375
|
+
// The workspace this computer expects the code to belong to. Once asked for
|
|
376
|
+
// — by flag or by environment, with or without a usable value — the pairing
|
|
377
|
+
// is workspace-bound or it does not happen: a bare flag stops here, and an
|
|
378
|
+
// empty or malformed value is refused by `runPair` before the code is spent.
|
|
379
|
+
// It is never dropped in favour of an unbound pairing.
|
|
380
|
+
if (flags['expect-account'] === true) {
|
|
381
|
+
return refuse('--expect-account needs a workspace id, e.g. --expect-account 0190a1b2-c3d4-7e5f-8a9b-0c1d2e3f4a5b');
|
|
382
|
+
}
|
|
383
|
+
const expectAccount = flagString(flags, 'expect-account') ?? process.env.PROHOST_EXPECT_ACCOUNT;
|
|
362
384
|
try {
|
|
363
385
|
await runPair({
|
|
364
386
|
code,
|
|
365
387
|
baseUrl: flagString(flags, 'base-url'),
|
|
366
388
|
wsUrl: flagString(flags, 'url') ?? process.env.PROHOST_WS_URL,
|
|
367
389
|
webhookUrl: flagString(flags, 'webhook-url'),
|
|
390
|
+
expectAccount,
|
|
368
391
|
});
|
|
369
|
-
const chosen = account ?? previousAccount;
|
|
370
|
-
if (chosen) {
|
|
371
|
-
try {
|
|
372
|
-
setAgentAccount(chosen, undefined);
|
|
373
|
-
process.stdout.write(` Account ${chosen}\n`);
|
|
374
|
-
}
|
|
375
|
-
catch (err) {
|
|
376
|
-
// Paired either way; only the carried-over account is gone.
|
|
377
|
-
if (!(err instanceof AccountError))
|
|
378
|
-
throw err;
|
|
379
|
-
process.stderr.write(`! ${err.message} This agent will use the default login.\n`);
|
|
380
|
-
}
|
|
381
|
-
}
|
|
382
|
-
return 0;
|
|
383
392
|
}
|
|
384
393
|
catch (err) {
|
|
385
394
|
if (err instanceof PairError) {
|
|
386
395
|
process.stderr.write(`✗ ${err.message}\n`);
|
|
396
|
+
report(err.outcome, err.message, err.revokedPreviousCredential);
|
|
387
397
|
return 1;
|
|
388
398
|
}
|
|
399
|
+
report('failed', 'Pairing failed unexpectedly.');
|
|
389
400
|
throw err;
|
|
390
401
|
}
|
|
402
|
+
// Recorded now, before the login step below: the pairing is done and stored,
|
|
403
|
+
// and nothing that follows can unsay it. Deliberately says nothing else —
|
|
404
|
+
// the agent and workspace are in `agent.json`, and a result file that
|
|
405
|
+
// repeated them would be one more place for a credential-adjacent fact.
|
|
406
|
+
report('paired', 'Paired.');
|
|
407
|
+
const chosen = account ?? previousAccount;
|
|
408
|
+
if (chosen) {
|
|
409
|
+
try {
|
|
410
|
+
setAgentAccount(chosen, undefined);
|
|
411
|
+
process.stdout.write(` Account ${chosen}\n`);
|
|
412
|
+
}
|
|
413
|
+
catch (err) {
|
|
414
|
+
// Paired either way; only the carried-over account is gone.
|
|
415
|
+
if (!(err instanceof AccountError))
|
|
416
|
+
throw err;
|
|
417
|
+
process.stderr.write(`! ${err.message} This agent will use the default login.\n`);
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
return 0;
|
|
391
421
|
}
|
|
392
422
|
async function runCommand(flags) {
|
|
393
423
|
const exec = flagString(flags, 'exec');
|
|
@@ -462,20 +492,19 @@ async function runCommand(flags) {
|
|
|
462
492
|
lock.release();
|
|
463
493
|
}
|
|
464
494
|
}
|
|
465
|
-
/**
|
|
466
|
-
function
|
|
495
|
+
/** What to say when an account label is not on this machine (any runtime); `undefined` when it is. */
|
|
496
|
+
function unknownAccountProblem(label) {
|
|
467
497
|
if (label === 'default')
|
|
468
|
-
return
|
|
498
|
+
return undefined;
|
|
469
499
|
try {
|
|
470
500
|
// Validates only; the write happens once pairing has succeeded.
|
|
471
501
|
if (loadAccounts().some((a) => a.label === label))
|
|
472
|
-
return
|
|
502
|
+
return undefined;
|
|
473
503
|
}
|
|
474
504
|
catch {
|
|
475
505
|
/* fall through */
|
|
476
506
|
}
|
|
477
|
-
|
|
478
|
-
return false;
|
|
507
|
+
return `✗ No account named "${label}" on this machine. Add it first: prohost agent account add ${label} --runtime claude|codex`;
|
|
479
508
|
}
|
|
480
509
|
/** `--account <label>` on install-daemon: persist it to agent.json. */
|
|
481
510
|
function applyAccountFlag(flags) {
|
package/dist/agent/contract.d.ts
CHANGED
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
* - `POST /v1/agent-runs/{id}/heartbeat` — `api_public/routers/agent_runs.py`
|
|
17
17
|
* - `POST /v1/agent-runs/{id}/events` — `api_public/routers/agent_runs.py`
|
|
18
18
|
* - `POST /v1/agent-pairing/redeem` — `api_public/routers/agent_pairing.py`
|
|
19
|
+
* - `POST /v1/agent-pairing/redeem-bound` — `api_public/routers/agent_pairing.py`
|
|
19
20
|
* - `POST /v1/conversations/{id}/messages` — `api_public/routers/conversations.py`
|
|
20
21
|
*/
|
|
21
22
|
/** Default host for the public API (`api_public`, mounted under `/v1`). */
|
|
@@ -103,6 +104,17 @@ export interface ToolEvent {
|
|
|
103
104
|
}
|
|
104
105
|
/** `POST` — exchange a one-time pairing code for a scoped credential. */
|
|
105
106
|
export declare const REDEEM_PATH = "/v1/agent-pairing/redeem";
|
|
107
|
+
/**
|
|
108
|
+
* `POST` — the same exchange, but only if the code belongs to the workspace
|
|
109
|
+
* named in `expected_account_id`. A code for any other workspace answers the
|
|
110
|
+
* same generic 404 as an unknown one and is not consumed.
|
|
111
|
+
*
|
|
112
|
+
* A separate path on purpose, so it fails closed: a server that predates it
|
|
113
|
+
* answers 404 and nothing is redeemed, where an older `/redeem` would ignore
|
|
114
|
+
* an unknown field and redeem unbound. So a 404 here must NEVER be retried on
|
|
115
|
+
* {@link REDEEM_PATH}.
|
|
116
|
+
*/
|
|
117
|
+
export declare const REDEEM_BOUND_PATH = "/v1/agent-pairing/redeem-bound";
|
|
106
118
|
/** `POST` — send a message into a conversation as the paired agent. */
|
|
107
119
|
export declare function conversationMessagesPath(conversationId: string): string;
|
|
108
120
|
/** `POST` — close out a run. Must be called for every run, success or not. */
|
package/dist/agent/contract.js
CHANGED
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
* - `POST /v1/agent-runs/{id}/heartbeat` — `api_public/routers/agent_runs.py`
|
|
17
17
|
* - `POST /v1/agent-runs/{id}/events` — `api_public/routers/agent_runs.py`
|
|
18
18
|
* - `POST /v1/agent-pairing/redeem` — `api_public/routers/agent_pairing.py`
|
|
19
|
+
* - `POST /v1/agent-pairing/redeem-bound` — `api_public/routers/agent_pairing.py`
|
|
19
20
|
* - `POST /v1/conversations/{id}/messages` — `api_public/routers/conversations.py`
|
|
20
21
|
*/
|
|
21
22
|
/** Default host for the public API (`api_public`, mounted under `/v1`). */
|
|
@@ -81,6 +82,17 @@ export const MAX_PROGRESS_CHARS = 200;
|
|
|
81
82
|
export const MAX_TOOL_EVENTS_PER_BATCH = 50;
|
|
82
83
|
/** `POST` — exchange a one-time pairing code for a scoped credential. */
|
|
83
84
|
export const REDEEM_PATH = '/v1/agent-pairing/redeem';
|
|
85
|
+
/**
|
|
86
|
+
* `POST` — the same exchange, but only if the code belongs to the workspace
|
|
87
|
+
* named in `expected_account_id`. A code for any other workspace answers the
|
|
88
|
+
* same generic 404 as an unknown one and is not consumed.
|
|
89
|
+
*
|
|
90
|
+
* A separate path on purpose, so it fails closed: a server that predates it
|
|
91
|
+
* answers 404 and nothing is redeemed, where an older `/redeem` would ignore
|
|
92
|
+
* an unknown field and redeem unbound. So a 404 here must NEVER be retried on
|
|
93
|
+
* {@link REDEEM_PATH}.
|
|
94
|
+
*/
|
|
95
|
+
export const REDEEM_BOUND_PATH = '/v1/agent-pairing/redeem-bound';
|
|
84
96
|
/** `POST` — send a message into a conversation as the paired agent. */
|
|
85
97
|
export function conversationMessagesPath(conversationId) {
|
|
86
98
|
return `/v1/conversations/${encodeURIComponent(conversationId)}/messages`;
|
|
@@ -10,6 +10,15 @@ export interface StoredAgent {
|
|
|
10
10
|
name: string;
|
|
11
11
|
user_id: string;
|
|
12
12
|
}
|
|
13
|
+
/**
|
|
14
|
+
* The ProhostAI workspace an agent is paired in, as the server named it when
|
|
15
|
+
* the code was redeemed.
|
|
16
|
+
*/
|
|
17
|
+
export interface ProhostAccount {
|
|
18
|
+
id: string;
|
|
19
|
+
/** ``null`` for a workspace that has no name. */
|
|
20
|
+
name: string | null;
|
|
21
|
+
}
|
|
13
22
|
export interface AgentCredentials {
|
|
14
23
|
/** Schema version, so a future format change can migrate rather than crash. */
|
|
15
24
|
version: number;
|
|
@@ -43,6 +52,17 @@ export interface AgentCredentials {
|
|
|
43
52
|
* — see `accounts.ts`. Absent means the CLI's own default login.
|
|
44
53
|
*/
|
|
45
54
|
account?: string;
|
|
55
|
+
/**
|
|
56
|
+
* The ProhostAI workspace this agent was paired in. Not to be confused with
|
|
57
|
+
* `account` above, which is a login on this machine and predates this field
|
|
58
|
+
* — nor with a "paired account", which on the server also means such a
|
|
59
|
+
* login. Hence `prohost_account`: the account on ProhostAI's side.
|
|
60
|
+
*
|
|
61
|
+
* Written by pairing since 0.11.0, from what the server reported. Absent in
|
|
62
|
+
* a file written by an older CLI, or paired against a server that did not
|
|
63
|
+
* say — nothing reads it to make a decision, so an absent one is fine.
|
|
64
|
+
*/
|
|
65
|
+
prohost_account?: ProhostAccount;
|
|
46
66
|
}
|
|
47
67
|
/**
|
|
48
68
|
* On-disk credential schema version.
|
package/dist/agent/daemon.d.ts
CHANGED
|
@@ -90,7 +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
|
-
/**
|
|
93
|
+
/**
|
|
94
|
+
* Runs allowed at once. Omitted unless `--concurrency` was given, so an
|
|
95
|
+
* installed daemon follows the CLI's default rather than freezing one.
|
|
96
|
+
*/
|
|
94
97
|
concurrency?: number;
|
|
95
98
|
/** Absolute checkouts each conversation gets its own worktree of. */
|
|
96
99
|
repos?: string[];
|
package/dist/agent/list.d.ts
CHANGED
|
@@ -15,6 +15,9 @@ export interface AgentHomeInfo {
|
|
|
15
15
|
home: string;
|
|
16
16
|
/** Agent name from `agent.json`; `undefined` when the home is not paired. */
|
|
17
17
|
name?: string;
|
|
18
|
+
/** Name of the ProhostAI workspace it was paired in, when pairing recorded one. */
|
|
19
|
+
pairedIn?: string;
|
|
20
|
+
/** The Claude Code / Codex login it runs on — not the workspace. */
|
|
18
21
|
account: string;
|
|
19
22
|
/** From the daemon's `--exec`, when a daemon names one. */
|
|
20
23
|
exec?: string;
|
package/dist/agent/list.js
CHANGED
|
@@ -87,6 +87,7 @@ export function findAgentHomes(options = {}) {
|
|
|
87
87
|
found.push({
|
|
88
88
|
home,
|
|
89
89
|
name: credentials?.agent?.name,
|
|
90
|
+
pairedIn: credentials?.prohost_account?.name ?? undefined,
|
|
90
91
|
account: credentials?.account ?? DEFAULT_ACCOUNT_LABEL,
|
|
91
92
|
exec: meta.exec,
|
|
92
93
|
daemon: meta.plist ? (daemonRunning(label) ? 'running' : 'installed') : 'not installed',
|
|
@@ -108,7 +109,10 @@ export async function renderAgentList(options = {}) {
|
|
|
108
109
|
lines.push(' (none paired on this machine — prohost agent pair --code <code>)');
|
|
109
110
|
for (const agent of agents) {
|
|
110
111
|
const running = agent.runPid ? `running (pid ${agent.runPid})` : `daemon ${agent.daemon}`;
|
|
111
|
-
|
|
112
|
+
// The ProhostAI workspace, when pairing recorded one (0.11.0+). `account`
|
|
113
|
+
// on the same line is the login on this machine, a different thing.
|
|
114
|
+
const pairedIn = agent.pairedIn ? ` · paired in ${agent.pairedIn}` : '';
|
|
115
|
+
lines.push(` ${agent.name ?? '(not paired)'}${pairedIn} · account ${agent.account} · ${running}`);
|
|
112
116
|
lines.push(` home ${agent.home}${agent.exec ? ` · exec ${agent.exec}` : ''}`);
|
|
113
117
|
}
|
|
114
118
|
const accounts = loadAccounts(env);
|
package/dist/agent/pair.d.ts
CHANGED
|
@@ -8,8 +8,23 @@
|
|
|
8
8
|
* Codes are single-use and expire after 15 minutes. Every redeem failure comes
|
|
9
9
|
* back as one generic 404 by design, so the error text here can't be more
|
|
10
10
|
* specific than "invalid, expired, or already used".
|
|
11
|
+
*
|
|
12
|
+
* Workspace-bound pairing (`--expect-account`)
|
|
13
|
+
* --------------------------------------------
|
|
14
|
+
* A code says nothing about which workspace it belongs to, and redeeming one
|
|
15
|
+
* makes this computer that workspace's runtime. Someone handed another
|
|
16
|
+
* person's code would pair their computer with that person's workspace. A
|
|
17
|
+
* caller that knows which workspace it means — the desktop app, signed in to
|
|
18
|
+
* one — passes its id, and the code is then redeemed on the bound endpoint,
|
|
19
|
+
* which refuses a code for any other workspace without consuming it.
|
|
20
|
+
*
|
|
21
|
+
* With an expectation, the bound endpoint is the ONLY one called. Its 404 is
|
|
22
|
+
* never retried on the plain endpoint: that would redeem the very code the
|
|
23
|
+
* check exists to stop, and against a server that predates the bound endpoint
|
|
24
|
+
* a fallback would turn "refused" into "redeemed unbound".
|
|
11
25
|
*/
|
|
12
26
|
import type { AgentCredentials } from './credentials.js';
|
|
27
|
+
import type { PairOutcome } from './pair_result.js';
|
|
13
28
|
export interface PairOptions {
|
|
14
29
|
/** The one-time code from the hire modal. */
|
|
15
30
|
code: string;
|
|
@@ -28,6 +43,14 @@ export interface PairOptions {
|
|
|
28
43
|
* needs the events pushed over HTTPS.
|
|
29
44
|
*/
|
|
30
45
|
webhookUrl?: string;
|
|
46
|
+
/**
|
|
47
|
+
* The id of the workspace this computer expects the code to belong to
|
|
48
|
+
* (`--expect-account` / `$PROHOST_EXPECT_ACCOUNT`). When set — to anything,
|
|
49
|
+
* including a value that turns out not to be an id — the pairing is bound:
|
|
50
|
+
* it either goes through the bound endpoint for exactly that workspace or
|
|
51
|
+
* does not happen. `undefined` is the unbound pairing, unchanged.
|
|
52
|
+
*/
|
|
53
|
+
expectAccount?: string;
|
|
31
54
|
fetchImpl?: typeof fetch;
|
|
32
55
|
/** Line-oriented output sink. Defaults to stdout. */
|
|
33
56
|
onLine?: (line: string) => void;
|
|
@@ -38,7 +61,20 @@ export interface PairResult {
|
|
|
38
61
|
credentials: AgentCredentials;
|
|
39
62
|
path: string;
|
|
40
63
|
}
|
|
64
|
+
/**
|
|
65
|
+
* A pairing that did not complete, with what kind of failure it was.
|
|
66
|
+
*
|
|
67
|
+
* `outcome` and `revokedPreviousCredential` are set where the failure is
|
|
68
|
+
* detected — by the branch that ran — so a caller never has to read them back
|
|
69
|
+
* out of `message`, which can contain text the server chose.
|
|
70
|
+
*/
|
|
41
71
|
export declare class PairError extends Error {
|
|
72
|
+
readonly outcome: Exclude<PairOutcome, 'paired'>;
|
|
73
|
+
/** This run removed a stored credential the redeem had already revoked. */
|
|
74
|
+
readonly revokedPreviousCredential: boolean;
|
|
75
|
+
constructor(message: string, outcome?: Exclude<PairOutcome, 'paired'>,
|
|
76
|
+
/** This run removed a stored credential the redeem had already revoked. */
|
|
77
|
+
revokedPreviousCredential?: boolean);
|
|
42
78
|
}
|
|
43
79
|
/**
|
|
44
80
|
* Redeem ``code`` and persist the resulting credentials.
|
package/dist/agent/pair.js
CHANGED
|
@@ -8,19 +8,63 @@
|
|
|
8
8
|
* Codes are single-use and expire after 15 minutes. Every redeem failure comes
|
|
9
9
|
* back as one generic 404 by design, so the error text here can't be more
|
|
10
10
|
* specific than "invalid, expired, or already used".
|
|
11
|
+
*
|
|
12
|
+
* Workspace-bound pairing (`--expect-account`)
|
|
13
|
+
* --------------------------------------------
|
|
14
|
+
* A code says nothing about which workspace it belongs to, and redeeming one
|
|
15
|
+
* makes this computer that workspace's runtime. Someone handed another
|
|
16
|
+
* person's code would pair their computer with that person's workspace. A
|
|
17
|
+
* caller that knows which workspace it means — the desktop app, signed in to
|
|
18
|
+
* one — passes its id, and the code is then redeemed on the bound endpoint,
|
|
19
|
+
* which refuses a code for any other workspace without consuming it.
|
|
20
|
+
*
|
|
21
|
+
* With an expectation, the bound endpoint is the ONLY one called. Its 404 is
|
|
22
|
+
* never retried on the plain endpoint: that would redeem the very code the
|
|
23
|
+
* check exists to stop, and against a server that predates the bound endpoint
|
|
24
|
+
* a fallback would turn "refused" into "redeemed unbound".
|
|
11
25
|
*/
|
|
12
26
|
import { USER_AGENT } from '../version.js';
|
|
13
27
|
import { baseUrlProblem, endpointProblem, isAllowedBaseUrl } from './base-url.js';
|
|
14
|
-
import { DEFAULT_API_BASE_URL, REDEEM_PATH } from './contract.js';
|
|
28
|
+
import { DEFAULT_API_BASE_URL, REDEEM_BOUND_PATH, REDEEM_PATH } from './contract.js';
|
|
15
29
|
import { CREDENTIALS_VERSION, clearCredentials, maskSecret, saveCredentials, tryLoadCredentials, wsUrlFromBaseUrl, } from './credentials.js';
|
|
16
30
|
import { DEFAULT_WS_URL } from '../listen.js';
|
|
31
|
+
/**
|
|
32
|
+
* A pairing that did not complete, with what kind of failure it was.
|
|
33
|
+
*
|
|
34
|
+
* `outcome` and `revokedPreviousCredential` are set where the failure is
|
|
35
|
+
* detected — by the branch that ran — so a caller never has to read them back
|
|
36
|
+
* out of `message`, which can contain text the server chose.
|
|
37
|
+
*/
|
|
17
38
|
export class PairError extends Error {
|
|
39
|
+
outcome;
|
|
40
|
+
revokedPreviousCredential;
|
|
41
|
+
constructor(message, outcome = 'failed',
|
|
42
|
+
/** This run removed a stored credential the redeem had already revoked. */
|
|
43
|
+
revokedPreviousCredential = false) {
|
|
44
|
+
super(message);
|
|
45
|
+
this.outcome = outcome;
|
|
46
|
+
this.revokedPreviousCredential = revokedPreviousCredential;
|
|
47
|
+
this.name = 'PairError';
|
|
48
|
+
}
|
|
18
49
|
}
|
|
19
50
|
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
20
51
|
/** Whether a value is a UUID string, which is how the server types ids. */
|
|
21
52
|
function isUuid(value) {
|
|
22
53
|
return typeof value === 'string' && UUID_PATTERN.test(value);
|
|
23
54
|
}
|
|
55
|
+
/**
|
|
56
|
+
* The workspace the server says the agent was paired in, or `undefined` when
|
|
57
|
+
* it did not say (an older server) or said something unusable.
|
|
58
|
+
*/
|
|
59
|
+
function serverAccount(value) {
|
|
60
|
+
if (!value || typeof value !== 'object' || !isUuid(value.id))
|
|
61
|
+
return undefined;
|
|
62
|
+
return { id: value.id, name: typeof value.name === 'string' && value.name ? value.name : null };
|
|
63
|
+
}
|
|
64
|
+
/** How a workspace is shown: its name when it has one, always its id. */
|
|
65
|
+
function describeAccount(account) {
|
|
66
|
+
return account.name ? `${account.name} (${account.id})` : `(unnamed workspace) (${account.id})`;
|
|
67
|
+
}
|
|
24
68
|
/**
|
|
25
69
|
* Derive the API base from the server-reported MCP URL, so the server wins —
|
|
26
70
|
* unless it is one the API key may not be sent to (see `base-url.ts`).
|
|
@@ -63,33 +107,55 @@ export async function runPair(options) {
|
|
|
63
107
|
// key comes back from it.
|
|
64
108
|
const baseProblem = baseUrlProblem(baseUrl);
|
|
65
109
|
if (baseProblem)
|
|
66
|
-
throw new PairError(`--base-url rejected: ${baseProblem}
|
|
110
|
+
throw new PairError(`--base-url rejected: ${baseProblem}`, 'invalid_arguments');
|
|
67
111
|
const wsProblem = options.wsUrl ? endpointProblem(options.wsUrl, 'ws') : undefined;
|
|
68
112
|
if (wsProblem)
|
|
69
|
-
throw new PairError(`--url rejected: ${wsProblem}
|
|
113
|
+
throw new PairError(`--url rejected: ${wsProblem}`, 'invalid_arguments');
|
|
70
114
|
if (options.webhookUrl && !options.webhookUrl.startsWith('https://')) {
|
|
71
|
-
throw new PairError('--webhook-url must be an https:// URL');
|
|
115
|
+
throw new PairError('--webhook-url must be an https:// URL', 'invalid_arguments');
|
|
116
|
+
}
|
|
117
|
+
// An expectation that was asked for is never quietly dropped: a value that
|
|
118
|
+
// is not a workspace id stops the pairing here, before the code is spent,
|
|
119
|
+
// rather than falling through to an unbound one.
|
|
120
|
+
const bound = options.expectAccount !== undefined;
|
|
121
|
+
const expected = bound && isUuid(options.expectAccount) ? options.expectAccount.toLowerCase() : undefined;
|
|
122
|
+
if (bound && expected === undefined) {
|
|
123
|
+
throw new PairError('--expect-account (or $PROHOST_EXPECT_ACCOUNT) must be a workspace id — a UUID such as ' +
|
|
124
|
+
'0190a1b2-c3d4-7e5f-8a9b-0c1d2e3f4a5b. Nothing was sent, and the code is unspent.', 'invalid_arguments');
|
|
72
125
|
}
|
|
73
126
|
let response;
|
|
74
127
|
try {
|
|
75
|
-
response = await fetchImpl(`${baseUrl}${REDEEM_PATH}`, {
|
|
128
|
+
response = await fetchImpl(`${baseUrl}${bound ? REDEEM_BOUND_PATH : REDEEM_PATH}`, {
|
|
76
129
|
method: 'POST',
|
|
77
130
|
headers: { 'Content-Type': 'application/json', 'User-Agent': USER_AGENT },
|
|
78
131
|
body: JSON.stringify({
|
|
79
132
|
code: options.code,
|
|
133
|
+
...(bound ? { expected_account_id: expected } : {}),
|
|
80
134
|
...(options.webhookUrl ? { webhook_url: options.webhookUrl } : {}),
|
|
81
135
|
}),
|
|
82
136
|
});
|
|
83
137
|
}
|
|
84
138
|
catch (err) {
|
|
85
|
-
throw new PairError(`Could not reach ${baseUrl}: ${err instanceof Error ? err.message : String(err)}
|
|
139
|
+
throw new PairError(`Could not reach ${baseUrl}: ${err instanceof Error ? err.message : String(err)}`, 'unreachable');
|
|
140
|
+
}
|
|
141
|
+
if (response.status === 404 && bound) {
|
|
142
|
+
// Deliberately NOT retried on the plain endpoint — see the module comment.
|
|
143
|
+
// The server answers one generic 404 for all of these, so say all of them.
|
|
144
|
+
throw new PairError('Pairing was refused, and nothing was paired. One of these is true:\n' +
|
|
145
|
+
' - the code is invalid, expired, or already used (codes are single-use and expire ' +
|
|
146
|
+
'15 minutes after they are generated);\n' +
|
|
147
|
+
` - the code is not for the workspace this computer expects (${expected});\n` +
|
|
148
|
+
' - this server does not support workspace-bound pairing yet.\n' +
|
|
149
|
+
'Generate a fresh code in the workspace you mean to pair with. This was not retried ' +
|
|
150
|
+
'without the workspace check: a code from a workspace you did not expect is exactly ' +
|
|
151
|
+
'what the check is there to stop.', 'bound_rejected');
|
|
86
152
|
}
|
|
87
153
|
if (response.status === 404) {
|
|
88
154
|
throw new PairError('Pairing code is invalid, expired, or already used. Codes are single-use and ' +
|
|
89
|
-
'expire 15 minutes after they are generated — generate a fresh one.');
|
|
155
|
+
'expire 15 minutes after they are generated — generate a fresh one.', 'code_rejected');
|
|
90
156
|
}
|
|
91
157
|
if (response.status === 429) {
|
|
92
|
-
throw new PairError('Too many pairing attempts from this IP. Wait an hour and retry.');
|
|
158
|
+
throw new PairError('Too many pairing attempts from this IP. Wait an hour and retry.', 'rate_limited');
|
|
93
159
|
}
|
|
94
160
|
if (!response.ok) {
|
|
95
161
|
throw new PairError(`Pairing failed with HTTP ${response.status}.`);
|
|
@@ -104,6 +170,30 @@ export async function runPair(options) {
|
|
|
104
170
|
if (!body || typeof body.api_key !== 'string' || !body.agent) {
|
|
105
171
|
throw new PairError('Pairing response was missing an api_key or agent.');
|
|
106
172
|
}
|
|
173
|
+
// Redeem revokes the credential previously paired for this agent, on every
|
|
174
|
+
// path that reaches here. So when what came back cannot be kept, whatever is
|
|
175
|
+
// on disk for the same agent is already dead — leaving it would send the
|
|
176
|
+
// operator chasing an `invalid_api_key` at `agent run` instead of the real
|
|
177
|
+
// cause.
|
|
178
|
+
const discardRevokedCredential = () => {
|
|
179
|
+
const stored = tryLoadCredentials(options.env);
|
|
180
|
+
return stored?.agent?.id === body.agent.id && clearCredentials(options.env);
|
|
181
|
+
};
|
|
182
|
+
// A bound redeem that succeeded names the workspace it paired in. One that
|
|
183
|
+
// does not — or names another — did not do what was asked, whatever the
|
|
184
|
+
// status code said; the credential it returned is not one to keep.
|
|
185
|
+
const prohostAccount = serverAccount(body.account);
|
|
186
|
+
if (bound && prohostAccount?.id.toLowerCase() !== expected) {
|
|
187
|
+
const staleRevoked = discardRevokedCredential();
|
|
188
|
+
throw new PairError(`The server did not confirm the workspace this computer expects (${expected}): it ` +
|
|
189
|
+
`${prohostAccount ? `answered for ${describeAccount(prohostAccount)}` : 'named no workspace'}.\n` +
|
|
190
|
+
' The pairing code is spent, and nothing from this pairing was saved. If an agent now ' +
|
|
191
|
+
'shows as paired in ProhostAI that you did not mean to pair, disconnect it there.' +
|
|
192
|
+
(staleRevoked
|
|
193
|
+
? '\n The previously stored credential for this agent was revoked by this redeem ' +
|
|
194
|
+
'and has been removed.'
|
|
195
|
+
: ''), 'workspace_unconfirmed', staleRevoked);
|
|
196
|
+
}
|
|
107
197
|
// The response is JSON off the wire, so the TypeScript cast proves nothing.
|
|
108
198
|
// Check the two fields that decide how this pairing behaves, rather than
|
|
109
199
|
// coercing them and reporting the consequences much later as a websocket
|
|
@@ -131,18 +221,15 @@ export async function runPair(options) {
|
|
|
131
221
|
if (!body.subscription_id) {
|
|
132
222
|
// Redeem revokes the previously paired credential unconditionally, even on
|
|
133
223
|
// this path (`connections.py` step 6 runs after the provisioning failure is
|
|
134
|
-
// swallowed)
|
|
135
|
-
|
|
136
|
-
// `invalid_api_key` at `agent run` instead of the real cause.
|
|
137
|
-
const stored = tryLoadCredentials(options.env);
|
|
138
|
-
const staleRevoked = stored?.agent?.id === body.agent.id && clearCredentials(options.env);
|
|
224
|
+
// swallowed) — see `discardRevokedCredential`.
|
|
225
|
+
const staleRevoked = discardRevokedCredential();
|
|
139
226
|
throw new PairError('Pairing completed but no event subscription was provisioned, so this agent ' +
|
|
140
227
|
'would never receive a run.\n' +
|
|
141
228
|
' The pairing code is spent either way — generate a fresh one and pair again.\n' +
|
|
142
229
|
(staleRevoked
|
|
143
230
|
? ' The previously stored credential for this agent was revoked by this ' +
|
|
144
231
|
'redeem and has been removed.'
|
|
145
|
-
: ' Nothing was saved.'));
|
|
232
|
+
: ' Nothing was saved.'), 'no_subscription', staleRevoked);
|
|
146
233
|
}
|
|
147
234
|
// Where the websocket host comes from, in order:
|
|
148
235
|
//
|
|
@@ -172,9 +259,15 @@ export async function runPair(options) {
|
|
|
172
259
|
events: body.events ?? [],
|
|
173
260
|
agent: body.agent,
|
|
174
261
|
paired_at: new Date().toISOString(),
|
|
262
|
+
// Only what the server just said: a workspace remembered from an earlier
|
|
263
|
+
// pairing would be a guess about this one.
|
|
264
|
+
...(prohostAccount ? { prohost_account: prohostAccount } : {}),
|
|
175
265
|
};
|
|
176
266
|
const file = saveCredentials(credentials, options.env);
|
|
177
267
|
onLine(`✓ Paired as "${body.agent.name}" (agent ${body.agent.id})`);
|
|
268
|
+
if (prohostAccount) {
|
|
269
|
+
onLine(` Workspace ${describeAccount(prohostAccount)}${bound ? ' — the workspace this computer expected' : ''}`);
|
|
270
|
+
}
|
|
178
271
|
onLine(` API key ${maskSecret(body.api_key)} (stored, never printed in full)`);
|
|
179
272
|
onLine(` Credentials ${file} (chmod 600)`);
|
|
180
273
|
onLine(` MCP URL ${credentials.mcp_url}`);
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A structured result of `prohost agent pair`, for a program that launched it.
|
|
3
|
+
*
|
|
4
|
+
* The CLI's stdout and stderr are for a person. They carry text the SERVER
|
|
5
|
+
* chose — the agent's name, the workspace's name — and whoever controls a
|
|
6
|
+
* workspace can name either anything, including a sentence this CLI prints
|
|
7
|
+
* itself. A caller that decides what happened by searching that output can
|
|
8
|
+
* therefore be steered by text an attacker wrote. This is what such a caller
|
|
9
|
+
* reads instead: `outcome` and `revoked_previous_credential` are set by the
|
|
10
|
+
* code path that ran, never derived from any text.
|
|
11
|
+
*
|
|
12
|
+
* Opt-in: written only when `$PROHOST_PAIR_RESULT_FILE` names an absolute
|
|
13
|
+
* path, on every exit path `agent pair` controls, success and failure. It
|
|
14
|
+
* never changes what pairing does: a path that is not absolute, or a write
|
|
15
|
+
* that fails, is one warning line on stderr and nothing else.
|
|
16
|
+
*
|
|
17
|
+
* It holds no secret and nothing from `agent.json`: not the pairing code, not
|
|
18
|
+
* the API key (not even its masked prefix), not the signing secret. `message`
|
|
19
|
+
* is for display only and must not be used to decide anything — it may
|
|
20
|
+
* contain server-chosen text.
|
|
21
|
+
*
|
|
22
|
+
* This module is the contract. A caller that wants to pin it (the desktop
|
|
23
|
+
* app's tests) reads the constants below.
|
|
24
|
+
*/
|
|
25
|
+
/** Environment variable naming the file to write. Must be an absolute path. */
|
|
26
|
+
export declare const PAIR_RESULT_FILE_ENV = "PROHOST_PAIR_RESULT_FILE";
|
|
27
|
+
/** `version` of the shape below. */
|
|
28
|
+
export declare const PAIR_RESULT_VERSION = 1;
|
|
29
|
+
/**
|
|
30
|
+
* Every value `outcome` can take.
|
|
31
|
+
*
|
|
32
|
+
* - `paired` — the code was redeemed and the credential stored.
|
|
33
|
+
* - `code_rejected` — an unbound redeem answered 404: the code is invalid,
|
|
34
|
+
* expired or already used.
|
|
35
|
+
* - `bound_rejected` — a workspace-bound redeem answered 404: any of the
|
|
36
|
+
* above, or the code is for another workspace, or the server has no bound
|
|
37
|
+
* redeem. Never retried unbound.
|
|
38
|
+
* - `workspace_unconfirmed` — a workspace-bound redeem succeeded but its
|
|
39
|
+
* answer did not name the expected workspace. The code is spent; nothing
|
|
40
|
+
* was saved.
|
|
41
|
+
* - `rate_limited` — 429.
|
|
42
|
+
* - `unreachable` — the request never got an answer.
|
|
43
|
+
* - `no_subscription` — redeemed, but no event subscription was provisioned.
|
|
44
|
+
* The code is spent; nothing was saved.
|
|
45
|
+
* - `invalid_arguments` — stopped before the code was spent: a missing code,
|
|
46
|
+
* a bare or malformed flag, an empty or malformed expectation, a refused URL.
|
|
47
|
+
* - `failed` — anything else.
|
|
48
|
+
*/
|
|
49
|
+
export declare const PAIR_OUTCOMES: readonly ["paired", "code_rejected", "bound_rejected", "workspace_unconfirmed", "rate_limited", "unreachable", "no_subscription", "invalid_arguments", "failed"];
|
|
50
|
+
export type PairOutcome = (typeof PAIR_OUTCOMES)[number];
|
|
51
|
+
export interface PairResult {
|
|
52
|
+
version: typeof PAIR_RESULT_VERSION;
|
|
53
|
+
outcome: PairOutcome;
|
|
54
|
+
/**
|
|
55
|
+
* True exactly when this run found the credential previously stored for the
|
|
56
|
+
* same agent already revoked by the redeem, and removed it from disk.
|
|
57
|
+
*/
|
|
58
|
+
revoked_previous_credential: boolean;
|
|
59
|
+
/** What the CLI said about this outcome. Display only. */
|
|
60
|
+
message: string;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* `message` with the pairing code removed, should anything have echoed it
|
|
64
|
+
* (a transport error quoting a URL or a body, say). Nothing this CLI writes
|
|
65
|
+
* includes the code; this is so that stays true whatever a library says.
|
|
66
|
+
*/
|
|
67
|
+
export declare function withoutCode(message: string, code: string | undefined): string;
|
|
68
|
+
/**
|
|
69
|
+
* Write `result` to the file `$PROHOST_PAIR_RESULT_FILE` names, if it names
|
|
70
|
+
* one. Atomic (a temp file beside it, then a rename) and owner-only. Never
|
|
71
|
+
* throws and never affects pairing: every failure is one line on stderr.
|
|
72
|
+
*/
|
|
73
|
+
export declare function writePairResult(result: PairResult, env?: NodeJS.ProcessEnv, warn?: (line: string) => void): void;
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A structured result of `prohost agent pair`, for a program that launched it.
|
|
3
|
+
*
|
|
4
|
+
* The CLI's stdout and stderr are for a person. They carry text the SERVER
|
|
5
|
+
* chose — the agent's name, the workspace's name — and whoever controls a
|
|
6
|
+
* workspace can name either anything, including a sentence this CLI prints
|
|
7
|
+
* itself. A caller that decides what happened by searching that output can
|
|
8
|
+
* therefore be steered by text an attacker wrote. This is what such a caller
|
|
9
|
+
* reads instead: `outcome` and `revoked_previous_credential` are set by the
|
|
10
|
+
* code path that ran, never derived from any text.
|
|
11
|
+
*
|
|
12
|
+
* Opt-in: written only when `$PROHOST_PAIR_RESULT_FILE` names an absolute
|
|
13
|
+
* path, on every exit path `agent pair` controls, success and failure. It
|
|
14
|
+
* never changes what pairing does: a path that is not absolute, or a write
|
|
15
|
+
* that fails, is one warning line on stderr and nothing else.
|
|
16
|
+
*
|
|
17
|
+
* It holds no secret and nothing from `agent.json`: not the pairing code, not
|
|
18
|
+
* the API key (not even its masked prefix), not the signing secret. `message`
|
|
19
|
+
* is for display only and must not be used to decide anything — it may
|
|
20
|
+
* contain server-chosen text.
|
|
21
|
+
*
|
|
22
|
+
* This module is the contract. A caller that wants to pin it (the desktop
|
|
23
|
+
* app's tests) reads the constants below.
|
|
24
|
+
*/
|
|
25
|
+
import { chmodSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
|
26
|
+
import path from 'node:path';
|
|
27
|
+
/** Environment variable naming the file to write. Must be an absolute path. */
|
|
28
|
+
export const PAIR_RESULT_FILE_ENV = 'PROHOST_PAIR_RESULT_FILE';
|
|
29
|
+
/** `version` of the shape below. */
|
|
30
|
+
export const PAIR_RESULT_VERSION = 1;
|
|
31
|
+
/**
|
|
32
|
+
* Every value `outcome` can take.
|
|
33
|
+
*
|
|
34
|
+
* - `paired` — the code was redeemed and the credential stored.
|
|
35
|
+
* - `code_rejected` — an unbound redeem answered 404: the code is invalid,
|
|
36
|
+
* expired or already used.
|
|
37
|
+
* - `bound_rejected` — a workspace-bound redeem answered 404: any of the
|
|
38
|
+
* above, or the code is for another workspace, or the server has no bound
|
|
39
|
+
* redeem. Never retried unbound.
|
|
40
|
+
* - `workspace_unconfirmed` — a workspace-bound redeem succeeded but its
|
|
41
|
+
* answer did not name the expected workspace. The code is spent; nothing
|
|
42
|
+
* was saved.
|
|
43
|
+
* - `rate_limited` — 429.
|
|
44
|
+
* - `unreachable` — the request never got an answer.
|
|
45
|
+
* - `no_subscription` — redeemed, but no event subscription was provisioned.
|
|
46
|
+
* The code is spent; nothing was saved.
|
|
47
|
+
* - `invalid_arguments` — stopped before the code was spent: a missing code,
|
|
48
|
+
* a bare or malformed flag, an empty or malformed expectation, a refused URL.
|
|
49
|
+
* - `failed` — anything else.
|
|
50
|
+
*/
|
|
51
|
+
export const PAIR_OUTCOMES = [
|
|
52
|
+
'paired',
|
|
53
|
+
'code_rejected',
|
|
54
|
+
'bound_rejected',
|
|
55
|
+
'workspace_unconfirmed',
|
|
56
|
+
'rate_limited',
|
|
57
|
+
'unreachable',
|
|
58
|
+
'no_subscription',
|
|
59
|
+
'invalid_arguments',
|
|
60
|
+
'failed',
|
|
61
|
+
];
|
|
62
|
+
/** Codes shorter than this are not redacted: the replacement would mangle ordinary words. */
|
|
63
|
+
const MIN_REDACTABLE_CODE = 6;
|
|
64
|
+
/**
|
|
65
|
+
* `message` with the pairing code removed, should anything have echoed it
|
|
66
|
+
* (a transport error quoting a URL or a body, say). Nothing this CLI writes
|
|
67
|
+
* includes the code; this is so that stays true whatever a library says.
|
|
68
|
+
*/
|
|
69
|
+
export function withoutCode(message, code) {
|
|
70
|
+
if (!code || code.length < MIN_REDACTABLE_CODE)
|
|
71
|
+
return message;
|
|
72
|
+
return message.split(code).join('[pairing code]');
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Write `result` to the file `$PROHOST_PAIR_RESULT_FILE` names, if it names
|
|
76
|
+
* one. Atomic (a temp file beside it, then a rename) and owner-only. Never
|
|
77
|
+
* throws and never affects pairing: every failure is one line on stderr.
|
|
78
|
+
*/
|
|
79
|
+
export function writePairResult(result, env = process.env, warn = (line) => process.stderr.write(`${line}\n`)) {
|
|
80
|
+
const target = env[PAIR_RESULT_FILE_ENV];
|
|
81
|
+
if (target === undefined)
|
|
82
|
+
return;
|
|
83
|
+
if (!path.isAbsolute(target)) {
|
|
84
|
+
warn(`! $${PAIR_RESULT_FILE_ENV} must be an absolute path; no pairing result was written.`);
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
const temp = path.join(path.dirname(target), `.${path.basename(target)}.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`);
|
|
88
|
+
try {
|
|
89
|
+
// `wx`: never write through something already sitting at the temp name.
|
|
90
|
+
writeFileSync(temp, `${JSON.stringify(result)}\n`, { mode: 0o600, flag: 'wx' });
|
|
91
|
+
// The mode above is masked by the umask; set it outright.
|
|
92
|
+
chmodSync(temp, 0o600);
|
|
93
|
+
renameSync(temp, target);
|
|
94
|
+
}
|
|
95
|
+
catch (err) {
|
|
96
|
+
rmSync(temp, { force: true });
|
|
97
|
+
warn(`! Could not write the pairing result to $${PAIR_RESULT_FILE_ENV}: ${err instanceof Error ? err.message : String(err)}`);
|
|
98
|
+
}
|
|
99
|
+
}
|
package/dist/agent/prompt.d.ts
CHANGED
|
@@ -74,7 +74,7 @@ export interface PromptContext {
|
|
|
74
74
|
idleTimeoutMs?: number;
|
|
75
75
|
/**
|
|
76
76
|
* Other runs may be executing on this machine at the same moment
|
|
77
|
-
* (`--concurrency`
|
|
77
|
+
* (the default; `--concurrency 1` turns it off).
|
|
78
78
|
*
|
|
79
79
|
* Said because it changes what is safe: an agent that believes it has the
|
|
80
80
|
* machine to itself switches branches in a shared checkout and writes
|
package/dist/agent/prompt.js
CHANGED
|
@@ -10,7 +10,7 @@ import { NO_REPLY_TOKEN } from './contract.js';
|
|
|
10
10
|
/**
|
|
11
11
|
* What the agent is told about sharing the machine and where its checkouts are.
|
|
12
12
|
*
|
|
13
|
-
* Empty
|
|
13
|
+
* Empty for `--concurrency 1` without `--repos`.
|
|
14
14
|
*/
|
|
15
15
|
function isolationLines(context) {
|
|
16
16
|
const lines = [];
|
|
@@ -18,6 +18,12 @@ function isolationLines(context) {
|
|
|
18
18
|
const failures = context.checkoutFailures ?? [];
|
|
19
19
|
if (context.parallel) {
|
|
20
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
|
+
// Without `--repos` — the usual pairing — nothing moves a run off the
|
|
22
|
+
// checkouts its standing instructions name, and `git switch` there changes
|
|
23
|
+
// the files under whichever run is reading them.
|
|
24
|
+
if (checkouts.length === 0 && failures.length === 0) {
|
|
25
|
+
lines.push('- No git checkout on this machine is private to this conversation.', ' Another run may be reading one while you work, so do not switch its', ' branch or leave it half-changed: if the work needs a branch of its', ' own, do it in a separate `git worktree` you create for it.');
|
|
26
|
+
}
|
|
21
27
|
}
|
|
22
28
|
if (checkouts.length > 0) {
|
|
23
29
|
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.');
|
package/dist/agent/run.d.ts
CHANGED
|
@@ -10,12 +10,13 @@
|
|
|
10
10
|
*
|
|
11
11
|
* Two deliberate safety properties:
|
|
12
12
|
*
|
|
13
|
-
* * **Runs are serialized
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
* each conversation its own git
|
|
13
|
+
* * **Runs in one conversation are serialized.** Two turns of one conversation
|
|
14
|
+
* never overlap (see `scheduler.ts`). Runs in *different* conversations may:
|
|
15
|
+
* up to `--concurrency` of them at once, {@link DEFAULT_CONCURRENCY} when the
|
|
16
|
+
* flag is absent, and frames beyond that queue. Agents hold working
|
|
17
|
+
* directories and rate limits, so every overlapping run is told it is
|
|
18
|
+
* sharing the machine, `--repos` gives each conversation its own git
|
|
19
|
+
* checkouts (see `worktrees.ts`), and `--concurrency 1` is one run at a time.
|
|
19
20
|
* * **Runs are deduped on ``run_id``.** Webhook deliveries retry, and each
|
|
20
21
|
* attempt is mirrored to this socket. Without dedupe a retry would execute
|
|
21
22
|
* the command a second time and post a second reply.
|
|
@@ -108,9 +109,10 @@ export interface AgentRunOptions {
|
|
|
108
109
|
*/
|
|
109
110
|
cwd?: string;
|
|
110
111
|
/**
|
|
111
|
-
* How many runs may execute at once. Defaults to
|
|
112
|
-
*
|
|
113
|
-
* conversation still wait for each other.
|
|
112
|
+
* How many runs may execute at once. Defaults to
|
|
113
|
+
* {@link DEFAULT_CONCURRENCY}. Above 1, runs in different conversations
|
|
114
|
+
* overlap; runs in the same conversation still wait for each other. 1 is one
|
|
115
|
+
* run at a time, in arrival order.
|
|
114
116
|
*/
|
|
115
117
|
concurrency?: number;
|
|
116
118
|
/**
|
package/dist/agent/run.js
CHANGED
|
@@ -10,12 +10,13 @@
|
|
|
10
10
|
*
|
|
11
11
|
* Two deliberate safety properties:
|
|
12
12
|
*
|
|
13
|
-
* * **Runs are serialized
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
* each conversation its own git
|
|
13
|
+
* * **Runs in one conversation are serialized.** Two turns of one conversation
|
|
14
|
+
* never overlap (see `scheduler.ts`). Runs in *different* conversations may:
|
|
15
|
+
* up to `--concurrency` of them at once, {@link DEFAULT_CONCURRENCY} when the
|
|
16
|
+
* flag is absent, and frames beyond that queue. Agents hold working
|
|
17
|
+
* directories and rate limits, so every overlapping run is told it is
|
|
18
|
+
* sharing the machine, `--repos` gives each conversation its own git
|
|
19
|
+
* checkouts (see `worktrees.ts`), and `--concurrency 1` is one run at a time.
|
|
19
20
|
* * **Runs are deduped on ``run_id``.** Webhook deliveries retry, and each
|
|
20
21
|
* attempt is mirrored to this socket. Without dedupe a retry would execute
|
|
21
22
|
* the command a second time and post a second reply.
|
|
@@ -43,7 +44,7 @@ import { HeartbeatLoop } from './heartbeat.js';
|
|
|
43
44
|
import { buildPrompt, replyFromStdout } from './prompt.js';
|
|
44
45
|
import { NO_CAPABILITIES, capabilityProbe } from './capabilities.js';
|
|
45
46
|
import { RunStore } from './runstore.js';
|
|
46
|
-
import { MAX_CONCURRENCY, RunScheduler } from './scheduler.js';
|
|
47
|
+
import { DEFAULT_CONCURRENCY, MAX_CONCURRENCY, RunScheduler } from './scheduler.js';
|
|
47
48
|
import { RUNTIME_STATUS_FEATURE, RuntimeStatusReporter, claudeQuotaProvider, codexConfiguredModel, codexQuotaProvider, modelFromExec, parseVersion, readCodexObservation, } from './runtime_status.js';
|
|
48
49
|
import { SessionStore, sessionKeyFor } from './sessionstore.js';
|
|
49
50
|
import { verifySignature } from './signature.js';
|
|
@@ -1152,11 +1153,11 @@ export async function runAgentHarness(options) {
|
|
|
1152
1153
|
}
|
|
1153
1154
|
return true;
|
|
1154
1155
|
};
|
|
1155
|
-
const concurrency = Math.min(MAX_CONCURRENCY, Math.max(1, Math.floor(options.concurrency ??
|
|
1156
|
+
const concurrency = Math.min(MAX_CONCURRENCY, Math.max(1, Math.floor(options.concurrency ?? DEFAULT_CONCURRENCY)));
|
|
1156
1157
|
const scheduler = new RunScheduler(concurrency);
|
|
1157
1158
|
const worktrees = new WorktreeManager({ repos: options.repos ?? [], env: options.env, log });
|
|
1158
1159
|
if (concurrency > 1) {
|
|
1159
|
-
log(`${timestamp()} ⚙ up to ${concurrency} runs at once — runs in the same conversation still take turns` +
|
|
1160
|
+
log(`${timestamp()} ⚙ up to ${concurrency} runs at once${options.concurrency === undefined ? ' (the default; --concurrency 1 for one at a time)' : ''} — runs in the same conversation still take turns` +
|
|
1160
1161
|
(worktrees.enabled
|
|
1161
1162
|
? ''
|
|
1162
1163
|
: '. No --repos given: runs share every checkout on this machine, so name the ' +
|
|
@@ -2,10 +2,11 @@
|
|
|
2
2
|
* Which accepted runs execute now, and which wait.
|
|
3
3
|
*
|
|
4
4
|
* `agent run` used to chain every run onto one promise: one local agent, one
|
|
5
|
-
* run at a time.
|
|
6
|
-
*
|
|
5
|
+
* run at a time. With a limit of 1 this scheduler is still exactly that chain
|
|
6
|
+
* — strict arrival order, nothing overlapping — and `--concurrency 1` asks for
|
|
7
|
+
* it. The harness default is {@link DEFAULT_CONCURRENCY}.
|
|
7
8
|
*
|
|
8
|
-
*
|
|
9
|
+
* A limit above 1 lifts the cap without lifting the rule that actually
|
|
9
10
|
* protects an answer: **two runs on the same lane never overlap, and they start
|
|
10
11
|
* in the order they arrived.** A lane is a conversation (see `sessionKeyFor`).
|
|
11
12
|
* Each turn resumes the session the previous turn left, so running two turns of
|
|
@@ -19,6 +20,13 @@
|
|
|
19
20
|
*/
|
|
20
21
|
/** Most runs one harness will execute at once, whatever the flag says. */
|
|
21
22
|
export declare const MAX_CONCURRENCY = 16;
|
|
23
|
+
/**
|
|
24
|
+
* Runs `agent run` executes at once when `--concurrency` is not given.
|
|
25
|
+
*
|
|
26
|
+
* Not 1: a one-line question in one thread should not sit behind a two-hour
|
|
27
|
+
* job in another. Not higher: every run spends the same login's quota.
|
|
28
|
+
*/
|
|
29
|
+
export declare const DEFAULT_CONCURRENCY = 3;
|
|
22
30
|
export declare class RunScheduler {
|
|
23
31
|
private readonly limit;
|
|
24
32
|
private readonly pending;
|
package/dist/agent/scheduler.js
CHANGED
|
@@ -2,10 +2,11 @@
|
|
|
2
2
|
* Which accepted runs execute now, and which wait.
|
|
3
3
|
*
|
|
4
4
|
* `agent run` used to chain every run onto one promise: one local agent, one
|
|
5
|
-
* run at a time.
|
|
6
|
-
*
|
|
5
|
+
* run at a time. With a limit of 1 this scheduler is still exactly that chain
|
|
6
|
+
* — strict arrival order, nothing overlapping — and `--concurrency 1` asks for
|
|
7
|
+
* it. The harness default is {@link DEFAULT_CONCURRENCY}.
|
|
7
8
|
*
|
|
8
|
-
*
|
|
9
|
+
* A limit above 1 lifts the cap without lifting the rule that actually
|
|
9
10
|
* protects an answer: **two runs on the same lane never overlap, and they start
|
|
10
11
|
* in the order they arrived.** A lane is a conversation (see `sessionKeyFor`).
|
|
11
12
|
* Each turn resumes the session the previous turn left, so running two turns of
|
|
@@ -19,6 +20,13 @@
|
|
|
19
20
|
*/
|
|
20
21
|
/** Most runs one harness will execute at once, whatever the flag says. */
|
|
21
22
|
export const MAX_CONCURRENCY = 16;
|
|
23
|
+
/**
|
|
24
|
+
* Runs `agent run` executes at once when `--concurrency` is not given.
|
|
25
|
+
*
|
|
26
|
+
* Not 1: a one-line question in one thread should not sit behind a two-hour
|
|
27
|
+
* job in another. Not higher: every run spends the same login's quota.
|
|
28
|
+
*/
|
|
29
|
+
export const DEFAULT_CONCURRENCY = 3;
|
|
22
30
|
export class RunScheduler {
|
|
23
31
|
limit;
|
|
24
32
|
pending = [];
|
|
@@ -72,10 +72,16 @@ export declare function laneDirName(lane: string): string;
|
|
|
72
72
|
* forever. A running harness instead reports the checkout it could not make on
|
|
73
73
|
* each run, and keeps answering.
|
|
74
74
|
*
|
|
75
|
+
* A leading `~` is the home directory. A shell only expands the one that
|
|
76
|
+
* starts the word, so in `--repos ~/app,~/site` the second arrives as typed —
|
|
77
|
+
* and resolved as-is it would name a directory called `~` under wherever the
|
|
78
|
+
* harness was started.
|
|
79
|
+
*
|
|
75
80
|
* @throws ReposFlagError naming the entry that is wrong.
|
|
76
81
|
*/
|
|
77
82
|
export declare function parseReposFlag(raw: string, options?: {
|
|
78
83
|
requireCheckouts?: boolean;
|
|
84
|
+
home?: string;
|
|
79
85
|
}): string[];
|
|
80
86
|
/** Whether `repo` is the top of a git checkout (a clone, or a worktree of one). */
|
|
81
87
|
export declare function isCheckout(repo: string): boolean;
|
package/dist/agent/worktrees.js
CHANGED
|
@@ -30,6 +30,7 @@
|
|
|
30
30
|
import { execFile } from 'node:child_process';
|
|
31
31
|
import { createHash } from 'node:crypto';
|
|
32
32
|
import { existsSync, mkdirSync, readdirSync, rmdirSync, rmSync, statSync, utimesSync, writeFileSync } from 'node:fs';
|
|
33
|
+
import os from 'node:os';
|
|
33
34
|
import path from 'node:path';
|
|
34
35
|
import { prohostHome } from './credentials.js';
|
|
35
36
|
/** Child-process env var naming the directory that holds this run's checkouts. */
|
|
@@ -83,6 +84,11 @@ function lastLine(text) {
|
|
|
83
84
|
* forever. A running harness instead reports the checkout it could not make on
|
|
84
85
|
* each run, and keeps answering.
|
|
85
86
|
*
|
|
87
|
+
* A leading `~` is the home directory. A shell only expands the one that
|
|
88
|
+
* starts the word, so in `--repos ~/app,~/site` the second arrives as typed —
|
|
89
|
+
* and resolved as-is it would name a directory called `~` under wherever the
|
|
90
|
+
* harness was started.
|
|
91
|
+
*
|
|
86
92
|
* @throws ReposFlagError naming the entry that is wrong.
|
|
87
93
|
*/
|
|
88
94
|
export function parseReposFlag(raw, options = {}) {
|
|
@@ -95,7 +101,7 @@ export function parseReposFlag(raw, options = {}) {
|
|
|
95
101
|
const repos = [];
|
|
96
102
|
const names = new Map();
|
|
97
103
|
for (const entry of entries) {
|
|
98
|
-
const repo = path.resolve(entry);
|
|
104
|
+
const repo = path.resolve(expandHome(entry, options.home));
|
|
99
105
|
if (options.requireCheckouts && !isCheckout(repo)) {
|
|
100
106
|
throw new ReposFlagError(`--repos: ${repo} is not the top of a git checkout (no .git there).`);
|
|
101
107
|
}
|
|
@@ -110,6 +116,11 @@ export function parseReposFlag(raw, options = {}) {
|
|
|
110
116
|
}
|
|
111
117
|
return repos;
|
|
112
118
|
}
|
|
119
|
+
function expandHome(entry, home = os.homedir()) {
|
|
120
|
+
if (entry === '~')
|
|
121
|
+
return home;
|
|
122
|
+
return entry.startsWith('~/') ? path.join(home, entry.slice(2)) : entry;
|
|
123
|
+
}
|
|
113
124
|
/** Whether `repo` is the top of a git checkout (a clone, or a worktree of one). */
|
|
114
125
|
export function isCheckout(repo) {
|
|
115
126
|
return existsSync(path.join(repo, '.git'));
|
package/dist/index.js
CHANGED
|
@@ -27,7 +27,8 @@ function printHelp() {
|
|
|
27
27
|
'',
|
|
28
28
|
'Usage:',
|
|
29
29
|
' prohost listen --forward-to <local-url> [--api-key <key>] [--url <ws-url>] [--machine <name>] [--subscription-id <id>]',
|
|
30
|
-
' prohost agent pair --code <pairing-code> [--account <
|
|
30
|
+
' prohost agent pair --code <pairing-code> [--expect-account <workspace-id>] [--account <label>]',
|
|
31
|
+
' [--webhook-url <https-url>] [--base-url <url>]',
|
|
31
32
|
" prohost agent run --exec '<command>' [--concurrency <n>] [--repos <checkout,…>] [--idle-timeout <seconds>]",
|
|
32
33
|
' [--timeout <seconds>] [--dry-run] [--workdir <dir>]',
|
|
33
34
|
" prohost agent install-daemon --exec '<command>' [--workdir <dir>] [--account <label>] Keep the agent running",
|
|
@@ -49,6 +50,10 @@ function printHelp() {
|
|
|
49
50
|
'',
|
|
50
51
|
'agent pair flags:',
|
|
51
52
|
' --code One-time pairing code from the hire modal. Defaults to $PROHOST_PAIRING_CODE.',
|
|
53
|
+
' --expect-account Pair only if the code belongs to this ProhostAI workspace (its id, a UUID).',
|
|
54
|
+
' A code for any other workspace is refused and left unspent, and the pairing',
|
|
55
|
+
' is never retried without the check. Defaults to $PROHOST_EXPECT_ACCOUNT.',
|
|
56
|
+
' Not the same as --account, which picks a Claude Code / Codex login.',
|
|
52
57
|
' --webhook-url Also push events to this HTTPS endpoint. Omit it: stream-only is the normal setup.',
|
|
53
58
|
' --base-url Public API base. Defaults to https://connect.prohost.ai. Must be https:// (http:// only for localhost).',
|
|
54
59
|
'',
|
|
@@ -60,10 +65,10 @@ function printHelp() {
|
|
|
60
65
|
' --timeout Seconds one run may take before it is killed, however busy it is.',
|
|
61
66
|
' Defaults to 0 — no wall-clock limit, because a long run is not a stuck',
|
|
62
67
|
' one. Set it if you want a hard ceiling as well as the stall watchdog.',
|
|
63
|
-
' --concurrency How many runs may execute at once, 1 to 16. Defaults to
|
|
68
|
+
' --concurrency How many runs may execute at once, 1 to 16. Defaults to 3. Runs in the same',
|
|
64
69
|
' conversation always take turns, so only different conversations overlap.',
|
|
65
70
|
' Every run spends the same login\'s quota, and they share this machine —',
|
|
66
|
-
' pair it with --repos if the agent edits code.',
|
|
71
|
+
' pair it with --repos if the agent edits code. Pass 1 for one run at a time.',
|
|
67
72
|
' --repos Comma-separated git checkouts the agent works on. Each conversation gets',
|
|
68
73
|
' its own `git worktree` of every one under $PROHOST_HOME/worktrees, kept for',
|
|
69
74
|
' the life of the thread and removed after 14 idle days if it is clean. The',
|
|
@@ -128,6 +133,9 @@ function printHelp() {
|
|
|
128
133
|
' PROHOST_API_KEY Used by `listen` when --api-key is not provided.',
|
|
129
134
|
' PROHOST_WS_URL Used when --url is not provided.',
|
|
130
135
|
' PROHOST_PAIRING_CODE Used by `agent pair` when --code is not provided.',
|
|
136
|
+
' PROHOST_EXPECT_ACCOUNT Used by `agent pair` when --expect-account is not provided.',
|
|
137
|
+
' PROHOST_PAIR_RESULT_FILE Absolute path. `agent pair` writes what happened there as JSON, for a',
|
|
138
|
+
' program that launched it to read instead of this output. See the README.',
|
|
131
139
|
' PROHOST_HOME Credential, ledger, workspace and log directory. Defaults to ~/.prohost.',
|
|
132
140
|
' PROHOST_ACCOUNTS_ROOT Machine-wide account registry shared by every agent. Defaults to ~/.prohost-accounts.',
|
|
133
141
|
];
|
package/dist/version.d.ts
CHANGED
|
@@ -7,6 +7,6 @@
|
|
|
7
7
|
* `package.json`, so a release bump that forgets this file fails the suite
|
|
8
8
|
* instead of shipping a `User-Agent` that lies about which build is calling.
|
|
9
9
|
*/
|
|
10
|
-
export declare const CLI_VERSION = "0.
|
|
10
|
+
export declare const CLI_VERSION = "0.12.0";
|
|
11
11
|
/** Sent on every HTTP request the CLI makes back into ProhostAI. */
|
|
12
|
-
export declare const USER_AGENT = "prohost-cli/0.
|
|
12
|
+
export declare const USER_AGENT = "prohost-cli/0.12.0";
|
package/dist/version.js
CHANGED
|
@@ -7,6 +7,6 @@
|
|
|
7
7
|
* `package.json`, so a release bump that forgets this file fails the suite
|
|
8
8
|
* instead of shipping a `User-Agent` that lies about which build is calling.
|
|
9
9
|
*/
|
|
10
|
-
export const CLI_VERSION = '0.
|
|
10
|
+
export const CLI_VERSION = '0.12.0';
|
|
11
11
|
/** Sent on every HTTP request the CLI makes back into ProhostAI. */
|
|
12
12
|
export const USER_AGENT = `prohost-cli/${CLI_VERSION}`;
|