@opsee/cli 0.11.13 → 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 +116 -0
- 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-up.ts +184 -4
- package/src/commands/foreman.ts +133 -11
- 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/text.ts +29 -0
- package/src/foreman/credential-store.ts +247 -0
- 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/README.md
CHANGED
|
@@ -225,14 +225,30 @@ or an API-key Account, a vendor plus the name of the environment variable that w
|
|
|
225
225
|
|
|
226
226
|
```sh
|
|
227
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
|
|
228
229
|
node bin/opsee.js foreman account add --vendor codex --key-env OPENAI_API_KEY --cap 3
|
|
229
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
|
|
230
232
|
node bin/opsee.js foreman account set work --cap 4 # more Slots, only ever by asking
|
|
231
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
|
|
232
236
|
node bin/opsee.js foreman account remove work
|
|
233
237
|
node bin/opsee.js foreman account resume work # lifts a quarantine (and any pause)
|
|
234
238
|
```
|
|
235
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
|
+
|
|
236
252
|
Several Accounts per vendor are fine, and worth having: each has its own name, cap and rate-limit
|
|
237
253
|
window, and a Run on one **Fails over to its siblings of the same vendor** while it is Paused (see
|
|
238
254
|
[Paused Accounts and Failover](#paused-accounts-and-failover)). Records live in
|
|
@@ -368,6 +384,106 @@ out of the real Run Record: the order off the Account with one implementer Slot,
|
|
|
368
384
|
order is the order the scheduler chose in, and the concurrency off the Account with two, where a
|
|
369
385
|
barrier the turns wait at proves they really overlapped.
|
|
370
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
|
+
|
|
371
487
|
### Paused Accounts and Failover
|
|
372
488
|
|
|
373
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",
|
package/src/cli.ts
CHANGED
|
@@ -1,21 +1,23 @@
|
|
|
1
1
|
import { execFile } from "node:child_process";
|
|
2
2
|
import { accessSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
3
3
|
import { homedir } from "node:os";
|
|
4
|
-
import { basename } from "node:path";
|
|
4
|
+
import { basename, join } from "node:path";
|
|
5
5
|
import { fileURLToPath } from "node:url";
|
|
6
6
|
import { Code, ConnectError } from "@connectrpc/connect";
|
|
7
7
|
import { authManager } from "@opsee/mcp-server/src/auth/manager.js";
|
|
8
8
|
import { startLoginFlow } from "@opsee/mcp-server/src/auth/login.js";
|
|
9
9
|
import { createClients, type ApiClients } from "@opsee/mcp-server/src/client/api.js";
|
|
10
10
|
import { parseCommand, usage } from "./args.js";
|
|
11
|
-
import { runAccountAdd, runAccountList, runAccountRemove, runAccountResume, runAccountSet, type AccountDeps } from "./commands/account.js";
|
|
11
|
+
import { runAccountAdd, runAccountList, runAccountRemove, runAccountResume, runAccountSet, runAccountSyncSkeleton, type AccountDeps } from "./commands/account.js";
|
|
12
|
+
import { runAccountUsage } from "./commands/account-usage.js";
|
|
13
|
+
import { runClaudeLaunch, type ClaudeLaunchDeps } from "./commands/claude-launcher.js";
|
|
12
14
|
import { runInitiativeCreate, runInitiativeList, runInitiativeMemory, runInitiativeNote, runInitiativeShow, type InitiativeDeps } from "./commands/initiative.js";
|
|
13
15
|
import type { CommandDeps } from "./commands/deps.js";
|
|
14
16
|
import { runForemanDebugReady, runForemanDebugServe, runForemanDebugTurn } from "./commands/foreman-debug.js";
|
|
15
17
|
import { currentRepoRoot, runForemanRun, type ForemanLocal } from "./commands/foreman.js";
|
|
16
18
|
import { runForemanPlan } from "./commands/foreman-plan.js";
|
|
17
19
|
import { runForemanService } from "./commands/foreman-service.js";
|
|
18
|
-
import { runForemanAttach, runForemanCancel, runForemanPause, runForemanRelease, runForemanResume, type ForemanControlDeps } from "./commands/foreman-control.js";
|
|
20
|
+
import { runForemanAttach, runForemanCancel, runForemanPause, runForemanRelease, runForemanResume, type ForemanControlDeps, runInteractiveCommand } from "./commands/foreman-control.js";
|
|
19
21
|
import { runForemanUp } from "./commands/foreman-up.js";
|
|
20
22
|
import { runForemanLogs, runForemanReview, runForemanStatus } from "./commands/foreman-views.js";
|
|
21
23
|
import { ClaudeWorkerAdapter } from "./foreman/claude-worker-adapter.js";
|
|
@@ -25,12 +27,16 @@ import { runLogin } from "./commands/login.js";
|
|
|
25
27
|
import { runWhoami, NOT_LOGGED_IN } from "./commands/whoami.js";
|
|
26
28
|
import { runInit } from "./commands/init.js";
|
|
27
29
|
import { accountsFilePath, FileAccountStore } from "./foreman/account-store.js";
|
|
28
|
-
import {
|
|
30
|
+
import { FileUsageStore, usageDirPath } from "./foreman/usage-store.js";
|
|
31
|
+
import { ProcessTable, otherForemanWorkersOn } from "./foreman/core/process-table.js";
|
|
29
32
|
import { ForemanError } from "./foreman/core/run.js";
|
|
30
33
|
import { TranscriptStore } from "./foreman/core/transcripts.js";
|
|
31
34
|
import { hostIsLaptop } from "./foreman/host.js";
|
|
32
35
|
import { daemonPidPath, foremanLocalDir, processTablePath, transcriptsDir } from "./foreman/local-dir.js";
|
|
33
|
-
import type
|
|
36
|
+
import { realConfigDirFs, type ConfigDirFs } from "./foreman/account.js";
|
|
37
|
+
import { VENDOR_BINARY } from "./foreman/vendor.js";
|
|
38
|
+
import { credentialPresence, realCredentialDeps } from "./foreman/credential-store.js";
|
|
39
|
+
import { AnthropicUsageSource } from "./foreman/usage-poller.js";
|
|
34
40
|
import { OpseeTrackerAdapter } from "./foreman/opsee-tracker-adapter.js";
|
|
35
41
|
import type { Vendor } from "./foreman/vendor.js";
|
|
36
42
|
import type { WorkerAdapter } from "./foreman/worker-adapter.js";
|
|
@@ -75,7 +81,7 @@ function foremanLocal(): ForemanLocal {
|
|
|
75
81
|
}
|
|
76
82
|
|
|
77
83
|
/** The real file system, narrowed to the two calls Account registration may make. */
|
|
78
|
-
const configDirFs: ConfigDirFs =
|
|
84
|
+
const configDirFs: ConfigDirFs = realConfigDirFs;
|
|
79
85
|
|
|
80
86
|
/** The CLI entry a service unit runs: bin/opsee.js beside src/, absolute (story 16). */
|
|
81
87
|
const CLI_BIN = fileURLToPath(new URL("../bin/opsee.js", import.meta.url));
|
|
@@ -157,15 +163,56 @@ function initiativeDeps(io: CliIo): InitiativeDeps {
|
|
|
157
163
|
return { clients: clients(), out: io.log };
|
|
158
164
|
}
|
|
159
165
|
|
|
166
|
+
/** What `opsee claude` needs: the Accounts, what is known about their limits, and a way to run the
|
|
167
|
+
* vendor's binary with this terminal handed to it. No client and no login — like the Account
|
|
168
|
+
* commands, it is local. */
|
|
169
|
+
function claudeLaunchDeps(io: CliIo): ClaudeLaunchDeps {
|
|
170
|
+
return {
|
|
171
|
+
store: new FileAccountStore(accountsFilePath()),
|
|
172
|
+
usage: new FileUsageStore(usageDirPath()),
|
|
173
|
+
out: io.log,
|
|
174
|
+
env: process.env,
|
|
175
|
+
exec: (command, args, env) => runInteractiveCommand({ command, args, cwd: process.cwd(), env }),
|
|
176
|
+
// The machine-local Process Table, so a session is not started on an Account a Foreman is
|
|
177
|
+
// already running Workers on (ADR-0013's per-identity concurrency).
|
|
178
|
+
busyAccounts: () => {
|
|
179
|
+
const table = foremanLocal().table;
|
|
180
|
+
const at = Date.now();
|
|
181
|
+
const busy = new Set<string>();
|
|
182
|
+
for (const row of table.liveWorkers()) {
|
|
183
|
+
if (otherForemanWorkersOn(table, row.account, at).length > 0) busy.add(row.account);
|
|
184
|
+
}
|
|
185
|
+
return busy;
|
|
186
|
+
},
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
|
|
160
190
|
function accountDeps(io: CliIo): AccountDeps {
|
|
161
191
|
return {
|
|
162
192
|
store: new FileAccountStore(accountsFilePath()),
|
|
193
|
+
usage: new FileUsageStore(usageDirPath()),
|
|
194
|
+
// `account usage --refresh` measures through the same source the daemon polls with.
|
|
195
|
+
usageSource: new AnthropicUsageSource(),
|
|
163
196
|
fs: configDirFs,
|
|
164
197
|
env: process.env,
|
|
165
198
|
out: io.log,
|
|
199
|
+
// `--login`: the vendor's *own* binary, interactive, with only the config-directory variable
|
|
200
|
+
// added. What it writes there is never read (ADR-0013) — only asked about, below.
|
|
201
|
+
login: (vendor, _configDir, env) =>
|
|
202
|
+
runInteractiveCommand({ command: VENDOR_BINARY[vendor], args: [], cwd: process.cwd(), env: { ...processEnvStrings(), ...env } }),
|
|
203
|
+
// Presence, never contents, and platform-aware: on macOS the credential is a Keychain item
|
|
204
|
+
// derived from the config directory rather than a file in it (credential-store.ts).
|
|
205
|
+
loggedIn: (vendor, configDir) => credentialPresence(vendor, configDir, realCredentialDeps),
|
|
166
206
|
};
|
|
167
207
|
}
|
|
168
208
|
|
|
209
|
+
/** `process.env` with the unset keys dropped, which is what a spawn's `env` must be. */
|
|
210
|
+
function processEnvStrings(): Record<string, string> {
|
|
211
|
+
const env: Record<string, string> = {};
|
|
212
|
+
for (const [key, value] of Object.entries(process.env)) if (value !== undefined) env[key] = value;
|
|
213
|
+
return env;
|
|
214
|
+
}
|
|
215
|
+
|
|
169
216
|
/** Runs one invocation of `opsee` and returns the exit code. One credential for the MCP server
|
|
170
217
|
* and the CLI: both read and write through mcp's auth module, and both talk to the backend through
|
|
171
218
|
* mcp's generated Connect-RPC clients (ADR-0010). */
|
|
@@ -212,6 +259,12 @@ export async function main(argv: string[], io: CliIo = console): Promise<number>
|
|
|
212
259
|
return await runAccountAdd(accountDeps(io), command.args);
|
|
213
260
|
case "foreman-account-list":
|
|
214
261
|
return await runAccountList(accountDeps(io));
|
|
262
|
+
case "foreman-account-usage":
|
|
263
|
+
return await runAccountUsage(accountDeps(io), command.args);
|
|
264
|
+
case "foreman-account-sync-skeleton":
|
|
265
|
+
return await runAccountSyncSkeleton(accountDeps(io), command.args);
|
|
266
|
+
case "claude-launch":
|
|
267
|
+
return await runClaudeLaunch(claudeLaunchDeps(io), command.args);
|
|
215
268
|
case "foreman-account-set":
|
|
216
269
|
return await runAccountSet(accountDeps(io), command.args);
|
|
217
270
|
case "foreman-account-remove":
|
|
@@ -231,6 +284,8 @@ export async function main(argv: string[], io: CliIo = console): Promise<number>
|
|
|
231
284
|
case "foreman-up":
|
|
232
285
|
return await runForemanUp({
|
|
233
286
|
store: new FileAccountStore(accountsFilePath()),
|
|
287
|
+
usage: new FileUsageStore(usageDirPath()),
|
|
288
|
+
ranking: command.args,
|
|
234
289
|
tracker: new OpseeTrackerAdapter(clients()),
|
|
235
290
|
adapterFor: (account) => workerAdapterFor(account),
|
|
236
291
|
repoRoot: currentRepoRoot,
|
|
@@ -248,6 +303,7 @@ export async function main(argv: string[], io: CliIo = console): Promise<number>
|
|
|
248
303
|
return await runForemanRun(
|
|
249
304
|
{
|
|
250
305
|
store: new FileAccountStore(accountsFilePath()),
|
|
306
|
+
usage: new FileUsageStore(usageDirPath()),
|
|
251
307
|
tracker: new OpseeTrackerAdapter(clients()),
|
|
252
308
|
adapterFor: (account) => workerAdapterFor(account),
|
|
253
309
|
repoRoot: currentRepoRoot,
|