opencode-auth-load-balancer 0.5.1 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,12 @@
1
+ /** A row's login health for the dashboards: what it holds, and what needs a re-login. */
2
+ import type { PoolAccount } from './types';
3
+ /** A setup-token row's credentials, `token` or `token+oauth`; '' for any other row. */
4
+ export declare function credentialTag(account: PoolAccount): string;
5
+ /**
6
+ * Login trouble on a row still in service: a lost half of a paired row,
7
+ * or an OAuth login about to expire. A parked row shows none — its state
8
+ * already reads `re-login` / `disabled`.
9
+ */
10
+ export declare function loginWarnings(account: PoolAccount, now: number): string[];
11
+ /** Why each lost login stopped working, and when: one dashboard line apiece. */
12
+ export declare function lostLoginNotes(account: PoolAccount, now: number): string[];
package/dist/notify.d.ts CHANGED
@@ -19,6 +19,12 @@ export interface ToastClient {
19
19
  * Best-effort: a failed toast never affects the request.
20
20
  */
21
21
  export declare function notifyOnSwitch(client: ToastClient, providerID: string, account: PoolAccount): Promise<void>;
22
+ /**
23
+ * Confirm a login that left a row holding both credentials, either half
24
+ * having arrived second: organization auto-pairing is a guess, so its result
25
+ * is shown, never silent.
26
+ */
27
+ export declare function notifyPaired(client: ToastClient, providerID: string, account: PoolAccount): Promise<void>;
22
28
  /**
23
29
  * Toast (once per account + source-model + exhaustion window) when a request's
24
30
  * model was auto-downgraded to a fallback because its MODEL TIER's weekly cap
@@ -0,0 +1,22 @@
1
+ /** Which pool row a fresh login belongs to: the rules `addAccount` lands it by. */
2
+ import { type PoolAccount, type TokenSet } from './types';
3
+ export declare function holdsCredential(row: PoolAccount, tokens: TokenSet): boolean;
4
+ /**
5
+ * Auto-pairing: the one row of the credential's organization, among those
6
+ * its usage readings do not rule out, that can take this kind of credential.
7
+ * A setup-token carries no account identity, so the organization narrows it
8
+ * — exact for personal Pro/Max accounts (one organization each), shared by
9
+ * Team/Enterprise seats — and the reset times tell seats apart. A working
10
+ * credential of the same kind is replaced only when the readings confirm
11
+ * the same account (a renewed token); an OAuth login never replaces one,
12
+ * since its account id already failed to match.
13
+ */
14
+ export declare function orgPartner(rows: readonly PoolAccount[], tokens: TokenSet): PoolAccount | undefined;
15
+ /**
16
+ * The one row of the token's organization, among those its usage readings do
17
+ * not rule out, that already serves the account with a working token of its
18
+ * own. Gist sync uses it to leave such an account alone: a second token for it
19
+ * adds nothing, and replacing one machine's token with another's would have
20
+ * two machines trading tokens for ever.
21
+ */
22
+ export declare function tokenSharer(rows: readonly PoolAccount[], tokens: TokenSet): PoolAccount | undefined;
@@ -24,4 +24,12 @@ export declare function pendingFilePath(): string;
24
24
  * same env-keyed memo as the two files above.
25
25
  */
26
26
  export declare function versionCacheFilePath(): string;
27
+ /** Path to the gist-sync state (role, gist id, encryption key, imported ids). Holds the key: owner-only. */
28
+ export declare function syncStateFilePath(): string;
29
+ /** Path to this machine's sync origin (random, not secret; outlives "stop syncing"). */
30
+ export declare function syncOriginFilePath(): string;
31
+ /** Path to the one-shot TUI → server sync request (consumed and deleted on read). */
32
+ export declare function syncIntentFilePath(): string;
33
+ /** Path to the latest sync outcome shown by the TUI (no secrets). */
34
+ export declare function syncStatusFilePath(): string;
27
35
  export {};
@@ -6,7 +6,28 @@ export declare const TOKEN_URL = "https://platform.claude.com/v1/oauth/token";
6
6
  export { OAUTH_HTTP_TIMEOUT_MS, USAGE_HTTP_TIMEOUT_MS } from '../http-timeouts';
7
7
  /** Dedicated usage endpoint — returns 5h + 7d utilization without consuming quota. */
8
8
  export declare const USAGE_URL = "https://api.anthropic.com/api/oauth/usage";
9
+ /** Default host; `rewriteUrl` applies `ANTHROPIC_BASE_URL` and `?beta=true` like on any request. */
10
+ export declare const MESSAGES_URL = "https://api.anthropic.com/v1/messages";
11
+ export declare const ANTHROPIC_VERSION = "2023-06-01";
12
+ /**
13
+ * Model for the usage probe that stands in for `USAGE_URL` on a
14
+ * `claude setup-token` row (see `fetchUsage` in usage.ts): Claude Code's
15
+ * small, fast model, so the one-token probe costs as little quota as any
16
+ * request can. An alias, not a dated snapshot, so it follows the model
17
+ * forward.
18
+ */
19
+ export declare const USAGE_PROBE_MODEL = "claude-haiku-4-5";
20
+ /** Claude Code's docs for `claude setup-token`, shown with the token login's prompt. */
21
+ export declare const SETUP_TOKEN_DOCS_URL = "https://code.claude.com/docs/en/authentication#generate-a-long-lived-token";
22
+ /** Response header naming the organization a request was served for. */
23
+ export declare const ORGANIZATION_ID_HEADER = "anthropic-organization-id";
9
24
  export declare const OAUTH_SCOPES: string[];
25
+ /**
26
+ * What `claude setup-token` asks for: an inference-only token valid for a
27
+ * year. `mintInferenceToken` (oauth.ts) asks a refresh grant for the same.
28
+ */
29
+ export declare const INFERENCE_SCOPE = "user:inference";
30
+ export declare const INFERENCE_TOKEN_LIFETIME_S: number;
10
31
  export declare const TOOL_PREFIX = "mcp_";
11
32
  /**
12
33
  * LAST-RESORT downgrade target when a MODEL-TIER weekly cap is exhausted and
@@ -6,3 +6,12 @@ export declare function authorize(): Promise<AuthorizeRequest>;
6
6
  export declare function exchange(input: string, verifier: string, redirectUri: string, expectedState?: string): Promise<TokenSet | null>;
7
7
  /** Refresh an access token. Throws on failure; message includes the HTTP status. */
8
8
  export declare function refresh(refreshToken: string): Promise<TokenSet>;
9
+ /**
10
+ * Mint the token `claude setup-token` prints from an OAuth login, with no
11
+ * Claude Code and no second browser approval: a refresh grant naming the
12
+ * scope and lifetime it wants, as Claude Code's own `claude auth login`
13
+ * mints its one-year token from a refresh token. The grant spends the
14
+ * refresh token like `refresh`, so the result carries the rotated one;
15
+ * `access`/`expires` are the minted token. Throws like `refresh`.
16
+ */
17
+ export declare function mintInferenceToken(refreshToken: string): Promise<TokenSet>;
@@ -0,0 +1,14 @@
1
+ import { type TokenSet } from '../../types';
2
+ import type { TokenLogin } from '../types';
3
+ /**
4
+ * The pasted token, checked before it can join the pool. All whitespace is
5
+ * dropped first: a terminal wraps the long token, so a copied one routinely
6
+ * carries line breaks. One probe then decides — only a 401/403 means the
7
+ * token does not authenticate (a 429 is a valid but exhausted one). Its
8
+ * response names the organization the token acts for and the account's
9
+ * usage windows: with no profile scope, that is all that ties the token to
10
+ * its account's OAuth login, and the usage seeds the row without a second
11
+ * probe.
12
+ */
13
+ export declare function exchangeSetupToken(input: string): Promise<TokenSet | null>;
14
+ export declare const setupTokenLogin: TokenLogin;
@@ -6,9 +6,22 @@ import type { PoolAccount, UsageSnapshot } from '../../types';
6
6
  * and the fraction scale are Anthropic-specific.
7
7
  */
8
8
  export declare function parseUsageHeaders(headers: Headers): Partial<UsageSnapshot> | null;
9
+ /**
10
+ * Claude Code's own quota check: one output token on the small model, shaped
11
+ * like every other request this plugin sends (identity + billing header,
12
+ * betas, UA). Any response to it — a 429 included — carries the unified
13
+ * 5h/7d headers. Rejects only on a network failure; the caller owns the body.
14
+ */
15
+ export declare function sendUsageProbe(access: string): Promise<Response>;
16
+ /** The usage a probe's response reports, or null when it carries none. */
17
+ export declare function probeSnapshot(headers: Headers, now: number): UsageSnapshot | null;
9
18
  /**
10
19
  * Poll the dedicated usage endpoint for authoritative 5h + 7d utilization without
11
20
  * consuming inference quota. Returns null on any failure — including a 200 whose
12
21
  * body is not the usage shape — so the caller keeps the last-known snapshot.
22
+ *
23
+ * A row without a refresh token is a `claude setup-token` bearer, scoped to
24
+ * `user:inference` alone; the endpoint demands `user:profile` and answers it
25
+ * 403, so such a row is measured with the one-token probe above instead.
13
26
  */
14
27
  export declare function fetchUsage(account: PoolAccount, now: number): Promise<UsageSnapshot | null>;
@@ -31,6 +31,19 @@ export interface AuthorizeRequest {
31
31
  /** Login prompt text; absent = the OAuth default ("Paste the authorization code here:"). */
32
32
  instructions?: string;
33
33
  }
34
+ /** A paste login for a long-lived token the provider's own CLI mints (see `ProviderAdapter.tokenLogin`). */
35
+ export interface TokenLogin {
36
+ /**
37
+ * What the token is called, spliced into the method label. The TUI
38
+ * re-logs a static row through the method whose label carries it.
39
+ */
40
+ readonly label: string;
41
+ /** Page explaining how to mint the token. */
42
+ readonly url: string;
43
+ readonly instructions: string;
44
+ /** The pasted token, verified; null when malformed or refused. */
45
+ exchange(input: string): Promise<TokenSet | null>;
46
+ }
34
47
  /** A device-code login (RFC 8628) in flight: where to approve it, and the wait for approval. */
35
48
  export interface DeviceLogin {
36
49
  /** Verification URL to open (the user code rides in it). */
@@ -108,6 +121,20 @@ export interface ProviderAdapter {
108
121
  * instead of cooling it down — no refresh can repair a revoked key.
109
122
  */
110
123
  tokensFromApiKey?(key: string): TokenSet;
124
+ /**
125
+ * Present on providers whose own CLI mints a long-lived bearer (Claude's
126
+ * `claude setup-token`: one year, inference-only). Registered as a paste
127
+ * login after the OAuth one; the token joins the pool as a static row —
128
+ * no refresh token, so it never rotates and a 401 parks it for a re-login.
129
+ */
130
+ tokenLogin?: TokenLogin;
131
+ /**
132
+ * Present on providers whose OAuth login can mint the bearer `tokenLogin`
133
+ * takes (Claude): spends `refreshToken` like `refresh`, returning the
134
+ * minted token as `access`/`expires` beside the rotated refresh token.
135
+ * Throws on failure.
136
+ */
137
+ mintInferenceToken?(refreshToken: string): Promise<TokenSet>;
111
138
  /** Set auth + provider headers on an outgoing request for the chosen account. */
112
139
  applyAuth(headers: Headers, account: PoolAccount): void;
113
140
  /** Optionally rewrite the request URL (e.g. add ?beta=true, swap base URL). */
package/dist/refresh.d.ts CHANGED
@@ -1,6 +1,18 @@
1
1
  import type { ProviderAdapter } from './providers/types';
2
2
  import { type PoolAccount } from './types';
3
+ /** The single-use refresh token + the generation it was read at (the CAS key). */
4
+ export interface RefreshAttempt {
5
+ readonly refresh: string;
6
+ readonly gen: number;
7
+ }
3
8
  export declare function needsRefresh(account: PoolAccount, now: number): boolean;
9
+ export declare function genOf(account: PoolAccount): number;
10
+ export declare function sameGeneration(account: PoolAccount, attempt: RefreshAttempt): boolean;
11
+ /**
12
+ * Run `fn` holding the account's refresh lock — taken by everything that
13
+ * spends its single-use refresh token (a refresh, a token mint).
14
+ */
15
+ export declare function withRefreshLock<T>(providerID: string, accountId: string, fn: () => Promise<T>): Promise<T>;
4
16
  /**
5
17
  * Ensure the account has a fresh access token, refreshing if needed. The rotated
6
18
  * refresh token is persisted immediately. On a genuine invalid_grant (the current
package/dist/status.d.ts CHANGED
@@ -12,6 +12,12 @@ interface AccountStatus {
12
12
  /** tier name → epoch ms until that model tier's cap resets (empty = none). */
13
13
  modelCooldownsUntil: Record<string, number>;
14
14
  disabledReason: string | null;
15
+ /** A setup-token row's credentials: `token`, `token+oauth`, or '' for any other row. */
16
+ login: string;
17
+ /** `oauth re-login` / `token re-login` / `oauth expires Nd` on a row still in service. */
18
+ warnings: string[];
19
+ /** Why each lost login stopped working, printed under the table. */
20
+ lostNotes: string[];
15
21
  /** The account that most recently served a request for this provider. */
16
22
  current: boolean;
17
23
  /** 1 = next candidate the scheduler would pick. */
@@ -0,0 +1,66 @@
1
+ import type { ProviderAdapter } from '../providers/types';
2
+ import type { PoolAccount, PoolFile, TokenSet } from '../types';
3
+ import { type ImportJob, type MergeMemory, type MergePlan } from './merge';
4
+ import { type ParsedPayload } from './payload';
5
+ import { type ImportedRef, type SkippedRef } from './state';
6
+ export interface ApplyResult {
7
+ /** The imported map after this plan. */
8
+ imported: Record<string, ImportedRef>;
9
+ /** The skipped map after this plan. */
10
+ skipped: Record<string, SkippedRef>;
11
+ added: number;
12
+ updated: number;
13
+ removed: number;
14
+ /** Entries that could not be landed yet (the provider could not be reached, or refused the secret). */
15
+ deferred: number;
16
+ }
17
+ /** What became of one import: the row it landed on, or left alone because the account already has a credential. */
18
+ export type Landing = {
19
+ kind: 'placed';
20
+ row: PoolAccount;
21
+ } | {
22
+ kind: 'skipped';
23
+ blocker: string;
24
+ holds: string;
25
+ };
26
+ /**
27
+ * Land one verified credential, deciding everything from the pool as it is
28
+ * under the lock (callers run this inside `mutatePool`):
29
+ * - a row of this provider that already holds the secret (or, for a key, the
30
+ * same account) is the user's own, whatever the plan saw earlier;
31
+ * - for a Claude token, a row that already serves the same account with a
32
+ * working token of its own, minted here, pasted, or imported from another
33
+ * entry, is never replaced: two machines that each hold a token for one
34
+ * account would otherwise trade them for ever. The entry is skipped;
35
+ * - in both cases the old imported secret (if the entry is a rotation) is
36
+ * taken back, and the row in the way is reported, so the entry is not
37
+ * probed again until that row changes;
38
+ * - an earlier import still held by its row is replaced in place, but only by
39
+ * a credential of the same provider; if the entry changed provider the old
40
+ * one is taken back and the new one placed on its own;
41
+ * - anything else is placed by the shared pairing (`placeTokens`), with the
42
+ * provider-confirmed identity of the probe only.
43
+ */
44
+ export declare function land(pool: PoolFile, { entry, previous }: ImportJob, tokens: TokenSet): Landing;
45
+ /**
46
+ * Carry out a merge plan. Each entry is landed independently: one failing
47
+ * (a probe that cannot be reached, a pool write that throws) is deferred and
48
+ * never blocks the rest, and what was already landed stays recorded.
49
+ */
50
+ export declare function applyPlan(plan: MergePlan, memory: Pick<MergeMemory, 'imported' | 'skipped'>, adapters?: readonly ProviderAdapter[]): Promise<ApplyResult>;
51
+ /** What `applySnapshot` takes besides the snapshot and the memory. */
52
+ export interface SnapshotOptions {
53
+ adapters?: readonly ProviderAdapter[];
54
+ /**
55
+ * Called with the running result after every pass, before the next one: the
56
+ * pool has changed by then, so the record of what was imported has to be
57
+ * saved by then too, or a later failure would leave rows nobody tracks.
58
+ */
59
+ afterPass?: (soFar: ApplyResult) => Promise<unknown>;
60
+ }
61
+ /**
62
+ * Bring the pool in line with `snapshot`. After a pass that removed something
63
+ * the plan is made again, so an entry that was kept out by the credential just
64
+ * removed is imported in the same cycle instead of the next one.
65
+ */
66
+ export declare function applySnapshot(snapshot: Pick<ParsedPayload, 'entries' | 'listed'>, memory: MergeMemory, options?: SnapshotOptions): Promise<ApplyResult>;
@@ -0,0 +1,19 @@
1
+ /** Blob format version; a reader refuses any other. */
2
+ export declare const BLOB_VERSION = 1;
3
+ /** Largest blob (and gist file) a reader accepts. */
4
+ export declare const MAX_BLOB_BYTES: number;
5
+ /** A fresh random AES-256 key. */
6
+ export declare function generateKey(): Buffer;
7
+ /** The key as it rides in the link's fragment. */
8
+ export declare function encodeKey(key: Buffer): string;
9
+ /** The key of a link fragment, or null when it is not a 32-byte base64url key. */
10
+ export declare function decodeKey(text: string): Buffer | null;
11
+ /** A short stable digest, used to recognize a credential without keeping it. */
12
+ export declare function fingerprint(secret: string): string;
13
+ /** Encrypt `plaintext` into the blob stored in the gist: a fresh nonce on every call. */
14
+ export declare function seal(plaintext: string, key: Buffer): string;
15
+ /**
16
+ * Decrypt a blob. Every failure is a typed `SyncError` that says nothing
17
+ * about the content: a wrong key and a tampered blob look the same by design.
18
+ */
19
+ export declare function open(text: string, key: Buffer): string;
@@ -0,0 +1,19 @@
1
+ import type { ProviderAdapter } from '../providers/types';
2
+ import { type GhRunner } from './github-auth';
3
+ export interface CycleDeps {
4
+ now: () => number;
5
+ runGh?: GhRunner;
6
+ adapters?: readonly ProviderAdapter[];
7
+ }
8
+ /** What the cycle tells the scheduler: how soon to try again if it fails. */
9
+ export interface CycleReport {
10
+ retryMs: number;
11
+ }
12
+ /** After GitHub refuses the account's write, nothing is attempted for this long (downloads go on). */
13
+ export declare const WRITE_BACKOFF_MS = 3600000;
14
+ /**
15
+ * Run one cycle. `force` is "Upload now": it needs a GitHub token (and says so
16
+ * if there is none), reads the gist afresh, and tries to write even inside the
17
+ * back-off after a refusal. Never writes without the lock the caller holds.
18
+ */
19
+ export declare function runCycle(deps: CycleDeps, force: boolean, report: CycleReport): Promise<string>;
@@ -0,0 +1,44 @@
1
+ import type { ProviderAdapter } from '../providers/types';
2
+ import { type GhRunner } from './github-auth';
3
+ export interface EngineDeps {
4
+ now: () => number;
5
+ runGh?: GhRunner;
6
+ adapters?: readonly ProviderAdapter[];
7
+ }
8
+ /** What an operation did; `backoffMs` asks the caller to leave GitHub alone that long. */
9
+ export interface Outcome {
10
+ ok: boolean;
11
+ message: string;
12
+ backoffMs?: number;
13
+ }
14
+ export interface SyncEngine {
15
+ /** One full cycle: download and apply, then upload this machine's own changes when it may. */
16
+ sync(reqAt?: number): Promise<Outcome>;
17
+ /**
18
+ * "Upload now": a cycle that insists on writing (it needs a GitHub token),
19
+ * or with `fresh` (or when this machine follows nothing) a new gist.
20
+ */
21
+ upload(fresh: boolean, reqAt?: number): Promise<Outcome>;
22
+ /** Start following a gist link, then sync it once. */
23
+ subscribe(link: string, reqAt?: number): Promise<Outcome>;
24
+ /** Stop syncing; imported accounts stay. */
25
+ forget(reqAt?: number): Promise<Outcome>;
26
+ /**
27
+ * The digest of this machine's own credentials when the gist may be out of
28
+ * date and this machine may write, else null.
29
+ */
30
+ unpublishedDigest(): Promise<string | null>;
31
+ /**
32
+ * The scheduled cycle. Call it with the cycle lock held: it looks at the
33
+ * persisted state again and returns null (does nothing) unless this machine
34
+ * still follows gist gistId and a cycle is due.
35
+ */
36
+ backgroundPoll(gistId: string): Promise<Outcome | null>;
37
+ /**
38
+ * The automatic upload, decided again under the lock: null unless this
39
+ * machine still follows gist gistId, no back-off is running, and its
40
+ * credentials still differ from the upload by digest.
41
+ */
42
+ backgroundPublish(gistId: string, digest: string): Promise<Outcome | null>;
43
+ }
44
+ export declare function createEngine(deps: EngineDeps): SyncEngine;
@@ -0,0 +1,9 @@
1
+ export type SyncErrorCode = 'bad-link' | 'not-set-up' | 'bad-blob' | 'bad-version' | 'decrypt' | 'rolled-back' | 'too-large' | 'rate-limited' | 'not-found' | 'network' | 'no-auth' | 'http' | 'write-denied';
2
+ /** A sync failure with a fixed, non-leaky message; `retryAfterMs` accompanies `rate-limited`. */
3
+ export declare class SyncError extends Error {
4
+ readonly code: SyncErrorCode;
5
+ readonly retryAfterMs?: number | undefined;
6
+ constructor(code: SyncErrorCode, retryAfterMs?: number | undefined);
7
+ }
8
+ /** The user-facing line for any thrown value: a sync error's own text, else a generic one. */
9
+ export declare function describeSyncError(error: unknown): string;
@@ -0,0 +1,33 @@
1
+ /** The one file a sync gist holds. */
2
+ export declare const GIST_FILE = "auth-load-balancer-sync.json";
3
+ export declare const GIST_ID_RE: RegExp;
4
+ export interface GistLink {
5
+ id: string;
6
+ key: Buffer;
7
+ }
8
+ /** The id and key of a `https://gist.github.com/<user>/<id>#<key>` link, or null for anything else. */
9
+ export declare function parseGistLink(link: string): GistLink | null;
10
+ /** The link to share: the gist's address plus the key as its fragment. */
11
+ export declare function formatGistLink(owner: string | undefined, id: string, key: Buffer): string;
12
+ export interface CreatedGist {
13
+ id: string;
14
+ owner?: string;
15
+ }
16
+ /** Create a secret gist holding `content`. */
17
+ export declare function createGist(token: string, content: string, now?: number): Promise<CreatedGist>;
18
+ /** Replace the sync file of gist `id` with `content`. */
19
+ export declare function updateGist(token: string, id: string, content: string, now?: number): Promise<void>;
20
+ export type GistRead = {
21
+ changed: false;
22
+ } | {
23
+ changed: true;
24
+ content: string;
25
+ etag?: string;
26
+ };
27
+ /**
28
+ * Read the sync file of gist `id`; a matching `etag` answers `changed: false`
29
+ * cheaply. With a `token` the read is authenticated (GitHub's own limit is far
30
+ * higher than the 60 an hour an anonymous address gets); a token GitHub
31
+ * refuses is not sent again, and the gist is read without credentials.
32
+ */
33
+ export declare function readGist(id: string, etag?: string, now?: number, token?: string): Promise<GistRead>;
@@ -0,0 +1,15 @@
1
+ /** Runs `gh auth token`; resolves its stdout, rejects when gh is missing, logged out, or slow. */
2
+ export type GhRunner = () => Promise<string>;
3
+ /** The slice of `execFile` used here, so tests never spawn the real gh. */
4
+ export type ExecFn = (file: string, args: string[], options: {
5
+ timeout: number;
6
+ windowsHide: boolean;
7
+ maxBuffer: number;
8
+ }, callback: (error: Error | null, stdout: string) => void) => unknown;
9
+ /** `execFile` with no shell: nothing to inject into, and the token never touches a command line. */
10
+ export declare function ghAuthToken(exec?: ExecFn): Promise<string>;
11
+ /**
12
+ * The GitHub token to upload with: `GITHUB_TOKEN`, then `GH_TOKEN`, then the
13
+ * gh CLI's. Null when none exists. Only uploading needs it.
14
+ */
15
+ export declare function discoverGithubToken(run?: GhRunner, env?: NodeJS.ProcessEnv): Promise<string | null>;
@@ -0,0 +1,29 @@
1
+ export type SyncAction = 'upload' | 'upload-new' | 'subscribe' | 'sync' | 'forget';
2
+ /** A TUI request is dead after this long, so an abandoned one cannot fire later. */
3
+ export declare const INTENT_TTL_MS: number;
4
+ /** A request stamped further ahead than this is not from a sane clock; it would otherwise outlive the TTL. */
5
+ export declare const INTENT_SKEW_MS = 60000;
6
+ /**
7
+ * A request the TUI hands the server through a file beside the pool (the TUI
8
+ * cannot import the plugin). `link` rides along for `subscribe` and holds the
9
+ * key, so the file is claimed and deleted the moment the server reads it.
10
+ */
11
+ export interface SyncIntent {
12
+ action: SyncAction;
13
+ at: number;
14
+ link?: string;
15
+ }
16
+ /** Whether a request is waiting (without claiming it). */
17
+ export declare function intentPending(): Promise<boolean>;
18
+ /**
19
+ * Claim the pending request, if any. Under a lock the file is renamed to a
20
+ * name of our own and THAT file is read and deleted, so of several opencode
21
+ * processes exactly one acts on it, and a newer request the TUI writes while
22
+ * we read is a different file that is left alone. (A bare rename is not an
23
+ * exclusive claim on Windows: two concurrent renames of one file can both
24
+ * succeed, which is why it runs under the lock.) The claimed file is deleted
25
+ * even when it does not parse. fterClaim is a test seam: it runs once the
26
+ * request is ours and before it is read, which is where the TUI can write a
27
+ * newer one.
28
+ */
29
+ export declare function takeIntent(now: number, waitMs?: number, afterClaim?: () => Promise<void>): Promise<SyncIntent | null>;
@@ -0,0 +1,40 @@
1
+ import type { SyncEngine } from './engine';
2
+ import { type SyncIntent } from './intent';
3
+ export { POLL_MS, PUBLISH_RETRY_MS } from './timing';
4
+ export declare const TICK_MS = 1000;
5
+ export declare const CHANGE_CHECK_MS = 5000;
6
+ export declare const DEBOUNCE_MS = 10000;
7
+ export interface LoopTimers {
8
+ set(run: () => void, ms: number): unknown;
9
+ clear(handle: unknown): void;
10
+ }
11
+ /** Timers that never keep the process alive. */
12
+ export declare const realTimers: LoopTimers;
13
+ /**
14
+ * Run `fn` unless another opencode process is mid-run; resolves undefined then.
15
+ * `staleMs` is four heartbeats: a live holder touches the lock every 5 s, so it
16
+ * is never stale, while a holder that died (a process killed mid-run) stops
17
+ * blocking the machine after 20 s instead of a minute.
18
+ */
19
+ export declare function withCycleLock<T>(fn: () => Promise<T>, waitMs: number): Promise<T | undefined>;
20
+ /** Where the TUI's requests come from; the file handshake unless a test replaces it. */
21
+ export interface IntentSource {
22
+ pending(): Promise<boolean>;
23
+ take(now: number): Promise<SyncIntent | null>;
24
+ }
25
+ export interface LoopDeps {
26
+ engine: SyncEngine;
27
+ now: () => number;
28
+ timers?: LoopTimers;
29
+ intents?: IntentSource;
30
+ lockWaitMs?: number;
31
+ disposeWaitMs?: number;
32
+ }
33
+ export interface SyncLoop {
34
+ start(): void;
35
+ /** Stop ticking and wait (bounded) for the step in flight, so its lock is released. */
36
+ dispose(): Promise<void>;
37
+ /** One scheduler step; exposed so tests drive time themselves. */
38
+ tick(): Promise<void>;
39
+ }
40
+ export declare function createSyncLoop(deps: LoopDeps): SyncLoop;
@@ -0,0 +1,49 @@
1
+ import type { PoolAccount } from '../types';
2
+ import { type ParsedPayload, type SyncEntry } from './payload';
3
+ import type { ImportedRef, SkippedRef } from './state';
4
+ /** An entry to land locally; `previous` is the row an earlier value of it was imported into. */
5
+ export interface ImportJob {
6
+ entry: SyncEntry;
7
+ previous?: ImportedRef;
8
+ }
9
+ /** What sync remembers about earlier cycles, and which machine this is. */
10
+ export interface MergeMemory {
11
+ origin: string;
12
+ imported: Readonly<Record<string, ImportedRef>>;
13
+ skipped: Readonly<Record<string, SkippedRef>>;
14
+ }
15
+ export interface MergePlan {
16
+ imports: ImportJob[];
17
+ /** Imported entries the gist no longer lists: entry key and where it landed. */
18
+ drops: [string, ImportedRef][];
19
+ /** Skip records of entries the gist no longer lists. */
20
+ forgets: string[];
21
+ }
22
+ /** Whether `row` carries the credential with this fingerprint as its static one. */
23
+ export declare function holdsFingerprint(row: PoolAccount, digest: string): boolean;
24
+ /** The digest of the static credential a row carries, or '' when it has none. */
25
+ export declare function staticFingerprint(row: PoolAccount): string;
26
+ /** Whether the row that kept an entry out is still in the way. */
27
+ export declare function blockerStands(accounts: readonly PoolAccount[], skip: SkippedRef): boolean;
28
+ /**
29
+ * Whether a skipped entry should be looked at again because the row that kept
30
+ * it out is gone, or no longer holds what it held.
31
+ */
32
+ export declare function hasFreedSkips(accounts: readonly PoolAccount[], skipped: Readonly<Record<string, SkippedRef>>): boolean;
33
+ /**
34
+ * What to do with the gist's entries, given what was imported before. Pure:
35
+ * - this machine's own entries (its origin) are never imported back;
36
+ * - a new entry is imported, unless a local row already holds its secret
37
+ * (that row is the user's own and sync never tracks, so never removes, it);
38
+ * - an entry that was skipped because the account already had a credential is
39
+ * not probed again while that credential is still in the way;
40
+ * - an entry whose secret changed is imported again, onto the row it was in,
41
+ * unless a local row already holds the new secret, which stays the user's
42
+ * own: the old imported secret is taken back and the entry is forgotten;
43
+ * - an entry whose secret is unchanged is left alone, so a row the user
44
+ * deleted or re-pointed stays that way until the gist changes it;
45
+ * - an imported entry the gist no longer lists is dropped. An entry that is
46
+ * listed but failed validation is NOT "no longer listed": it is left as is.
47
+ * Rows that were never imported are not reachable from a plan.
48
+ */
49
+ export declare function planMerge(accounts: readonly PoolAccount[], snapshot: Pick<ParsedPayload, 'entries' | 'listed'>, memory: MergeMemory): MergePlan;
@@ -0,0 +1,2 @@
1
+ /** This machine's origin; created on first use, under a lock so two windows never make two. */
2
+ export declare function machineOrigin(): Promise<string>;
@@ -0,0 +1,31 @@
1
+ /**
2
+ * What this machine lists in the gist. A row's static credential is the
3
+ * machine's own unless sync put it there: a row that still holds a credential
4
+ * recorded in the imported map is imported, never uploaded, so a token travels
5
+ * from the machine that has it and never back.
6
+ */
7
+ import { type PoolAccount, type PoolFile } from '../types';
8
+ import { type SyncEntry } from './payload';
9
+ import type { ImportedRef } from './state';
10
+ /**
11
+ * The static credential of a row: a Claude `inferenceToken`, or a Kimi API
12
+ * key. A Kimi row only counts when it carries the static-credential expiry as
13
+ * well as no refresh token: an OAuth sign-in can also leave a refresh-less row,
14
+ * with a short-lived access token that must never leave the machine.
15
+ */
16
+ export declare function staticSecret(row: PoolAccount): string | undefined;
17
+ /**
18
+ * This machine's own static credentials, sorted by row id so equal pools
19
+ * produce equal payloads. Every entry goes through the receiver's own
20
+ * validation (a label too long is trimmed, a row whose id or secret a receiver
21
+ * would refuse is left out): this machine never produces a snapshot others
22
+ * would reject.
23
+ */
24
+ export declare function ownEntries(pool: PoolFile, origin: string, imported: Readonly<Record<string, ImportedRef>>): SyncEntry[];
25
+ /**
26
+ * What of `own` to list next to `others` (the entries of every other machine,
27
+ * carried over untouched): not a secret a machine with a smaller origin already
28
+ * lists (one copy is enough, and two machines never both give theirs up
29
+ * because the smaller origin keeps it), and no more than the gist can hold.
30
+ */
31
+ export declare function listable(own: readonly SyncEntry[], others: readonly SyncEntry[], origin: string): SyncEntry[];