@opsee/cli 0.11.12 → 0.11.18
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/README.md +143 -9
- package/package.json +2 -2
- package/src/args.ts +167 -15
- package/src/cli.ts +62 -6
- package/src/commands/account-usage.ts +106 -0
- package/src/commands/account.ts +134 -1
- package/src/commands/claude-launcher.ts +183 -0
- package/src/commands/foreman-debug.ts +11 -4
- package/src/commands/foreman-up.ts +184 -4
- package/src/commands/foreman.ts +133 -11
- package/src/commands/init.ts +4 -8
- package/src/foreman/account.ts +106 -13
- package/src/foreman/claude-worker-adapter.ts +100 -1
- package/src/foreman/config-skeleton.ts +167 -0
- package/src/foreman/core/run.ts +157 -7
- package/src/foreman/core/scheduler.ts +139 -0
- package/src/foreman/core/short-turn.ts +80 -0
- package/src/foreman/core/start-discovery.ts +158 -0
- package/src/foreman/core/text.ts +29 -0
- package/src/foreman/core/triage.ts +10 -47
- package/src/foreman/core/verifier.ts +51 -4
- package/src/foreman/credential-store.ts +247 -0
- package/src/foreman/run-recipe.ts +37 -8
- package/src/foreman/usage-activity.ts +102 -0
- package/src/foreman/usage-format.ts +107 -0
- package/src/foreman/usage-poller.ts +489 -0
- package/src/foreman/usage-store.ts +213 -0
- package/src/foreman/usage.ts +257 -0
- package/src/foreman/vendor.ts +23 -0
- package/src/foreman/worker-adapter.ts +12 -0
- package/src/init/prompt.ts +17 -18
- package/src/init/run-recipe-config.ts +11 -12
package/README.md
CHANGED
|
@@ -102,15 +102,33 @@ environment, so an app that reads the variable needs no placeholder at all. Vite
|
|
|
102
102
|
kind: it ignores `PORT` and takes `--port`, and `--strictPort` makes it fail rather than drift to
|
|
103
103
|
the next free port, which is the silent-corruption hazard AGENTS.md describes for two dev servers.
|
|
104
104
|
|
|
105
|
-
`opsee init` writes the block once. `
|
|
106
|
-
`
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
105
|
+
`opsee init` writes the block once. `gates.test` and `gates.lint` default to `commands.test` and
|
|
106
|
+
`commands.lint`; `start` is written only when `commands.dev` (then `commands.start`) or `--start`
|
|
107
|
+
already says how to serve the app, and the readiness URL and the port variable travel with it.
|
|
108
|
+
`typecheck` has no analyzer equivalent, so it is the one value a terminal is asked for.
|
|
109
|
+
|
|
110
|
+
**It never asks how to start the app.** That question has no answer at setup time in a repository
|
|
111
|
+
with several programs: "the app" means whichever one a Task's Verification happens to exercise, and
|
|
112
|
+
no Task exists yet. A block with Gates and no `start` is a complete recipe — the Gates are the whole
|
|
113
|
+
Verdict for most repositories — and the Verifier says so when a Task does ask for a browser round.
|
|
114
|
+
The flags `--start`, `--readiness-url` and `--port-env` still write one for anyone who wants it
|
|
115
|
+
pinned.
|
|
116
|
+
|
|
117
|
+
When a Task's Verification asks for a browser round and the recipe has no `start`, the Foreman runs
|
|
118
|
+
a **Start turn** before giving up: one short unattended turn in the Task's Workspace
|
|
119
|
+
(`core/start-discovery.ts`), given that Task's Verification section, which answers with the command
|
|
120
|
+
that serves the program the Verification exercises. That is the whole reason `opsee init` can stop
|
|
121
|
+
asking — in a repository with several programs the answer depends on the Task, and a Task exists by
|
|
122
|
+
then. The answer must take the leased port (`{port}` or the port variable) or it is refused: a
|
|
123
|
+
command that serves on its own default is the failure that looks like success, because the readiness
|
|
124
|
+
URL answers from a server the Foreman never started. It is agent-authored, so it runs under the
|
|
125
|
+
Gates' filtered environment like every other command from the repository, and it lands verbatim on
|
|
126
|
+
the Run Record as the app's command.
|
|
127
|
+
|
|
128
|
+
`start` without `readiness_url` is still refused: that is half a recipe, which would serve the app
|
|
129
|
+
and never learn it came up. An existing `foreman` block, the user's or the analyzer's, is never
|
|
130
|
+
rewritten: a re-run reports `unchanged` whatever the flags say, and a block whose `verify` is not a
|
|
131
|
+
known word is reported and kept. The YAML is spliced textually, the block appended in the
|
|
114
132
|
file's own indentation with no blank line before it, because a parsed-and-reserialised document
|
|
115
133
|
would lose the analyzer's header comments; the JSON copy is re-serialised in its own indentation
|
|
116
134
|
with `foreman` after the analyzer's members. The two are only ever both written when both exist
|
|
@@ -207,14 +225,30 @@ or an API-key Account, a vendor plus the name of the environment variable that w
|
|
|
207
225
|
|
|
208
226
|
```sh
|
|
209
227
|
node bin/opsee.js foreman account add --vendor claude --config-dir ~/.claude-work --name work
|
|
228
|
+
node bin/opsee.js foreman account add --vendor claude --config-dir ~/.claude-spare --login --name spare
|
|
210
229
|
node bin/opsee.js foreman account add --vendor codex --key-env OPENAI_API_KEY --cap 3
|
|
211
230
|
node bin/opsee.js foreman account list
|
|
231
|
+
node bin/opsee.js foreman account usage # what is left, and how old the number is
|
|
212
232
|
node bin/opsee.js foreman account set work --cap 4 # more Slots, only ever by asking
|
|
213
233
|
node bin/opsee.js foreman account set work --max-turns 40 --stall-timeout 600000
|
|
234
|
+
node bin/opsee.js foreman account set work --threshold 75 # hold it back earlier than the default 90%
|
|
235
|
+
node bin/opsee.js foreman account sync-skeleton # share settings/skills between Accounts
|
|
214
236
|
node bin/opsee.js foreman account remove work
|
|
215
237
|
node bin/opsee.js foreman account resume work # lifts a quarantine (and any pause)
|
|
216
238
|
```
|
|
217
239
|
|
|
240
|
+
`--login` is for the second Account and after: it makes the config directory, runs **the vendor's
|
|
241
|
+
own** login inside it with that vendor's config-directory variable set, and registers only if a
|
|
242
|
+
credential was left behind. It refuses an unknown vendor, a name already taken or a cap out of
|
|
243
|
+
range *before* spawning anything, so a typo never costs an interactive sign-in.
|
|
244
|
+
|
|
245
|
+
The Foreman reads nothing the login writes. It asks only **whether** a credential exists, and where
|
|
246
|
+
that lives is the vendor's business, not a given: on macOS Claude Code writes no file at all and
|
|
247
|
+
keeps the credential in a Keychain item it derives from the config directory, so a file check there
|
|
248
|
+
answers "no" however well the login went (`credential-store.ts`). The answer is three-valued, and
|
|
249
|
+
the third is the point — a machine that cannot tell returns *unknown*, the Account is registered
|
|
250
|
+
anyway, and the command says it could not confirm. Only a positive *absent* refuses.
|
|
251
|
+
|
|
218
252
|
Several Accounts per vendor are fine, and worth having: each has its own name, cap and rate-limit
|
|
219
253
|
window, and a Run on one **Fails over to its siblings of the same vendor** while it is Paused (see
|
|
220
254
|
[Paused Accounts and Failover](#paused-accounts-and-failover)). Records live in
|
|
@@ -350,6 +384,106 @@ out of the real Run Record: the order off the Account with one implementer Slot,
|
|
|
350
384
|
order is the order the scheduler chose in, and the concurrency off the Account with two, where a
|
|
351
385
|
barrier the turns wait at proves they really overlapped.
|
|
352
386
|
|
|
387
|
+
### Headroom: how much of an Account is left
|
|
388
|
+
|
|
389
|
+
An Account's **Headroom** is what remains of its tightest live rate-limit window, so an Account at
|
|
390
|
+
20% of its five-hour and 98% of its seven-day has 2% — the minimum, never an average, because a
|
|
391
|
+
limit is a limit.
|
|
392
|
+
|
|
393
|
+
Most of it arrives free. Claude Code puts every window it tracks, with a utilization and a reset, on
|
|
394
|
+
each `rate_limit_event` — including the `allowed_warning` that fires at 90% of a window, before
|
|
395
|
+
anything is refused. Those numbers are recorded against the Lane's own Account as the turn runs. An
|
|
396
|
+
Account nobody is running reports nothing, so the daemon polls those from the vendor's own usage
|
|
397
|
+
endpoint; that poll is the one place in this package that reads a credential, and
|
|
398
|
+
[ADR-0013](../docs/adr/0013-credential-boundary-and-account-types.md) was amended to permit exactly
|
|
399
|
+
it and nothing more — read wherever the vendor keeps it, which is a Keychain item on macOS and a
|
|
400
|
+
file elsewhere. `usage-poller-boundary.test.ts` holds both backends to it: only this config
|
|
401
|
+
directory's own credential is ever asked for, only Anthropic is ever contacted, and no
|
|
402
|
+
credential-derived value reaches anything written or printed.
|
|
403
|
+
|
|
404
|
+
```sh
|
|
405
|
+
node bin/opsee.js foreman account usage # a line per Account: what is left, and why
|
|
406
|
+
node bin/opsee.js foreman account usage --windows # every window behind that number
|
|
407
|
+
node bin/opsee.js foreman account usage --refresh # measure now, rather than printing what is known
|
|
408
|
+
```
|
|
409
|
+
|
|
410
|
+
The default is one line per Account, because the question is *which Account has room* and the answer
|
|
411
|
+
is one number: the headroom of its tightest live window. An Account nothing is known about says
|
|
412
|
+
`never observed` rather than showing as fresh, and an API-key Account says in words that it reports
|
|
413
|
+
no usage — it is never treated as spent. When **nothing** has been measured the command says where
|
|
414
|
+
usage comes from instead of printing a table of dashes.
|
|
415
|
+
|
|
416
|
+
**The endpoint has a request cap**, and the intervals are built around it: roughly 28-30 requests an hour
|
|
417
|
+
per identity, over a trailing window rather than a refilling bucket, so a burst saturates the
|
|
418
|
+
identity for a full hour and waiting does not return the headroom early. An idle Account is asked at
|
|
419
|
+
most every 5 minutes, an exhausted one every 10, never more often than a 3-minute floor, and each
|
|
420
|
+
Account's schedule is nudged off the others' so a fleet does not ask in lockstep. A 429 is waited out
|
|
421
|
+
over the window that produced it rather than retried in minutes. `--refresh` honours the same floors:
|
|
422
|
+
a number under 3 minutes old is served as it stands, so running it in a loop cannot spend the hour's
|
|
423
|
+
requests.
|
|
424
|
+
|
|
425
|
+
Records live in `~/.opsee/foreman-usage/` (or `OPSEE_FOREMAN_USAGE_DIR`), one file per Account,
|
|
426
|
+
owner-only. **Usage is an optimisation and never a dependency**: stale, missing, unparseable or a
|
|
427
|
+
failed poll all fall back to the behaviour below, which is to find out by being refused. A failed
|
|
428
|
+
poll is not a credential failure and can never contribute to a quarantine.
|
|
429
|
+
|
|
430
|
+
### Holds: stopping before the vendor says no
|
|
431
|
+
|
|
432
|
+
An Account at or past its threshold — 90% by default, where the vendor's own warning fires, and
|
|
433
|
+
`account set <name> --threshold <pct>` per Account — is **held**: no new Worker starts on it until
|
|
434
|
+
that window resets. `account list` says `held` rather than `paused`, because the two ask different
|
|
435
|
+
things of a reader. A pause is Anthropic refusing; a hold is the Foreman declining first.
|
|
436
|
+
|
|
437
|
+
A hold is derived from usage and policy every tick and never stored, so raising `--threshold` lifts
|
|
438
|
+
one with nothing to clear, and a window resetting lifts one by itself.
|
|
439
|
+
|
|
440
|
+
### Dispatch strategies: which Account gets the next Task
|
|
441
|
+
|
|
442
|
+
```sh
|
|
443
|
+
node bin/opsee.js foreman run 42 --strategy best
|
|
444
|
+
node bin/opsee.js foreman up --strategy consume-first --hysteresis 5 --cooldown 60000
|
|
445
|
+
```
|
|
446
|
+
|
|
447
|
+
- `order` — registration order. **The default**, and exactly what the Foreman did before any of
|
|
448
|
+
this existed, so upgrading cannot silently move anyone's Tasks to a different Account.
|
|
449
|
+
|
|
450
|
+
Under `order`, a Run on a fleet still has to be told where to start (`--account <name>`): nothing
|
|
451
|
+
has said which Account to prefer, so `chooseAccount` refuses rather than guessing. Under `best` or
|
|
452
|
+
`consume-first` it starts on the Account the strategy would pick, because that is the same
|
|
453
|
+
question already answered. A Run queued for the daemon leaves the choice unresolved unless a name
|
|
454
|
+
was given: a `RunRequestRow` carries no strategy, so the daemon's policy is the one that applies.
|
|
455
|
+
- `best` — most Headroom first. What an overnight Run across several Accounts wants.
|
|
456
|
+
- `consume-first` — soonest-resetting Account first, draining one before the next is touched.
|
|
457
|
+
|
|
458
|
+
The Lanes are ranked **once per tick**, not once per Ready Task, and each Task then orders its own
|
|
459
|
+
eligible Lanes by that ranking — otherwise a Task pinned to one Account would write a one-Lane order
|
|
460
|
+
into the ranking state and drag every later Task onto it. `--hysteresis` (10 points) and
|
|
461
|
+
`--cooldown` (5 minutes) stop two Accounts a point apart from swapping on every dispatch.
|
|
462
|
+
|
|
463
|
+
### `opsee claude`: the same Accounts, interactively
|
|
464
|
+
|
|
465
|
+
```sh
|
|
466
|
+
node bin/opsee.js claude # the Account with the most Headroom
|
|
467
|
+
node bin/opsee.js claude --print-choice # resolve and print, launch nothing
|
|
468
|
+
node bin/opsee.js claude --account spare -- --model opus
|
|
469
|
+
```
|
|
470
|
+
|
|
471
|
+
Runs the real `claude` with `CLAUDE_CONFIG_DIR` pointed at the chosen Account and every argument
|
|
472
|
+
passed through untouched. **No credential moves**: a config directory you have logged into is
|
|
473
|
+
already a whole identity, and the vendor's own variable is the supported way to select one.
|
|
474
|
+
|
|
475
|
+
Its own flags are read only from the front and stop at the first token it does not recognise, or at
|
|
476
|
+
a bare `--`, so an argument meant for `claude` can never be eaten. It skips an Account that is
|
|
477
|
+
quarantined, Paused, held, or one a Foreman already has Workers on — a session there would double
|
|
478
|
+
what that identity is running. Told `--account` explicitly it obeys and warns instead of refusing;
|
|
479
|
+
the person at the keyboard is not overruled about their own subscription.
|
|
480
|
+
|
|
481
|
+
`account sync-skeleton` symlinks the account-independent entries (`settings.json`, `CLAUDE.md`,
|
|
482
|
+
`skills`, `commands`, `agents`, `.mcp.json`) from one directory — `~/.claude` by default — into each
|
|
483
|
+
Account's, so only history differs between them. It moves no bytes, never replaces a file you wrote,
|
|
484
|
+
and refuses anything credential-shaped as well as `.claude.json` by name, since that one carries the
|
|
485
|
+
account identity and sharing it would quietly make two Accounts one.
|
|
486
|
+
|
|
353
487
|
### Paused Accounts and Failover
|
|
354
488
|
|
|
355
489
|
When a Worker Adapter reports a rate limit, the Account it ran under becomes **Paused**
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@opsee/cli",
|
|
3
|
-
"version": "0.11.
|
|
3
|
+
"version": "0.11.18",
|
|
4
4
|
"description": "Opsee CLI — the opsee binary: login, whoami, and the home of the Foreman",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
"@bufbuild/protobuf": "^2.14.0",
|
|
18
18
|
"@connectrpc/connect": "^2.1.2",
|
|
19
19
|
"@connectrpc/connect-node": "^2.1.2",
|
|
20
|
-
"@opsee/mcp-server": "0.11.
|
|
20
|
+
"@opsee/mcp-server": "0.11.18",
|
|
21
21
|
"tsx": "^4.23.12"
|
|
22
22
|
},
|
|
23
23
|
"devDependencies": {
|
package/src/args.ts
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { AccountUsageArgs } from "./commands/account-usage.js";
|
|
2
|
+
import type { ClaudeLaunchArgs } from "./commands/claude-launcher.js";
|
|
3
|
+
import type { ForemanRunArgs, ForemanUpArgs, RankingArgs } from "./commands/foreman.js";
|
|
4
|
+
import type { DispatchStrategy } from "./foreman/core/scheduler.js";
|
|
2
5
|
import type { ForemanPlanArgs } from "./commands/foreman-plan.js";
|
|
3
6
|
import type { ForemanServiceArgs } from "./commands/foreman-service.js";
|
|
4
7
|
import type { ForemanLogsArgs, ForemanReviewArgs, ForemanStatusArgs } from "./commands/foreman-views.js";
|
|
@@ -7,6 +10,8 @@ import type { RecipeFlags } from "./init/run-recipe-config.js";
|
|
|
7
10
|
|
|
8
11
|
/** Raw options of `opsee foreman account add`; the account module validates them. */
|
|
9
12
|
export interface AccountAddArgs {
|
|
13
|
+
/** Run the vendor's own login in the new config directory before registering (spec §7). */
|
|
14
|
+
login?: boolean;
|
|
10
15
|
vendor?: string;
|
|
11
16
|
name?: string;
|
|
12
17
|
configDir?: string;
|
|
@@ -21,6 +26,8 @@ export interface AccountSetArgs {
|
|
|
21
26
|
cap?: string;
|
|
22
27
|
maxTurns?: string;
|
|
23
28
|
stallTimeoutMs?: string;
|
|
29
|
+
/** How much of a rate-limit window may be spent before the Account is held back. */
|
|
30
|
+
thresholdPct?: string;
|
|
24
31
|
}
|
|
25
32
|
|
|
26
33
|
/** Raw options of `opsee initiative list`. Validated where the command runs, like the account
|
|
@@ -83,6 +90,11 @@ export type Command =
|
|
|
83
90
|
| { kind: "init"; projectKey?: string; recipe: RecipeFlags }
|
|
84
91
|
| { kind: "foreman-account-add"; args: AccountAddArgs }
|
|
85
92
|
| { kind: "foreman-account-list" }
|
|
93
|
+
/** What is known about each Account's rate-limit windows (the multi-account headroom design). */
|
|
94
|
+
| { kind: "foreman-account-usage"; args: AccountUsageArgs }
|
|
95
|
+
/** Symlinks the account-independent config entries from one canonical directory into every
|
|
96
|
+
* claude subscription Account's (the multi-account headroom design, §5). */
|
|
97
|
+
| { kind: "foreman-account-sync-skeleton"; args: { from?: string } }
|
|
86
98
|
/** Changes an Account's Slot cap and per-turn limits (story 27): raising a cap is always this. */
|
|
87
99
|
| { kind: "foreman-account-set"; args: AccountSetArgs }
|
|
88
100
|
| { kind: "foreman-account-remove"; name: string }
|
|
@@ -97,7 +109,7 @@ export type Command =
|
|
|
97
109
|
| { kind: "initiative-memory"; args: InitiativeMemoryArgs }
|
|
98
110
|
| { kind: "initiative-note"; args: InitiativeNoteArgs }
|
|
99
111
|
/** The daemon that idles until given work (story 13); a placeholder until the Process Table lands. */
|
|
100
|
-
| { kind: "foreman-up" }
|
|
112
|
+
| { kind: "foreman-up"; args: ForemanUpArgs }
|
|
101
113
|
/** A Run scoped to one Initiative, in the foreground until no Ready Task remains (story 14). */
|
|
102
114
|
| { kind: "foreman-run"; args: ForemanRunArgs }
|
|
103
115
|
/** An attended planning session on an Initiative, in a Workspace of its own (story 10). */
|
|
@@ -123,11 +135,14 @@ export type Command =
|
|
|
123
135
|
| { kind: "foreman-debug-serve"; port: number }
|
|
124
136
|
/** Hidden: runs one unattended Worker turn through the Worker Adapter and prints its report. */
|
|
125
137
|
| { kind: "foreman-debug-turn"; args: DebugTurnArgs }
|
|
138
|
+
/** `opsee claude [args…]`: the vendor's own binary on the Account with the most headroom (the
|
|
139
|
+
* multi-account headroom design, §5). */
|
|
140
|
+
| { kind: "claude-launch"; args: ClaudeLaunchArgs }
|
|
126
141
|
| { kind: "help" }
|
|
127
142
|
| { kind: "usage-error"; message: string }
|
|
128
143
|
| { kind: "unknown"; input: string };
|
|
129
144
|
|
|
130
|
-
const ACCOUNT_ADD_OPTIONS: Record<string, keyof AccountAddArgs
|
|
145
|
+
const ACCOUNT_ADD_OPTIONS: Record<string, keyof Omit<AccountAddArgs, "login">> = {
|
|
131
146
|
"--vendor": "vendor",
|
|
132
147
|
"--name": "name",
|
|
133
148
|
"--config-dir": "configDir",
|
|
@@ -139,6 +154,11 @@ function parseAccountAdd(argv: string[]): Command {
|
|
|
139
154
|
const args: AccountAddArgs = {};
|
|
140
155
|
for (let i = 0; i < argv.length; i++) {
|
|
141
156
|
const token = argv[i];
|
|
157
|
+
// The one flag here that takes no value; everything else is `--flag <value>`.
|
|
158
|
+
if (token === "--login") {
|
|
159
|
+
args.login = true;
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
142
162
|
const eq = token.indexOf("=");
|
|
143
163
|
const flag = eq === -1 ? token : token.slice(0, eq);
|
|
144
164
|
const field = ACCOUNT_ADD_OPTIONS[flag];
|
|
@@ -159,9 +179,11 @@ const ACCOUNT_SET_OPTIONS: Record<string, keyof Omit<AccountSetArgs, "name">> =
|
|
|
159
179
|
"--cap": "cap",
|
|
160
180
|
"--max-turns": "maxTurns",
|
|
161
181
|
"--stall-timeout": "stallTimeoutMs",
|
|
182
|
+
"--threshold": "thresholdPct",
|
|
162
183
|
};
|
|
163
184
|
|
|
164
|
-
const ACCOUNT_SET_USAGE =
|
|
185
|
+
const ACCOUNT_SET_USAGE =
|
|
186
|
+
"foreman account set needs an Account name and at least one of --cap, --max-turns, --stall-timeout, --threshold: foreman account set <name> --cap <n>";
|
|
165
187
|
|
|
166
188
|
function parseAccountSet(argv: string[]): Command {
|
|
167
189
|
const [name, ...rest] = argv;
|
|
@@ -219,7 +241,7 @@ function parseDebugTurn(argv: string[]): Command {
|
|
|
219
241
|
return { kind: "foreman-debug-turn", args: { account, cwd, prompt, resume: values["--resume"], maxTurns, stallTimeoutMs, raw } };
|
|
220
242
|
}
|
|
221
243
|
|
|
222
|
-
const FOREMAN_SUBCOMMANDS = "up | run <initiativeId> | plan <initiativeId> | status | logs <task> | review [initiativeId] | attach <taskId> | release <taskId> | cancel <taskId> | pause <initiativeId> | resume <initiativeId> | service install | service uninstall | service status | account add | account list | account set | account remove | account resume";
|
|
244
|
+
const FOREMAN_SUBCOMMANDS = "up | run <initiativeId> | plan <initiativeId> | status | logs <task> | review [initiativeId] | attach <taskId> | release <taskId> | cancel <taskId> | pause <initiativeId> | resume <initiativeId> | service install | service uninstall | service status | account add | account list | account usage | account set | account remove | account resume | account sync-skeleton";
|
|
223
245
|
|
|
224
246
|
/** The task-scoped controls (attach, release, cancel) take a Task id; pause and resume an
|
|
225
247
|
* Initiative id. All take exactly one positive integer. */
|
|
@@ -231,9 +253,38 @@ function parseForemanControl(kind: "foreman-attach" | "foreman-release" | "forem
|
|
|
231
253
|
return kind === "foreman-pause" || kind === "foreman-resume" ? { kind, initiativeId: id } : { kind, taskId: id };
|
|
232
254
|
}
|
|
233
255
|
|
|
234
|
-
|
|
256
|
+
/** The dispatch-policy flags (`RankingArgs`), taken by both `foreman run` and `foreman up`. */
|
|
257
|
+
const RANKING_VALUE_OPTIONS = ["--strategy", "--hysteresis", "--cooldown"] as const;
|
|
258
|
+
const STRATEGIES: readonly DispatchStrategy[] = ["order", "best", "consume-first"];
|
|
259
|
+
|
|
260
|
+
/** Reads the three policy flags out of the values already collected, or says which one is wrong.
|
|
261
|
+
* The strategy is checked against the list here rather than deeper down, because a typo in it
|
|
262
|
+
* would otherwise be a silent fall back to registration order. */
|
|
263
|
+
function rankingArgs(values: Partial<Record<string, string>>): RankingArgs | { error: string } {
|
|
264
|
+
const strategy = values["--strategy"];
|
|
265
|
+
if (strategy !== undefined && !(STRATEGIES as readonly string[]).includes(strategy)) {
|
|
266
|
+
return { error: `--strategy must be one of: ${STRATEGIES.join(", ")}` };
|
|
267
|
+
}
|
|
268
|
+
const hysteresisPct = values["--hysteresis"] === undefined ? undefined : Number(values["--hysteresis"]);
|
|
269
|
+
const cooldownMs = values["--cooldown"] === undefined ? undefined : Number(values["--cooldown"]);
|
|
270
|
+
// Zero is meaningful for both — no margin, no cooldown — so these are not `positiveInteger`.
|
|
271
|
+
for (const [flag, value] of [["--hysteresis", hysteresisPct], ["--cooldown", cooldownMs]] as const) {
|
|
272
|
+
if (value !== undefined && (!Number.isInteger(value) || value < 0)) return { error: `${flag} must be a whole number of ${flag === "--hysteresis" ? "percentage points" : "milliseconds"}` };
|
|
273
|
+
}
|
|
274
|
+
if (hysteresisPct !== undefined && hysteresisPct > 100) return { error: "--hysteresis is a margin in percentage points, so it is at most 100" };
|
|
275
|
+
// Only what was actually asked for: an absent flag is absent from the result rather than a key
|
|
276
|
+
// holding undefined, so `foreman up` with no flags parses to an empty policy.
|
|
277
|
+
return {
|
|
278
|
+
...(strategy === undefined ? {} : { strategy: strategy as DispatchStrategy }),
|
|
279
|
+
...(hysteresisPct === undefined ? {} : { hysteresisPct }),
|
|
280
|
+
...(cooldownMs === undefined ? {} : { cooldownMs }),
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const FOREMAN_RUN_VALUE_OPTIONS = ["--account", "--task", "--max-turns", "--stall-timeout", ...RANKING_VALUE_OPTIONS] as const;
|
|
235
285
|
type ForemanRunValueOption = (typeof FOREMAN_RUN_VALUE_OPTIONS)[number];
|
|
236
|
-
const FOREMAN_RUN_USAGE =
|
|
286
|
+
const FOREMAN_RUN_USAGE =
|
|
287
|
+
"foreman run needs an Initiative id: foreman run <initiativeId> [--account <name>] [--once] [--task <id>] [--max-turns <n>] [--stall-timeout <ms>] [--strategy <order|best|consume-first>] [--hysteresis <pct>] [--cooldown <ms>]";
|
|
237
288
|
|
|
238
289
|
function positiveInteger(text: string | undefined): number | undefined | null {
|
|
239
290
|
if (text === undefined) return undefined;
|
|
@@ -270,7 +321,9 @@ function parseForemanRun(argv: string[]): Command {
|
|
|
270
321
|
if (taskId === null || maxTurns === null || stallTimeoutMs === null) {
|
|
271
322
|
return { kind: "usage-error", message: "--task, --max-turns and --stall-timeout must be positive integers" };
|
|
272
323
|
}
|
|
273
|
-
|
|
324
|
+
const ranking = rankingArgs(values);
|
|
325
|
+
if ("error" in ranking) return { kind: "usage-error", message: ranking.error };
|
|
326
|
+
return { kind: "foreman-run", args: { initiativeId, account: values["--account"], once, taskId, maxTurns, stallTimeoutMs, ...ranking } };
|
|
274
327
|
}
|
|
275
328
|
|
|
276
329
|
const FOREMAN_PLAN_VALUE_OPTIONS = ["--account", "--skill", "--memory"] as const;
|
|
@@ -383,11 +436,27 @@ function parseForemanReview(argv: string[]): Command {
|
|
|
383
436
|
return { kind: "foreman-review", args: { initiativeId: id } };
|
|
384
437
|
}
|
|
385
438
|
|
|
439
|
+
const FOREMAN_UP_USAGE = "foreman up takes only the dispatch policy: foreman up [--strategy <order|best|consume-first>] [--hysteresis <pct>] [--cooldown <ms>]";
|
|
440
|
+
|
|
441
|
+
function parseForemanUp(argv: string[]): Command {
|
|
442
|
+
const values: Partial<Record<string, string>> = {};
|
|
443
|
+
for (let i = 0; i < argv.length; i++) {
|
|
444
|
+
const token = argv[i];
|
|
445
|
+
const eq = token.indexOf("=");
|
|
446
|
+
const flag = eq === -1 ? token : token.slice(0, eq);
|
|
447
|
+
if (!(RANKING_VALUE_OPTIONS as readonly string[]).includes(flag)) return { kind: "usage-error", message: FOREMAN_UP_USAGE };
|
|
448
|
+
const value = eq === -1 ? argv[++i] : token.slice(eq + 1);
|
|
449
|
+
if (value === undefined || (eq === -1 && value.startsWith("--"))) return { kind: "usage-error", message: `${flag} needs a value` };
|
|
450
|
+
values[flag] = value;
|
|
451
|
+
}
|
|
452
|
+
const ranking = rankingArgs(values);
|
|
453
|
+
if ("error" in ranking) return { kind: "usage-error", message: ranking.error };
|
|
454
|
+
return { kind: "foreman-up", args: ranking };
|
|
455
|
+
}
|
|
456
|
+
|
|
386
457
|
function parseForeman(argv: string[]): Command {
|
|
387
458
|
const [group, action, ...rest] = argv;
|
|
388
|
-
if (group === "up")
|
|
389
|
-
return action === undefined ? { kind: "foreman-up" } : { kind: "usage-error", message: "foreman up takes no arguments" };
|
|
390
|
-
}
|
|
459
|
+
if (group === "up") return parseForemanUp([action, ...rest].filter((t): t is string => t !== undefined));
|
|
391
460
|
if (group === "run") return parseForemanRun(argv.slice(1));
|
|
392
461
|
if (group === "plan") return parseForemanPlan(argv.slice(1));
|
|
393
462
|
if (group === "service") return parseForemanService(action, rest);
|
|
@@ -418,6 +487,27 @@ function parseForeman(argv: string[]): Command {
|
|
|
418
487
|
return parseAccountAdd(rest);
|
|
419
488
|
case "list":
|
|
420
489
|
return { kind: "foreman-account-list" };
|
|
490
|
+
case "usage": {
|
|
491
|
+
const args: AccountUsageArgs = {};
|
|
492
|
+
for (const token of rest) {
|
|
493
|
+
if (token === "--refresh") args.refresh = true;
|
|
494
|
+
else if (token === "--windows") args.windows = true;
|
|
495
|
+
else return { kind: "usage-error", message: "foreman account usage takes only --refresh and --windows" };
|
|
496
|
+
}
|
|
497
|
+
return { kind: "foreman-account-usage", args };
|
|
498
|
+
}
|
|
499
|
+
case "sync-skeleton": {
|
|
500
|
+
if (rest.length === 0) return { kind: "foreman-account-sync-skeleton", args: {} };
|
|
501
|
+
const [flag, value] = rest;
|
|
502
|
+
if (flag === "--from" && value !== undefined && !value.startsWith("--") && rest.length === 2) {
|
|
503
|
+
return { kind: "foreman-account-sync-skeleton", args: { from: value } };
|
|
504
|
+
}
|
|
505
|
+
if (flag.startsWith("--from=") && rest.length === 1) {
|
|
506
|
+
const from = flag.slice("--from=".length);
|
|
507
|
+
return from === "" ? { kind: "usage-error", message: "--from needs a directory" } : { kind: "foreman-account-sync-skeleton", args: { from } };
|
|
508
|
+
}
|
|
509
|
+
return { kind: "usage-error", message: "foreman account sync-skeleton takes only --from <dir>" };
|
|
510
|
+
}
|
|
421
511
|
case "set":
|
|
422
512
|
return parseAccountSet(rest);
|
|
423
513
|
case "remove":
|
|
@@ -425,7 +515,7 @@ function parseForeman(argv: string[]): Command {
|
|
|
425
515
|
case "resume":
|
|
426
516
|
return rest[0] ? { kind: "foreman-account-resume", name: rest[0] } : { kind: "usage-error", message: "foreman account resume needs an Account name" };
|
|
427
517
|
default:
|
|
428
|
-
return { kind: "usage-error", message: "foreman account needs one of: add, list, set, remove, resume" };
|
|
518
|
+
return { kind: "usage-error", message: "foreman account needs one of: add, list, usage, set, remove, resume, sync-skeleton" };
|
|
429
519
|
}
|
|
430
520
|
}
|
|
431
521
|
|
|
@@ -534,6 +624,42 @@ function parseInitiative(argv: string[]): Command {
|
|
|
534
624
|
}
|
|
535
625
|
}
|
|
536
626
|
|
|
627
|
+
/**
|
|
628
|
+
* `opsee claude [our flags] [the vendor's arguments…]`.
|
|
629
|
+
*
|
|
630
|
+
* The launcher's own flags are read only from the front, and reading stops at the first token that
|
|
631
|
+
* is not one of them — or at a bare `--`. Everything from there on is the vendor's, verbatim.
|
|
632
|
+
*
|
|
633
|
+
* It has to work this way round: `claude` has its own flags and may one day have these names too,
|
|
634
|
+
* and a launcher that grabbed `--account` from the middle of a command line would silently eat an
|
|
635
|
+
* argument meant for the vendor. Stopping at the first unrecognised token means the ambiguity can
|
|
636
|
+
* only ever be resolved in the vendor's favour, and `--` says so explicitly.
|
|
637
|
+
*/
|
|
638
|
+
function parseClaudeLaunch(argv: string[]): Command {
|
|
639
|
+
const args: ClaudeLaunchArgs = { printChoice: false, passthrough: [] };
|
|
640
|
+
let i = 0;
|
|
641
|
+
for (; i < argv.length; i++) {
|
|
642
|
+
const token = argv[i];
|
|
643
|
+
if (token === "--") {
|
|
644
|
+
i++;
|
|
645
|
+
break;
|
|
646
|
+
}
|
|
647
|
+
if (token === "--print-choice") {
|
|
648
|
+
args.printChoice = true;
|
|
649
|
+
continue;
|
|
650
|
+
}
|
|
651
|
+
if (token === "--account" || token.startsWith("--account=")) {
|
|
652
|
+
const value = token === "--account" ? argv[++i] : token.slice("--account=".length);
|
|
653
|
+
if (value === undefined || value === "" || value.startsWith("--")) return { kind: "usage-error", message: "--account needs an Account name" };
|
|
654
|
+
args.account = value;
|
|
655
|
+
continue;
|
|
656
|
+
}
|
|
657
|
+
break;
|
|
658
|
+
}
|
|
659
|
+
args.passthrough = argv.slice(i);
|
|
660
|
+
return { kind: "claude-launch", args };
|
|
661
|
+
}
|
|
662
|
+
|
|
537
663
|
export function parseCommand(argv: string[]): Command {
|
|
538
664
|
const [first, ...rest] = argv;
|
|
539
665
|
switch (first) {
|
|
@@ -547,6 +673,8 @@ export function parseCommand(argv: string[]): Command {
|
|
|
547
673
|
return parseForeman(rest);
|
|
548
674
|
case "initiative":
|
|
549
675
|
return parseInitiative(rest);
|
|
676
|
+
case "claude":
|
|
677
|
+
return parseClaudeLaunch(rest);
|
|
550
678
|
case undefined:
|
|
551
679
|
case "help":
|
|
552
680
|
case "--help":
|
|
@@ -602,10 +730,20 @@ export function usage(): string {
|
|
|
602
730
|
" Run Recipe values for a non-interactive run; {port} in a",
|
|
603
731
|
" command or URL is replaced with the Worker's port",
|
|
604
732
|
"",
|
|
733
|
+
" opsee claude [--account <name>] [--print-choice] [-- ] [claude arguments…]",
|
|
734
|
+
" Run the real claude on the registered Account with the most",
|
|
735
|
+
" headroom, by pointing CLAUDE_CONFIG_DIR at it. Arguments after",
|
|
736
|
+
" the launcher's own are handed to claude untouched",
|
|
737
|
+
"",
|
|
605
738
|
"Foreman:",
|
|
606
|
-
" opsee foreman up
|
|
739
|
+
" opsee foreman up [--strategy <order|best|consume-first>] [--hysteresis <pct>] [--cooldown <ms>]",
|
|
740
|
+
" Start the Foreman daemon: it Reconciles each tick and serves Runs",
|
|
741
|
+
" queued by foreman run. --strategy orders the Accounts a Ready Task",
|
|
742
|
+
" may run on: registration order, most headroom left, or one Account",
|
|
743
|
+
" drained before the next is touched",
|
|
607
744
|
" opsee foreman run <initiativeId> [--account <name>] [--once] [--task <id>]",
|
|
608
|
-
" [--max-turns <n>] [--stall-timeout <ms>]",
|
|
745
|
+
" [--max-turns <n>] [--stall-timeout <ms>] [--strategy <order|best|consume-first>]",
|
|
746
|
+
" [--hysteresis <pct>] [--cooldown <ms>]",
|
|
609
747
|
" Run the Initiative's Ready Tasks one by one from this checkout:",
|
|
610
748
|
" a Workspace and branch per Task, one unattended Worker turn each,",
|
|
611
749
|
" Completion Reports into Initiative memory, moves on the board",
|
|
@@ -646,10 +784,24 @@ export function usage(): string {
|
|
|
646
784
|
" Workers finish either way, both are on the Run Record",
|
|
647
785
|
"",
|
|
648
786
|
"Foreman Accounts (stored by path or variable name only; the credential is never read):",
|
|
649
|
-
" opsee foreman account add --vendor <claude|codex> --config-dir <dir> [--name <n>] [--cap <n>]",
|
|
787
|
+
" opsee foreman account add --vendor <claude|codex> --config-dir <dir> [--name <n>] [--cap <n>] [--login]",
|
|
788
|
+
" --login makes the directory and runs the vendor's own login in",
|
|
789
|
+
" it first, so a second Account needs no set-up by hand",
|
|
650
790
|
" opsee foreman account add --vendor <claude|codex> --key-env <VAR> [--name <n>] [--cap <n>]",
|
|
651
791
|
" opsee foreman account list",
|
|
792
|
+
" opsee foreman account sync-skeleton [--from <dir>]",
|
|
793
|
+
" Symlink the shared config entries (settings.json, CLAUDE.md,",
|
|
794
|
+
" skills, commands, agents) from one directory — ~/.claude by",
|
|
795
|
+
" default — into every claude subscription Account's, so only",
|
|
796
|
+
" history differs between them. A credential is never linked",
|
|
797
|
+
" opsee foreman account usage [--refresh] [--windows]",
|
|
798
|
+
" What is left on each Account: the headroom of the window that",
|
|
799
|
+
" decides dispatch, when it resets, and whether it is held back.",
|
|
800
|
+
" --windows shows every window; --refresh measures now rather than",
|
|
801
|
+
" printing only what a Run or the daemon has already recorded",
|
|
652
802
|
" opsee foreman account set <name> [--cap <n>] [--max-turns <n>] [--stall-timeout <ms>]",
|
|
803
|
+
" [--threshold <pct>] How much of a rate-limit window may be spent before the Account",
|
|
804
|
+
" is held back (90 by default, where the vendor warns)",
|
|
653
805
|
" The Account's Slots and per-turn limits. The cap is how many",
|
|
654
806
|
" Workers may run on it at once, one Slot of it reserved for",
|
|
655
807
|
" Verifiers; it defaults to 2 and is only ever raised here",
|