@zhafron/opencode-kiro-auth 1.1.3 → 1.2.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.
package/README.md CHANGED
@@ -9,6 +9,9 @@ OpenCode plugin for AWS Kiro (CodeWhisperer) providing access to the latest Clau
9
9
  - Automated token refresh and rate limit handling with exponential backoff.
10
10
  - Native thinking mode support via virtual model mappings.
11
11
  - Decoupled storage for credentials and real-time usage metadata.
12
+ - Configurable request timeout and iteration limits to prevent hangs.
13
+ - Automatic port selection for auth server to avoid conflicts.
14
+ - Usage tracking with automatic retry on sync failures.
12
15
 
13
16
  ## Installation
14
17
 
@@ -68,12 +71,71 @@ Add the plugin to your `opencode.json` or `opencode.jsonc`:
68
71
  3. Follow the terminal instructions to complete the AWS Builder ID authentication.
69
72
  4. Configuration template will be automatically created at `~/.config/opencode/kiro.json` on first load.
70
73
 
74
+ ## Configuration
75
+
76
+ The plugin supports extensive configuration options. Edit `~/.config/opencode/kiro.json`:
77
+
78
+ ```json
79
+ {
80
+ "account_selection_strategy": "lowest-usage",
81
+ "default_region": "us-east-1",
82
+ "rate_limit_retry_delay_ms": 5000,
83
+ "rate_limit_max_retries": 3,
84
+ "max_request_iterations": 100,
85
+ "request_timeout_ms": 300000,
86
+ "token_expiry_buffer_ms": 120000,
87
+ "usage_sync_max_retries": 3,
88
+ "auth_server_port_start": 19847,
89
+ "auth_server_port_range": 10,
90
+ "usage_tracking_enabled": true,
91
+ "enable_log_api_request": false
92
+ }
93
+ ```
94
+
95
+ ### Configuration Options
96
+
97
+ - `account_selection_strategy`: Account rotation strategy (`sticky`, `round-robin`, `lowest-usage`)
98
+ - `default_region`: AWS region (`us-east-1`, `us-west-2`)
99
+ - `rate_limit_retry_delay_ms`: Delay between rate limit retries (1000-60000ms)
100
+ - `rate_limit_max_retries`: Maximum retry attempts for rate limits (0-10)
101
+ - `max_request_iterations`: Maximum loop iterations to prevent hangs (10-1000)
102
+ - `request_timeout_ms`: Request timeout in milliseconds (60000-600000ms)
103
+ - `token_expiry_buffer_ms`: Token refresh buffer time (30000-300000ms)
104
+ - `usage_sync_max_retries`: Retry attempts for usage sync (0-5)
105
+ - `auth_server_port_start`: Starting port for auth server (1024-65535)
106
+ - `auth_server_port_range`: Number of ports to try (1-100)
107
+ - `usage_tracking_enabled`: Enable usage tracking and toast notifications
108
+ - `enable_log_api_request`: Enable detailed API request logging
109
+
110
+ ### Environment Variables
111
+
112
+ All configuration options can be overridden via environment variables:
113
+
114
+ - `KIRO_ACCOUNT_SELECTION_STRATEGY`
115
+ - `KIRO_DEFAULT_REGION`
116
+ - `KIRO_RATE_LIMIT_RETRY_DELAY_MS`
117
+ - `KIRO_RATE_LIMIT_MAX_RETRIES`
118
+ - `KIRO_MAX_REQUEST_ITERATIONS`
119
+ - `KIRO_REQUEST_TIMEOUT_MS`
120
+ - `KIRO_TOKEN_EXPIRY_BUFFER_MS`
121
+ - `KIRO_USAGE_SYNC_MAX_RETRIES`
122
+ - `KIRO_AUTH_SERVER_PORT_START`
123
+ - `KIRO_AUTH_SERVER_PORT_RANGE`
124
+ - `KIRO_USAGE_TRACKING_ENABLED`
125
+ - `KIRO_ENABLE_LOG_API_REQUEST`
126
+
71
127
  ## Storage
72
128
 
129
+ **Linux/macOS:**
73
130
  - Credentials: `~/.config/opencode/kiro-accounts.json`
74
131
  - Usage Tracking: `~/.config/opencode/kiro-usage.json`
75
132
  - Plugin Config: `~/.config/opencode/kiro.json`
76
133
 
134
+ **Windows:**
135
+ - Credentials: `%APPDATA%\opencode\kiro-accounts.json`
136
+ - Usage Tracking: `%APPDATA%\opencode\kiro-usage.json`
137
+ - Plugin Config: `%APPDATA%\opencode\kiro.json`
138
+
77
139
  ## Acknowledgements
78
140
 
79
141
  Special thanks to [AIClient-2-API](https://github.com/justlovemaki/AIClient-2-API) for providing the foundational Kiro authentication logic and request patterns.
@@ -9,7 +9,6 @@ export declare const KIRO_CONSTANTS: {
9
9
  BASE_URL: string;
10
10
  USAGE_LIMITS_URL: string;
11
11
  DEFAULT_REGION: KiroRegion;
12
- ACCESS_TOKEN_EXPIRY_BUFFER_MS: number;
13
12
  AXIOS_TIMEOUT: number;
14
13
  USER_AGENT: string;
15
14
  KIRO_VERSION: string;
package/dist/constants.js CHANGED
@@ -33,7 +33,6 @@ export const KIRO_CONSTANTS = {
33
33
  BASE_URL: 'https://q.{{region}}.amazonaws.com/generateAssistantResponse',
34
34
  USAGE_LIMITS_URL: 'https://q.{{region}}.amazonaws.com/getUsageLimits',
35
35
  DEFAULT_REGION: 'us-east-1',
36
- ACCESS_TOKEN_EXPIRY_BUFFER_MS: 60000,
37
36
  AXIOS_TIMEOUT: 120000,
38
37
  USER_AGENT: 'KiroIDE',
39
38
  KIRO_VERSION: '0.7.5',
@@ -1,5 +1,5 @@
1
1
  import type { KiroAuthDetails, RefreshParts } from '../plugin/types';
2
2
  export declare function decodeRefreshToken(refresh: string): RefreshParts;
3
- export declare function accessTokenExpired(auth: KiroAuthDetails): boolean;
3
+ export declare function accessTokenExpired(auth: KiroAuthDetails, bufferMs?: number): boolean;
4
4
  export declare function validateAuthDetails(auth: KiroAuthDetails): boolean;
5
5
  export declare function encodeRefreshToken(parts: RefreshParts): string;
package/dist/kiro/auth.js CHANGED
@@ -1,4 +1,3 @@
1
- import { KIRO_CONSTANTS } from '../constants';
2
1
  export function decodeRefreshToken(refresh) {
3
2
  const parts = refresh.split('|');
4
3
  if (parts.length < 2)
@@ -9,10 +8,10 @@ export function decodeRefreshToken(refresh) {
9
8
  return { refreshToken, clientId: parts[1], clientSecret: parts[2], authMethod: 'idc' };
10
9
  return { refreshToken, authMethod: 'idc' };
11
10
  }
12
- export function accessTokenExpired(auth) {
11
+ export function accessTokenExpired(auth, bufferMs = 120000) {
13
12
  if (!auth.access || !auth.expires)
14
13
  return true;
15
- return Date.now() >= auth.expires - KIRO_CONSTANTS.ACCESS_TOKEN_EXPIRY_BUFFER_MS;
14
+ return Date.now() >= auth.expires - bufferMs;
16
15
  }
17
16
  export function validateAuthDetails(auth) {
18
17
  return !!auth.refresh && auth.authMethod === 'idc' && !!auth.clientId && !!auth.clientSecret;
@@ -111,9 +111,20 @@ export class AccountManager {
111
111
  this.accounts[i] = a;
112
112
  }
113
113
  removeAccount(a) {
114
+ const removedIndex = this.accounts.findIndex((x) => x.id === a.id);
115
+ if (removedIndex === -1)
116
+ return;
114
117
  this.accounts = this.accounts.filter((x) => x.id !== a.id);
115
118
  delete this.usage[a.id];
116
- this.cursor = Math.max(0, Math.min(this.cursor, this.accounts.length - 1));
119
+ if (this.accounts.length === 0) {
120
+ this.cursor = 0;
121
+ }
122
+ else if (this.cursor >= this.accounts.length) {
123
+ this.cursor = this.accounts.length - 1;
124
+ }
125
+ else if (removedIndex <= this.cursor && this.cursor > 0) {
126
+ this.cursor--;
127
+ }
117
128
  }
118
129
  updateFromAuth(a, auth) {
119
130
  const acc = this.accounts.find((x) => x.id === a.id);
@@ -87,11 +87,6 @@ function applyEnvOverrides(config) {
87
87
  const env = process.env;
88
88
  return {
89
89
  ...config,
90
- session_recovery: parseBooleanEnv(env.KIRO_SESSION_RECOVERY, config.session_recovery),
91
- auto_resume: parseBooleanEnv(env.KIRO_AUTO_RESUME, config.auto_resume),
92
- proactive_token_refresh: parseBooleanEnv(env.KIRO_PROACTIVE_TOKEN_REFRESH, config.proactive_token_refresh),
93
- token_refresh_interval_seconds: parseNumberEnv(env.KIRO_TOKEN_REFRESH_INTERVAL_SECONDS, config.token_refresh_interval_seconds),
94
- token_refresh_buffer_seconds: parseNumberEnv(env.KIRO_TOKEN_REFRESH_BUFFER_SECONDS, config.token_refresh_buffer_seconds),
95
90
  account_selection_strategy: env.KIRO_ACCOUNT_SELECTION_STRATEGY
96
91
  ? AccountSelectionStrategySchema.catch('lowest-usage').parse(env.KIRO_ACCOUNT_SELECTION_STRATEGY)
97
92
  : config.account_selection_strategy,
@@ -100,7 +95,14 @@ function applyEnvOverrides(config) {
100
95
  : config.default_region,
101
96
  rate_limit_retry_delay_ms: parseNumberEnv(env.KIRO_RATE_LIMIT_RETRY_DELAY_MS, config.rate_limit_retry_delay_ms),
102
97
  rate_limit_max_retries: parseNumberEnv(env.KIRO_RATE_LIMIT_MAX_RETRIES, config.rate_limit_max_retries),
103
- usage_tracking_enabled: parseBooleanEnv(env.KIRO_USAGE_TRACKING_ENABLED, config.usage_tracking_enabled)
98
+ max_request_iterations: parseNumberEnv(env.KIRO_MAX_REQUEST_ITERATIONS, config.max_request_iterations),
99
+ request_timeout_ms: parseNumberEnv(env.KIRO_REQUEST_TIMEOUT_MS, config.request_timeout_ms),
100
+ token_expiry_buffer_ms: parseNumberEnv(env.KIRO_TOKEN_EXPIRY_BUFFER_MS, config.token_expiry_buffer_ms),
101
+ usage_sync_max_retries: parseNumberEnv(env.KIRO_USAGE_SYNC_MAX_RETRIES, config.usage_sync_max_retries),
102
+ auth_server_port_start: parseNumberEnv(env.KIRO_AUTH_SERVER_PORT_START, config.auth_server_port_start),
103
+ auth_server_port_range: parseNumberEnv(env.KIRO_AUTH_SERVER_PORT_RANGE, config.auth_server_port_range),
104
+ usage_tracking_enabled: parseBooleanEnv(env.KIRO_USAGE_TRACKING_ENABLED, config.usage_tracking_enabled),
105
+ enable_log_api_request: parseBooleanEnv(env.KIRO_ENABLE_LOG_API_REQUEST, config.enable_log_api_request)
104
106
  };
105
107
  }
106
108
  export function loadConfig(directory) {
@@ -5,41 +5,44 @@ export declare const RegionSchema: z.ZodEnum<["us-east-1", "us-west-2"]>;
5
5
  export type Region = z.infer<typeof RegionSchema>;
6
6
  export declare const KiroConfigSchema: z.ZodObject<{
7
7
  $schema: z.ZodOptional<z.ZodString>;
8
- session_recovery: z.ZodDefault<z.ZodBoolean>;
9
- auto_resume: z.ZodDefault<z.ZodBoolean>;
10
- proactive_token_refresh: z.ZodDefault<z.ZodBoolean>;
11
- token_refresh_interval_seconds: z.ZodDefault<z.ZodNumber>;
12
- token_refresh_buffer_seconds: z.ZodDefault<z.ZodNumber>;
13
8
  account_selection_strategy: z.ZodDefault<z.ZodEnum<["sticky", "round-robin", "lowest-usage"]>>;
14
9
  default_region: z.ZodDefault<z.ZodEnum<["us-east-1", "us-west-2"]>>;
15
10
  rate_limit_retry_delay_ms: z.ZodDefault<z.ZodNumber>;
16
11
  rate_limit_max_retries: z.ZodDefault<z.ZodNumber>;
12
+ max_request_iterations: z.ZodDefault<z.ZodNumber>;
13
+ request_timeout_ms: z.ZodDefault<z.ZodNumber>;
14
+ token_expiry_buffer_ms: z.ZodDefault<z.ZodNumber>;
15
+ usage_sync_max_retries: z.ZodDefault<z.ZodNumber>;
16
+ auth_server_port_start: z.ZodDefault<z.ZodNumber>;
17
+ auth_server_port_range: z.ZodDefault<z.ZodNumber>;
17
18
  usage_tracking_enabled: z.ZodDefault<z.ZodBoolean>;
18
19
  enable_log_api_request: z.ZodDefault<z.ZodBoolean>;
19
20
  }, "strip", z.ZodTypeAny, {
20
- session_recovery: boolean;
21
- auto_resume: boolean;
22
- proactive_token_refresh: boolean;
23
- token_refresh_interval_seconds: number;
24
- token_refresh_buffer_seconds: number;
25
21
  account_selection_strategy: "sticky" | "round-robin" | "lowest-usage";
26
22
  default_region: "us-east-1" | "us-west-2";
27
23
  rate_limit_retry_delay_ms: number;
28
24
  rate_limit_max_retries: number;
25
+ max_request_iterations: number;
26
+ request_timeout_ms: number;
27
+ token_expiry_buffer_ms: number;
28
+ usage_sync_max_retries: number;
29
+ auth_server_port_start: number;
30
+ auth_server_port_range: number;
29
31
  usage_tracking_enabled: boolean;
30
32
  enable_log_api_request: boolean;
31
33
  $schema?: string | undefined;
32
34
  }, {
33
35
  $schema?: string | undefined;
34
- session_recovery?: boolean | undefined;
35
- auto_resume?: boolean | undefined;
36
- proactive_token_refresh?: boolean | undefined;
37
- token_refresh_interval_seconds?: number | undefined;
38
- token_refresh_buffer_seconds?: number | undefined;
39
36
  account_selection_strategy?: "sticky" | "round-robin" | "lowest-usage" | undefined;
40
37
  default_region?: "us-east-1" | "us-west-2" | undefined;
41
38
  rate_limit_retry_delay_ms?: number | undefined;
42
39
  rate_limit_max_retries?: number | undefined;
40
+ max_request_iterations?: number | undefined;
41
+ request_timeout_ms?: number | undefined;
42
+ token_expiry_buffer_ms?: number | undefined;
43
+ usage_sync_max_retries?: number | undefined;
44
+ auth_server_port_start?: number | undefined;
45
+ auth_server_port_range?: number | undefined;
43
46
  usage_tracking_enabled?: boolean | undefined;
44
47
  enable_log_api_request?: boolean | undefined;
45
48
  }>;
@@ -3,28 +3,30 @@ export const AccountSelectionStrategySchema = z.enum(['sticky', 'round-robin', '
3
3
  export const RegionSchema = z.enum(['us-east-1', 'us-west-2']);
4
4
  export const KiroConfigSchema = z.object({
5
5
  $schema: z.string().optional(),
6
- session_recovery: z.boolean().default(true),
7
- auto_resume: z.boolean().default(true),
8
- proactive_token_refresh: z.boolean().default(true),
9
- token_refresh_interval_seconds: z.number().min(60).max(3600).default(300),
10
- token_refresh_buffer_seconds: z.number().min(60).max(1800).default(600),
11
6
  account_selection_strategy: AccountSelectionStrategySchema.default('lowest-usage'),
12
7
  default_region: RegionSchema.default('us-east-1'),
13
8
  rate_limit_retry_delay_ms: z.number().min(1000).max(60000).default(5000),
14
9
  rate_limit_max_retries: z.number().min(0).max(10).default(3),
10
+ max_request_iterations: z.number().min(10).max(1000).default(100),
11
+ request_timeout_ms: z.number().min(60000).max(600000).default(300000),
12
+ token_expiry_buffer_ms: z.number().min(30000).max(300000).default(120000),
13
+ usage_sync_max_retries: z.number().min(0).max(5).default(3),
14
+ auth_server_port_start: z.number().min(1024).max(65535).default(19847),
15
+ auth_server_port_range: z.number().min(1).max(100).default(10),
15
16
  usage_tracking_enabled: z.boolean().default(true),
16
17
  enable_log_api_request: z.boolean().default(false)
17
18
  });
18
19
  export const DEFAULT_CONFIG = {
19
- session_recovery: true,
20
- auto_resume: true,
21
- proactive_token_refresh: true,
22
- token_refresh_interval_seconds: 300,
23
- token_refresh_buffer_seconds: 600,
24
20
  account_selection_strategy: 'lowest-usage',
25
21
  default_region: 'us-east-1',
26
22
  rate_limit_retry_delay_ms: 5000,
27
23
  rate_limit_max_retries: 3,
24
+ max_request_iterations: 100,
25
+ request_timeout_ms: 300000,
26
+ token_expiry_buffer_ms: 120000,
27
+ usage_sync_max_retries: 3,
28
+ auth_server_port_start: 19847,
29
+ auth_server_port_range: 10,
28
30
  usage_tracking_enabled: true,
29
31
  enable_log_api_request: false
30
32
  };
@@ -18,7 +18,7 @@ export interface IDCAuthData {
18
18
  expiresIn: number;
19
19
  region: KiroRegion;
20
20
  }
21
- export declare function startIDCAuthServer(authData: IDCAuthData, port?: number): Promise<{
21
+ export declare function startIDCAuthServer(authData: IDCAuthData, startPort?: number, portRange?: number): Promise<{
22
22
  url: string;
23
23
  waitForAuth: () => Promise<KiroIDCTokenResult>;
24
24
  }>;
@@ -1,8 +1,38 @@
1
1
  import { createServer } from 'node:http';
2
2
  import { getIDCAuthHtml, getSuccessHtml, getErrorHtml } from './auth-page';
3
3
  import * as logger from './logger';
4
- export function startIDCAuthServer(authData, port = 19847) {
5
- return new Promise((resolve, reject) => {
4
+ async function tryPort(port) {
5
+ return new Promise((resolve) => {
6
+ const testServer = createServer();
7
+ testServer.once('error', () => resolve(false));
8
+ testServer.once('listening', () => {
9
+ testServer.close();
10
+ resolve(true);
11
+ });
12
+ testServer.listen(port, '127.0.0.1');
13
+ });
14
+ }
15
+ async function findAvailablePort(startPort, range) {
16
+ for (let i = 0; i < range; i++) {
17
+ const port = startPort + i;
18
+ const available = await tryPort(port);
19
+ if (available)
20
+ return port;
21
+ }
22
+ throw new Error(`No available ports in range ${startPort}-${startPort + range - 1}. Please close other applications using these ports.`);
23
+ }
24
+ export async function startIDCAuthServer(authData, startPort = 19847, portRange = 10) {
25
+ return new Promise(async (resolve, reject) => {
26
+ let port;
27
+ try {
28
+ port = await findAvailablePort(startPort, portRange);
29
+ logger.log(`Auth server will use port ${port}`);
30
+ }
31
+ catch (error) {
32
+ logger.error('Failed to find available port', error);
33
+ reject(error);
34
+ return;
35
+ }
6
36
  let server = null;
7
37
  let timeoutId = null;
8
38
  let resolver = null;
@@ -20,25 +50,46 @@ export function startIDCAuthServer(authData, port = 19847) {
20
50
  };
21
51
  const poll = async () => {
22
52
  try {
23
- const body = new URLSearchParams({
24
- grant_type: 'urn:ietf:params:oauth:grant-type:device_code',
25
- device_code: authData.deviceCode,
26
- client_id: authData.clientId,
27
- client_secret: authData.clientSecret
28
- });
53
+ const body = {
54
+ grantType: 'urn:ietf:params:oauth:grant-type:device_code',
55
+ deviceCode: authData.deviceCode,
56
+ clientId: authData.clientId,
57
+ clientSecret: authData.clientSecret
58
+ };
29
59
  const res = await fetch(`https://oidc.${authData.region}.amazonaws.com/token`, {
30
60
  method: 'POST',
31
- headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
32
- body: body.toString()
61
+ headers: { 'Content-Type': 'application/json' },
62
+ body: JSON.stringify(body)
33
63
  });
34
- const d = await res.json();
64
+ const responseText = await res.text();
65
+ let d = {};
66
+ if (responseText) {
67
+ try {
68
+ d = JSON.parse(responseText);
69
+ }
70
+ catch (parseError) {
71
+ logger.error(`Auth polling error: Failed to parse JSON (status ${res.status})`, parseError);
72
+ throw parseError;
73
+ }
74
+ }
35
75
  if (res.ok) {
36
- const acc = d.access_token, ref = d.refresh_token, exp = Date.now() + d.expires_in * 1000;
37
- const infoRes = await fetch('https://view.awsapps.com/api/user/info', {
38
- headers: { Authorization: `Bearer ${acc}` }
39
- });
40
- const info = await infoRes.json();
41
- const email = info.email || info.userName || 'builder-id@aws.amazon.com';
76
+ const acc = d.access_token || d.accessToken, ref = d.refresh_token || d.refreshToken, exp = Date.now() + (d.expires_in || d.expiresIn || 0) * 1000;
77
+ let email = 'builder-id@aws.amazon.com';
78
+ try {
79
+ const infoRes = await fetch('https://view.awsapps.com/api/user/info', {
80
+ headers: { Authorization: `Bearer ${acc}` }
81
+ });
82
+ if (infoRes.ok) {
83
+ const info = await infoRes.json();
84
+ email = info.email || info.userName || email;
85
+ }
86
+ else {
87
+ logger.warn(`User info request failed with status ${infoRes.status}; using fallback email`);
88
+ }
89
+ }
90
+ catch (infoError) {
91
+ logger.warn(`Failed to fetch user info; using fallback email: ${infoError?.message || infoError}`);
92
+ }
42
93
  status.status = 'success';
43
94
  if (resolver)
44
95
  resolver({
@@ -57,7 +108,7 @@ export function startIDCAuthServer(authData, port = 19847) {
57
108
  else {
58
109
  status.status = 'failed';
59
110
  status.error = d.error_description || d.error;
60
- logger.error(`Auth polling failed: ${status.error}`);
111
+ logger.error(`Auth polling failed a: ${status.error}`);
61
112
  if (rejector)
62
113
  rejector(new Error(status.error));
63
114
  setTimeout(cleanup, 2000);
@@ -66,7 +117,7 @@ export function startIDCAuthServer(authData, port = 19847) {
66
117
  catch (e) {
67
118
  status.status = 'failed';
68
119
  status.error = e.message;
69
- logger.error(`Auth polling error: ${e.message}`, e);
120
+ logger.error(`Auth polling error b: ${e.message}`, e);
70
121
  if (rejector)
71
122
  rejector(e);
72
123
  setTimeout(cleanup, 2000);
@@ -63,13 +63,20 @@ async function withLock(path, fn) {
63
63
  }
64
64
  }
65
65
  export async function loadAccounts() {
66
- try {
67
- const content = await fs.readFile(getStoragePath(), 'utf-8');
68
- return JSON.parse(content);
69
- }
70
- catch {
71
- return { version: 1, accounts: [], activeIndex: -1 };
72
- }
66
+ const path = getStoragePath();
67
+ return withLock(path, async () => {
68
+ try {
69
+ const content = await fs.readFile(path, 'utf-8');
70
+ const parsed = JSON.parse(content);
71
+ if (!parsed || !Array.isArray(parsed.accounts)) {
72
+ return { version: 1, accounts: [], activeIndex: -1 };
73
+ }
74
+ return parsed;
75
+ }
76
+ catch {
77
+ return { version: 1, accounts: [], activeIndex: -1 };
78
+ }
79
+ });
73
80
  }
74
81
  export async function saveAccounts(storage) {
75
82
  const path = getStoragePath();
@@ -86,13 +93,20 @@ export async function saveAccounts(storage) {
86
93
  }
87
94
  }
88
95
  export async function loadUsage() {
89
- try {
90
- const content = await fs.readFile(getUsagePath(), 'utf-8');
91
- return JSON.parse(content);
92
- }
93
- catch {
94
- return { version: 1, usage: {} };
95
- }
96
+ const path = getUsagePath();
97
+ return withLock(path, async () => {
98
+ try {
99
+ const content = await fs.readFile(path, 'utf-8');
100
+ const parsed = JSON.parse(content);
101
+ if (!parsed || typeof parsed.usage !== 'object' || parsed.usage === null) {
102
+ return { version: 1, usage: {} };
103
+ }
104
+ return parsed;
105
+ }
106
+ catch {
107
+ return { version: 1, usage: {} };
108
+ }
109
+ });
96
110
  }
97
111
  export async function saveUsage(storage) {
98
112
  const path = getUsagePath();
package/dist/plugin.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { loadConfig } from './plugin/config';
2
+ import { exec } from 'node:child_process';
2
3
  import { AccountManager, generateAccountId } from './plugin/accounts';
3
4
  import { accessTokenExpired, encodeRefreshToken } from './kiro/auth';
4
5
  import { refreshAccessToken } from './plugin/token';
@@ -23,6 +24,19 @@ const formatUsageMessage = (usedCount, limitCount, email) => {
23
24
  }
24
25
  return `Usage (${email}): ${usedCount}`;
25
26
  };
27
+ const openBrowser = (url) => {
28
+ const escapedUrl = url.replace(/"/g, '\\"');
29
+ const platform = process.platform;
30
+ const command = platform === 'win32'
31
+ ? `cmd /c start "" "${escapedUrl}"`
32
+ : platform === 'darwin'
33
+ ? `open "${escapedUrl}"`
34
+ : `xdg-open "${escapedUrl}"`;
35
+ exec(command, (error) => {
36
+ if (error)
37
+ logger.warn(`Failed to open browser automatically: ${error.message}`, error);
38
+ });
39
+ };
26
40
  export const createKiroPlugin = (id) => async ({ client, directory }) => {
27
41
  const config = loadConfig(directory);
28
42
  const showToast = (message, variant) => {
@@ -46,7 +60,19 @@ export const createKiroPlugin = (id) => async ({ client, directory }) => {
46
60
  const think = model.endsWith('-thinking') || !!body.providerOptions?.thinkingConfig;
47
61
  const budget = body.providerOptions?.thinkingConfig?.thinkingBudget || 20000;
48
62
  let retry = 0;
63
+ let iterations = 0;
64
+ const startTime = Date.now();
65
+ const maxIterations = config.max_request_iterations;
66
+ const timeoutMs = config.request_timeout_ms;
49
67
  while (true) {
68
+ iterations++;
69
+ const elapsed = Date.now() - startTime;
70
+ if (iterations > maxIterations) {
71
+ throw new Error(`Request exceeded max iterations (${maxIterations}). All accounts may be unhealthy or rate-limited.`);
72
+ }
73
+ if (elapsed > timeoutMs) {
74
+ throw new Error(`Request timeout after ${Math.ceil(elapsed / 1000)}s. Max timeout: ${Math.ceil(timeoutMs / 1000)}s.`);
75
+ }
50
76
  const count = am.getAccountCount();
51
77
  if (count === 0)
52
78
  throw new Error('No accounts. Login first.');
@@ -67,7 +93,7 @@ export const createKiroPlugin = (id) => async ({ client, directory }) => {
67
93
  showToast(formatUsageMessage(acc.usedCount, acc.limitCount, acc.realEmail || acc.email), variant);
68
94
  }
69
95
  let auth = am.toAuthDetails(acc);
70
- if (accessTokenExpired(auth)) {
96
+ if (accessTokenExpired(auth, config.token_expiry_buffer_ms)) {
71
97
  try {
72
98
  logger.log(`Refreshing token for ${acc.realEmail || acc.email}`);
73
99
  auth = await refreshAccessToken(auth);
@@ -122,13 +148,24 @@ export const createKiroPlugin = (id) => async ({ client, directory }) => {
122
148
  }, apiTimestamp);
123
149
  }
124
150
  if (res.ok) {
125
- if (config.usage_tracking_enabled)
126
- fetchUsageLimits(auth)
127
- .then((u) => {
128
- updateAccountQuota(acc, u, am);
129
- am.saveToDisk();
130
- })
131
- .catch((e) => logger.warn(`Usage sync failed for ${acc.realEmail || acc.email}: ${e.message}`));
151
+ if (config.usage_tracking_enabled) {
152
+ const syncUsage = async (attempt = 0) => {
153
+ try {
154
+ const u = await fetchUsageLimits(auth);
155
+ updateAccountQuota(acc, u, am);
156
+ await am.saveToDisk();
157
+ }
158
+ catch (e) {
159
+ if (attempt < config.usage_sync_max_retries) {
160
+ const delay = 1000 * Math.pow(2, attempt);
161
+ await sleep(delay);
162
+ return syncUsage(attempt + 1);
163
+ }
164
+ logger.warn(`Usage sync failed for ${acc.realEmail || acc.email} after ${attempt + 1} attempts: ${e.message}`);
165
+ }
166
+ };
167
+ syncUsage().catch(() => { });
168
+ }
132
169
  if (prep.streaming) {
133
170
  const s = transformKiroStream(res, model, prep.conversationId);
134
171
  return new Response(new ReadableStream({
@@ -248,10 +285,11 @@ export const createKiroPlugin = (id) => async ({ client, directory }) => {
248
285
  const region = config.default_region;
249
286
  try {
250
287
  const authData = await authorizeKiroIDC(region);
251
- const { url, waitForAuth } = await startIDCAuthServer(authData);
288
+ const { url, waitForAuth } = await startIDCAuthServer(authData, config.auth_server_port_start, config.auth_server_port_range);
289
+ openBrowser(url);
252
290
  resolve({
253
291
  url,
254
- instructions: 'Opening browser...',
292
+ instructions: `Open this URL to continue: ${url}`,
255
293
  method: 'auto',
256
294
  callback: async () => {
257
295
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zhafron/opencode-kiro-auth",
3
- "version": "1.1.3",
3
+ "version": "1.2.1",
4
4
  "description": "OpenCode plugin for AWS Kiro (CodeWhisperer) providing access to Claude models",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",