@sunerpy/opencode-kiro-auth 0.10.0 → 0.11.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.
package/README.md CHANGED
@@ -112,6 +112,11 @@ Paid-overage protection is on by default; see
112
112
  [Overage protection](docs/CONFIGURATION.md#overage-protection) before disabling
113
113
  `stop_on_overage`.
114
114
 
115
+ Running multiple OpenCode processes at once? `distribute_across_processes`
116
+ (default `true`) and `per_request_spread` (default `false`) control how load
117
+ spreads across accounts — see
118
+ [Account distribution across processes](docs/CONFIGURATION.md#account-distribution-across-processes).
119
+
115
120
  ## Multiple accounts & rotation
116
121
 
117
122
  You can register more than one Kiro account and let the plugin spread
@@ -8,6 +8,8 @@ export declare class AccountManager {
8
8
  private lastUsageToastTime;
9
9
  private rrCursor;
10
10
  private stickyId?;
11
+ private startIndex;
12
+ private perRequestSpread;
11
13
  private quotaAvoidanceEnabled;
12
14
  private quotaReserveThreshold;
13
15
  private stopOnOverage;
@@ -17,12 +19,16 @@ export declare class AccountManager {
17
19
  quotaReserveThreshold?: number;
18
20
  stopOnOverage?: boolean;
19
21
  overageThreshold?: number;
22
+ startIndex?: number;
23
+ perRequestSpread?: boolean;
20
24
  });
21
25
  static loadFromDisk(strategy?: AccountSelectionStrategy, opts?: {
22
26
  quotaAvoidanceEnabled?: boolean;
23
27
  quotaReserveThreshold?: number;
24
28
  stopOnOverage?: boolean;
25
29
  overageThreshold?: number;
30
+ distributeAcrossProcesses?: boolean;
31
+ perRequestSpread?: boolean;
26
32
  }): Promise<AccountManager>;
27
33
  getAccountCount(): number;
28
34
  getAccounts(): ManagedAccount[];
@@ -15,8 +15,10 @@ export class AccountManager {
15
15
  strategy;
16
16
  lastToastTime = 0;
17
17
  lastUsageToastTime = 0;
18
- rrCursor = 0;
18
+ rrCursor;
19
19
  stickyId;
20
+ startIndex;
21
+ perRequestSpread;
20
22
  quotaAvoidanceEnabled;
21
23
  quotaReserveThreshold;
22
24
  stopOnOverage;
@@ -29,6 +31,9 @@ export class AccountManager {
29
31
  this.quotaReserveThreshold = opts?.quotaReserveThreshold ?? 0.95;
30
32
  this.stopOnOverage = opts?.stopOnOverage ?? true;
31
33
  this.overageThreshold = opts?.overageThreshold ?? 0;
34
+ this.startIndex = opts?.startIndex ?? 0;
35
+ this.perRequestSpread = opts?.perRequestSpread ?? false;
36
+ this.rrCursor = this.startIndex;
32
37
  }
33
38
  static async loadFromDisk(strategy, opts) {
34
39
  const rows = kiroDb.getAccounts();
@@ -56,7 +61,23 @@ export class AccountManager {
56
61
  overageCount: r.overage_count || 0,
57
62
  lastSync: r.last_sync
58
63
  }));
59
- return new AccountManager(accounts, strategy || 'sticky', opts);
64
+ let startIndex = 0;
65
+ if (opts?.distributeAcrossProcesses !== false) {
66
+ try {
67
+ startIndex = await kiroDb.nextAssignmentIndex();
68
+ }
69
+ catch (error) {
70
+ logger.warn('assignment index failed, using 0', {
71
+ error: error instanceof Error ? error.message : String(error)
72
+ });
73
+ startIndex = 0;
74
+ }
75
+ }
76
+ return new AccountManager(accounts, strategy || 'sticky', {
77
+ ...opts,
78
+ startIndex,
79
+ perRequestSpread: opts?.perRequestSpread
80
+ });
60
81
  }
61
82
  getAccountCount() {
62
83
  return this.accounts.length;
@@ -140,10 +161,26 @@ export class AccountManager {
140
161
  const nearFull = available.filter((a) => ratio(a) >= this.quotaReserveThreshold);
141
162
  candidatePool = ample.length > 0 ? ample : nearFull;
142
163
  }
164
+ const sorted = [...this.accounts].sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
165
+ const N = sorted.length;
143
166
  let selected;
144
167
  if (candidatePool.length > 0) {
145
- if (this.strategy === 'sticky') {
146
- selected = candidatePool.find((a) => a.id === this.stickyId) || candidatePool[0];
168
+ if (this.perRequestSpread) {
169
+ selected = [...candidatePool].sort((a, b) => (a.usedCount || 0) - (b.usedCount || 0) || (a.lastUsed || 0) - (b.lastUsed || 0))[0];
170
+ }
171
+ else if (this.strategy === 'sticky') {
172
+ if (this.stickyId) {
173
+ selected = candidatePool.find((a) => a.id === this.stickyId);
174
+ }
175
+ if (!selected) {
176
+ for (let k = 0; k < N; k++) {
177
+ const candidate = sorted[(this.startIndex + k) % N];
178
+ if (candidate && candidatePool.some((account) => account.id === candidate.id)) {
179
+ selected = candidate;
180
+ break;
181
+ }
182
+ }
183
+ }
147
184
  if (selected)
148
185
  this.stickyId = selected.id;
149
186
  }
@@ -151,8 +188,21 @@ export class AccountManager {
151
188
  selected = candidatePool[this.rrCursor % candidatePool.length];
152
189
  this.rrCursor++;
153
190
  }
154
- else if (this.strategy === 'lowest-usage') {
155
- selected = [...candidatePool].sort((a, b) => (a.usedCount || 0) - (b.usedCount || 0) || (a.lastUsed || 0) - (b.lastUsed || 0))[0];
191
+ else {
192
+ const sortedIndexById = new Map(sorted.map((account, index) => [account.id, index]));
193
+ selected = [...candidatePool].sort((a, b) => {
194
+ const usageDifference = (a.usedCount || 0) - (b.usedCount || 0);
195
+ if (usageDifference !== 0)
196
+ return usageDifference;
197
+ const lastUsedDifference = (a.lastUsed || 0) - (b.lastUsed || 0);
198
+ if (lastUsedDifference !== 0)
199
+ return lastUsedDifference;
200
+ const aIndex = sortedIndexById.get(a.id) ?? 0;
201
+ const bIndex = sortedIndexById.get(b.id) ?? 0;
202
+ const aOffset = (((aIndex - this.startIndex) % N) + N) % N;
203
+ const bOffset = (((bIndex - this.startIndex) % N) + N) % N;
204
+ return aOffset - bOffset;
205
+ })[0];
156
206
  }
157
207
  }
158
208
  if (!selected) {
@@ -19,6 +19,16 @@ export declare const KiroConfigSchema: z.ZodObject<{
19
19
  idc_region: z.ZodOptional<z.ZodEnum<["us-east-1", "us-east-2", "us-west-1", "us-west-2", "af-south-1", "ap-east-1", "ap-south-2", "ap-southeast-3", "ap-southeast-5", "ap-southeast-4", "ap-south-1", "ap-southeast-6", "ap-northeast-3", "ap-northeast-2", "ap-southeast-1", "ap-southeast-2", "ap-east-2", "ap-southeast-7", "ap-northeast-1", "ca-central-1", "ca-west-1", "eu-central-1", "eu-west-1", "eu-west-2", "eu-south-1", "eu-west-3", "eu-south-2", "eu-north-1", "eu-central-2", "il-central-1", "mx-central-1", "me-south-1", "me-central-1", "sa-east-1"]>>;
20
20
  idc_profile_arn: z.ZodOptional<z.ZodString>;
21
21
  account_selection_strategy: z.ZodDefault<z.ZodEnum<["sticky", "round-robin", "lowest-usage"]>>;
22
+ /**
23
+ * Give each process a distinct start index so simultaneous starts spread
24
+ * across accounts.
25
+ */
26
+ distribute_across_processes: z.ZodDefault<z.ZodBoolean>;
27
+ /**
28
+ * Re-pick the lowest-usage account for every request instead of pinning one.
29
+ * Overrides sticky selection.
30
+ */
31
+ per_request_spread: z.ZodDefault<z.ZodBoolean>;
22
32
  /**
23
33
  * Softly avoid accounts whose usage ratio is at/above
24
34
  * `quota_reserve_threshold` when other accounts still have room. When ALL
@@ -84,6 +94,8 @@ export declare const KiroConfigSchema: z.ZodObject<{
84
94
  auto_effort_mapping: z.ZodDefault<z.ZodBoolean>;
85
95
  }, "strip", z.ZodTypeAny, {
86
96
  account_selection_strategy: "sticky" | "round-robin" | "lowest-usage";
97
+ distribute_across_processes: boolean;
98
+ per_request_spread: boolean;
87
99
  quota_avoidance_enabled: boolean;
88
100
  quota_reserve_threshold: number;
89
101
  stop_on_overage: boolean;
@@ -115,6 +127,8 @@ export declare const KiroConfigSchema: z.ZodObject<{
115
127
  idc_region?: "us-east-1" | "us-east-2" | "us-west-1" | "us-west-2" | "af-south-1" | "ap-east-1" | "ap-south-2" | "ap-southeast-3" | "ap-southeast-5" | "ap-southeast-4" | "ap-south-1" | "ap-southeast-6" | "ap-northeast-3" | "ap-northeast-2" | "ap-southeast-1" | "ap-southeast-2" | "ap-east-2" | "ap-southeast-7" | "ap-northeast-1" | "ca-central-1" | "ca-west-1" | "eu-central-1" | "eu-west-1" | "eu-west-2" | "eu-south-1" | "eu-west-3" | "eu-south-2" | "eu-north-1" | "eu-central-2" | "il-central-1" | "mx-central-1" | "me-south-1" | "me-central-1" | "sa-east-1" | undefined;
116
128
  idc_profile_arn?: string | undefined;
117
129
  account_selection_strategy?: "sticky" | "round-robin" | "lowest-usage" | undefined;
130
+ distribute_across_processes?: boolean | undefined;
131
+ per_request_spread?: boolean | undefined;
118
132
  quota_avoidance_enabled?: boolean | undefined;
119
133
  quota_reserve_threshold?: number | undefined;
120
134
  stop_on_overage?: boolean | undefined;
@@ -51,6 +51,16 @@ export const KiroConfigSchema = z.object({
51
51
  idc_region: RegionSchema.optional(),
52
52
  idc_profile_arn: z.string().optional(),
53
53
  account_selection_strategy: AccountSelectionStrategySchema.default('lowest-usage'),
54
+ /**
55
+ * Give each process a distinct start index so simultaneous starts spread
56
+ * across accounts.
57
+ */
58
+ distribute_across_processes: z.boolean().default(true),
59
+ /**
60
+ * Re-pick the lowest-usage account for every request instead of pinning one.
61
+ * Overrides sticky selection.
62
+ */
63
+ per_request_spread: z.boolean().default(false),
54
64
  /**
55
65
  * Softly avoid accounts whose usage ratio is at/above
56
66
  * `quota_reserve_threshold` when other accounts still have room. When ALL
@@ -117,6 +127,8 @@ export const KiroConfigSchema = z.object({
117
127
  });
118
128
  export const DEFAULT_CONFIG = {
119
129
  account_selection_strategy: 'lowest-usage',
130
+ distribute_across_processes: true,
131
+ per_request_spread: false,
120
132
  quota_avoidance_enabled: true,
121
133
  quota_reserve_threshold: 0.95,
122
134
  stop_on_overage: true,
@@ -1,6 +1,7 @@
1
1
  import type { ManagedAccount } from '../types.js';
2
2
  export { getKeepAliveLockPath, getRefreshLockPath } from '../paths.js';
3
3
  type LockRelease = () => Promise<void>;
4
+ export declare function withDatabaseLockSync<T>(dbPath: string, fn: () => T): T;
4
5
  export declare function withDatabaseLock<T>(dbPath: string, fn: () => Promise<T>): Promise<T>;
5
6
  export declare function withRefreshLock<T>(accountId: string, fn: () => Promise<T>): Promise<T>;
6
7
  export declare function tryAcquireKeepAliveLock(): Promise<LockRelease | null>;
@@ -1,5 +1,5 @@
1
1
  import { createHash } from 'node:crypto';
2
- import { existsSync, promises as fs } from 'node:fs';
2
+ import { existsSync, promises as fs, mkdirSync, writeFileSync } from 'node:fs';
3
3
  import { dirname } from 'node:path';
4
4
  import lockfile from 'proper-lockfile';
5
5
  import { isPermanentError } from '../health.js';
@@ -30,6 +30,47 @@ const KEEP_ALIVE_LOCK_OPTIONS = {
30
30
  retries: 0,
31
31
  realpath: false
32
32
  };
33
+ const SYNC_LOCK_OPTIONS = { stale: 10000, retries: 0, realpath: false };
34
+ const SYNC_LOCK_DEADLINE_MS = 10000;
35
+ function blockingBackoff(ms) {
36
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
37
+ }
38
+ function isLockContention(e) {
39
+ return typeof e === 'object' && e !== null && 'code' in e && e.code === 'ELOCKED';
40
+ }
41
+ export function withDatabaseLockSync(dbPath, fn) {
42
+ if (!existsSync(dbPath)) {
43
+ mkdirSync(dirname(dbPath), { recursive: true });
44
+ writeFileSync(dbPath, '');
45
+ }
46
+ // proper-lockfile's sync API rejects a retries object, so bounded lock-acquisition backoff is
47
+ // done here to serialize the synchronous constructor/init (schema + migrations) across processes.
48
+ const deadline = Date.now() + SYNC_LOCK_DEADLINE_MS;
49
+ let release = null;
50
+ let attempt = 0;
51
+ for (;;) {
52
+ try {
53
+ release = lockfile.lockSync(dbPath, SYNC_LOCK_OPTIONS);
54
+ break;
55
+ }
56
+ catch (e) {
57
+ if (!isLockContention(e) || Date.now() >= deadline)
58
+ throw e;
59
+ blockingBackoff(Math.min(100 * 2 ** attempt++, 500));
60
+ }
61
+ }
62
+ try {
63
+ return fn();
64
+ }
65
+ finally {
66
+ try {
67
+ release();
68
+ }
69
+ catch (e) {
70
+ console.warn('Failed to release lock:', e);
71
+ }
72
+ }
73
+ }
33
74
  export async function withDatabaseLock(dbPath, fn) {
34
75
  if (!existsSync(dbPath)) {
35
76
  const dir = dbPath.substring(0, dbPath.lastIndexOf('/'));
@@ -7,15 +7,22 @@ export function runMigrations(db) {
7
7
  migrateDropRefreshTokenUniqueIndex(db);
8
8
  migrateRemovedAccountsTable(db);
9
9
  migrateOverageColumn(db);
10
+ migratePluginMetaTable(db);
10
11
  }
11
12
  function migrateToUniqueRefreshToken(db) {
12
- const hasIndex = db
13
- .prepare("SELECT name FROM sqlite_master WHERE type='index' AND name='idx_refresh_token_unique'")
14
- .get();
15
- if (hasIndex)
13
+ const indexProbe = db.prepare("SELECT name FROM sqlite_master WHERE type='index' AND name='idx_refresh_token_unique'");
14
+ if (indexProbe.get())
16
15
  return;
17
- db.exec('BEGIN TRANSACTION');
16
+ // BEGIN IMMEDIATE (not deferred BEGIN): a deferred read-then-write txn raises unrecoverable
17
+ // SQLITE_BUSY_SNAPSHOT (busy_timeout cannot retry it) if another connection writes after the
18
+ // read snapshot. Taking the write lock at BEGIN makes concurrent processes serialize via
19
+ // busy_timeout instead of racing into a snapshot conflict.
20
+ db.exec('BEGIN IMMEDIATE');
18
21
  try {
22
+ if (indexProbe.get()) {
23
+ db.exec('COMMIT');
24
+ return;
25
+ }
19
26
  const duplicates = db
20
27
  .prepare(`
21
28
  SELECT refresh_token, COUNT(*) as count
@@ -167,3 +174,6 @@ function migrateOverageColumn(db) {
167
174
  db.exec('ALTER TABLE accounts ADD COLUMN overage_count INTEGER DEFAULT 0');
168
175
  }
169
176
  }
177
+ function migratePluginMetaTable(db) {
178
+ db.exec('CREATE TABLE IF NOT EXISTS plugin_meta (key TEXT PRIMARY KEY, value INTEGER NOT NULL)');
179
+ }
@@ -18,6 +18,7 @@ export declare class KiroDatabase {
18
18
  isAccountRemoved(id: string): Promise<boolean>;
19
19
  clearRemovedAccount(id: string): Promise<void>;
20
20
  listRemovedAccounts(): Promise<string[]>;
21
+ nextAssignmentIndex(): Promise<number>;
21
22
  markAccountsUnhealthy(ids: string[], reason: string): Promise<void>;
22
23
  private rowToAccount;
23
24
  close(): void;
@@ -2,7 +2,7 @@ import Database from 'libsql';
2
2
  import { existsSync, mkdirSync } from 'node:fs';
3
3
  import { homedir } from 'node:os';
4
4
  import { join } from 'node:path';
5
- import { deduplicateAccounts, mergeAccounts, withDatabaseLock } from './locked-operations.js';
5
+ import { deduplicateAccounts, mergeAccounts, withDatabaseLock, withDatabaseLockSync } from './locked-operations.js';
6
6
  import { runMigrations } from './migrations.js';
7
7
  function getBaseDir() {
8
8
  const p = process.platform;
@@ -21,7 +21,7 @@ export class KiroDatabase {
21
21
  mkdirSync(dir, { recursive: true });
22
22
  this.db = new Database(path);
23
23
  this.db.pragma('busy_timeout = 5000');
24
- this.init();
24
+ withDatabaseLockSync(this.path, () => this.init());
25
25
  }
26
26
  init() {
27
27
  this.db.pragma('journal_mode = WAL');
@@ -185,6 +185,14 @@ export class KiroDatabase {
185
185
  async listRemovedAccounts() {
186
186
  return this.db.prepare('SELECT id FROM removed_accounts').all().map((r) => r.id);
187
187
  }
188
+ async nextAssignmentIndex() {
189
+ return withDatabaseLock(this.path, async () => {
190
+ const row = this.db
191
+ .prepare("INSERT INTO plugin_meta(key,value) VALUES('assignment_cursor',0) ON CONFLICT(key) DO UPDATE SET value=value+1 RETURNING value")
192
+ .get();
193
+ return row?.value ?? 0;
194
+ });
195
+ }
188
196
  async markAccountsUnhealthy(ids, reason) {
189
197
  if (ids.length === 0)
190
198
  return;
package/dist/plugin.js CHANGED
@@ -52,7 +52,9 @@ export const createKiroPlugin = (id) => async ({ client, directory }) => {
52
52
  quotaAvoidanceEnabled: config.quota_avoidance_enabled,
53
53
  quotaReserveThreshold: config.quota_reserve_threshold,
54
54
  stopOnOverage: config.stop_on_overage,
55
- overageThreshold: config.overage_threshold
55
+ overageThreshold: config.overage_threshold,
56
+ distributeAcrossProcesses: config.distribute_across_processes,
57
+ perRequestSpread: config.per_request_spread
56
58
  });
57
59
  authHandler.setAccountManager(accountManager);
58
60
  const requestHandler = new RequestHandler(accountManager, config, repository, client);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sunerpy/opencode-kiro-auth",
3
- "version": "0.10.0",
3
+ "version": "0.11.0",
4
4
  "description": "OpenCode plugin for AWS Kiro (CodeWhisperer) providing access to Claude models",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",