@sunerpy/opencode-kiro-auth 0.15.1 → 0.15.3

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
@@ -109,6 +109,14 @@ multi-account or long-idle setups, enable
109
109
  (`token_keepalive_enabled: true`) to keep idle accounts' tokens fresh while
110
110
  OpenCode is running.
111
111
 
112
+ For long-running agent tasks that are frequently interrupted by upstream
113
+ `ECONNRESET` event-stream failures, enable
114
+ `"stream_buffer_until_complete": true`. The plugin then withholds a failed
115
+ attempt from OpenCode and safely retries it instead of exposing a partial
116
+ assistant response or partial tool call. See
117
+ [stream recovery configuration](docs/CONFIGURATION.md#options) for the latency
118
+ and quota tradeoffs.
119
+
112
120
  Paid-overage protection is on by default; see
113
121
  [Overage protection](docs/CONFIGURATION.md#overage-protection) before disabling
114
122
  `stop_on_overage`.
@@ -231,6 +239,12 @@ accounts", `/connect` vs `opencode auth login`, and Kiro CLI OAuth users whose
231
239
  sync doesn't start — are covered in
232
240
  [docs/TROUBLESHOOTING.md](docs/TROUBLESHOOTING.md).
233
241
 
242
+ `Kiro upstream event stream failed unexpectedly` means the upstream HTTP 200
243
+ event stream ended before completion metadata. Live-stream mode cannot safely
244
+ replay after output because that could duplicate text or execute a tool twice.
245
+ Enable `stream_buffer_until_complete` when task continuity is more important
246
+ than seeing tokens arrive live.
247
+
234
248
  ## Migration
235
249
 
236
250
  If you're upgrading from a version that used the provider id `kiro` instead of
@@ -1,5 +1,6 @@
1
1
  import type { AccountRepository } from '../../infrastructure/database/account-repository.js';
2
2
  import type { AccountManager } from '../../plugin/accounts.js';
3
+ import { refreshAccessToken } from '../../plugin/token.js';
3
4
  import type { KiroAuthDetails, ManagedAccount } from '../../plugin/types.js';
4
5
  type ToastFunction = (message: string, variant: 'info' | 'warning' | 'success' | 'error') => void;
5
6
  interface TokenRefresherConfig {
@@ -7,6 +8,11 @@ interface TokenRefresherConfig {
7
8
  auto_sync_kiro_cli: boolean;
8
9
  account_selection_strategy: 'sticky' | 'round-robin' | 'lowest-usage';
9
10
  }
11
+ interface TokenRefresherDependencies {
12
+ refreshAccessToken: typeof refreshAccessToken;
13
+ sleep: (delayMs: number) => Promise<void>;
14
+ random: () => number;
15
+ }
10
16
  /** Outcome of a forced refresh; `dead` distinguishes refresh-token-dead
11
17
  * (needs re-login) from a transient failure (network/5xx). */
12
18
  export interface ForceRefreshResult {
@@ -27,8 +33,12 @@ export declare class TokenRefresher {
27
33
  private syncFromKiroCli;
28
34
  private repository;
29
35
  private readonly inFlight;
36
+ private readonly pendingPersistence;
30
37
  private readonly lastDeadToastAt;
31
- constructor(config: TokenRefresherConfig, accountManager: AccountManager, syncFromKiroCli: () => Promise<void>, repository: AccountRepository);
38
+ private readonly refreshAccessToken;
39
+ private readonly sleep;
40
+ private readonly random;
41
+ constructor(config: TokenRefresherConfig, accountManager: AccountManager, syncFromKiroCli: () => Promise<void>, repository: AccountRepository, dependencies?: Partial<TokenRefresherDependencies>);
32
42
  refreshIfNeeded(account: ManagedAccount, auth: KiroAuthDetails, showToast: ToastFunction): Promise<{
33
43
  account: ManagedAccount;
34
44
  shouldContinue: boolean;
@@ -36,7 +46,9 @@ export declare class TokenRefresher {
36
46
  forceRefresh(account: ManagedAccount, showToast: ToastFunction): Promise<ForceRefreshResult>;
37
47
  private startOrJoinRefresh;
38
48
  private runLockedRefresh;
49
+ private persistRefreshedAccount;
39
50
  private readLatestAuth;
51
+ private syncPersistedAccountReference;
40
52
  private handleRefreshError;
41
53
  }
42
54
  export {};
@@ -1,10 +1,24 @@
1
1
  import { accessTokenExpired } from '../../kiro/auth.js';
2
- import { KiroTokenRefreshError } from '../../plugin/errors.js';
2
+ import { KiroTokenRefreshError, TokenPersistenceError } from '../../plugin/errors.js';
3
3
  import { isRefreshTokenDead, toDeadReason } from '../../plugin/health.js';
4
4
  import * as logger from '../../plugin/logger.js';
5
5
  import { withRefreshLock } from '../../plugin/storage/locked-operations.js';
6
6
  import { refreshAccessToken } from '../../plugin/token.js';
7
7
  const DEAD_TOAST_DEBOUNCE_MS = 60000;
8
+ const TOKEN_PERSISTENCE_MAX_ATTEMPTS = 3;
9
+ const TOKEN_PERSISTENCE_RETRY_DELAY_MS = 250;
10
+ function isTransientPersistenceError(error) {
11
+ const code = typeof error === 'object' && error !== null && 'code' in error
12
+ ? String(error.code).toUpperCase()
13
+ : '';
14
+ const message = error instanceof Error ? error.message : String(error);
15
+ return (code === 'SQLITE_BUSY' ||
16
+ code === 'SQLITE_LOCKED' ||
17
+ /SQLITE_(?:BUSY|LOCKED)|database is locked|lock file is already being held/i.test(message));
18
+ }
19
+ function defaultSleep(ms) {
20
+ return new Promise((resolve) => setTimeout(resolve, ms));
21
+ }
8
22
  /**
9
23
  * Decide whether a refresh failure means the refresh token / OIDC client is
10
24
  * dead (permanent, needs re-login) or is merely transient (network/5xx).
@@ -13,6 +27,9 @@ const DEAD_TOAST_DEBOUNCE_MS = 60000;
13
27
  * the stored credentials are unusable, so the account needs a re-login.
14
28
  */
15
29
  export function isRefreshErrorDead(error) {
30
+ if (error instanceof TokenPersistenceError) {
31
+ return false;
32
+ }
16
33
  if (error instanceof KiroTokenRefreshError) {
17
34
  if (error.code === 'MISSING_CREDENTIALS' || error.code === 'INVALID_RESPONSE') {
18
35
  return true;
@@ -39,12 +56,19 @@ export class TokenRefresher {
39
56
  syncFromKiroCli;
40
57
  repository;
41
58
  inFlight = new Map();
59
+ pendingPersistence = new Map();
42
60
  lastDeadToastAt = new Map();
43
- constructor(config, accountManager, syncFromKiroCli, repository) {
61
+ refreshAccessToken;
62
+ sleep;
63
+ random;
64
+ constructor(config, accountManager, syncFromKiroCli, repository, dependencies = {}) {
44
65
  this.config = config;
45
66
  this.accountManager = accountManager;
46
67
  this.syncFromKiroCli = syncFromKiroCli;
47
68
  this.repository = repository;
69
+ this.refreshAccessToken = dependencies.refreshAccessToken ?? refreshAccessToken;
70
+ this.sleep = dependencies.sleep ?? defaultSleep;
71
+ this.random = dependencies.random ?? Math.random;
48
72
  }
49
73
  async refreshIfNeeded(account, auth, showToast) {
50
74
  if (!accessTokenExpired(auth, this.config.token_expiry_buffer_ms)) {
@@ -64,6 +88,10 @@ export class TokenRefresher {
64
88
  return { ok: true, dead: false };
65
89
  }
66
90
  catch (e) {
91
+ if (e instanceof TokenPersistenceError) {
92
+ logger.error('Forced token refresh persistence failed', { email: account.email });
93
+ return { ok: false, dead: false };
94
+ }
67
95
  const dead = isRefreshErrorDead(e);
68
96
  logger.error('Forced token refresh failed', {
69
97
  email: account.email,
@@ -94,16 +122,50 @@ export class TokenRefresher {
94
122
  }
95
123
  async runLockedRefresh(account, getAuthFallback) {
96
124
  await withRefreshLock(account.id, async () => {
97
- const { latestAuth } = await this.readLatestAuth(account);
98
- if (latestAuth && !accessTokenExpired(latestAuth, this.config.token_expiry_buffer_ms)) {
99
- this.accountManager.updateFromAuth(account, latestAuth);
125
+ const { latestAccount, latestAuth } = await this.readLatestAuth(account);
126
+ if (latestAccount &&
127
+ latestAuth &&
128
+ !accessTokenExpired(latestAuth, this.config.token_expiry_buffer_ms)) {
129
+ this.pendingPersistence.delete(account.id);
130
+ this.accountManager.publishAuthCandidate(latestAccount, false);
100
131
  return;
101
132
  }
102
- const newAuth = await refreshAccessToken(latestAuth ?? getAuthFallback());
103
- this.accountManager.updateFromAuth(account, newAuth);
104
- await this.repository.batchSave(this.accountManager.getAccounts());
133
+ const pendingCandidate = this.pendingPersistence.get(account.id);
134
+ if (pendingCandidate) {
135
+ await this.persistRefreshedAccount(pendingCandidate);
136
+ this.pendingPersistence.delete(account.id);
137
+ this.accountManager.publishAuthCandidate(pendingCandidate);
138
+ return;
139
+ }
140
+ const newAuth = await this.refreshAccessToken(latestAuth ?? getAuthFallback());
141
+ const candidate = this.accountManager.createAuthCandidate(latestAccount ?? account, newAuth);
142
+ this.pendingPersistence.set(account.id, candidate);
143
+ await this.persistRefreshedAccount(candidate);
144
+ this.pendingPersistence.delete(account.id);
145
+ this.accountManager.publishAuthCandidate(candidate);
105
146
  });
106
147
  }
148
+ async persistRefreshedAccount(candidate) {
149
+ for (let attempt = 1; attempt <= TOKEN_PERSISTENCE_MAX_ATTEMPTS; attempt++) {
150
+ try {
151
+ await this.repository.save(candidate);
152
+ return;
153
+ }
154
+ catch (error) {
155
+ const retryable = isTransientPersistenceError(error);
156
+ if (!retryable || attempt === TOKEN_PERSISTENCE_MAX_ATTEMPTS) {
157
+ throw new TokenPersistenceError();
158
+ }
159
+ logger.warn('Token persistence failed; retrying', {
160
+ email: candidate.email,
161
+ attempt,
162
+ maxAttempts: TOKEN_PERSISTENCE_MAX_ATTEMPTS
163
+ });
164
+ const jitter = Math.floor(TOKEN_PERSISTENCE_RETRY_DELAY_MS * 0.25 * this.random());
165
+ await this.sleep(TOKEN_PERSISTENCE_RETRY_DELAY_MS * attempt + jitter);
166
+ }
167
+ }
168
+ }
107
169
  async readLatestAuth(account) {
108
170
  this.repository.invalidateCache();
109
171
  const accounts = await this.repository.findAll();
@@ -111,42 +173,41 @@ export class TokenRefresher {
111
173
  if (!latestAccount) {
112
174
  return { latestAccount: null, latestAuth: null };
113
175
  }
176
+ this.syncPersistedAccountReference(account, latestAccount);
177
+ return {
178
+ latestAccount,
179
+ latestAuth: this.accountManager.toAuthDetails(account)
180
+ };
181
+ }
182
+ syncPersistedAccountReference(account, latestAccount) {
114
183
  account.email = latestAccount.email;
115
184
  account.authMethod = latestAccount.authMethod;
116
185
  account.region = latestAccount.region;
117
- if (latestAccount.oidcRegion !== undefined) {
118
- account.oidcRegion = latestAccount.oidcRegion;
119
- }
120
- else {
186
+ if (latestAccount.oidcRegion === undefined)
121
187
  delete account.oidcRegion;
122
- }
123
- if (latestAccount.clientId !== undefined) {
124
- account.clientId = latestAccount.clientId;
125
- }
126
- else {
188
+ else
189
+ account.oidcRegion = latestAccount.oidcRegion;
190
+ if (latestAccount.clientId === undefined)
127
191
  delete account.clientId;
128
- }
129
- if (latestAccount.clientSecret !== undefined) {
130
- account.clientSecret = latestAccount.clientSecret;
131
- }
132
- else {
192
+ else
193
+ account.clientId = latestAccount.clientId;
194
+ if (latestAccount.clientSecret === undefined)
133
195
  delete account.clientSecret;
134
- }
135
- if (latestAccount.profileArn !== undefined) {
136
- account.profileArn = latestAccount.profileArn;
137
- }
138
- else {
196
+ else
197
+ account.clientSecret = latestAccount.clientSecret;
198
+ if (latestAccount.profileArn === undefined)
139
199
  delete account.profileArn;
140
- }
200
+ else
201
+ account.profileArn = latestAccount.profileArn;
141
202
  account.refreshToken = latestAccount.refreshToken;
142
203
  account.accessToken = latestAccount.accessToken;
143
204
  account.expiresAt = latestAccount.expiresAt;
144
- return {
145
- latestAccount,
146
- latestAuth: this.accountManager.toAuthDetails(account)
147
- };
148
205
  }
149
206
  async handleRefreshError(error, account, showToast) {
207
+ if (error instanceof TokenPersistenceError) {
208
+ logger.error('Token refresh persistence failed', { email: account.email });
209
+ return { account, shouldContinue: true };
210
+ }
150
211
  const message = error instanceof Error ? error.message : String(error);
151
212
  logger.error('Token refresh failed', {
152
213
  email: account.email,
@@ -118,6 +118,11 @@ export class ErrorHandler {
118
118
  if (result.dead) {
119
119
  return this.markDeadAndSwitchOrFail(account, errorReason, response.status, context, nextForced, showToast);
120
120
  }
121
+ // Record this account as already force-refreshed even on a transient
122
+ // failure: the at-most-once-per-request force-refresh invariant bounds
123
+ // the retry loop. A candidate that was refreshed but not yet persisted
124
+ // is retried by TokenRefresher's pendingPersistence path, which does
125
+ // not re-call AWS, so dropping nextForced here is unnecessary.
121
126
  return this.transientForbidden(errorReason, response.status, { ...context, forcedRefreshAccountIds: nextForced }, showToast, signal);
122
127
  }
123
128
  return this.markDeadAndSwitchOrFail(account, errorReason, response.status, context, forced, showToast);
@@ -14,7 +14,6 @@ import { RetryStrategy } from './retry-strategy.js';
14
14
  import { SdkEventStreamIterationError, UpstreamUnexpectedError } from './stream-error.js';
15
15
  const KIRO_API_PATTERN = /^(https?:\/\/)?q\.[a-z0-9-]+\.amazonaws\.com/;
16
16
  const REAUTH_FAILURE_COOLDOWN_MS = 60000;
17
- const MAX_STREAM_ATTEMPTS = 3;
18
17
  function describeError(error, depth = 0) {
19
18
  if (!(error instanceof Error))
20
19
  return String(error);
@@ -192,7 +191,8 @@ export class RequestHandler {
192
191
  account: acc.email,
193
192
  accountId: acc.id,
194
193
  streamAttempt,
195
- maxStreamAttempts: MAX_STREAM_ATTEMPTS,
194
+ maxStreamAttempts: this.config.stream_max_attempts,
195
+ streamDeliveryMode: this.config.stream_buffer_until_complete ? 'buffered' : 'live',
196
196
  ...details
197
197
  });
198
198
  if (this.config.enable_log_effort_debug) {
@@ -296,6 +296,7 @@ export class RequestHandler {
296
296
  onComplete: completeRequest,
297
297
  onTerminal: cleanupRequest,
298
298
  onCancel: (reason) => requestController.abort(reason),
299
+ bufferUntilComplete: this.config.stream_buffer_until_complete,
299
300
  mapError: (error) => {
300
301
  logger.error('Kiro SDK event stream iteration failed', streamLogDetails({
301
302
  outcome: 'terminated_after_output',
@@ -323,7 +324,7 @@ export class RequestHandler {
323
324
  if (e instanceof SdkEventStreamIterationError) {
324
325
  streamFailureCount++;
325
326
  const streamError = new UpstreamUnexpectedError(e, false);
326
- if (streamFailureCount >= MAX_STREAM_ATTEMPTS) {
327
+ if (streamFailureCount >= this.config.stream_max_attempts) {
327
328
  logger.error('Kiro SDK event stream iteration failed', streamLogDetails({
328
329
  outcome: 'exhausted',
329
330
  platform: process.platform,
@@ -10,6 +10,7 @@ export interface SdkResponseLifecycle {
10
10
  onTerminal?: () => void;
11
11
  onCancel?: (reason: unknown) => void;
12
12
  mapError?: (error: SdkEventStreamIterationError, emittedOutput: true) => unknown;
13
+ bufferUntilComplete?: boolean;
13
14
  }
14
15
  export declare class ResponseHandler {
15
16
  handleSuccess(response: Response, model: string, conversationId: string, streaming: boolean): Promise<Response>;
@@ -115,6 +115,18 @@ function isSemanticChunk(chunk) {
115
115
  function encodeSseChunk(chunk) {
116
116
  return new TextEncoder().encode(`data: ${JSON.stringify(chunk)}\n\n`);
117
117
  }
118
+ function bufferedSseResponse(chunks) {
119
+ let index = 0;
120
+ return new Response(new ReadableStream({
121
+ pull(controller) {
122
+ const chunk = chunks[index++];
123
+ if (chunk)
124
+ controller.enqueue(chunk);
125
+ if (index >= chunks.length)
126
+ controller.close();
127
+ }
128
+ }, { highWaterMark: 0 }), { headers: { 'Content-Type': 'text/event-stream' } });
129
+ }
118
130
  export class ResponseHandler {
119
131
  async handleSuccess(response, model, conversationId, streaming) {
120
132
  if (streaming) {
@@ -148,22 +160,34 @@ export class ResponseHandler {
148
160
  const wrapped = wrapSdkEventStream(sdkResponse, lifecycle.signal, lifecycle.onUpstreamWaitStart, lifecycle.onUpstreamWaitEnd, lifecycle.onIterationError);
149
161
  const transformed = transformSdkStream(wrapped.response, model, conversationId);
150
162
  const buffered = [];
163
+ if (lifecycle.bufferUntilComplete) {
164
+ try {
165
+ while (true) {
166
+ const item = await transformed.next();
167
+ if (item.done) {
168
+ await lifecycle.onComplete?.();
169
+ lifecycle.onTerminal?.();
170
+ return bufferedSseResponse(buffered);
171
+ }
172
+ buffered.push(encodeSseChunk(item.value));
173
+ }
174
+ }
175
+ catch (error) {
176
+ try {
177
+ await transformed.return(undefined);
178
+ }
179
+ catch { }
180
+ await wrapped.closeRaw();
181
+ throw error;
182
+ }
183
+ }
151
184
  let firstSemantic;
152
185
  while (true) {
153
186
  const item = await transformed.next();
154
187
  if (item.done) {
155
188
  await lifecycle.onComplete?.();
156
189
  lifecycle.onTerminal?.();
157
- let index = 0;
158
- return new Response(new ReadableStream({
159
- pull(controller) {
160
- const chunk = buffered[index++];
161
- if (chunk)
162
- controller.enqueue(chunk);
163
- if (index >= buffered.length)
164
- controller.close();
165
- }
166
- }, { highWaterMark: 0 }), { headers: { 'Content-Type': 'text/event-stream' } });
190
+ return bufferedSseResponse(buffered);
167
191
  }
168
192
  const encoded = encodeSseChunk(item.value);
169
193
  if (isSemanticChunk(item.value)) {
@@ -50,6 +50,9 @@ export declare class AccountManager {
50
50
  addAccount(a: ManagedAccount): void;
51
51
  removeAccount(a: ManagedAccount): void;
52
52
  updateFromAuth(a: ManagedAccount, auth: KiroAuthDetails): void;
53
+ createAuthCandidate(a: ManagedAccount, auth: KiroAuthDetails): ManagedAccount;
54
+ publishAuthCandidate(candidate: ManagedAccount, syncKiroCli?: boolean): void;
55
+ private writeAuthCandidateToKiroCli;
53
56
  markRateLimited(a: ManagedAccount, ms: number): void;
54
57
  markUnhealthy(a: ManagedAccount, reason: string, recovery?: number): void;
55
58
  saveToDisk(): Promise<void>;
@@ -287,34 +287,56 @@ export class AccountManager {
287
287
  this.cursor--;
288
288
  }
289
289
  updateFromAuth(a, auth) {
290
- const acc = this.accounts.find((x) => x.id === a.id);
291
- if (acc) {
292
- acc.accessToken = auth.access;
293
- acc.expiresAt = auth.expires;
294
- acc.lastUsed = Date.now();
295
- if (auth.email)
296
- acc.email = auth.email;
297
- const p = decodeRefreshToken(auth.refresh);
298
- acc.refreshToken = p.refreshToken;
299
- if (p.profileArn)
300
- acc.profileArn = p.profileArn;
301
- if (p.clientId)
302
- acc.clientId = p.clientId;
303
- acc.failCount = 0;
304
- acc.isHealthy = true;
305
- delete acc.unhealthyReason;
306
- delete acc.recoveryTime;
307
- kiroDb.upsertAccount(acc).catch((e) => logger.warn('DB write failed', {
308
- method: 'updateFromAuth',
309
- email: acc.email,
310
- error: e instanceof Error ? e.message : String(e)
311
- }));
312
- writeToKiroCli(acc).catch((e) => logger.warn('CLI write failed', {
313
- method: 'updateFromAuth',
314
- email: acc.email,
315
- error: e instanceof Error ? e.message : String(e)
316
- }));
317
- }
290
+ const account = this.accounts.find((item) => item.id === a.id);
291
+ if (!account)
292
+ return;
293
+ const candidate = this.createAuthCandidate(account, auth);
294
+ this.publishAuthCandidate(candidate, false);
295
+ kiroDb.upsertAccount(candidate).catch((e) => logger.warn('DB write failed', {
296
+ method: 'updateFromAuth',
297
+ email: candidate.email,
298
+ error: e instanceof Error ? e.message : String(e)
299
+ }));
300
+ this.writeAuthCandidateToKiroCli(candidate);
301
+ }
302
+ createAuthCandidate(a, auth) {
303
+ const candidate = { ...a };
304
+ candidate.accessToken = auth.access;
305
+ candidate.expiresAt = auth.expires;
306
+ candidate.lastUsed = Date.now();
307
+ if (auth.email)
308
+ candidate.email = auth.email;
309
+ const p = decodeRefreshToken(auth.refresh);
310
+ candidate.refreshToken = p.refreshToken;
311
+ if (p.profileArn)
312
+ candidate.profileArn = p.profileArn;
313
+ if (p.clientId)
314
+ candidate.clientId = p.clientId;
315
+ candidate.failCount = 0;
316
+ candidate.isHealthy = true;
317
+ delete candidate.unhealthyReason;
318
+ delete candidate.recoveryTime;
319
+ return candidate;
320
+ }
321
+ publishAuthCandidate(candidate, syncKiroCli = true) {
322
+ const account = this.accounts.find((item) => item.id === candidate.id);
323
+ if (!account)
324
+ return;
325
+ Object.assign(account, candidate);
326
+ if (candidate.unhealthyReason === undefined)
327
+ delete account.unhealthyReason;
328
+ if (candidate.recoveryTime === undefined)
329
+ delete account.recoveryTime;
330
+ if (!syncKiroCli)
331
+ return;
332
+ this.writeAuthCandidateToKiroCli(account);
333
+ }
334
+ writeAuthCandidateToKiroCli(account) {
335
+ writeToKiroCli(account).catch((e) => logger.warn('CLI write failed', {
336
+ method: 'updateFromAuth',
337
+ email: account.email,
338
+ error: e instanceof Error ? e.message : String(e)
339
+ }));
318
340
  }
319
341
  markRateLimited(a, ms) {
320
342
  const acc = this.accounts.find((x) => x.id === a.id);
@@ -139,6 +139,8 @@ function applyEnvOverrides(config) {
139
139
  sdk_response_timeout_ms: parseNumberEnv(env.KIRO_SDK_RESPONSE_TIMEOUT_MS, config.sdk_response_timeout_ms),
140
140
  stream_event_timeout_enabled: parseBooleanEnv(env.KIRO_STREAM_EVENT_TIMEOUT_ENABLED, config.stream_event_timeout_enabled),
141
141
  request_timeout_ms: parseNumberEnv(env.KIRO_REQUEST_TIMEOUT_MS, config.request_timeout_ms),
142
+ stream_buffer_until_complete: parseBooleanEnv(env.KIRO_STREAM_BUFFER_UNTIL_COMPLETE, config.stream_buffer_until_complete),
143
+ stream_max_attempts: parseNumberEnv(env.KIRO_STREAM_MAX_ATTEMPTS, config.stream_max_attempts),
142
144
  token_expiry_buffer_ms: parseNumberEnv(env.KIRO_TOKEN_EXPIRY_BUFFER_MS, config.token_expiry_buffer_ms),
143
145
  usage_sync_max_retries: parseNumberEnv(env.KIRO_USAGE_SYNC_MAX_RETRIES, config.usage_sync_max_retries),
144
146
  auth_server_port_start: parseNumberEnv(env.KIRO_AUTH_SERVER_PORT_START, config.auth_server_port_start),
@@ -77,6 +77,17 @@ export declare const KiroConfigSchema: z.ZodObject<{
77
77
  * Only used when stream_event_timeout_enabled is true.
78
78
  */
79
79
  request_timeout_ms: z.ZodDefault<z.ZodNumber>;
80
+ /**
81
+ * Consume the complete Kiro event stream before exposing any semantic output
82
+ * downstream. This trades live token display for safe retries after a
83
+ * mid-stream transport failure without duplicating content or tool calls.
84
+ */
85
+ stream_buffer_until_complete: z.ZodDefault<z.ZodBoolean>;
86
+ /**
87
+ * Maximum number of complete event-stream attempts. Buffered mode can safely
88
+ * use every attempt even when the failed upstream stream produced output.
89
+ */
90
+ stream_max_attempts: z.ZodDefault<z.ZodNumber>;
80
91
  token_expiry_buffer_ms: z.ZodDefault<z.ZodNumber>;
81
92
  /**
82
93
  * Opt-in leader-elected keep-alive that proactively rotates idle-account
@@ -129,6 +140,8 @@ export declare const KiroConfigSchema: z.ZodObject<{
129
140
  sdk_response_timeout_ms: number;
130
141
  stream_event_timeout_enabled: boolean;
131
142
  request_timeout_ms: number;
143
+ stream_buffer_until_complete: boolean;
144
+ stream_max_attempts: number;
132
145
  token_expiry_buffer_ms: number;
133
146
  token_keepalive_enabled: boolean;
134
147
  token_keepalive_interval_ms: number;
@@ -165,6 +178,8 @@ export declare const KiroConfigSchema: z.ZodObject<{
165
178
  sdk_response_timeout_ms?: number | undefined;
166
179
  stream_event_timeout_enabled?: boolean | undefined;
167
180
  request_timeout_ms?: number | undefined;
181
+ stream_buffer_until_complete?: boolean | undefined;
182
+ stream_max_attempts?: number | undefined;
168
183
  token_expiry_buffer_ms?: number | undefined;
169
184
  token_keepalive_enabled?: boolean | undefined;
170
185
  token_keepalive_interval_ms?: number | undefined;
@@ -109,6 +109,17 @@ export const KiroConfigSchema = z.object({
109
109
  * Only used when stream_event_timeout_enabled is true.
110
110
  */
111
111
  request_timeout_ms: z.number().min(30000).max(600000).default(120000),
112
+ /**
113
+ * Consume the complete Kiro event stream before exposing any semantic output
114
+ * downstream. This trades live token display for safe retries after a
115
+ * mid-stream transport failure without duplicating content or tool calls.
116
+ */
117
+ stream_buffer_until_complete: z.boolean().default(false),
118
+ /**
119
+ * Maximum number of complete event-stream attempts. Buffered mode can safely
120
+ * use every attempt even when the failed upstream stream produced output.
121
+ */
122
+ stream_max_attempts: z.number().int().min(1).max(10).default(3),
112
123
  token_expiry_buffer_ms: z.number().min(30000).max(300000).default(300000),
113
124
  /**
114
125
  * Opt-in leader-elected keep-alive that proactively rotates idle-account
@@ -162,6 +173,8 @@ export const DEFAULT_CONFIG = {
162
173
  sdk_response_timeout_ms: 300000,
163
174
  stream_event_timeout_enabled: false,
164
175
  request_timeout_ms: 120000,
176
+ stream_buffer_until_complete: false,
177
+ stream_max_attempts: 3,
165
178
  token_expiry_buffer_ms: 300000,
166
179
  token_keepalive_enabled: false,
167
180
  token_keepalive_interval_ms: 600000,
@@ -3,6 +3,9 @@ export declare class KiroTokenRefreshError extends Error {
3
3
  originalError?: Error;
4
4
  constructor(message: string, code?: string, originalError?: Error);
5
5
  }
6
+ export declare class TokenPersistenceError extends Error {
7
+ constructor();
8
+ }
6
9
  export declare class KiroQuotaExhaustedError extends Error {
7
10
  recoveryTime?: number;
8
11
  constructor(message: string, recoveryTime?: number);
@@ -8,6 +8,12 @@ export class KiroTokenRefreshError extends Error {
8
8
  this.originalError = originalError;
9
9
  }
10
10
  }
11
+ export class TokenPersistenceError extends Error {
12
+ constructor() {
13
+ super('Failed to persist refreshed credentials');
14
+ this.name = 'TokenPersistenceError';
15
+ }
16
+ }
11
17
  export class KiroQuotaExhaustedError extends Error {
12
18
  recoveryTime;
13
19
  constructor(message, recoveryTime) {
@@ -2,7 +2,6 @@ import type { ManagedAccount } from '../types.js';
2
2
  export { getKeepAliveLockPath, getRefreshLockPath } from '../paths.js';
3
3
  type LockRelease = () => Promise<void>;
4
4
  export declare function withDatabaseLockSync<T>(dbPath: string, fn: () => T): T;
5
- export declare function withDatabaseLock<T>(dbPath: string, fn: () => Promise<T>): Promise<T>;
6
5
  export declare function withRefreshLock<T>(accountId: string, fn: () => Promise<T>): Promise<T>;
7
6
  export declare function tryAcquireKeepAliveLock(): Promise<LockRelease | null>;
8
7
  export declare function withKeepAliveLock<T>(fn: () => Promise<T>): Promise<T | null>;
@@ -5,14 +5,6 @@ import lockfile from 'proper-lockfile';
5
5
  import { isPermanentError } from '../health.js';
6
6
  import { getKeepAliveLockPath, getRefreshLockPath } from '../paths.js';
7
7
  export { getKeepAliveLockPath, getRefreshLockPath } from '../paths.js';
8
- const DATABASE_LOCK_OPTIONS = {
9
- stale: 10000,
10
- retries: 0,
11
- realpath: false
12
- };
13
- const DATABASE_LOCK_DEADLINE_MS = 10000;
14
- const DATABASE_LOCK_MIN_BACKOFF_MS = 25;
15
- const DATABASE_LOCK_MAX_BACKOFF_MS = 250;
16
8
  const REFRESH_LOCK_OPTIONS = {
17
9
  stale: 15000,
18
10
  retries: 0,
@@ -31,8 +23,14 @@ const SYNC_LOCK_DEADLINE_MS = 10000;
31
23
  function blockingBackoff(ms) {
32
24
  Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
33
25
  }
34
- function isLockContention(e) {
35
- return typeof e === 'object' && e !== null && 'code' in e && e.code === 'ELOCKED';
26
+ function isRetryableLockAcquisitionError(e) {
27
+ if (typeof e !== 'object' || e === null || !('code' in e))
28
+ return false;
29
+ // proper-lockfile may surface ENOENT from mtimePrecision.probe() when another
30
+ // process removes the newly-created lock directory during stale takeover.
31
+ // Keep this scoped to the bounded lock-acquisition loops below so unrelated
32
+ // filesystem ENOENT errors still propagate immediately.
33
+ return e.code === 'ELOCKED' || e.code === 'ENOENT';
36
34
  }
37
35
  function asyncBackoff(attempt, remainingMs, minBackoffMs, maxBackoffMs) {
38
36
  const ceiling = Math.min(minBackoffMs * 2 ** Math.min(attempt, 4), maxBackoffMs, remainingMs);
@@ -40,23 +38,6 @@ function asyncBackoff(attempt, remainingMs, minBackoffMs, maxBackoffMs) {
40
38
  const delay = floor + Math.floor(Math.random() * (ceiling - floor + 1));
41
39
  return new Promise((resolve) => setTimeout(resolve, delay));
42
40
  }
43
- async function acquireDatabaseLock(dbPath) {
44
- // A deadline avoids fixed retry-count starvation; jitter keeps contenders
45
- // from repeatedly attempting the atomic mkdir in lockstep.
46
- const deadline = Date.now() + DATABASE_LOCK_DEADLINE_MS;
47
- let attempt = 0;
48
- for (;;) {
49
- try {
50
- return await lockfile.lock(dbPath, DATABASE_LOCK_OPTIONS);
51
- }
52
- catch (e) {
53
- const remainingMs = deadline - Date.now();
54
- if (!isLockContention(e) || remainingMs <= 0)
55
- throw e;
56
- await asyncBackoff(attempt++, remainingMs, DATABASE_LOCK_MIN_BACKOFF_MS, DATABASE_LOCK_MAX_BACKOFF_MS);
57
- }
58
- }
59
- }
60
41
  async function acquireRefreshLock(lockPath) {
61
42
  // A deadline avoids fixed retry-count starvation; jitter keeps contenders
62
43
  // from repeatedly attempting the atomic mkdir in lockstep.
@@ -68,7 +49,7 @@ async function acquireRefreshLock(lockPath) {
68
49
  }
69
50
  catch (e) {
70
51
  const remainingMs = deadline - Date.now();
71
- if (!isLockContention(e) || remainingMs <= 0)
52
+ if (!isRetryableLockAcquisitionError(e) || remainingMs <= 0)
72
53
  throw e;
73
54
  await asyncBackoff(attempt++, remainingMs, REFRESH_LOCK_MIN_BACKOFF_MS, REFRESH_LOCK_MAX_BACKOFF_MS);
74
55
  }
@@ -90,7 +71,7 @@ export function withDatabaseLockSync(dbPath, fn) {
90
71
  break;
91
72
  }
92
73
  catch (e) {
93
- if (!isLockContention(e) || Date.now() >= deadline)
74
+ if (!isRetryableLockAcquisitionError(e) || Date.now() >= deadline)
94
75
  throw e;
95
76
  blockingBackoff(Math.min(100 * 2 ** attempt++, 500));
96
77
  }
@@ -107,27 +88,6 @@ export function withDatabaseLockSync(dbPath, fn) {
107
88
  }
108
89
  }
109
90
  }
110
- export async function withDatabaseLock(dbPath, fn) {
111
- if (!existsSync(dbPath)) {
112
- await fs.mkdir(dirname(dbPath), { recursive: true });
113
- await fs.writeFile(dbPath, '');
114
- }
115
- let release = null;
116
- try {
117
- release = await acquireDatabaseLock(dbPath);
118
- return await fn();
119
- }
120
- finally {
121
- if (release) {
122
- try {
123
- await release();
124
- }
125
- catch (e) {
126
- console.warn('Failed to release lock:', e);
127
- }
128
- }
129
- }
130
- }
131
91
  export async function withRefreshLock(accountId, fn) {
132
92
  const lockPath = getRefreshLockPath(accountId);
133
93
  if (!existsSync(lockPath)) {
@@ -1,4 +1,56 @@
1
+ const REFRESH_TOKEN_DEDUP_MARKER = 'refresh_token_dedup_migration_version';
2
+ const REFRESH_TOKEN_DEDUP_VERSION = 1;
3
+ const MIGRATION_LOCK_DEADLINE_MS = 30_000;
4
+ const MIGRATION_LOCK_MIN_BACKOFF_MS = 25;
5
+ const MIGRATION_LOCK_MAX_BACKOFF_MS = 500;
6
+ class MigrationLockTimeoutError extends Error {
7
+ name = 'MigrationLockTimeoutError';
8
+ code = 'KIRO_DB_MIGRATION_LOCK_TIMEOUT';
9
+ constructor(cause) {
10
+ super(`Timed out after ${MIGRATION_LOCK_DEADLINE_MS}ms waiting for the migration write lock`, {
11
+ cause
12
+ });
13
+ }
14
+ }
15
+ function isSqliteBusy(error) {
16
+ if (typeof error !== 'object' || error === null || !('code' in error))
17
+ return false;
18
+ return typeof error.code === 'string' && error.code.startsWith('SQLITE_BUSY');
19
+ }
20
+ function blockingBackoff(ms) {
21
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
22
+ }
23
+ function beginImmediateWithRetry(db) {
24
+ const deadline = Date.now() + MIGRATION_LOCK_DEADLINE_MS;
25
+ let backoffMs = MIGRATION_LOCK_MIN_BACKOFF_MS;
26
+ for (;;) {
27
+ try {
28
+ db.exec('BEGIN IMMEDIATE');
29
+ return;
30
+ }
31
+ catch (error) {
32
+ if (!isSqliteBusy(error))
33
+ throw error;
34
+ const remainingMs = deadline - Date.now();
35
+ if (remainingMs <= 0)
36
+ throw new MigrationLockTimeoutError(error);
37
+ const jitterMs = Math.floor(Math.random() * Math.max(1, backoffMs / 4));
38
+ blockingBackoff(Math.min(backoffMs + jitterMs, remainingMs));
39
+ backoffMs = Math.min(backoffMs * 2, MIGRATION_LOCK_MAX_BACKOFF_MS);
40
+ }
41
+ }
42
+ }
43
+ function hasRefreshTokenDedupMarker(db) {
44
+ const marker = db
45
+ .prepare('SELECT value FROM plugin_meta WHERE key = ?')
46
+ .get(REFRESH_TOKEN_DEDUP_MARKER);
47
+ if (typeof marker !== 'object' || marker === null)
48
+ return false;
49
+ const value = 'value' in marker ? marker.value : undefined;
50
+ return typeof value === 'number' && value >= REFRESH_TOKEN_DEDUP_VERSION;
51
+ }
1
52
  export function runMigrations(db) {
53
+ migratePluginMetaTable(db);
2
54
  migrateToUniqueRefreshToken(db);
3
55
  migrateRealEmailColumn(db);
4
56
  migrateUsageTable(db);
@@ -7,19 +59,18 @@ export function runMigrations(db) {
7
59
  migrateDropRefreshTokenUniqueIndex(db);
8
60
  migrateRemovedAccountsTable(db);
9
61
  migrateOverageColumn(db);
10
- migratePluginMetaTable(db);
11
62
  }
12
63
  function migrateToUniqueRefreshToken(db) {
13
- const indexProbe = db.prepare("SELECT name FROM sqlite_master WHERE type='index' AND name='idx_refresh_token_unique'");
14
- if (indexProbe.get())
64
+ if (hasRefreshTokenDedupMarker(db))
15
65
  return;
66
+ const indexProbe = db.prepare("SELECT name FROM sqlite_master WHERE type='index' AND name='idx_refresh_token_unique'");
16
67
  // BEGIN IMMEDIATE (not deferred BEGIN): a deferred read-then-write txn raises unrecoverable
17
68
  // SQLITE_BUSY_SNAPSHOT (busy_timeout cannot retry it) if another connection writes after the
18
69
  // read snapshot. Taking the write lock at BEGIN makes concurrent processes serialize via
19
70
  // busy_timeout instead of racing into a snapshot conflict.
20
- db.exec('BEGIN IMMEDIATE');
71
+ beginImmediateWithRetry(db);
21
72
  try {
22
- if (indexProbe.get()) {
73
+ if (hasRefreshTokenDedupMarker(db)) {
23
74
  db.exec('COMMIT');
24
75
  return;
25
76
  }
@@ -55,7 +106,11 @@ function migrateToUniqueRefreshToken(db) {
55
106
  }
56
107
  }
57
108
  }
58
- db.exec('CREATE UNIQUE INDEX idx_refresh_token_unique ON accounts(refresh_token)');
109
+ if (!indexProbe.get()) {
110
+ db.exec('CREATE UNIQUE INDEX idx_refresh_token_unique ON accounts(refresh_token)');
111
+ }
112
+ db.prepare(`INSERT INTO plugin_meta(key, value) VALUES(?, ?)
113
+ ON CONFLICT(key) DO UPDATE SET value = excluded.value`).run(REFRESH_TOKEN_DEDUP_MARKER, REFRESH_TOKEN_DEDUP_VERSION);
59
114
  db.exec('COMMIT');
60
115
  }
61
116
  catch (e) {
@@ -134,14 +189,23 @@ function migrateOidcRegionColumn(db) {
134
189
  db.exec('ALTER TABLE accounts ADD COLUMN oidc_region TEXT');
135
190
  }
136
191
  // Backfill: historically `region` was used for both service + OIDC.
137
- db.exec("UPDATE accounts SET oidc_region = region WHERE oidc_region IS NULL OR oidc_region = ''");
192
+ const needsBackfill = db
193
+ .prepare("SELECT 1 FROM accounts WHERE oidc_region IS NULL OR oidc_region = '' LIMIT 1")
194
+ .get();
195
+ if (needsBackfill) {
196
+ db.exec("UPDATE accounts SET oidc_region = region WHERE oidc_region IS NULL OR oidc_region = ''");
197
+ }
138
198
  }
139
199
  function migrateDropRefreshTokenUniqueIndex(db) {
140
200
  // Drop the UNIQUE index on refresh_token — it was only needed for ON CONFLICT(refresh_token)
141
201
  // upsert mechanics. Now that we use ON CONFLICT(id), this index is unnecessary and actively
142
202
  // harmful: duplicate rows (same account, different legacy vs hash id) share the same
143
203
  // refresh_token, causing UNIQUE constraint violations on every upsert.
144
- db.exec('DROP INDEX IF EXISTS idx_refresh_token_unique');
204
+ const hasUniqueIndex = db
205
+ .prepare("SELECT name FROM sqlite_master WHERE type='index' AND name='idx_refresh_token_unique'")
206
+ .get();
207
+ if (hasUniqueIndex)
208
+ db.exec('DROP INDEX IF EXISTS idx_refresh_token_unique');
145
209
  // Clean up duplicate rows: same email + same refresh_token but different ids.
146
210
  // Keep the deterministic hash id (64-char hex), delete legacy kiro-cli-sync-* rows.
147
211
  const duplicates = db
@@ -175,5 +239,18 @@ function migrateOverageColumn(db) {
175
239
  }
176
240
  }
177
241
  function migratePluginMetaTable(db) {
178
- db.exec('CREATE TABLE IF NOT EXISTS plugin_meta (key TEXT PRIMARY KEY, value INTEGER NOT NULL)');
242
+ const tableProbe = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='plugin_meta'");
243
+ if (tableProbe.get())
244
+ return;
245
+ beginImmediateWithRetry(db);
246
+ try {
247
+ if (!tableProbe.get()) {
248
+ db.exec('CREATE TABLE plugin_meta (key TEXT PRIMARY KEY, value INTEGER NOT NULL)');
249
+ }
250
+ db.exec('COMMIT');
251
+ }
252
+ catch (error) {
253
+ db.exec('ROLLBACK');
254
+ throw error;
255
+ }
179
256
  }
@@ -4,6 +4,7 @@ export declare class KiroDatabase {
4
4
  private db;
5
5
  private path;
6
6
  constructor(path?: string);
7
+ private withImmediateTransaction;
7
8
  private init;
8
9
  getAccounts(): any[];
9
10
  private upsertAccountInternal;
@@ -2,8 +2,32 @@ 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, withDatabaseLockSync } from './locked-operations.js';
5
+ import { deduplicateAccounts, mergeAccounts, withDatabaseLockSync } from './locked-operations.js';
6
6
  import { runMigrations } from './migrations.js';
7
+ const DATABASE_BUSY_TIMEOUT_MS = 5_000;
8
+ const WRITE_LOCK_DEADLINE_MS = 30_000;
9
+ const WRITE_LOCK_MIN_BACKOFF_MS = 25;
10
+ const WRITE_LOCK_MAX_BACKOFF_MS = 500;
11
+ class DatabaseWriteLockTimeoutError extends Error {
12
+ name = 'DatabaseWriteLockTimeoutError';
13
+ code = 'KIRO_DB_WRITE_LOCK_TIMEOUT';
14
+ constructor(cause) {
15
+ super(`Timed out after ${WRITE_LOCK_DEADLINE_MS}ms waiting for the database write lock`, {
16
+ cause
17
+ });
18
+ }
19
+ }
20
+ function isSqliteBusy(error) {
21
+ if (typeof error !== 'object' || error === null || !('code' in error))
22
+ return false;
23
+ return typeof error.code === 'string' && error.code.startsWith('SQLITE_BUSY');
24
+ }
25
+ function asyncBackoff(attempt, remainingMs) {
26
+ const ceiling = Math.min(WRITE_LOCK_MIN_BACKOFF_MS * 2 ** Math.min(attempt, 5), WRITE_LOCK_MAX_BACKOFF_MS, remainingMs);
27
+ const floor = Math.max(1, Math.floor(ceiling / 2));
28
+ const delay = floor + Math.floor(Math.random() * (ceiling - floor + 1));
29
+ return new Promise((resolve) => setTimeout(resolve, delay));
30
+ }
7
31
  function getBaseDir() {
8
32
  const p = process.platform;
9
33
  if (p === 'win32')
@@ -20,9 +44,55 @@ export class KiroDatabase {
20
44
  if (!existsSync(dir))
21
45
  mkdirSync(dir, { recursive: true });
22
46
  this.db = new Database(path);
23
- this.db.pragma('busy_timeout = 5000');
47
+ this.db.pragma(`busy_timeout = ${DATABASE_BUSY_TIMEOUT_MS}`);
24
48
  withDatabaseLockSync(this.path, () => this.init());
25
49
  }
50
+ async withImmediateTransaction(fn) {
51
+ const deadline = Date.now() + WRITE_LOCK_DEADLINE_MS;
52
+ let attempt = 0;
53
+ for (;;) {
54
+ const busyTimeoutResult = this.db.pragma('busy_timeout', { simple: true });
55
+ const busyTimeout = typeof busyTimeoutResult === 'number'
56
+ ? busyTimeoutResult
57
+ : typeof busyTimeoutResult === 'object' && busyTimeoutResult !== null
58
+ ? Reflect.get(busyTimeoutResult, 'timeout')
59
+ : undefined;
60
+ if (typeof busyTimeout !== 'number') {
61
+ throw new TypeError('Expected numeric busy_timeout from libsql');
62
+ }
63
+ let begun = false;
64
+ let beginError;
65
+ try {
66
+ this.db.pragma('busy_timeout = 0');
67
+ this.db.exec('BEGIN IMMEDIATE');
68
+ begun = true;
69
+ }
70
+ catch (error) {
71
+ beginError = error;
72
+ }
73
+ finally {
74
+ this.db.pragma(`busy_timeout = ${busyTimeout}`);
75
+ }
76
+ if (!begun) {
77
+ if (!isSqliteBusy(beginError))
78
+ throw beginError;
79
+ const remainingMs = deadline - Date.now();
80
+ if (remainingMs <= 0)
81
+ throw new DatabaseWriteLockTimeoutError(beginError);
82
+ await asyncBackoff(attempt++, remainingMs);
83
+ continue;
84
+ }
85
+ try {
86
+ const result = fn();
87
+ this.db.exec('COMMIT');
88
+ return result;
89
+ }
90
+ catch (error) {
91
+ this.db.exec('ROLLBACK');
92
+ throw error;
93
+ }
94
+ }
95
+ }
26
96
  init() {
27
97
  this.db.pragma('journal_mode = WAL');
28
98
  this.db.exec(`
@@ -79,96 +149,64 @@ export class KiroDatabase {
79
149
  this.db.prepare('DELETE FROM accounts WHERE id IN (SELECT id FROM removed_accounts)').run();
80
150
  }
81
151
  async upsertAccount(acc) {
82
- await withDatabaseLock(this.path, async () => {
152
+ await this.withImmediateTransaction(() => {
83
153
  const existing = this.getAccounts().map(this.rowToAccount);
84
154
  const merged = mergeAccounts(existing, [acc]);
85
155
  const deduplicated = deduplicateAccounts(merged);
86
156
  const writable = deduplicated.filter((a) => !this.isRemovedSync(a.id));
87
- this.db.exec('BEGIN TRANSACTION');
88
- try {
89
- this.purgeRemovedAccountsSync();
90
- for (const account of writable) {
91
- this.upsertAccountInternal(account);
92
- }
93
- this.db.exec('COMMIT');
94
- }
95
- catch (e) {
96
- this.db.exec('ROLLBACK');
97
- throw e;
157
+ this.purgeRemovedAccountsSync();
158
+ for (const account of writable) {
159
+ this.upsertAccountInternal(account);
98
160
  }
99
161
  });
100
162
  }
101
163
  async batchUpsertAccounts(accounts) {
102
- await withDatabaseLock(this.path, async () => {
164
+ await this.withImmediateTransaction(() => {
103
165
  const existing = this.getAccounts().map(this.rowToAccount);
104
166
  const merged = mergeAccounts(existing, accounts);
105
167
  const deduplicated = deduplicateAccounts(merged);
106
168
  const writable = deduplicated.filter((a) => !this.isRemovedSync(a.id));
107
- this.db.exec('BEGIN TRANSACTION');
108
- try {
109
- this.purgeRemovedAccountsSync();
110
- for (const account of writable) {
111
- this.upsertAccountInternal(account);
112
- }
113
- this.db.exec('COMMIT');
114
- }
115
- catch (e) {
116
- this.db.exec('ROLLBACK');
117
- throw e;
169
+ this.purgeRemovedAccountsSync();
170
+ for (const account of writable) {
171
+ this.upsertAccountInternal(account);
118
172
  }
119
173
  });
120
174
  }
121
175
  async deleteAccount(id) {
122
- await withDatabaseLock(this.path, async () => {
176
+ await this.withImmediateTransaction(() => {
123
177
  this.db.prepare('DELETE FROM accounts WHERE id = ?').run(id);
124
178
  });
125
179
  }
126
180
  async removeAccountWithTombstone(id) {
127
- await withDatabaseLock(this.path, async () => {
128
- this.db.exec('BEGIN TRANSACTION');
129
- try {
130
- this.db.prepare('DELETE FROM accounts WHERE id = ?').run(id);
131
- this.db
132
- .prepare('INSERT OR REPLACE INTO removed_accounts (id, removed_at) VALUES (?, ?)')
133
- .run(id, Date.now());
134
- this.db.exec('COMMIT');
135
- }
136
- catch (e) {
137
- this.db.exec('ROLLBACK');
138
- throw e;
139
- }
181
+ await this.withImmediateTransaction(() => {
182
+ this.db.prepare('DELETE FROM accounts WHERE id = ?').run(id);
183
+ this.db
184
+ .prepare('INSERT OR REPLACE INTO removed_accounts (id, removed_at) VALUES (?, ?)')
185
+ .run(id, Date.now());
140
186
  });
141
187
  }
142
188
  async cleanupSupersededIdentities(keepId, email, authMethod, profileArn) {
143
- const supersededIds = [];
144
- await withDatabaseLock(this.path, async () => {
145
- this.db.exec('BEGIN TRANSACTION');
146
- try {
147
- const rows = this.db
148
- .prepare('SELECT id FROM accounts WHERE email = ? AND auth_method = ? AND profile_arn IS ? AND id != ?')
149
- .all(email, authMethod, profileArn ?? null, keepId);
150
- for (const row of rows) {
151
- if (typeof row === 'object' && row !== null && 'id' in row && typeof row.id === 'string')
152
- supersededIds.push(row.id);
153
- }
154
- const deleteStmt = this.db.prepare('DELETE FROM accounts WHERE id = ?');
155
- const tombstoneStmt = this.db.prepare('INSERT OR REPLACE INTO removed_accounts (id, removed_at) VALUES (?, ?)');
156
- const removedAt = Date.now();
157
- for (const id of supersededIds) {
158
- deleteStmt.run(id);
159
- tombstoneStmt.run(id, removedAt);
160
- }
161
- this.db.exec('COMMIT');
189
+ return this.withImmediateTransaction(() => {
190
+ const supersededIds = [];
191
+ const rows = this.db
192
+ .prepare('SELECT id FROM accounts WHERE email = ? AND auth_method = ? AND profile_arn IS ? AND id != ?')
193
+ .all(email, authMethod, profileArn ?? null, keepId);
194
+ for (const row of rows) {
195
+ if (typeof row === 'object' && row !== null && 'id' in row && typeof row.id === 'string')
196
+ supersededIds.push(row.id);
162
197
  }
163
- catch (e) {
164
- this.db.exec('ROLLBACK');
165
- throw e;
198
+ const deleteStmt = this.db.prepare('DELETE FROM accounts WHERE id = ?');
199
+ const tombstoneStmt = this.db.prepare('INSERT OR REPLACE INTO removed_accounts (id, removed_at) VALUES (?, ?)');
200
+ const removedAt = Date.now();
201
+ for (const id of supersededIds) {
202
+ deleteStmt.run(id);
203
+ tombstoneStmt.run(id, removedAt);
166
204
  }
205
+ return supersededIds;
167
206
  });
168
- return supersededIds;
169
207
  }
170
208
  async addRemovedAccount(id) {
171
- await withDatabaseLock(this.path, async () => {
209
+ await this.withImmediateTransaction(() => {
172
210
  this.db
173
211
  .prepare('INSERT OR REPLACE INTO removed_accounts (id, removed_at) VALUES (?, ?)')
174
212
  .run(id, Date.now());
@@ -178,7 +216,7 @@ export class KiroDatabase {
178
216
  return !!this.db.prepare('SELECT id FROM removed_accounts WHERE id = ?').get(id);
179
217
  }
180
218
  async clearRemovedAccount(id) {
181
- await withDatabaseLock(this.path, async () => {
219
+ await this.withImmediateTransaction(() => {
182
220
  this.db.prepare('DELETE FROM removed_accounts WHERE id = ?').run(id);
183
221
  });
184
222
  }
@@ -186,7 +224,7 @@ export class KiroDatabase {
186
224
  return this.db.prepare('SELECT id FROM removed_accounts').all().map((r) => r.id);
187
225
  }
188
226
  async nextAssignmentIndex() {
189
- return withDatabaseLock(this.path, async () => {
227
+ return this.withImmediateTransaction(() => {
190
228
  const row = this.db
191
229
  .prepare("INSERT INTO plugin_meta(key,value) VALUES('assignment_cursor',0) ON CONFLICT(key) DO UPDATE SET value=value+1 RETURNING value")
192
230
  .get();
@@ -196,11 +234,9 @@ export class KiroDatabase {
196
234
  async markAccountsUnhealthy(ids, reason) {
197
235
  if (ids.length === 0)
198
236
  return;
199
- await withDatabaseLock(this.path, async () => {
237
+ await this.withImmediateTransaction(() => {
200
238
  const now = Date.now();
201
- this.db.exec('BEGIN TRANSACTION');
202
- try {
203
- const stmt = this.db.prepare(`
239
+ const stmt = this.db.prepare(`
204
240
  UPDATE accounts
205
241
  SET is_healthy = 0,
206
242
  unhealthy_reason = ?,
@@ -210,14 +246,8 @@ export class KiroDatabase {
210
246
  last_sync = ?
211
247
  WHERE id = ?
212
248
  `);
213
- for (const id of ids) {
214
- stmt.run(reason, now, id);
215
- }
216
- this.db.exec('COMMIT');
217
- }
218
- catch (e) {
219
- this.db.exec('ROLLBACK');
220
- throw e;
249
+ for (const id of ids) {
250
+ stmt.run(reason, now, id);
221
251
  }
222
252
  });
223
253
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sunerpy/opencode-kiro-auth",
3
- "version": "0.15.1",
3
+ "version": "0.15.3",
4
4
  "description": "OpenCode plugin for AWS Kiro (CodeWhisperer) providing access to Claude models",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",