openlimiter 1.2.0 → 1.3.1

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.
Files changed (55) hide show
  1. package/README.md +45 -16
  2. package/dist/bin.js +53 -4
  3. package/dist/bin.js.map +1 -1
  4. package/dist/cli.d.ts +110 -11
  5. package/dist/cli.d.ts.map +1 -1
  6. package/dist/cli.js +1064 -64
  7. package/dist/cli.js.map +1 -1
  8. package/dist/codex-device-login.d.ts +87 -0
  9. package/dist/codex-device-login.d.ts.map +1 -0
  10. package/dist/codex-device-login.js +291 -0
  11. package/dist/codex-device-login.js.map +1 -0
  12. package/dist/config.d.ts +44 -1
  13. package/dist/config.d.ts.map +1 -1
  14. package/dist/config.js +129 -7
  15. package/dist/config.js.map +1 -1
  16. package/dist/hub-auth.d.ts +137 -0
  17. package/dist/hub-auth.d.ts.map +1 -0
  18. package/dist/hub-auth.js +280 -0
  19. package/dist/hub-auth.js.map +1 -0
  20. package/dist/hub-sync.d.ts +144 -0
  21. package/dist/hub-sync.d.ts.map +1 -0
  22. package/dist/hub-sync.js +370 -0
  23. package/dist/hub-sync.js.map +1 -0
  24. package/dist/hub.d.ts +60 -0
  25. package/dist/hub.d.ts.map +1 -0
  26. package/dist/hub.js +178 -0
  27. package/dist/hub.js.map +1 -0
  28. package/dist/index.d.ts +6 -2
  29. package/dist/index.d.ts.map +1 -1
  30. package/dist/index.js +6 -2
  31. package/dist/index.js.map +1 -1
  32. package/dist/ingest.d.ts +26 -0
  33. package/dist/ingest.d.ts.map +1 -1
  34. package/dist/ingest.js +116 -1
  35. package/dist/ingest.js.map +1 -1
  36. package/dist/qr.d.ts.map +1 -1
  37. package/dist/qr.js +1 -0
  38. package/dist/qr.js.map +1 -1
  39. package/dist/session.d.ts +46 -0
  40. package/dist/session.d.ts.map +1 -0
  41. package/dist/session.js +121 -0
  42. package/dist/session.js.map +1 -0
  43. package/dist/statusline.d.ts +14 -0
  44. package/dist/statusline.d.ts.map +1 -1
  45. package/dist/statusline.js +258 -19
  46. package/dist/statusline.js.map +1 -1
  47. package/dist/terminal.d.ts +109 -0
  48. package/dist/terminal.d.ts.map +1 -0
  49. package/dist/terminal.js +974 -0
  50. package/dist/terminal.js.map +1 -0
  51. package/package.json +4 -4
  52. package/dist/serve.d.ts +0 -170
  53. package/dist/serve.d.ts.map +0 -1
  54. package/dist/serve.js +0 -533
  55. package/dist/serve.js.map +0 -1
package/dist/cli.js CHANGED
@@ -1,13 +1,20 @@
1
- import { PROVIDER_CODES, buildAdvice, canonicalJson, dedupeFailures, failureFromConnectorReason, freshness, mergeSnapshots, normalizeMeters, normalizeMetersReport, readSnapshotCache } from "@openlimiter/core";
2
- import { antigravityFixture, claudeFixture, codexFixture, grokFixture, kimiFixture, connectors, manualFixture, opencodeFixture, openrouterFixture, parseAntigravityPayload, parseClaudePayload, parseCodexPayload, parseGrokPayload, parseKimiPayload, parseManualPayload, parseOpencodePayload, parseOpenrouterPayload } from "@openlimiter/connectors";
1
+ import { ACQUISITION_OUTCOME_SENTENCE, CREDENTIAL_FAILURE_SENTENCE, PROVIDER_CODES, acquireRefreshLock, clearRefreshSpawnFailure, mergeAcquiredSnapshots, createFetchTransport, readRefreshSpawnFailure, antigravitySpec, buildAdvice, canonicalJson, claudeSpec, codexSpec, dedupeFailures, desktopHoldsCache, failureFromConnectorReason, freshness, geminiCliSpec, grokSpec, isProviderDue, kimiSpec, mergeSnapshots, normalizeMeters, normalizeMetersReport, openrouterSpec, readAcquisitionCredential, readAcquisitionSchedule, readSnapshotCache, readWindowsCredentialWith, resolveStateDirectory, runAcquisition, spawnDetachedRefresh, writeAcquisitionSchedule, probeAntigravity, resolveAgyExecutablePath } from "@openlimiter/core";
2
+ import { antigravityFixture, claudeFixture, codexFixture, grokFixture, kimiFixture, connectors, manualFixture, opencodeFixture, openrouterFixture, parseAntigravityCodeAssistPayload, parseAntigravityPayload, parseClaudePayload, parseCodexPayload, parseGeminiCliPayload, parseGrokPayload, parseKimiPayload, parseManualPayload, parseOpencodePayload, parseOpenrouterPayload } from "@openlimiter/connectors";
3
3
  import { AGENT_COMPATIBILITY, agentContextFromCache, agentContextSpillFromCache, agentVersionCompatibility, changeAgentHook, detectAgentInstallation, readAgentHookStatus, runAgentHook, validateAgentExecutableStamp, writeAgentContextSnapshot, loadHostedContextTrust, renderClaudeStatusline } from "@openlimiter/adapters";
4
+ import { execFile, spawn } from "node:child_process";
5
+ import { randomUUID } from "node:crypto";
4
6
  import { homedir } from "node:os";
5
- import { STATUSLINE_KEYS, defaultConfig, initialize, isStatuslineKey, readConfig, readStatuslineConfig, setStatuslineValue, statuslineValueText, writeConfig } from "./config.js";
6
- import { INGEST_PROVENANCE, MANUAL_PROVENANCE, STATUSLINE_PROVENANCE, environmentWithLocalMarkers, STDIN_BYTE_LIMIT, parseJsonText, persistSnapshots, readManualDocument, withProvenance } from "./ingest.js";
7
+ import { PROVIDER_KEYS, STATUSLINE_KEYS, defaultConfig, initialize, isProviderKey, isStatuslineKey, providerValueText, readConfig, readProvidersConfig, readStatuslineConfig, setProviderValue, setStatuslineValue, statuslineValueText, writeConfig } from "./config.js";
8
+ import { ACQUISITION_PROVENANCE, ANTIGRAVITY_STATUSLINE_PROVENANCE, GROK_STATUSLINE_PROVENANCE, INGEST_PROVENANCE, MANUAL_PROVENANCE, STATUSLINE_PROVENANCE, environmentWithLocalMarkers, STDIN_BYTE_LIMIT, parseAntigravityStatuslinePayload, parseGrokStatuslinePayload, parseJsonText, persistSnapshots, readManualDocument, withProvenance } from "./ingest.js";
7
9
  import { UnavailableCredentialStore } from "./credentials.js";
8
- import { DEFAULT_SERVE_PORT, serveBanner, startQuotaServer } from "./serve.js";
9
10
  import { failureLine, failureLines, renderTable, supportsColor } from "./render.js";
10
- import { renderStatuslineLayout, statuslineColor } from "./statusline.js";
11
+ import { isStatuslineHost, renderStatuslineLayout, statuslineColor } from "./statusline.js";
12
+ import { TERMINAL_HOST_NAMES, installHost, terminalHide, terminalShow, terminalStatusTable, uninstallHost } from "./terminal.js";
13
+ import { createFetchHubTransport, hubConfigured } from "./hub.js";
14
+ import { REVOKED_SENTENCE, ensureFreshSession, isAborted, runDeviceLogin } from "./hub-auth.js";
15
+ import { runSync } from "./hub-sync.js";
16
+ import { deleteSession, readSession, writeSession } from "./session.js";
17
+ import { DeviceLoginError, LOGIN_TIMEOUT_MILLISECONDS, SystemDeviceLoginRunner, managedCodexHome, startCodexDeviceLogin, versionIsSupported as codexVersionIsSupported } from "./codex-device-login.js";
11
18
  /** Exit codes. Enumerated so a script can tell these cases apart. */
12
19
  export const EXIT_OK = 0;
13
20
  export const EXIT_FAILURE = 1;
@@ -26,7 +33,111 @@ function defaults() {
26
33
  openLimiterScript: process.argv[1] ?? "",
27
34
  nodeExecutable: process.execPath,
28
35
  platform: process.platform,
29
- detectedAgentInstallations: {}
36
+ detectedAgentInstallations: {},
37
+ /*
38
+ * The library defaults reach nothing outside this process, exactly as the
39
+ * standard input reader above does. The executable injects the real
40
+ * transport, the real spawner and the real credential helper, so a test or
41
+ * another tool that calls runCli in process opens no socket, starts no
42
+ * child and runs no shell unless it asked for one.
43
+ */
44
+ acquisitionTransport: async () => {
45
+ throw new Error("No acquisition transport was injected");
46
+ },
47
+ spawnDetached: () => undefined,
48
+ probeAntigravity: async () => ({ ok: false, reason: "not_running" }),
49
+ promptChoice: async () => "",
50
+ hubTransport: async () => {
51
+ throw new Error("No hub transport was injected");
52
+ },
53
+ openBrowser: () => undefined,
54
+ sleep: (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)),
55
+ emit: () => undefined,
56
+ codexDeviceLoginRunnerFactory: () => ({
57
+ start: async () => {
58
+ throw new DeviceLoginError("spawn");
59
+ }
60
+ })
61
+ };
62
+ }
63
+ /** The line separator every multi line report in this file joins on. */
64
+ const NEWLINE = "\n";
65
+ /**
66
+ * The dependencies that actually reach the world, built once for the executable.
67
+ *
68
+ * Exported rather than inlined in the executable because the executable has two
69
+ * entry paths, the ordinary one and the wrapped status line, and the wrapped one
70
+ * used to build its own smaller set. A wrapped status line therefore rendered
71
+ * bars forever and never started the refresh that keeps them true, which is the
72
+ * one configuration a person following the install instructions ends up with.
73
+ * One factory, both paths, and a test can assert what is in it.
74
+ */
75
+ /**
76
+ * Run one helper executable with arguments and a timeout, and hand back its
77
+ * standard output or a plain failure.
78
+ *
79
+ * Shared by two dependencies that otherwise have nothing to do with each
80
+ * other: reading one Windows Credential Manager entry, and applying an owner
81
+ * only ACL to the session file. Both are "run this helper, get its stdout or
82
+ * nothing back", so both get the same runner rather than two copies of the
83
+ * same `execFile` wrapper.
84
+ */
85
+ const execFileRunner = async (executable, helperArguments, timeoutMilliseconds) => await new Promise((resolve) => {
86
+ execFile(executable, [...helperArguments], { timeout: timeoutMilliseconds, maxBuffer: 262_144, windowsHide: true }, (error, stdout) => {
87
+ resolve(error === null ? { ok: true, stdout } : { ok: false });
88
+ });
89
+ });
90
+ /** Open a URL in the person's browser, best effort and never awaited. */
91
+ function openBrowserPlatform(url, platform) {
92
+ if (!/^https:\/\//u.test(url) || url.length > 2_048)
93
+ return;
94
+ const [command, commandArguments] = platform === "win32"
95
+ ? ["cmd", ["/c", "start", "", url]]
96
+ : platform === "darwin"
97
+ ? ["open", [url]]
98
+ : ["xdg-open", [url]];
99
+ try {
100
+ const child = spawn(command, commandArguments, { stdio: "ignore", detached: true });
101
+ child.once("error", () => undefined);
102
+ child.unref();
103
+ }
104
+ catch {
105
+ /* The code and the address are already printed, which is enough on its
106
+ own to sign in from. Opening a tab is a convenience, not the path. */
107
+ }
108
+ }
109
+ export function runtimeDependencies() {
110
+ return {
111
+ acquisitionTransport: createFetchTransport(),
112
+ probeAntigravity,
113
+ resolveExecutablePath: resolveAgyExecutablePath,
114
+ spawnDetached: (executable, argumentsList, options) => {
115
+ const child = spawn(executable, [...argumentsList], {
116
+ detached: true,
117
+ stdio: "ignore",
118
+ windowsHide: true
119
+ });
120
+ /*
121
+ * A detached spawn reports a missing executable asynchronously, long
122
+ * after this function returned and usually after the render has already
123
+ * been printed. Without this listener that arrives as an unhandled error
124
+ * event and takes the host process down with it, which for a status line
125
+ * means taking down somebody's terminal prompt.
126
+ */
127
+ child.once("error", () => {
128
+ options.onError?.();
129
+ });
130
+ /* Unreferenced so this process can exit while the refresh continues. */
131
+ child.unref();
132
+ },
133
+ windowsCredentialRunner: execFileRunner,
134
+ windowsAclRunner: execFileRunner,
135
+ hubTransport: createFetchHubTransport(),
136
+ openBrowser: (url) => openBrowserPlatform(url, process.platform),
137
+ emit: (line) => {
138
+ process.stdout.write(line + "\n");
139
+ },
140
+ codexDeviceLoginRunnerFactory: (executable) => new SystemDeviceLoginRunner(executable)
30
141
  };
31
142
  }
32
143
  function succeed(stdout) {
@@ -125,6 +236,346 @@ async function refresh(dependencies, now) {
125
236
  const persisted = await persistSnapshots(report.snapshots, dependencies.stateDirectory, now);
126
237
  return { snapshots: persisted.merged, failures };
127
238
  }
239
+ /* ---------------------------------------------------------- acquisition */
240
+ /**
241
+ * How a terminal with no desktop app gets fresh bars.
242
+ *
243
+ * Everything below is wiring, and only wiring. Credential discovery, the closed
244
+ * endpoint table, the cadence, the backoff and the lock all live in the core
245
+ * package, and the parsers all live in the connector package; this is the one
246
+ * place that knows both exist. That separation is why a test can prove a whole
247
+ * round without a socket and without a real login.
248
+ */
249
+ /**
250
+ * The specifications this machine will run, wired to the real parsers.
251
+ *
252
+ * Claude is here whatever the switch says, because a row that is off still owes
253
+ * a person the sentence explaining why its bar is older than the rest. Every
254
+ * other provider is unconditional: a provider with no local login simply
255
+ * reports that it has none.
256
+ */
257
+ export function acquisitionSpecs(providers) {
258
+ return [
259
+ claudeSpec({ parse: parseClaudePayload, enabled: providers.claude.poll }),
260
+ codexSpec(parseCodexPayload),
261
+ geminiCliSpec(parseGeminiCliPayload),
262
+ antigravitySpec(parseAntigravityCodeAssistPayload),
263
+ grokSpec(parseGrokPayload),
264
+ kimiSpec(parseKimiPayload),
265
+ openrouterSpec(parseOpenrouterPayload)
266
+ ];
267
+ }
268
+ /**
269
+ * Where each provider's credential is read from.
270
+ *
271
+ * Six of them belong to a vendor's own client and are read off disk, unchanged,
272
+ * by the core. OpenRouter is the exception and always was: its key is one a
273
+ * person typed into this tool, so it lives in the operating system credential
274
+ * store under this product's own name and is read from there.
275
+ */
276
+ function credentialReader(dependencies) {
277
+ const runner = dependencies.windowsCredentialRunner;
278
+ return async (provider) => {
279
+ if (provider === "OPENROUTER") {
280
+ let secret;
281
+ try {
282
+ secret = await dependencies.credentialStore.get("openlimiter", "openrouter");
283
+ }
284
+ catch {
285
+ return { ok: false, reason: "unreadable" };
286
+ }
287
+ /*
288
+ * The environment is the second place, and today it is the only one that
289
+ * works in a published build: this package ships no credential store
290
+ * driver, so the store above always answers nothing until one is wired.
291
+ * A terminal person therefore has one documented way to supply the key,
292
+ * and it is the same variable a shell profile already knows how to keep.
293
+ */
294
+ secret ??= dependencies.environment["OPENLIMITER_OPENROUTER_KEY"] ?? null;
295
+ return secret === null || secret === ""
296
+ ? { ok: false, reason: "absent" }
297
+ : {
298
+ ok: true,
299
+ credential: {
300
+ secret,
301
+ accountId: null,
302
+ expiresAtMilliseconds: null,
303
+ origin: "user_key"
304
+ }
305
+ };
306
+ }
307
+ return await readAcquisitionCredential(provider, {
308
+ platform: dependencies.platform,
309
+ environment: dependencies.environment,
310
+ homeDirectory: dependencies.homeDirectory,
311
+ now: dependencies.now(),
312
+ ...(runner === undefined || dependencies.platform !== "win32"
313
+ ? {}
314
+ : {
315
+ readWindowsCredential: async (target) => await readWindowsCredentialWith(target, { runCommand: runner })
316
+ })
317
+ });
318
+ };
319
+ }
320
+ /**
321
+ * What is written onto every acquired reading before it is validated.
322
+ *
323
+ * Two facts, both about this process rather than about the provider: the
324
+ * reading arrived over the network just now, and this command is what wrote it.
325
+ * The second is what stops two refreshers on one machine from both polling.
326
+ */
327
+ function acquisitionStamp(meters, credential) {
328
+ void credential;
329
+ return withProvenance(meters, ACQUISITION_PROVENANCE).map((meter) => ({ ...meter, writer: "cli" }));
330
+ }
331
+ /**
332
+ * What doctor can say about one provider without asking the provider anything.
333
+ *
334
+ * Doctor never touches the network, so this reads three local things and stops:
335
+ * whether a credential exists, what the schedule says, and whether the cache
336
+ * already holds a fresh row for this provider. That is enough to tell apart the
337
+ * four states a person cares about, which are "not set up here", "waiting out a
338
+ * backoff", "set up and current" and "set up and something is wrong".
339
+ */
340
+ async function acquisitionStatusRow(spec, schedule, readCredential, snapshots, now) {
341
+ const entry = schedule[spec.provider];
342
+ const nextAttemptAt = entry?.nextAttemptAt ?? null;
343
+ /* The same sentence the round itself printed. A provider that says something
344
+ better than the shared vocabulary must say it in both places, or doctor
345
+ quietly contradicts the command a person just ran. */
346
+ const sentence = (outcome) => spec.outcomeSentence?.[outcome] ?? ACQUISITION_OUTCOME_SENTENCE[outcome];
347
+ if (spec.enabled === false) {
348
+ return {
349
+ provider: spec.provider,
350
+ detected: false,
351
+ status: "off",
352
+ reason: spec.disabledReason ?? null,
353
+ nextAttemptAt: null,
354
+ disclosure: spec.disclosure
355
+ };
356
+ }
357
+ const credential = await readCredential(spec.credentialProvider);
358
+ if (!credential.ok) {
359
+ const absent = credential.reason === "absent";
360
+ return {
361
+ provider: spec.provider,
362
+ detected: !absent,
363
+ status: absent ? "not_detected" : "stale",
364
+ reason: CREDENTIAL_FAILURE_SENTENCE[credential.reason],
365
+ nextAttemptAt,
366
+ disclosure: spec.disclosure
367
+ };
368
+ }
369
+ const held = credential.credential;
370
+ const accountId = spec.accountIdFor?.(held) ?? null;
371
+ const disclosure = spec.disclosureFor?.(held) ?? spec.disclosure;
372
+ const identity = {
373
+ provider: spec.provider,
374
+ ...(accountId === null ? {} : { accountId }),
375
+ detected: true,
376
+ disclosure
377
+ };
378
+ if (!isProviderDue(entry, now)) {
379
+ return {
380
+ ...identity,
381
+ status: "waiting",
382
+ reason: entry === undefined ? null : sentence(entry.outcome),
383
+ nextAttemptAt
384
+ };
385
+ }
386
+ const fresh = snapshots.some((snapshot) => snapshot.provider === spec.provider &&
387
+ freshness(snapshot.observedAt, snapshot.expiresAt, now) === "fresh");
388
+ return {
389
+ ...identity,
390
+ status: fresh ? "read" : "stale",
391
+ reason: fresh
392
+ ? null
393
+ : entry === undefined
394
+ ? "this provider has not been read yet on this machine"
395
+ : sentence(entry.outcome),
396
+ nextAttemptAt
397
+ };
398
+ }
399
+ /** The acquisition block doctor prints under the connector block. */
400
+ async function acquisitionDoctorRows(dependencies, snapshots, now) {
401
+ const providers = await readProvidersConfig(dependencies.stateDirectory);
402
+ const schedule = await readAcquisitionSchedule(dependencies.stateDirectory);
403
+ const readCredential = credentialReader(dependencies);
404
+ const rows = [];
405
+ for (const spec of acquisitionSpecs(providers)) {
406
+ rows.push(acquisitionLine(await acquisitionStatusRow(spec, schedule, readCredential, snapshots, now), snapshots));
407
+ }
408
+ const failedAt = await readRefreshSpawnFailure(dependencies.stateDirectory);
409
+ if (failedAt !== null) {
410
+ rows.push("REFRESH SPAWN FAILED " + failedAt +
411
+ " the background refresh could not be started on this machine");
412
+ }
413
+ return [ACQUISITION_HEADER, ...rows].join(NEWLINE);
414
+ }
415
+ /**
416
+ * Where a person can still see a reading this row could not take.
417
+ *
418
+ * Antigravity and Gemini CLI meter the same Google Code Assist pool. When
419
+ * Google withholds the reading from this client but another tool on the machine
420
+ * has already written one, the honest thing is to point at it rather than leave
421
+ * a bare refusal, so the person knows the number exists and where.
422
+ */
423
+ function sharedQuotaNote(row, snapshots) {
424
+ if (row.provider !== "ANTIGRAVITY")
425
+ return null;
426
+ return snapshots.some((snapshot) => snapshot.provider === "GEMINI_CLI")
427
+ ? "the shared Code Assist quota is shown under gemini_cli"
428
+ : null;
429
+ }
430
+ /** One row of the refresh report, in the space separated grammar doctor uses. */
431
+ function acquisitionLine(row, snapshots = []) {
432
+ const shared = row.status === "read" ? null : sharedQuotaNote(row, snapshots);
433
+ const note = [row.reason ?? row.disclosure ?? "", shared ?? ""]
434
+ .filter((part) => part !== "")
435
+ .join(", ");
436
+ return [
437
+ row.provider.toLowerCase() + (row.accountId === undefined ? "" : "/" + row.accountId),
438
+ row.detected ? "yes" : "no",
439
+ row.status,
440
+ row.nextAttemptAt ?? "NONE",
441
+ note
442
+ ].join(" ").trimEnd();
443
+ }
444
+ const ACQUISITION_HEADER = "PROVIDER DETECTED STATUS NEXT NOTE";
445
+ /**
446
+ * Run one round of acquisition and fold what it found into the cache.
447
+ *
448
+ * Three refusals come before any request. A desktop that wrote inside the last
449
+ * interval already owns this machine's refreshing, so this command stands down
450
+ * rather than doubling the traffic a provider sees. A refresh already running
451
+ * holds the lock, so this one exits instead of racing it on the same
452
+ * credentials. And a provider inside its own backoff is skipped by the runner
453
+ * without being asked anything.
454
+ *
455
+ * Only a successful read writes. A refusal, a rate limit or a shape this build
456
+ * did not understand leaves every cached row exactly where it was, to age out
457
+ * through the ordinary freshness rule.
458
+ */
459
+ async function refreshCommand(dependencies, now) {
460
+ const lock = await acquireRefreshLock(dependencies.stateDirectory);
461
+ if (!lock.ok) {
462
+ return succeed([
463
+ ACQUISITION_HEADER,
464
+ "SKIPPED another refresh is already running on this machine"
465
+ ].join(NEWLINE));
466
+ }
467
+ try {
468
+ /*
469
+ * Desktop ownership is decided HERE, under the lock, and nowhere else.
470
+ * Checking it before taking the lock read a cache that a desktop could
471
+ * start writing a millisecond later, and this round would then poll every
472
+ * provider a second time for nothing. One check, on the only side of the
473
+ * lock where the answer cannot change underneath it.
474
+ */
475
+ const settled = await readSnapshotCache(dependencies.stateDirectory);
476
+ if (desktopHoldsCache(settled.ok ? settled.snapshots : [], now)) {
477
+ return succeed([
478
+ ACQUISITION_HEADER,
479
+ "SKIPPED the desktop app refreshed this cache inside the last interval"
480
+ ].join(NEWLINE));
481
+ }
482
+ /* A round that got this far is proof a refresh can start on this machine. */
483
+ await clearRefreshSpawnFailure(dependencies.stateDirectory);
484
+ const providers = await readProvidersConfig(dependencies.stateDirectory);
485
+ const schedule = await readAcquisitionSchedule(dependencies.stateDirectory);
486
+ const result = await runAcquisition(acquisitionSpecs(providers), {
487
+ transport: dependencies.acquisitionTransport,
488
+ now,
489
+ schedule,
490
+ readCredential: credentialReader(dependencies),
491
+ stamp: acquisitionStamp,
492
+ ...(dependencies.probeAntigravity === undefined
493
+ ? {}
494
+ : { probeAntigravity: dependencies.probeAntigravity }),
495
+ ...(dependencies.resolveExecutablePath === undefined
496
+ ? {}
497
+ : { resolveExecutablePath: dependencies.resolveExecutablePath })
498
+ });
499
+ /*
500
+ * Ownership is checked before every write, not once at the start. A round
501
+ * can outlive its lock if this machine was suspended mid refresh, and a
502
+ * round that lost its lock must not write over the round that took it.
503
+ *
504
+ * The refresh lock only coordinates other copies of THIS tool. The desktop
505
+ * tray is a separate process that knows nothing about it, so the write
506
+ * itself has to be safe against a desktop row that appeared since this
507
+ * round started reading. `mergeAcquiredSnapshots` decides that per row,
508
+ * inside the cache lock both processes do share, keeping whichever row is
509
+ * newer and leaving a tie with the row already there.
510
+ */
511
+ for (const report of result.reports) {
512
+ if (!(await lock.stillOwned())) {
513
+ return succeed([
514
+ ACQUISITION_HEADER,
515
+ "SKIPPED this refresh lost its lock before it could write"
516
+ ].join(NEWLINE));
517
+ }
518
+ if (!report.ok)
519
+ continue;
520
+ try {
521
+ await mergeAcquiredSnapshots(report, dependencies.stateDirectory);
522
+ }
523
+ catch {
524
+ /* One provider's write failing is that provider's problem. The rest of
525
+ the round still has readings worth keeping. */
526
+ }
527
+ }
528
+ if (!(await lock.stillOwned())) {
529
+ return succeed([
530
+ ACQUISITION_HEADER,
531
+ "SKIPPED this refresh lost its lock before it could write"
532
+ ].join(NEWLINE));
533
+ }
534
+ await writeAcquisitionSchedule(result.schedule, dependencies.stateDirectory);
535
+ const merged = await cachedSnapshots(dependencies.stateDirectory);
536
+ await writeAgentContextSnapshot(merged, dependencies.stateDirectory, now, PROVIDER_CODES).catch(() => undefined);
537
+ /*
538
+ * A sync after a successful refresh, when a session exists. This round
539
+ * already runs off the status line's own path (`startRefreshBehind` spawns
540
+ * it detached and never waits), so nothing here can add to a render.
541
+ */
542
+ await triggerSyncAfterRefresh(dependencies, now);
543
+ return succeed([
544
+ ACQUISITION_HEADER,
545
+ ...result.rows.map((row) => acquisitionLine(row, merged))
546
+ ].join(NEWLINE));
547
+ }
548
+ catch {
549
+ return fail(EXIT_FAILURE, "openlimiter refresh: the refresh did not complete.");
550
+ }
551
+ finally {
552
+ await lock.release();
553
+ }
554
+ }
555
+ /**
556
+ * Start a refresh behind a render, when one is worth starting.
557
+ *
558
+ * Every failure here is swallowed on purpose. This is called from a status line
559
+ * and from a snapshot, and neither of them may fail because a refresh could not
560
+ * be started: the bars this render already has are still true.
561
+ */
562
+ async function startRefreshBehind(dependencies, snapshots, now) {
563
+ try {
564
+ await spawnDetachedRefresh({
565
+ snapshots,
566
+ now,
567
+ ...(dependencies.stateDirectory === undefined
568
+ ? {}
569
+ : { stateDirectory: dependencies.stateDirectory }),
570
+ nodeExecutable: dependencies.nodeExecutable,
571
+ openLimiterScript: dependencies.openLimiterScript,
572
+ spawn: dependencies.spawnDetached
573
+ });
574
+ }
575
+ catch {
576
+ /* Nothing to report and nothing a person could do about it. */
577
+ }
578
+ }
128
579
  function demoSnapshots(now) {
129
580
  const raw = [
130
581
  ...(parseClaudePayload(claudeFixture(now), now) ?? []),
@@ -139,7 +590,14 @@ function demoSnapshots(now) {
139
590
  return normalizeMeters(withProvenance(raw, INGEST_PROVENANCE));
140
591
  }
141
592
  function doctorRows(snapshots, environment, now) {
142
- const lines = ["CONNECTOR DETECTED FRESHNESS DRIFT"];
593
+ /*
594
+ * PAYLOAD, not DETECTED. This column has always meant "a payload for this
595
+ * connector was pushed into this process", which is a different question from
596
+ * the acquisition table's "a login for this provider exists on this machine",
597
+ * and the two answered differently for the same provider under the same word.
598
+ * One of them had to be renamed and this is the one whose word was wrong.
599
+ */
600
+ const lines = ["CONNECTOR PAYLOAD FRESHNESS DRIFT"];
143
601
  for (const connector of connectors) {
144
602
  const provider = connector.id.toUpperCase();
145
603
  const states = snapshots
@@ -160,9 +618,22 @@ function doctorRows(snapshots, environment, now) {
160
618
  return lines.join("\n");
161
619
  }
162
620
  const help = [
621
+ "openlimiter",
622
+ "openlimiter setup",
623
+ "openlimiter login [--open]",
624
+ "openlimiter logout",
625
+ "openlimiter whoami",
626
+ "openlimiter sync",
163
627
  "openlimiter init",
164
628
  "openlimiter snapshot [--refresh]",
165
- "openlimiter statusline",
629
+ "openlimiter statusline [--host claude|antigravity|grok|codex|shell]",
630
+ "openlimiter terminal [--yes] [--host <id>]",
631
+ "openlimiter terminal status",
632
+ "openlimiter terminal install <host>",
633
+ "openlimiter terminal uninstall <host>",
634
+ "openlimiter terminal show <provider ...>",
635
+ "openlimiter terminal hide <provider ...>",
636
+ "openlimiter refresh",
166
637
  "openlimiter hook [--dry-run]",
167
638
  "openlimiter hooks install <agent>",
168
639
  "openlimiter hooks uninstall <agent>",
@@ -172,36 +643,60 @@ const help = [
172
643
  "openlimiter ingest [--provider <id>] [--payload <json>]",
173
644
  "openlimiter config get statusline[.<key>]",
174
645
  "openlimiter config set statusline.<key> <value>",
646
+ "openlimiter config get providers[.<key>]",
647
+ "openlimiter config set providers.<key> <value>",
175
648
  "openlimiter doctor",
176
649
  "openlimiter demo",
177
650
  "openlimiter export",
178
- "openlimiter serve [--port <n>] [--host <address>] [--no-qr]",
179
651
  "",
180
652
  "statusline keys: " + STATUSLINE_KEYS.join(", ") + ".",
653
+ "providers keys: " + PROVIDER_KEYS.join(", ") + ".",
654
+ "terminal hosts: " + TERMINAL_HOST_NAMES.join(", ") + ".",
181
655
  "statusline and ingest read JSON from standard input when it is piped in.",
182
- "serve publishes read only quota on your local network, behind a token that",
183
- "changes on every start. It is for a trusted network, not the internet.",
656
+ "openlimiter with no arguments runs setup: sign in, connect, show bars in.",
657
+ "login opens the device code sign in; sync uploads one round to the hub when",
658
+ "a session exists, and refresh triggers it automatically after itself.",
659
+ "refresh reads the logins your provider tools already stored on this machine",
660
+ "and asks each provider for its own usage, at most once every 15 minutes. It",
661
+ "stands down while the desktop app is running. statusline and snapshot start",
662
+ "it in the background when the cache is older than a minute.",
184
663
  "Exit codes: 0 success, 1 failure, 2 usage, 3 no bounded quota data."
185
664
  ].join("\n");
186
665
  /**
187
- * Parse a Claude Code statusline payload from standard input and cache it.
666
+ * Parse a status line host's payload from standard input and cache it.
188
667
  *
189
668
  * This is the path that gives the tool something to meter. It performs no
190
- * network access at all: it validates the JSON that Claude Code already wrote
191
- * to this process. Every failure returns null so the caller can fall back to
192
- * the cache instead of breaking the host tool.
669
+ * network access at all: it validates the JSON the host already wrote to this
670
+ * process. Every failure returns null so the caller can fall back to the
671
+ * cache instead of breaking the host tool.
672
+ *
673
+ * Which parser runs, and which provenance the reading is stamped with, are
674
+ * decided by the host. Codex names no scripting interface at all (its status
675
+ * line draws only its own built in items) and shell prompts read the cache
676
+ * only, so neither ever hands this anything to parse.
193
677
  */
194
- async function ingestStandardInput(dependencies, now) {
678
+ async function ingestStandardInput(dependencies, now, host = "claude") {
679
+ if (host === "codex" || host === "shell")
680
+ return null;
195
681
  try {
196
682
  const document = parseJsonText(await dependencies.readStandardInput());
197
683
  if (!document.ok)
198
684
  return null;
199
- const meters = parseClaudePayload(document.value, now);
685
+ const meters = host === "antigravity"
686
+ ? parseAntigravityStatuslinePayload(document.value, now)
687
+ : host === "grok"
688
+ ? parseGrokStatuslinePayload(document.value, now)
689
+ : parseClaudePayload(document.value, now);
200
690
  if (meters === null)
201
691
  return null;
202
- /* Claude Code wrote this to our standard input in this session. It is the
203
- one live reading the product currently has, and it says so. */
204
- const incoming = normalizeMeters(withProvenance(meters, STATUSLINE_PROVENANCE));
692
+ const provenance = host === "antigravity"
693
+ ? ANTIGRAVITY_STATUSLINE_PROVENANCE
694
+ : host === "grok"
695
+ ? GROK_STATUSLINE_PROVENANCE
696
+ : STATUSLINE_PROVENANCE;
697
+ /* The host wrote this to our standard input in this session. It is a live
698
+ reading, and it says so. */
699
+ const incoming = normalizeMeters(withProvenance(meters, provenance));
205
700
  if (incoming.length === 0)
206
701
  return null;
207
702
  try {
@@ -246,6 +741,9 @@ async function snapshotCommand(dependencies, argumentsList, now) {
246
741
  return fail(EXIT_FAILURE, "openlimiter snapshot: quota state could not be read.");
247
742
  }
248
743
  const snapshots = cached.ok ? cached.snapshots : [];
744
+ if (argumentsList.includes("--refresh")) {
745
+ await startRefreshBehind(dependencies, snapshots, now);
746
+ }
249
747
  try {
250
748
  await writeAgentContextSnapshot(snapshots, dependencies.stateDirectory, now, PROVIDER_CODES);
251
749
  }
@@ -294,6 +792,7 @@ async function doctorCommand(dependencies, now) {
294
792
  const dropped = cached.ok ? cached.dropped : 0;
295
793
  const lines = [
296
794
  doctorRows(snapshots, environment, now),
795
+ await acquisitionDoctorRows(dependencies, snapshots, now),
297
796
  "CACHE " + status + " DROPPED " + String(dropped)
298
797
  ];
299
798
  const category = cacheFailureCategory(cached);
@@ -393,18 +892,33 @@ async function ingestCommand(dependencies, argumentsList, now) {
393
892
  /**
394
893
  * Draw the statusline.
395
894
  *
396
- * Standard input first, so a Claude Code session payload is ingested and drawn
397
- * in the same call, then the cache. The layout comes from the configuration
398
- * file and the fallback is the layout's own default, so a machine with no
399
- * configuration still gets bars.
895
+ * Standard input first, so a host's session payload is ingested and drawn in
896
+ * the same call, then the cache. `--host` names which host is asking, which
897
+ * decides both how standard input is parsed and which grammar the bar style
898
+ * draws (a provider's own window carries no tag, every other window does).
899
+ * Absent or unrecognised falls back to `claude`, which is what every
900
+ * installation before this one already assumed. The layout comes from the
901
+ * configuration file and the fallback is the layout's own default, so a
902
+ * machine with no configuration still gets bars.
400
903
  *
401
904
  * `bars false` hands the whole job back to the adapter that produced the 0.1.0
402
905
  * line. That path is byte for byte what it always was, which is the point of
403
906
  * keeping it: it is the escape hatch for anything already parsing this output.
404
907
  */
405
- async function statuslineCommand(dependencies, now) {
406
- const ingested = await ingestStandardInput(dependencies, now);
908
+ async function statuslineCommand(dependencies, argumentsList, now) {
909
+ const hostFlag = flagValue(argumentsList, "--host");
910
+ const host = hostFlag !== undefined && isStatuslineHost(hostFlag)
911
+ ? hostFlag.toLowerCase()
912
+ : "claude";
913
+ const ingested = await ingestStandardInput(dependencies, now, host);
407
914
  const snapshots = ingested ?? await cachedSnapshots(dependencies.stateDirectory);
915
+ /*
916
+ * The refresh that keeps the other providers current starts here and is never
917
+ * waited for. This render draws whatever the cache already holds, the child
918
+ * outlives this process, and the next render shows what it found. That is the
919
+ * whole reason a terminal person needs no background service.
920
+ */
921
+ await startRefreshBehind(dependencies, snapshots, now);
408
922
  if (ingested === null) {
409
923
  await writeAgentContextSnapshot(snapshots, dependencies.stateDirectory, now, PROVIDER_CODES).catch(() => undefined);
410
924
  }
@@ -417,7 +931,8 @@ async function statuslineCommand(dependencies, now) {
417
931
  snapshots,
418
932
  now,
419
933
  config,
420
- color: statuslineColor(config.color, dependencies.environment, dependencies.colorOutput)
934
+ color: statuslineColor(config.color, dependencies.environment, dependencies.colorOutput),
935
+ host
421
936
  }));
422
937
  }
423
938
  const agentAliases = {
@@ -580,14 +1095,18 @@ async function explicitStatusCommand(dependencies, argumentsList, now) {
580
1095
  : succeed(stdout);
581
1096
  }
582
1097
  /* --------------------------------------------------------------- config */
583
- /** The one section this command reads and writes. */
1098
+ /** The two sections this command reads and writes. */
584
1099
  const CONFIG_SECTION = "statusline";
1100
+ const PROVIDERS_SECTION = "providers";
585
1101
  const configUsage = [
586
1102
  "openlimiter config: use one of",
587
1103
  " openlimiter config get statusline[.<key>]",
588
1104
  " openlimiter config set statusline.<key> <value>",
589
- "Keys: " + STATUSLINE_KEYS.join(", ") + "."
590
- ].join("\n");
1105
+ " openlimiter config get providers[.<key>]",
1106
+ " openlimiter config set providers.<key> <value>",
1107
+ "Statusline keys: " + STATUSLINE_KEYS.join(", ") + ".",
1108
+ "Providers keys: " + PROVIDER_KEYS.join(", ") + "."
1109
+ ].join(NEWLINE);
591
1110
  /**
592
1111
  * Split `statusline.width` into its section and its key.
593
1112
  *
@@ -610,7 +1129,50 @@ function parseConfigPath(target) {
610
1129
  function configGet(keys, statusline) {
611
1130
  return keys
612
1131
  .map((key) => CONFIG_SECTION + "." + key + "=" + statuslineValueText(statusline, key))
613
- .join("\n");
1132
+ .join(NEWLINE);
1133
+ }
1134
+ function providersGet(keys, providers) {
1135
+ return keys
1136
+ .map((key) => PROVIDERS_SECTION + "." + key + "=" + providerValueText(providers, key))
1137
+ .join(NEWLINE);
1138
+ }
1139
+ /**
1140
+ * Read or change one provider switch.
1141
+ *
1142
+ * Split from the statusline path rather than folded into it because the two
1143
+ * sections mean different things: a statusline key changes what a person sees,
1144
+ * and a providers key changes what this machine asks a provider. Only the
1145
+ * second one has a network consequence, and it deserves its own words.
1146
+ */
1147
+ async function providersConfigCommand(dependencies, action, key, value) {
1148
+ if (key !== null && !isProviderKey(key)) {
1149
+ return fail(EXIT_USAGE, "openlimiter config: unknown providers key. Known keys: " +
1150
+ PROVIDER_KEYS.join(", ") + ".");
1151
+ }
1152
+ const stored = await readConfig(dependencies.stateDirectory);
1153
+ if (!stored.ok && stored.reason !== "missing") {
1154
+ return fail(EXIT_FAILURE, "openlimiter config: configuration could not be read.");
1155
+ }
1156
+ const config = stored.ok ? stored.config : defaultConfig(dependencies.environment);
1157
+ if (action === "get") {
1158
+ return succeed(providersGet(key === null ? PROVIDER_KEYS : [key], config.providers));
1159
+ }
1160
+ if (key === null) {
1161
+ return fail(EXIT_USAGE, "openlimiter config: set needs a key, as in providers.claude.poll.");
1162
+ }
1163
+ if (value === undefined) {
1164
+ return fail(EXIT_USAGE, "openlimiter config: set needs a value.");
1165
+ }
1166
+ const update = setProviderValue(config.providers, key, value);
1167
+ if (!update.ok)
1168
+ return fail(EXIT_USAGE, "openlimiter config: " + update.message);
1169
+ try {
1170
+ await writeConfig({ ...config, providers: update.providers }, dependencies.stateDirectory);
1171
+ }
1172
+ catch {
1173
+ return fail(EXIT_FAILURE, "openlimiter config: configuration could not be written.");
1174
+ }
1175
+ return succeed(providersGet([key], update.providers));
614
1176
  }
615
1177
  /**
616
1178
  * Read or change the statusline layout.
@@ -626,8 +1188,14 @@ async function configCommand(dependencies, argumentsList) {
626
1188
  return fail(EXIT_USAGE, configUsage);
627
1189
  }
628
1190
  const target = parseConfigPath(argumentsList[2]);
629
- if (target === null || target.section !== CONFIG_SECTION) {
630
- return fail(EXIT_USAGE, "openlimiter config: only the statusline section can be read or written.");
1191
+ if (target === null)
1192
+ return fail(EXIT_USAGE, configUsage);
1193
+ if (target.section === PROVIDERS_SECTION) {
1194
+ return await providersConfigCommand(dependencies, action, target.key, argumentsList[3]);
1195
+ }
1196
+ if (target.section !== CONFIG_SECTION) {
1197
+ return fail(EXIT_USAGE, "openlimiter config: only the statusline and providers sections can be " +
1198
+ "read or written.");
631
1199
  }
632
1200
  if (target.key !== null && !isStatuslineKey(target.key)) {
633
1201
  return fail(EXIT_USAGE, "openlimiter config: unknown statusline key. Known keys: " +
@@ -661,56 +1229,484 @@ async function configCommand(dependencies, argumentsList) {
661
1229
  }
662
1230
  return succeed(configGet([target.key], update.statusline));
663
1231
  }
1232
+ const terminalUsage = [
1233
+ "openlimiter terminal [--yes] [--host <id>]",
1234
+ "openlimiter terminal status",
1235
+ "openlimiter terminal install <host>",
1236
+ "openlimiter terminal uninstall <host>",
1237
+ "openlimiter terminal show <provider ...>",
1238
+ "openlimiter terminal hide <provider ...>",
1239
+ "",
1240
+ "hosts: " + TERMINAL_HOST_NAMES.join(", ") + "."
1241
+ ].join("\n");
1242
+ /** The provider ids this machine has a login or a key for, right now. */
1243
+ async function detectedProviderIds(dependencies) {
1244
+ const environment = await environmentWithLocalMarkers(dependencies.environment, dependencies.stateDirectory);
1245
+ return connectors
1246
+ .filter((connector) => connector.detect(environment))
1247
+ .map((connector) => connector.id);
1248
+ }
1249
+ function terminalContext(dependencies, detected) {
1250
+ return {
1251
+ homeDirectory: dependencies.homeDirectory,
1252
+ ...(dependencies.stateDirectory === undefined
1253
+ ? {}
1254
+ : { stateDirectory: dependencies.stateDirectory }),
1255
+ platform: dependencies.platform,
1256
+ detectedProviders: detected,
1257
+ ...(dependencies.windowsCredentialRunner === undefined
1258
+ ? {}
1259
+ : { shellRunner: dependencies.windowsCredentialRunner })
1260
+ };
1261
+ }
1262
+ /**
1263
+ * Wire, unwire and report on a status line host, and choose what a terminal
1264
+ * shows.
1265
+ *
1266
+ * A bare call is the checklist: every host this build knows, whether it is
1267
+ * already wired, and the one line that wires the rest. It never opens an
1268
+ * interactive prompt, because this command runs as often from a script as
1269
+ * from a person at a keyboard and a prompt neither can answer would hang one
1270
+ * of them. `--yes` is the unattended equivalent of answering yes to every
1271
+ * host in the checklist; `--host <id>` wires exactly one.
1272
+ */
1273
+ async function terminalCommand(dependencies, argumentsList) {
1274
+ const action = argumentsList[1];
1275
+ const detected = await detectedProviderIds(dependencies);
1276
+ const context = terminalContext(dependencies, detected);
1277
+ const knownHost = (value) => value !== undefined && TERMINAL_HOST_NAMES.includes(value.toLowerCase());
1278
+ if (action === "status") {
1279
+ return succeed(await terminalStatusTable(context));
1280
+ }
1281
+ if (action === "install" || action === "uninstall") {
1282
+ const host = argumentsList[2];
1283
+ if (!knownHost(host)) {
1284
+ return fail(EXIT_USAGE, "openlimiter terminal: " + action + " needs a known host. hosts: " +
1285
+ TERMINAL_HOST_NAMES.join(", ") + ".");
1286
+ }
1287
+ const result = action === "install"
1288
+ ? await installHost(host, context)
1289
+ : await uninstallHost(host, context);
1290
+ return result.ok ? succeed(result.message) : fail(EXIT_FAILURE, result.message);
1291
+ }
1292
+ if (action === "show" || action === "hide") {
1293
+ const providerIds = argumentsList.slice(2);
1294
+ if (providerIds.length === 0) {
1295
+ return fail(EXIT_USAGE, "openlimiter terminal: " + action + " needs at least one provider id.");
1296
+ }
1297
+ const result = action === "show"
1298
+ ? await terminalShow(providerIds, context)
1299
+ : await terminalHide(providerIds, context);
1300
+ return result.ok ? succeed(result.message) : fail(EXIT_USAGE, result.message);
1301
+ }
1302
+ if (action === undefined || action === "--yes" || action === "--host") {
1303
+ const hostFlag = flagValue(argumentsList, "--host");
1304
+ if (argumentsList.includes("--host") && !knownHost(hostFlag)) {
1305
+ return fail(EXIT_USAGE, "openlimiter terminal: --host needs a known host. hosts: " +
1306
+ TERMINAL_HOST_NAMES.join(", ") + ".");
1307
+ }
1308
+ if (knownHost(hostFlag)) {
1309
+ const result = await installHost(hostFlag, context);
1310
+ return result.ok ? succeed(result.message) : fail(EXIT_FAILURE, result.message);
1311
+ }
1312
+ if (argumentsList.includes("--yes")) {
1313
+ const lines = [];
1314
+ let allOk = true;
1315
+ for (const host of TERMINAL_HOST_NAMES) {
1316
+ const result = await installHost(host, context);
1317
+ if (!result.ok)
1318
+ allOk = false;
1319
+ lines.push(host + ": " + (result.message.split("\n")[0] ?? result.message));
1320
+ }
1321
+ return allOk ? succeed(lines.join("\n")) : fail(EXIT_FAILURE, lines.join("\n"));
1322
+ }
1323
+ const table = await terminalStatusTable(context);
1324
+ return succeed([
1325
+ table,
1326
+ "",
1327
+ "Wire one host: openlimiter terminal install <host>",
1328
+ "Wire every host this build supports: openlimiter terminal --yes",
1329
+ "hosts: " + TERMINAL_HOST_NAMES.join(", ") + "."
1330
+ ].join("\n"));
1331
+ }
1332
+ return fail(EXIT_USAGE, terminalUsage);
1333
+ }
1334
+ /* ------------------------------------------------------------------- hub */
664
1335
  /**
665
- * Publish the cached quota on the local network, read only.
1336
+ * Sign in to the hub through the device code flow.
666
1337
  *
667
- * This is the one command that does not finish. It returns its banner as soon
668
- * as the socket is bound, and the listening socket is what keeps the process
669
- * alive afterwards, so the caller writes the banner exactly once and then gets
670
- * out of the way.
1338
+ * The code and the address are shown the moment the hub hands them over,
1339
+ * through `dependencies.emit`, because the poll that follows can take up to
1340
+ * three minutes and a person watching a blank terminal for that long is the
1341
+ * whole flow failing in a way no exit code explains. `--open` additionally
1342
+ * opens a browser tab; the code and the address are printed either way.
671
1343
  */
672
- async function serveCommand(dependencies, argumentsList) {
673
- const portText = flagValue(argumentsList, "--port");
674
- if (argumentsList.includes("--port") && portText === undefined) {
675
- return fail(EXIT_USAGE, "openlimiter serve: the port flag needs a value.");
1344
+ async function loginCommand(dependencies, argumentsList) {
1345
+ const outcome = await runDeviceLogin({
1346
+ environment: dependencies.environment,
1347
+ transport: dependencies.hubTransport,
1348
+ sleep: dependencies.sleep,
1349
+ emit: dependencies.emit,
1350
+ ...(dependencies.interruptSignal === undefined ? {} : { interruptSignal: dependencies.interruptSignal }),
1351
+ openBrowser: dependencies.openBrowser,
1352
+ open: argumentsList.includes("--open")
1353
+ });
1354
+ if (outcome.kind === "signed_in") {
1355
+ await writeSession(outcome.session, {
1356
+ ...(dependencies.stateDirectory === undefined ? {} : { directory: dependencies.stateDirectory }),
1357
+ platform: dependencies.platform,
1358
+ ...(dependencies.windowsAclRunner === undefined ? {} : { windowsAclRunner: dependencies.windowsAclRunner })
1359
+ });
1360
+ return succeed("Signed in as " + outcome.session.accountLabel + ".");
1361
+ }
1362
+ if (outcome.kind === "cancelled")
1363
+ return fail(EXIT_FAILURE, "openlimiter login: cancelled.");
1364
+ if (outcome.kind === "denied")
1365
+ return fail(EXIT_FAILURE, "openlimiter login: the sign in was denied.");
1366
+ if (outcome.kind === "expired") {
1367
+ return fail(EXIT_FAILURE, outcome.message ?? "openlimiter login: the code expired before it was approved.");
676
1368
  }
677
- const port = portText === undefined ? DEFAULT_SERVE_PORT : Number(portText);
678
- if (!Number.isInteger(port) || port < 0 || port > 65_535) {
679
- return fail(EXIT_USAGE, "openlimiter serve: the port must be a whole number from 0 to 65535.");
1369
+ if (outcome.kind === "not_configured") {
1370
+ return fail(EXIT_FAILURE, "openlimiter login: the hub is not configured on this build.");
680
1371
  }
681
- const host = flagValue(argumentsList, "--host");
682
- if (argumentsList.includes("--host") && host === undefined) {
683
- return fail(EXIT_USAGE, "openlimiter serve: the host flag needs a value.");
1372
+ return fail(EXIT_FAILURE, "openlimiter login: " + outcome.message + ".");
1373
+ }
1374
+ /** Forget the stored session. Nothing on the hub is asked to do anything. */
1375
+ async function logoutCommand(dependencies) {
1376
+ await deleteSession(dependencies.stateDirectory);
1377
+ return succeed("Signed out.");
1378
+ }
1379
+ /** Print who is signed in, or say plainly that nobody is. */
1380
+ async function whoamiCommand(dependencies) {
1381
+ const session = await readSession(dependencies.stateDirectory);
1382
+ if (session === null)
1383
+ return fail(EXIT_FAILURE, "openlimiter whoami: not signed in.");
1384
+ return succeed(["Account: " + session.accountLabel, "Device: " + session.deviceId].join(NEWLINE));
1385
+ }
1386
+ /** Persist a session that renewal freshened, using the caller's own options. */
1387
+ async function persistRenewedSession(dependencies, session) {
1388
+ await writeSession(session, {
1389
+ ...(dependencies.stateDirectory === undefined ? {} : { directory: dependencies.stateDirectory }),
1390
+ platform: dependencies.platform,
1391
+ ...(dependencies.windowsAclRunner === undefined ? {} : { windowsAclRunner: dependencies.windowsAclRunner })
1392
+ });
1393
+ }
1394
+ /**
1395
+ * Build and upload one sync envelope, from the CLI's own explicit command.
1396
+ *
1397
+ * Renewal happens here, before anything is uploaded, exactly as the
1398
+ * deliverable requires: a token within an hour of expiry or already expired
1399
+ * is refreshed first, and a hub side revocation ends the session rather than
1400
+ * being treated as an ordinary network failure.
1401
+ */
1402
+ async function syncCommand(dependencies, now) {
1403
+ const directory = dependencies.stateDirectory ?? resolveStateDirectory();
1404
+ const session = await readSession(directory);
1405
+ if (session === null) {
1406
+ return fail(EXIT_FAILURE, "openlimiter sync: not signed in, run openlimiter login.");
684
1407
  }
1408
+ const renewal = await ensureFreshSession(session, now, dependencies.environment, dependencies.hubTransport);
1409
+ if (renewal.kind === "revoked") {
1410
+ await deleteSession(directory);
1411
+ return fail(EXIT_FAILURE, REVOKED_SENTENCE);
1412
+ }
1413
+ if (renewal.kind === "error") {
1414
+ return fail(EXIT_FAILURE, "openlimiter sync: could not renew the session, try again.");
1415
+ }
1416
+ if (renewal.kind === "renewed")
1417
+ await persistRenewedSession(dependencies, renewal.session);
1418
+ const active = renewal.session;
1419
+ const snapshots = await cachedSnapshots(dependencies.stateDirectory);
1420
+ const result = await runSync({
1421
+ directory,
1422
+ environment: dependencies.environment,
1423
+ transport: dependencies.hubTransport,
1424
+ now,
1425
+ token: active.token,
1426
+ deviceId: active.deviceId,
1427
+ snapshots
1428
+ });
1429
+ if (result.kind === "accepted") {
1430
+ return succeed("Synced: accepted, tier " + (result.tier ?? "free") + ", rows " + String(result.rows) + ".");
1431
+ }
1432
+ if (result.kind === "nothing_to_sync")
1433
+ return succeed("openlimiter sync: nothing to sync yet.");
1434
+ if (result.kind === "revoked") {
1435
+ await deleteSession(directory);
1436
+ return fail(EXIT_FAILURE, REVOKED_SENTENCE);
1437
+ }
1438
+ if (result.kind === "rejected") {
1439
+ return fail(EXIT_FAILURE, "openlimiter sync: the hub rejected the upload.");
1440
+ }
1441
+ return fail(EXIT_FAILURE, "openlimiter sync: the hub could not be reached, try again.");
1442
+ }
1443
+ /**
1444
+ * Renew and upload behind an acquisition round, when a session exists.
1445
+ *
1446
+ * Called only from `refreshCommand`, which already runs off the status line's
1447
+ * own path (see `startRefreshBehind`): a round started from a status line
1448
+ * spawns this whole command detached and never waits on it, so nothing here
1449
+ * can add to the milliseconds a render takes. Every failure is swallowed: a
1450
+ * refresh that could not sync still refreshed, and a hub that is unconfigured,
1451
+ * unreachable or has revoked this device is not this command's business to
1452
+ * report.
1453
+ */
1454
+ async function triggerSyncAfterRefresh(dependencies, now) {
685
1455
  try {
686
- const handle = await startQuotaServer({
687
- port,
688
- ...(host === undefined ? {} : { host }),
689
- stateDirectory: dependencies.stateDirectory,
690
- now: dependencies.now
1456
+ const directory = dependencies.stateDirectory ?? resolveStateDirectory();
1457
+ const session = await readSession(directory);
1458
+ if (session === null)
1459
+ return;
1460
+ const renewal = await ensureFreshSession(session, now, dependencies.environment, dependencies.hubTransport);
1461
+ if (renewal.kind === "revoked") {
1462
+ await deleteSession(directory);
1463
+ return;
1464
+ }
1465
+ if (renewal.kind === "error")
1466
+ return;
1467
+ if (renewal.kind === "renewed")
1468
+ await persistRenewedSession(dependencies, renewal.session);
1469
+ const active = renewal.session;
1470
+ const snapshots = await cachedSnapshots(dependencies.stateDirectory);
1471
+ const outcome = await runSync({
1472
+ directory,
1473
+ environment: dependencies.environment,
1474
+ transport: dependencies.hubTransport,
1475
+ now,
1476
+ token: active.token,
1477
+ deviceId: active.deviceId,
1478
+ snapshots
691
1479
  });
692
- dependencies.onListening?.(handle);
693
- return succeed(serveBanner(handle, {
694
- color: dependencies.colorOutput,
695
- withoutQr: argumentsList.includes("--no-qr")
696
- }));
1480
+ if (outcome.kind === "revoked")
1481
+ await deleteSession(directory);
697
1482
  }
698
1483
  catch {
699
- return fail(EXIT_FAILURE, "openlimiter serve: that address and port could not be opened.");
1484
+ /* A refresh that could not sync still refreshed. */
1485
+ }
1486
+ }
1487
+ /* -------------------------------------------------------------- setup */
1488
+ const SETUP_SIGN_IN_PROMPT = "Sign in to sync your bars to the hub and your phone (free)";
1489
+ async function promptOrSkip(dependencies, question) {
1490
+ const answer = await dependencies.promptChoice?.(question) ?? "";
1491
+ return !answer.trim().toLowerCase().startsWith("s");
1492
+ }
1493
+ /** Step one: sign in, or say plainly why this machine did not. */
1494
+ async function setupSignInStep(dependencies) {
1495
+ const lines = ["1. Sign in", SETUP_SIGN_IN_PROMPT];
1496
+ const existing = await readSession(dependencies.stateDirectory);
1497
+ if (existing !== null) {
1498
+ lines.push("Already signed in as " + existing.accountLabel + ".");
1499
+ return lines;
1500
+ }
1501
+ if (!hubConfigured(dependencies.environment)) {
1502
+ lines.push("Skipped: the hub is not configured on this build.");
1503
+ return lines;
1504
+ }
1505
+ const proceed = await promptOrSkip(dependencies, "Enter to sign in, S to skip: ");
1506
+ if (!proceed) {
1507
+ lines.push("Skipped.");
1508
+ return lines;
1509
+ }
1510
+ const outcome = await runDeviceLogin({
1511
+ environment: dependencies.environment,
1512
+ transport: dependencies.hubTransport,
1513
+ sleep: dependencies.sleep,
1514
+ emit: dependencies.emit,
1515
+ ...(dependencies.interruptSignal === undefined ? {} : { interruptSignal: dependencies.interruptSignal }),
1516
+ openBrowser: dependencies.openBrowser,
1517
+ open: false
1518
+ });
1519
+ if (outcome.kind === "signed_in") {
1520
+ await writeSession(outcome.session, {
1521
+ ...(dependencies.stateDirectory === undefined ? {} : { directory: dependencies.stateDirectory }),
1522
+ platform: dependencies.platform,
1523
+ ...(dependencies.windowsAclRunner === undefined ? {} : { windowsAclRunner: dependencies.windowsAclRunner })
1524
+ });
1525
+ lines.push("Signed in as " + outcome.session.accountLabel + ".");
700
1526
  }
1527
+ else if (outcome.kind === "cancelled") {
1528
+ lines.push("Cancelled.");
1529
+ }
1530
+ else {
1531
+ lines.push("Could not sign in this time. Run openlimiter login later.");
1532
+ }
1533
+ return lines;
1534
+ }
1535
+ /** Which acquisition provider reads this agent's own login, when one exists. */
1536
+ const AGENT_CREDENTIAL_PROVIDER = {
1537
+ claude: "CLAUDE",
1538
+ codex: "CODEX",
1539
+ gemini: "GEMINI_CLI",
1540
+ antigravity: "ANTIGRAVITY",
1541
+ grok: "GROK",
1542
+ kimi: "KIMI"
1543
+ };
1544
+ /**
1545
+ * Agents whose device style sign in is untested on this build (decision D5):
1546
+ * shown as "verified on install" instead of a plain install nudge, and never
1547
+ * offered an interactive sign in of their own.
1548
+ */
1549
+ const UNVERIFIED_DEVICE_LOGIN_AGENTS = new Set(["grok", "kimi"]);
1550
+ const CONNECT_ROW_LABEL = {
1551
+ use_current_login: "use current login",
1552
+ sign_in: "sign in",
1553
+ install: "install",
1554
+ verified_on_install: "verified on install"
1555
+ };
1556
+ async function connectRowState(agent, installed, readCredential) {
1557
+ if (installed === null) {
1558
+ return UNVERIFIED_DEVICE_LOGIN_AGENTS.has(agent) ? "verified_on_install" : "install";
1559
+ }
1560
+ const provider = AGENT_CREDENTIAL_PROVIDER[agent];
1561
+ if (provider === undefined)
1562
+ return "sign_in";
1563
+ const credential = await readCredential(provider);
1564
+ return credential.ok ? "use_current_login" : "sign_in";
1565
+ }
1566
+ function codexFailureSentence(reason) {
1567
+ if (reason === "not_installed")
1568
+ return "not installed";
1569
+ if (reason === "too_old")
1570
+ return "this version is too old, upgrade Codex";
1571
+ if (reason === "storage")
1572
+ return "could not prepare a folder for this sign in";
1573
+ if (reason === "spawn")
1574
+ return "could not be started";
1575
+ return "printed no code to sign in with";
1576
+ }
1577
+ /**
1578
+ * Offer Codex's device sign in, watch it to an ending, and say which.
1579
+ *
1580
+ * `codex login --device-auth` runs with `CODEX_HOME` pointed at a folder this
1581
+ * product owns under its own state directory, so the person's own Codex
1582
+ * configuration is never touched. Ctrl C cancels the wait and kills the
1583
+ * child; the built in 180 second timeout does the same when nobody finishes.
1584
+ */
1585
+ async function runCodexDeviceSignIn(dependencies, installed) {
1586
+ if (!codexVersionIsSupported(installed.version)) {
1587
+ return "Codex: " + codexFailureSentence("too_old") + ".";
1588
+ }
1589
+ const stateDirectory = dependencies.stateDirectory ?? resolveStateDirectory();
1590
+ const sessionId = randomUUID().replace(/-/gu, "");
1591
+ const home = managedCodexHome(stateDirectory, sessionId);
1592
+ if (home === null)
1593
+ return "Codex: " + codexFailureSentence("storage") + ".";
1594
+ const runner = dependencies.codexDeviceLoginRunnerFactory(installed.executable);
1595
+ let started;
1596
+ try {
1597
+ started = await startCodexDeviceLogin(runner, home, Date.now());
1598
+ }
1599
+ catch (error) {
1600
+ const reason = error instanceof DeviceLoginError ? error.reason : "spawn";
1601
+ return "Codex: " + codexFailureSentence(reason) + ".";
1602
+ }
1603
+ const { session, start } = started;
1604
+ dependencies.emit("Codex code: " + start.userCode);
1605
+ dependencies.emit("Codex at: " + start.verificationUrl);
1606
+ const deadline = Date.now() + LOGIN_TIMEOUT_MILLISECONDS;
1607
+ for (;;) {
1608
+ if (isAborted(dependencies.interruptSignal)) {
1609
+ session.cancel();
1610
+ return "Codex: sign in cancelled.";
1611
+ }
1612
+ const state = await session.state(Date.now());
1613
+ if (state.kind === "complete")
1614
+ return "Codex: signed in.";
1615
+ if (state.kind === "cancelled")
1616
+ return "Codex: sign in cancelled.";
1617
+ if (state.kind === "timed_out")
1618
+ return "Codex: sign in timed out.";
1619
+ if (state.kind === "failed") {
1620
+ return "Codex: " + codexFailureSentence(state.reason) + ".";
1621
+ }
1622
+ if (Date.now() >= deadline) {
1623
+ session.cancel();
1624
+ return "Codex: sign in timed out.";
1625
+ }
1626
+ await dependencies.sleep(1_000);
1627
+ }
1628
+ }
1629
+ /** Step two: detect installed agent CLIs and their logins, one row each. */
1630
+ async function setupConnectStep(dependencies) {
1631
+ const lines = ["2. Connect"];
1632
+ const environment = await environmentWithLocalMarkers(dependencies.environment, dependencies.stateDirectory);
1633
+ const readCredential = credentialReader(dependencies);
1634
+ let codexInstalled = null;
1635
+ let codexNeedsSignIn = false;
1636
+ for (const agent of CONNECT_AGENT_IDS) {
1637
+ const installed = await detectAgentInstallation(agent, {
1638
+ environment,
1639
+ platform: dependencies.platform
1640
+ });
1641
+ const state = await connectRowState(agent, installed, readCredential);
1642
+ lines.push(agent + ": " + CONNECT_ROW_LABEL[state]);
1643
+ if (agent === "codex") {
1644
+ codexInstalled = installed;
1645
+ codexNeedsSignIn = state === "sign_in";
1646
+ }
1647
+ }
1648
+ if (codexNeedsSignIn && codexInstalled !== null) {
1649
+ const proceed = await promptOrSkip(dependencies, "Codex has no login yet. Sign in now? Enter to start, S to skip: ");
1650
+ if (proceed)
1651
+ lines.push(await runCodexDeviceSignIn(dependencies, codexInstalled));
1652
+ }
1653
+ else {
1654
+ await promptOrSkip(dependencies, "Enter to accept: ");
1655
+ }
1656
+ return lines;
1657
+ }
1658
+ const CONNECT_AGENT_IDS = [
1659
+ "claude",
1660
+ "codex",
1661
+ "gemini",
1662
+ "antigravity",
1663
+ "grok",
1664
+ "kimi",
1665
+ "opencode"
1666
+ ];
1667
+ /** Step three: the existing terminal checklist, unchanged. */
1668
+ async function setupShowBarsStep(dependencies) {
1669
+ const result = await terminalCommand(dependencies, ["terminal"]);
1670
+ return ["3. Show bars in", result.stdout];
1671
+ }
1672
+ /**
1673
+ * The three step first run: sign in, connect, show bars in, then the bars
1674
+ * themselves, once.
1675
+ */
1676
+ async function setupCommand(dependencies, now) {
1677
+ const sections = [];
1678
+ sections.push(...(await setupSignInStep(dependencies)));
1679
+ sections.push(...(await setupConnectStep(dependencies)));
1680
+ sections.push(...(await setupShowBarsStep(dependencies)));
1681
+ const snapshots = await cachedSnapshots(dependencies.stateDirectory);
1682
+ sections.push(renderTable(snapshots, now, dependencies.colorOutput));
1683
+ return succeed(sections.join(NEWLINE));
701
1684
  }
702
1685
  export async function runCli(argumentsList, overrides = {}) {
703
1686
  const dependencies = { ...defaults(), ...overrides };
704
- const command = argumentsList[0] ?? "help";
1687
+ const command = argumentsList[0] ?? "setup";
705
1688
  const now = dependencies.now();
706
1689
  try {
1690
+ if (command === "setup")
1691
+ return await setupCommand(dependencies, now);
1692
+ if (command === "login")
1693
+ return await loginCommand(dependencies, argumentsList);
1694
+ if (command === "logout")
1695
+ return await logoutCommand(dependencies);
1696
+ if (command === "whoami")
1697
+ return await whoamiCommand(dependencies);
1698
+ if (command === "sync")
1699
+ return await syncCommand(dependencies, now);
707
1700
  if (command === "init")
708
1701
  return await initCommand(dependencies);
709
1702
  if (command === "snapshot") {
710
1703
  return await snapshotCommand(dependencies, argumentsList, now);
711
1704
  }
712
1705
  if (command === "statusline") {
713
- return await statuslineCommand(dependencies, now);
1706
+ return await statuslineCommand(dependencies, argumentsList, now);
1707
+ }
1708
+ if (command === "terminal") {
1709
+ return await terminalCommand(dependencies, argumentsList);
714
1710
  }
715
1711
  if (command === "config") {
716
1712
  return await configCommand(dependencies, argumentsList);
@@ -723,6 +1719,8 @@ export async function runCli(argumentsList, overrides = {}) {
723
1719
  if (command === "status") {
724
1720
  return await explicitStatusCommand(dependencies, argumentsList, now);
725
1721
  }
1722
+ if (command === "refresh")
1723
+ return await refreshCommand(dependencies, now);
726
1724
  if (command === "doctor")
727
1725
  return await doctorCommand(dependencies, now);
728
1726
  if (command === "demo") {
@@ -733,8 +1731,6 @@ export async function runCli(argumentsList, overrides = {}) {
733
1731
  if (command === "ingest") {
734
1732
  return await ingestCommand(dependencies, argumentsList, now);
735
1733
  }
736
- if (command === "serve")
737
- return await serveCommand(dependencies, argumentsList);
738
1734
  if (command === "help" || command === "--help" || command === "-h") {
739
1735
  return succeed(help);
740
1736
  }
@@ -748,6 +1744,10 @@ export async function runCli(argumentsList, overrides = {}) {
748
1744
  */
749
1745
  if (command === "hook")
750
1746
  return { exitCode: EXIT_OK, stdout: "", stderr: "" };
1747
+ /* A detached refresh writes to a discarded stream and has nobody to tell,
1748
+ so it fails quietly rather than leaving an exit code nothing reads. */
1749
+ if (command === "refresh")
1750
+ return { exitCode: EXIT_OK, stdout: "", stderr: "" };
751
1751
  if (command === "statusline") {
752
1752
  return { exitCode: EXIT_OK, stdout: "OpenLimiter UNKNOWN", stderr: "" };
753
1753
  }