@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/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 { ProcessTable } from "./foreman/core/process-table.js";
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 { ConfigDirFs } from "./foreman/account.js";
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 = { statSync, accessSync };
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,
@@ -0,0 +1,106 @@
1
+ import type { Account } from "../foreman/account.js";
2
+ import { printableOneLine } from "../foreman/core/text.js";
3
+ import { summaryRows, windowRows } from "../foreman/usage-format.js";
4
+ import { pollUsage, SERVE_TTL_MS, staleFor } from "../foreman/usage-poller.js";
5
+ import { usageByAccount } from "../foreman/usage-store.js";
6
+ import type { AccountDeps } from "./account.js";
7
+
8
+ /** What `opsee foreman account usage` was asked for. */
9
+ export interface AccountUsageArgs {
10
+ /** Measure now, rather than printing only what is already known. */
11
+ refresh?: boolean;
12
+ /** Every window, rather than the one that decides dispatch. */
13
+ windows?: boolean;
14
+ }
15
+
16
+ /**
17
+ * `opsee foreman account usage`: what is known about every Account's rate-limit windows.
18
+ *
19
+ * A line per Account by default, because the question a human is asking is *which Account has room*
20
+ * and the answer is one number — the headroom of its tightest window. `--windows` shows the rest.
21
+ *
22
+ * It used to print a row per window always, and, when it knew nothing, a table of dashes with no
23
+ * hint that usage only arrives from a Worker turn or the daemon. On a freshly set-up machine that
24
+ * looked like a broken command rather than an unmeasured one.
25
+ */
26
+ export async function runAccountUsage(deps: AccountDeps, args: AccountUsageArgs): Promise<number> {
27
+ const accounts = deps.store.load();
28
+ if (accounts.length === 0) {
29
+ deps.out("No Accounts registered. Run `opsee foreman account add` to register one.");
30
+ return 0;
31
+ }
32
+ const now = (deps.now ?? Date.now)();
33
+ if (args.refresh) await refresh(deps, accounts, now);
34
+
35
+ const known = usageByAccount(deps.usage);
36
+ // What the Foreman could ever have a number for: the vendor the poller speaks to, and a
37
+ // subscription rather than a key. A Codex Account counted here would be told to start the daemon,
38
+ // which will never measure it.
39
+ const measurable = accounts.filter((a) => a.vendor === "claude" && a.type === "subscription");
40
+ // "Nothing has been measured" is only true when nothing has been *tried* either: an Account whose
41
+ // every poll was refused has no windows, and saying "neither has happened here" would bury the
42
+ // refusal in the one state where it most needs reading.
43
+ const untried = measurable.every((a) => {
44
+ const record = known.get(a.name);
45
+ return Object.keys(record?.windows ?? {}).length === 0 && !record?.pollFailedAt;
46
+ });
47
+ if (measurable.length > 0 && untried) {
48
+ for (const line of nothingKnown(args)) deps.out(line);
49
+ return 0;
50
+ }
51
+ for (const line of args.windows ? windowRows(accounts, known, now) : summaryRows(accounts, known, now)) deps.out(line);
52
+ return 0;
53
+ }
54
+
55
+ /**
56
+ * Measures the Accounts whose numbers are too old to print, and says what happened.
57
+ *
58
+ * `staleFor` rather than "all of them": the endpoint has a request cap (usage-poller.ts), so a command a
59
+ * human can run in a loop must not be able to spend the hour's worth of requests. A number younger
60
+ * than `SERVE_TTL_MS` is served as it stands, and an Account inside a rate-limit window is not asked
61
+ * however explicitly it was requested.
62
+ */
63
+ async function refresh(deps: AccountDeps, accounts: Account[], now: number): Promise<void> {
64
+ if (!deps.usage || !deps.usageSource) {
65
+ deps.out("This build has no way to measure usage directly; it can only show what a Run or the daemon has already recorded.");
66
+ return;
67
+ }
68
+ const measurable = accounts.filter((a) => a.vendor === "claude" && a.type === "subscription");
69
+ if (measurable.length === 0) {
70
+ // Not the same as "everything is fresh": there was nothing this could ever have asked about.
71
+ deps.out("No Account here reports usage: the poller speaks to claude subscriptions, and these are not.");
72
+ return;
73
+ }
74
+ const due = staleFor(measurable, usageByAccount(deps.usage), now);
75
+ if (due.length === 0) {
76
+ deps.out(`Everything measurable was measured less than ${Math.round(SERVE_TTL_MS / 60_000)} minutes ago; showing that rather than asking again.`);
77
+ return;
78
+ }
79
+ const { polled, failed } = await pollUsage({ store: deps.usage, source: deps.usageSource, accounts: due, now: () => now });
80
+ const reasons = new Set<string>();
81
+ for (const account of due) {
82
+ const failure = deps.usage.loadOne(account.name)?.pollFailure;
83
+ if (failure) reasons.add(printableOneLine(failure));
84
+ }
85
+ // Said either way: a refresh that measured in silence is indistinguishable from one that did
86
+ // nothing, and the no-op path was the only one that spoke.
87
+ deps.out(
88
+ failed === 0
89
+ ? `Measured ${polled} of ${polled}.`
90
+ : `Measured ${polled} of ${polled + failed}${reasons.size > 0 ? `; the rest could not be asked (${[...reasons].join("; ")})` : ""}.`,
91
+ );
92
+ }
93
+
94
+ /** What to say when no Account has ever been measured: where usage comes from, and how to get some. */
95
+ function nothingKnown(args: AccountUsageArgs): string[] {
96
+ return [
97
+ "No usage has been measured yet.",
98
+ "",
99
+ "Usage reaches the Foreman two ways, and neither has happened here:",
100
+ " a Worker reports its Account's windows as it runs — start a Run (opsee foreman run <initiativeId>)",
101
+ " the daemon measures the Accounts that are idle — opsee foreman up",
102
+ "",
103
+ ...(args.refresh ? [] : ["Or measure now: opsee foreman account usage --refresh"]),
104
+ ];
105
+ }
106
+
@@ -1,3 +1,6 @@
1
+ import { mkdirSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { join, resolve } from "node:path";
1
4
  import type { AccountAddArgs, AccountSetArgs } from "../args.js";
2
5
  import {
3
6
  AccountError,
@@ -9,10 +12,18 @@ import {
9
12
  noteResumed,
10
13
  removeAccount,
11
14
  resumeAccount,
15
+ expandHome,
12
16
  setAccountLimits,
17
+ validateAccountSpec,
13
18
  type ConfigDirFs,
19
+ type SubscriptionAccount,
14
20
  } from "../foreman/account.js";
15
21
  import type { AccountStore } from "../foreman/account-store.js";
22
+ import { usageByAccount, type UsageStore } from "../foreman/usage-store.js";
23
+ import type { CredentialPresence } from "../foreman/credential-store.js";
24
+ import type { UsageSource } from "../foreman/usage-poller.js";
25
+ import { VENDOR_CONFIG_DIR_ENV, type Vendor } from "../foreman/vendor.js";
26
+ import { formatSkeletonResults, syncSkeleton } from "../foreman/config-skeleton.js";
16
27
  import { printableOneLine } from "../foreman/core/text.js";
17
28
 
18
29
  /** Account commands are local: no backend, no login. The fs seam is the whole of their file-system
@@ -22,6 +33,37 @@ export interface AccountDeps {
22
33
  fs: ConfigDirFs;
23
34
  env: Readonly<Record<string, string | undefined>>;
24
35
  out: (line: string) => void;
36
+ /** What is known about each Account's rate-limit windows (the multi-account headroom design).
37
+ * Optional throughout: usage is an optimisation, and every command here works without it. */
38
+ usage?: UsageStore;
39
+ /** How `account usage --refresh` measures. Absent, the command can only print what a Run or the
40
+ * daemon already recorded, and says so. */
41
+ usageSource?: UsageSource;
42
+ now?: () => number;
43
+ /**
44
+ * Runs the vendor's own interactive login in `configDir`, with the terminal handed to it, and
45
+ * resolves with its exit code (`--login`, spec §7).
46
+ *
47
+ * A seam, and deliberately the only new one: what it does is spawn the vendor's binary with the
48
+ * config-directory variable set. Nothing here reads what that login writes — the boundary this
49
+ * command has always respected (ADR-0013) is unchanged, and `--login` succeeds or fails on the
50
+ * vendor's exit code alone rather than on the Foreman inspecting a credentials file.
51
+ */
52
+ login?: (vendor: Vendor, configDir: string, env: Record<string, string>) => Promise<number>;
53
+ /**
54
+ * Whether a login left a credential for `configDir`: `present`, `absent`, or `unknown`
55
+ * (`credential-store.ts`).
56
+ *
57
+ * Presence only — no credential is read. Quitting the vendor's REPL without signing in exits 0,
58
+ * so an exit code alone would register an Account against an empty directory, which the Foreman
59
+ * then dispatches onto and quarantines on its second failure.
60
+ *
61
+ * **`unknown` must never refuse.** The first version of this asked whether a file existed, which
62
+ * on macOS is always "no" because the credential lives in the Keychain — so it refused three
63
+ * Accounts that had been logged into perfectly well. A check that cannot answer has to stand
64
+ * aside and say so.
65
+ */
66
+ loggedIn?: (vendor: Vendor, configDir: string) => Promise<CredentialPresence> | CredentialPresence;
25
67
  }
26
68
 
27
69
  /** Returns the process exit code. Validation failures throw AccountError for the CLI to report. */
@@ -29,6 +71,19 @@ export async function runAccountAdd(deps: AccountDeps, args: AccountAddArgs): Pr
29
71
  if (args.vendor === undefined) {
30
72
  throw new AccountError("--vendor is required");
31
73
  }
74
+ if (args.login) {
75
+ // Everything the registration would refuse, asked before the directory is made and before an
76
+ // interactive sign-in is spent: an unknown vendor, a name already taken, a cap out of range.
77
+ const { vendor } = validateAccountSpec(deps.store, {
78
+ vendor: args.vendor,
79
+ name: args.name,
80
+ configDir: args.configDir,
81
+ keyRef: args.keyEnv,
82
+ cap: positiveInteger("--cap", args.cap),
83
+ });
84
+ const refused = await runVendorLogin(deps, args, vendor);
85
+ if (refused !== undefined) return refused;
86
+ }
32
87
  const account = registerAccount(deps.store, deps.fs, {
33
88
  vendor: args.vendor,
34
89
  name: args.name,
@@ -43,13 +98,62 @@ export async function runAccountAdd(deps: AccountDeps, args: AccountAddArgs): Pr
43
98
  return 0;
44
99
  }
45
100
 
101
+ /**
102
+ * Makes the config directory and runs the vendor's login in it, before anything is registered.
103
+ *
104
+ * Returns an exit code when the Account must **not** be registered, and undefined to carry on.
105
+ * Registering a directory nobody managed to log into would give the Foreman an Account it will
106
+ * dispatch onto and quarantine on the second failure — worse than no Account at all.
107
+ */
108
+ async function runVendorLogin(deps: AccountDeps, args: AccountAddArgs, vendor: Vendor): Promise<number | undefined> {
109
+ if (args.configDir === undefined) {
110
+ throw new AccountError("--login needs --config-dir: it makes that directory and logs the vendor into it. An API-key Account has nothing to log into.");
111
+ }
112
+ if (!deps.login) {
113
+ throw new AccountError("--login is not available here");
114
+ }
115
+ // The same expansion registration uses. A second one drifted on bare `~`, which would have
116
+ // logged in under `<cwd>/~` and then stored `$HOME` — the item written under one string and looked
117
+ // up under another, which on macOS is a Keychain miss with no visible cause.
118
+ const configDir = resolve(expandHome(args.configDir));
119
+ // Owner-only, like every other directory holding an identity (the accounts file, the usage store).
120
+ // Codex additionally requires its config directory to exist before it will start.
121
+ mkdirSync(configDir, { recursive: true, mode: 0o700 });
122
+ deps.out(`Running ${vendor}'s own login in ${configDir}. Sign in as the Account you want registered, then leave the session.`);
123
+ const code = await deps.login(vendor, configDir, { [VENDOR_CONFIG_DIR_ENV[vendor]]: configDir });
124
+ if (code !== 0) {
125
+ deps.out(`The login did not complete (${vendor} exited ${code}); nothing was registered. The directory ${configDir} is left in place, so running this again resumes where it stopped.`);
126
+ return code;
127
+ }
128
+ // An exit code of 0 only says the vendor's CLI closed cleanly, which is also what quitting its
129
+ // prompt without signing in does. Registering on that alone hands the Foreman an Account it will
130
+ // dispatch onto and quarantine on the second failure — the thing this whole path exists to avoid.
131
+ //
132
+ // Only a positive `absent` refuses. `unknown` registers and says why it could not check: being
133
+ // unable to see a credential is not evidence that there is none, and the cost of getting that
134
+ // backwards is refusing a good Account, which is worse than registering one that needs a login.
135
+ const presence = (await deps.loggedIn?.(vendor, configDir)) ?? "unknown";
136
+ if (presence === "absent") {
137
+ deps.out(`${vendor} closed with no sign-in recorded for ${configDir}; nothing was registered. Run this again and complete the login, or register the directory yourself once you have.`);
138
+ return 1;
139
+ }
140
+ if (presence === "unknown") {
141
+ deps.out(
142
+ `Registered without confirming the sign-in: this machine did not answer whether ${vendor} stored a credential for ${configDir}. ` +
143
+ `If the login did not complete, the Account is quarantined on its second turn; log the vendor in and run "opsee foreman account resume <name>".`,
144
+ );
145
+ }
146
+ return undefined;
147
+ }
148
+
46
149
  export async function runAccountList(deps: AccountDeps): Promise<number> {
47
150
  const accounts = deps.store.load();
48
151
  if (accounts.length === 0) {
49
152
  deps.out("No Accounts registered. Run `opsee foreman account add` to register one.");
50
153
  return 0;
51
154
  }
52
- for (const line of formatAccountTable(accounts)) deps.out(line);
155
+ const now = (deps.now ?? Date.now)();
156
+ for (const line of formatAccountTable(accounts, now, usageByAccount(deps.usage))) deps.out(line);
53
157
  return 0;
54
158
  }
55
159
 
@@ -61,6 +165,7 @@ export async function runAccountSet(deps: AccountDeps, args: AccountSetArgs): Pr
61
165
  cap: positiveInteger("--cap", args.cap),
62
166
  maxTurns: positiveInteger("--max-turns", args.maxTurns),
63
167
  stallTimeoutMs: positiveInteger("--stall-timeout", args.stallTimeoutMs),
168
+ thresholdPct: positiveInteger("--threshold", args.thresholdPct),
64
169
  });
65
170
  deps.out(`Account "${account.name}": ${describeLimits(account)}`);
66
171
  return 0;
@@ -119,3 +224,31 @@ export async function runAccountRemove(deps: AccountDeps, name: string): Promise
119
224
  deps.out(`Removed Account "${removed.name}"`);
120
225
  return 0;
121
226
  }
227
+
228
+ /**
229
+ * `opsee foreman account sync-skeleton [--from <dir>]` (the multi-account headroom design, §5).
230
+ *
231
+ * Symlinks the account-independent config entries from one canonical directory into every claude
232
+ * subscription Account's, so registering a second Account does not mean maintaining a second set of
233
+ * settings and skills. Claude Accounts only: the entries are Claude Code's own file names, and
234
+ * another vendor's config directory holds different files under different names.
235
+ *
236
+ * Never a credential (`config-skeleton.ts` refuses credential-shaped names whatever it is asked),
237
+ * and never a file a person wrote: a real file already in place is reported and left alone.
238
+ */
239
+ export async function runAccountSyncSkeleton(deps: AccountDeps, args: { from?: string }): Promise<number> {
240
+ const canonical = args.from ?? join(homedir(), ".claude");
241
+ const targets = deps.store.load().filter((a): a is SubscriptionAccount => a.vendor === "claude" && a.type === "subscription");
242
+ if (targets.length === 0) {
243
+ deps.out("No claude subscription Account is registered, so there is nothing to link into. Register one with: opsee foreman account add --vendor claude --config-dir <dir>");
244
+ return 0;
245
+ }
246
+ deps.out(`Linking the shared config entries from ${canonical} into ${targets.length === 1 ? "1 Account" : `${targets.length} Accounts`}:`);
247
+ const results = syncSkeleton({ canonical, targets: targets.map((a) => a.configDir) });
248
+ for (const line of formatSkeletonResults(results)) deps.out(line);
249
+ const refused = results.filter((r) => r.outcome === "refused").length;
250
+ if (refused > 0) {
251
+ deps.out(`${refused === 1 ? "1 entry was" : `${refused} entries were`} left alone; nothing was overwritten. Move or delete the file named above to share the canonical one.`);
252
+ }
253
+ return 0;
254
+ }
@@ -0,0 +1,183 @@
1
+ import { isPaused, isQuarantined, thresholdFor, type Account, type SubscriptionAccount } from "../foreman/account.js";
2
+ import type { AccountStore } from "../foreman/account-store.js";
3
+ import { DEFAULT_RANKING_POLICY, rankLanes } from "../foreman/core/scheduler.js";
4
+ import { printableOneLine } from "../foreman/core/text.js";
5
+ import { describeAge, headroom, thresholdHold, type AccountUsage } from "../foreman/usage.js";
6
+ import { usageByAccount, type UsageStore } from "../foreman/usage-store.js";
7
+ import { VENDOR_CONFIG_DIR_ENV } from "../foreman/vendor.js";
8
+
9
+ /**
10
+ * `opsee claude [args…]` — the launcher (the multi-account headroom design, §5).
11
+ *
12
+ * Picks the registered Claude Account with the most headroom and runs the vendor's own binary under
13
+ * it, by naming that Account's config directory in `CLAUDE_CONFIG_DIR` and nothing else. Every
14
+ * argument the caller gave is handed on untouched.
15
+ *
16
+ * **No credential is read, copied or moved.** A config directory the user has logged into is
17
+ * already a complete, isolated Claude Code identity, and the vendor's own variable is the supported
18
+ * way to select one — so the interactive half of multi-account needs no credential store and
19
+ * nothing here crosses the boundary ADR-0013 draws. What it costs is that history and per-account
20
+ * state live per directory; `config-skeleton.ts` narrows that to history alone.
21
+ */
22
+ export interface ClaudeLaunchArgs {
23
+ /** Use this Account whatever usage says. A human who names one is not overruled. */
24
+ account?: string;
25
+ /** Resolve and print the choice, launch nothing. For scripts and for looking before leaping. */
26
+ printChoice: boolean;
27
+ /** The vendor's own arguments, verbatim. */
28
+ passthrough: string[];
29
+ }
30
+
31
+ export interface ClaudeLaunchDeps {
32
+ store: AccountStore;
33
+ /** Absent, or holding nothing, means the choice is made blind — and says so. */
34
+ usage?: UsageStore;
35
+ now?: () => number;
36
+ out: (line: string) => void;
37
+ env: Readonly<Record<string, string | undefined>>;
38
+ /** Runs the vendor's binary with the terminal inherited and resolves with its exit code. The one
39
+ * seam this command has, so the whole of the choosing is testable without a `claude` on PATH. */
40
+ exec: (command: string, args: string[], env: Record<string, string>) => Promise<number>;
41
+ /**
42
+ * Which Accounts a Foreman already has live Workers on (the Process Table).
43
+ *
44
+ * Asked because a session started here runs under the same vendor identity as those Workers, and
45
+ * two things on one identity is twice the concurrency its owner chose — the concentration the
46
+ * per-Account caps exist to limit (ADR-0013). The Run refuses to double up at its `contended`
47
+ * filter; a launcher that ignored the question would walk straight past that guard.
48
+ *
49
+ * Optional: absent, the launcher behaves as though nothing is running, which is what a machine
50
+ * with no Process Table yet actually means.
51
+ */
52
+ busyAccounts?: () => ReadonlySet<string>;
53
+ }
54
+
55
+ /** The vendor's binary. Not configurable: `opsee claude` launching something other than `claude`
56
+ * would be a way to have the Foreman's Account selection point at an arbitrary program. */
57
+ const CLAUDE_BINARY = "claude";
58
+
59
+ /** Exit code when no Account can be chosen. Not 1, which the vendor itself uses for an ordinary
60
+ * failed turn, so a script can tell "the launcher refused" from "claude ran and said no". */
61
+ export const NO_ACCOUNT_EXIT = 3;
62
+
63
+ export async function runClaudeLaunch(deps: ClaudeLaunchDeps, args: ClaudeLaunchArgs): Promise<number> {
64
+ const now = (deps.now ?? Date.now)();
65
+ const known = usageByAccount(deps.usage);
66
+ const busy = deps.busyAccounts?.() ?? new Set<string>();
67
+
68
+ // Subscriptions only. An API-key Account has no config directory to point at, and handing the key
69
+ // to an interactive session is a different thing from choosing between logins — the caller can do
70
+ // that themselves by setting the variable.
71
+ const candidates = deps.store.load().filter((a): a is SubscriptionAccount => a.vendor === "claude" && a.type === "subscription");
72
+
73
+ if (args.account !== undefined) {
74
+ const named = candidates.find((a) => a.name === args.account);
75
+ if (!named) {
76
+ deps.out(
77
+ `No subscription Account named "${args.account}" for claude. Registered: ${candidates.map((a) => a.name).join(", ") || "none"}. ` +
78
+ `Register one with: opsee foreman account add --vendor claude --config-dir <dir>`,
79
+ );
80
+ return NO_ACCOUNT_EXIT;
81
+ }
82
+ // Told explicitly, it obeys. Refusing here would be the launcher overruling the person at the
83
+ // keyboard about their own subscription; what it owes them is what it knows, not a veto.
84
+ deps.out(`${describeChoice(named, known, now)}${warning(named, known, now, busy)}`);
85
+ return args.printChoice ? 0 : await launch(deps, named, args.passthrough);
86
+ }
87
+
88
+ if (candidates.length === 0) {
89
+ deps.out(
90
+ deps.store.load().some((a) => a.vendor === "claude")
91
+ ? "Every registered claude Account is an API key, and there is no config directory to launch against. Register a subscription Account: opsee foreman account add --vendor claude --config-dir <dir>"
92
+ : "No claude Account is registered. Register one with: opsee foreman account add --vendor claude --config-dir <dir>",
93
+ );
94
+ return NO_ACCOUNT_EXIT;
95
+ }
96
+
97
+ // The same three questions the Run asks of a Lane, in the same order, so `opsee claude` and an
98
+ // overnight Run never disagree about whether an Account is usable.
99
+ const usable = candidates.filter(
100
+ (a) => !isQuarantined(a) && !isPaused(a, now) && !busy.has(a.name) && !thresholdHold(known.get(a.name), thresholdFor(a), now),
101
+ );
102
+ if (usable.length === 0) {
103
+ deps.out(refusal(candidates, known, now, busy));
104
+ return NO_ACCOUNT_EXIT;
105
+ }
106
+
107
+ // `best` regardless of the Run's configured strategy: a person waiting at a prompt wants the
108
+ // Account with the most room, not one being deliberately drained first. Fresh ranking state, so
109
+ // there is no hysteresis to carry — there is no previous leader in a one-shot command.
110
+ const { lanes } = rankLanes(
111
+ usable.map((account) => ({ account })),
112
+ known,
113
+ { ...DEFAULT_RANKING_POLICY, strategy: "best" },
114
+ {},
115
+ now,
116
+ );
117
+ const chosen = lanes[0].account;
118
+ deps.out(describeChoice(chosen, known, now));
119
+ return args.printChoice ? 0 : await launch(deps, chosen, args.passthrough);
120
+ }
121
+
122
+ async function launch(deps: ClaudeLaunchDeps, account: SubscriptionAccount, passthrough: string[]): Promise<number> {
123
+ const env: Record<string, string> = {};
124
+ for (const [key, value] of Object.entries(deps.env)) if (value !== undefined) env[key] = value;
125
+ // The one thing added, and last, so a `CLAUDE_CONFIG_DIR` already in the caller's environment
126
+ // cannot quietly win over the Account they were just told they are running as.
127
+ env[VENDOR_CONFIG_DIR_ENV.claude] = account.configDir;
128
+ return await deps.exec(CLAUDE_BINARY, passthrough, env);
129
+ }
130
+
131
+ /** The line printed before the vendor takes the terminal: which Account, and how much is known
132
+ * about it. "never observed" rather than a blank, so a blind choice is never read as a measured
133
+ * one. */
134
+ function describeChoice(account: Account, known: ReadonlyMap<string, AccountUsage>, now: number): string {
135
+ const record = known.get(account.name);
136
+ const left = headroom(record, now);
137
+ if (left.kind === "unknown") {
138
+ return `opsee: claude on Account "${account.name}" (never observed — nothing is known about its limits yet)`;
139
+ }
140
+ return `opsee: claude on Account "${account.name}" (${left.remainingPct}% left of ${left.tightest}, seen ${describeAge(left.observedAt, now)})`;
141
+ }
142
+
143
+ /** What else the caller should know about an Account they named themselves. */
144
+ function warning(account: Account, known: ReadonlyMap<string, AccountUsage>, now: number, busy: ReadonlySet<string>): string {
145
+ if (isQuarantined(account)) return `\nopsee: warning — this Account is quarantined (${printableOneLine(account.quarantined!.reason)}); the vendor may refuse the session`;
146
+ const until = isPaused(account, now) ? account.paused!.until : undefined;
147
+ if (until) return `\nopsee: warning — this Account is Paused until ${until}; the vendor may refuse the session`;
148
+ if (busy.has(account.name)) {
149
+ return `\nopsee: warning — a Foreman already has Workers on this Account, so this session adds to the concurrency that identity is running`;
150
+ }
151
+ const hold = thresholdHold(known.get(account.name), thresholdFor(account), now);
152
+ return hold ? `\nopsee: warning — this Account is at ${hold.reason}, past its ${thresholdFor(account)}% threshold; the Foreman would not start a Worker on it` : "";
153
+ }
154
+
155
+ /** Why nothing could be launched, and the soonest moment that changes. A refusal that does not say
156
+ * when to come back is a refusal the reader has to go and investigate. */
157
+ function refusal(candidates: Account[], known: ReadonlyMap<string, AccountUsage>, now: number, busy: ReadonlySet<string>): string {
158
+ let soonest: Date | undefined;
159
+ const lines: string[] = [];
160
+ for (const account of candidates) {
161
+ if (isQuarantined(account)) {
162
+ lines.push(` ${account.name}: quarantined (${printableOneLine(account.quarantined!.reason)}) — log the vendor back in, then: opsee foreman account resume ${account.name}`);
163
+ continue;
164
+ }
165
+ if (busy.has(account.name)) {
166
+ lines.push(` ${account.name}: a Foreman has live Workers on it — a session here would double what that identity is running`);
167
+ continue;
168
+ }
169
+ const paused = isPaused(account, now) ? new Date(account.paused!.until) : undefined;
170
+ const hold = thresholdHold(known.get(account.name), thresholdFor(account), now);
171
+ const until = paused ?? hold?.until;
172
+ if (until && (!soonest || until < soonest)) soonest = until;
173
+ if (paused) lines.push(` ${account.name}: Paused until ${paused.toISOString()} — the vendor refused it`);
174
+ else if (hold) lines.push(` ${account.name}: held at ${hold.reason} until ${hold.until.toISOString()} — its own threshold, not a refusal`);
175
+ }
176
+ return [
177
+ "opsee: no claude Account is usable right now.",
178
+ ...lines,
179
+ soonest
180
+ ? `The soonest window resets at ${soonest.toISOString()}. Register another Account, raise a threshold (opsee foreman account set <name> --threshold <pct>), or pass --account <name> to use one anyway.`
181
+ : "Register another Account, or pass --account <name> to use one anyway.",
182
+ ].join("\n");
183
+ }
@@ -2,7 +2,7 @@ import { resolve } from "node:path";
2
2
  import type { DebugTurnArgs } from "../args.js";
3
3
  import { AccountError, type Account } from "../foreman/account.js";
4
4
  import type { AccountStore } from "../foreman/account-store.js";
5
- import { loadRunRecipe, startApp, waitForReady, type RunRecipe, type StartOptions, type AppHandle, type WaitOptions, type Readiness } from "../foreman/run-recipe.js";
5
+ import { loadRunRecipe, startable, startApp, waitForReady, type RunRecipe, type StartableRecipe, type StartOptions, type AppHandle, type WaitOptions, type Readiness } from "../foreman/run-recipe.js";
6
6
  import type { TrackerAdapter } from "../foreman/tracker-adapter.js";
7
7
  import type { TurnRequest, WorkerAdapter } from "../foreman/worker-adapter.js";
8
8
 
@@ -75,7 +75,7 @@ export interface ForemanDebugServeDeps {
75
75
  timeoutMs?: number;
76
76
  /** Test seams; the real recipe functions by default. */
77
77
  load?: (root: string) => RunRecipe;
78
- start?: (recipe: RunRecipe, options: StartOptions) => AppHandle;
78
+ start?: (recipe: StartableRecipe, options: StartOptions) => AppHandle;
79
79
  wait?: (url: string, options: WaitOptions) => Promise<Readiness>;
80
80
  }
81
81
 
@@ -84,9 +84,16 @@ export interface ForemanDebugServeDeps {
84
84
  * the app up until Ctrl-C. Exit 1 when the recipe is missing or the app never becomes ready. */
85
85
  export async function runForemanDebugServe(deps: ForemanDebugServeDeps, port: number): Promise<number> {
86
86
  const recipe = (deps.load ?? loadRunRecipe)(deps.root);
87
+ // This command exists to serve the app, so a recipe that cannot is the whole failure, said here
88
+ // rather than as a spawn error from a command that is the empty string.
89
+ const servable = startable(recipe);
90
+ if (!servable) {
91
+ deps.out(`.opsee/config: the foreman block has no start command, so there is no app to serve. Add foreman.start, or let the Verifier work it out.`);
92
+ return 1;
93
+ }
87
94
  const timeoutMs = deps.timeoutMs ?? 120_000;
88
- const app = (deps.start ?? startApp)(recipe, { port, cwd: deps.root, onOutput: (line) => deps.out(` | ${line}`) });
89
- deps.out(`starting: ${app.command} (${recipe.portEnv}=${port}${app.pid ? `, pid ${app.pid}` : ""})`);
95
+ const app = (deps.start ?? startApp)(servable, { port, cwd: deps.root, onOutput: (line) => deps.out(` | ${line}`) });
96
+ deps.out(`starting: ${app.command} (${servable.portEnv}=${port}${app.pid ? `, pid ${app.pid}` : ""})`);
90
97
  deps.out(`waiting for ${app.readinessUrl} (up to ${timeoutMs}ms)`);
91
98
  try {
92
99
  const ready = await (deps.wait ?? waitForReady)(app.readinessUrl, { timeoutMs, signal: app.exitSignal });