@bitkyc08/opencodex 2.7.43-preview.20260728 → 2.8.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.
Files changed (173) hide show
  1. package/README.md +8 -1
  2. package/bin/ocx.mjs +47 -22
  3. package/gui/dist/assets/index-BDjpkcRN.js +67 -0
  4. package/gui/dist/assets/index-BHsKRFh9.css +1 -0
  5. package/gui/dist/index.html +2 -2
  6. package/package.json +1 -1
  7. package/src/AGENTS.md +28 -0
  8. package/src/adapters/anthropic.ts +15 -6
  9. package/src/adapters/cursor/discovery.ts +4 -1
  10. package/src/adapters/cursor/effort-map.ts +3 -0
  11. package/src/adapters/cursor/native-exec-shell.ts +18 -6
  12. package/src/adapters/cursor/protobuf-events.ts +24 -2
  13. package/src/adapters/cursor/protobuf-request.ts +1 -2
  14. package/src/adapters/cursor/tool-definitions.ts +68 -29
  15. package/src/adapters/google-wire-compiler.ts +4 -0
  16. package/src/adapters/google.ts +128 -2
  17. package/src/adapters/identity.ts +12 -2
  18. package/src/adapters/kiro.ts +64 -7
  19. package/src/adapters/mimo-free.ts +2 -0
  20. package/src/adapters/openai-responses.ts +246 -59
  21. package/src/claude/agents-inject.ts +5 -0
  22. package/src/claude/alias.ts +94 -14
  23. package/src/claude/inbound.ts +26 -9
  24. package/src/claude/outbound.ts +6 -3
  25. package/src/cli/account-auth.ts +1 -1
  26. package/src/cli/agent-driven.ts +37 -0
  27. package/src/cli/catalog-prewarm.ts +24 -0
  28. package/src/cli/claude.ts +35 -10
  29. package/src/cli/doctor.ts +71 -19
  30. package/src/cli/help.ts +42 -6
  31. package/src/cli/index.ts +93 -19
  32. package/src/cli/interactive-confirm.ts +133 -0
  33. package/src/cli/opencode.ts +701 -0
  34. package/src/cli/provider-runtime.ts +3 -0
  35. package/src/cli/provider.ts +31 -10
  36. package/src/cli/star-prompt.ts +79 -18
  37. package/src/cli/status.ts +47 -13
  38. package/src/cli/v2.ts +10 -1
  39. package/src/codex/account-id.ts +34 -0
  40. package/src/codex/account-lifecycle.ts +4 -1
  41. package/src/codex/account-namespace-match.ts +63 -0
  42. package/src/codex/account-namespaces.ts +149 -0
  43. package/src/codex/account-pause.ts +20 -0
  44. package/src/codex/account-store.ts +2 -0
  45. package/src/codex/account-usability.ts +6 -1
  46. package/src/codex/app-server-processes.ts +511 -0
  47. package/src/codex/auth-api.ts +293 -34
  48. package/src/codex/auth-collision.ts +2 -1
  49. package/src/codex/auth-context.ts +60 -17
  50. package/src/codex/catalog/bundled.ts +9 -2
  51. package/src/codex/catalog/parsing.ts +42 -2
  52. package/src/codex/catalog/provider-fetch.ts +264 -70
  53. package/src/codex/catalog/sync.ts +45 -8
  54. package/src/codex/catalog.ts +2 -2
  55. package/src/codex/features.ts +524 -5
  56. package/src/codex/history-provider.ts +145 -1
  57. package/src/codex/inject.ts +114 -14
  58. package/src/codex/main-account.ts +2 -8
  59. package/src/codex/pool-rotation.ts +186 -0
  60. package/src/codex/quota.ts +92 -2
  61. package/src/codex/routing.ts +695 -106
  62. package/src/codex/runtime.ts +10 -1
  63. package/src/codex/shim.ts +4 -1
  64. package/src/codex/subagent-defaults.ts +550 -0
  65. package/src/codex/subagent-model-fallback.ts +2 -0
  66. package/src/codex/sync.ts +3 -0
  67. package/src/config.ts +574 -25
  68. package/src/generated/jawcode-model-metadata.ts +12 -12
  69. package/src/github/star-state.ts +191 -0
  70. package/src/images/artifacts.ts +516 -0
  71. package/src/images/fulfill-video.ts +163 -0
  72. package/src/images/fulfill.ts +111 -0
  73. package/src/images/index.ts +4 -0
  74. package/src/images/loop.ts +789 -0
  75. package/src/images/plan.ts +133 -0
  76. package/src/images/synthetic-tool.ts +133 -0
  77. package/src/images/types.ts +41 -0
  78. package/src/images/xai-client.ts +141 -0
  79. package/src/images/xai-video-client.ts +163 -0
  80. package/src/lib/admin-secrets.ts +25 -0
  81. package/src/lib/bun-binary-validator.d.mts +3 -0
  82. package/src/lib/bun-binary-validator.mjs +18 -0
  83. package/src/lib/bun-runtime.ts +6 -20
  84. package/src/lib/config-ownership.ts +327 -0
  85. package/src/lib/crash-guard.ts +2 -0
  86. package/src/lib/destination-policy.ts +132 -7
  87. package/src/lib/pinned-http.ts +151 -0
  88. package/src/lib/process-control.ts +2 -2
  89. package/src/lib/provider-outbound.ts +167 -0
  90. package/src/lib/provider-url.ts +14 -0
  91. package/src/lib/proxy-env.ts +18 -0
  92. package/src/lib/shadow-call.ts +30 -0
  93. package/src/lib/test-home-guard.ts +90 -0
  94. package/src/lib/win-exec.ts +12 -2
  95. package/src/lib/windows-elevation.ts +81 -3
  96. package/src/lib/windows-secret-acl.ts +189 -12
  97. package/src/lib/winsw.ts +2 -0
  98. package/src/oauth/anthropic-routing.ts +570 -0
  99. package/src/oauth/health.ts +6 -0
  100. package/src/oauth/index.ts +310 -75
  101. package/src/oauth/key-providers.ts +38 -8
  102. package/src/oauth/kimi.ts +2 -0
  103. package/src/oauth/kiro-credentials.ts +373 -12
  104. package/src/oauth/kiro.ts +424 -43
  105. package/src/oauth/login-cli.ts +33 -6
  106. package/src/oauth/store.ts +56 -4
  107. package/src/oauth/types.ts +11 -0
  108. package/src/providers/alibaba-region-migration.ts +16 -3
  109. package/src/providers/antigravity-models.ts +3 -0
  110. package/src/providers/api-keys.ts +13 -6
  111. package/src/providers/derive.ts +8 -2
  112. package/src/providers/key-failover.ts +24 -4
  113. package/src/providers/model-discovery.ts +356 -0
  114. package/src/providers/quota.ts +233 -29
  115. package/src/providers/registry.ts +125 -3
  116. package/src/responses/parser.ts +11 -0
  117. package/src/responses/state.ts +22 -8
  118. package/src/responses/tool-groups.ts +19 -0
  119. package/src/router.ts +19 -7
  120. package/src/server/auth-cors.ts +114 -24
  121. package/src/server/claude-messages.ts +8 -1
  122. package/src/server/gui-static.ts +30 -6
  123. package/src/server/images.ts +303 -9
  124. package/src/server/index.ts +77 -9
  125. package/src/server/lifecycle.ts +25 -1
  126. package/src/server/live.ts +75 -25
  127. package/src/server/management/agent-settings-routes.ts +106 -8
  128. package/src/server/management/combo-routes.ts +7 -0
  129. package/src/server/management/config-routes.ts +22 -7
  130. package/src/server/management/context.ts +11 -1
  131. package/src/server/management/logs-usage-routes.ts +167 -3
  132. package/src/server/management/model-routes.ts +46 -13
  133. package/src/server/management/oauth-account-routes.ts +163 -17
  134. package/src/server/management/provider-routes.ts +73 -10
  135. package/src/server/management/shared.ts +2 -2
  136. package/src/server/management/sidebar-routes.ts +39 -0
  137. package/src/server/management/system-restart.ts +172 -0
  138. package/src/server/management/system-routes.ts +33 -10
  139. package/src/server/management-api.ts +5 -3
  140. package/src/server/management-auth.ts +216 -0
  141. package/src/server/proxy-liveness.ts +14 -3
  142. package/src/server/responses/compact.ts +21 -13
  143. package/src/server/responses/core.ts +614 -172
  144. package/src/server/responses/upstream-error.ts +48 -0
  145. package/src/server/responses-image-gen-repair.ts +118 -0
  146. package/src/server/responses-item-id-repair.ts +10 -85
  147. package/src/server/sse-payload-rewrite.ts +116 -0
  148. package/src/server/startup-action-control.ts +30 -14
  149. package/src/server/system-env.ts +28 -10
  150. package/src/service.ts +284 -19
  151. package/src/storage/cleanup-job.ts +57 -0
  152. package/src/storage/cleanup.ts +1504 -28
  153. package/src/storage/policy-job.ts +387 -0
  154. package/src/storage/policy-scheduler.ts +40 -0
  155. package/src/storage/policy-worker.ts +53 -0
  156. package/src/storage/policy.ts +522 -0
  157. package/src/storage/restore-job.ts +253 -0
  158. package/src/storage/restore-worker.ts +52 -0
  159. package/src/storage/storage-mutation-coordinator.ts +109 -0
  160. package/src/storage/worker-lifecycle.ts +81 -0
  161. package/src/tray/windows.ts +34 -4
  162. package/src/types.ts +107 -1
  163. package/src/update/badge.ts +72 -0
  164. package/src/update/index.ts +36 -18
  165. package/src/update/job.ts +111 -16
  166. package/src/update/npm-invocation.d.mts +23 -0
  167. package/src/update/npm-invocation.mjs +94 -0
  168. package/src/usage/debug.ts +2 -0
  169. package/src/usage/expected-prices.ts +6 -5
  170. package/src/usage/log.ts +12 -0
  171. package/src/web-search/loop.ts +57 -16
  172. package/gui/dist/assets/index-CjKFJHSC.js +0 -65
  173. package/gui/dist/assets/index-DfVGuN88.css +0 -1
package/src/oauth/kimi.ts CHANGED
@@ -3,6 +3,7 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
3
3
  import * as os from "node:os";
4
4
  import { join } from "node:path";
5
5
  import { randomUUID } from "node:crypto";
6
+ import { recordOwnedConfigPath } from "../lib/config-ownership";
6
7
  import { getConfigDir } from "../config";
7
8
  import type { OAuthController, OAuthCredentials } from "./types";
8
9
 
@@ -106,6 +107,7 @@ function getDeviceId(): string {
106
107
  if ((e as { code?: string })?.code !== "ENOENT") throw e;
107
108
  }
108
109
  const id = randomUUID().replace(/-/g, "");
110
+ recordOwnedConfigPath(getConfigDir(), p);
109
111
  if (!existsSync(getConfigDir())) mkdirSync(getConfigDir(), { recursive: true });
110
112
  writeFileSync(p, id + "\n", { mode: 0o600 });
111
113
  deviceIdCache = id;
@@ -1,9 +1,16 @@
1
- import { existsSync, readFileSync } from "node:fs";
1
+ import { randomUUID } from "node:crypto";
2
+ import { chmodSync, closeSync, existsSync, fsyncSync, linkSync, openSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
2
3
  import { homedir } from "node:os";
3
- import { isAbsolute, join } from "node:path";
4
+ import { isAbsolute, join, posix, win32 } from "node:path";
4
5
  import { Database } from "bun:sqlite";
5
6
 
6
7
  const DEFAULT_EXPIRES_MS = 3600_000;
8
+ const KIRO_CLI_RECOVERY_SUFFIX = ".opencodex-recovery";
9
+ const KIRO_CLI_RECOVERY_HEADER_V1 = Buffer.from("opencodex-kiro-session-v1\n", "utf8");
10
+ const KIRO_CLI_RECOVERY_HEADER = Buffer.from("opencodex-kiro-session-v2\n", "utf8");
11
+ const KIRO_CLI_RECOVERY_PROCESS_INSTANCE = randomUUID();
12
+ const KIRO_CLI_RECOVERY_PROCESS_INSTANCE_PATTERN = /^[A-Za-z0-9._-]{1,128}$/;
13
+ const SQLITE_DATABASE_HEADER = Buffer.from("SQLite format 3\0", "binary");
7
14
  const KIRO_REGION_PATTERN = /^[a-z]{2}(?:-[a-z]+)+-\d$/;
8
15
  const CLIENT_ID_HASH_PATTERN = /^[A-Za-z0-9_-]{1,128}$/;
9
16
  const TOKEN_KEYS = [
@@ -32,7 +39,14 @@ export type KiroDiagnosticStatus =
32
39
  | "registration_found";
33
40
 
34
41
  export interface KiroImportDiagnostic {
35
- location: "kiro-creds-file" | "kiro-cli-db-env" | "kiro-cli-data" | "kiro-cli-linux-data" | "amazon-q-data" | "kiro-sso-cache";
42
+ location:
43
+ | "kiro-creds-file"
44
+ | "kiro-cli-db-env"
45
+ | "kiro-cli-data"
46
+ | "kiro-cli-linux-data"
47
+ | "kiro-cli-windows-data"
48
+ | "amazon-q-data"
49
+ | "kiro-sso-cache";
36
50
  status: KiroDiagnosticStatus;
37
51
  }
38
52
 
@@ -49,6 +63,13 @@ export interface ImportedKiroCredential {
49
63
  clientSecret?: string;
50
64
  }
51
65
 
66
+ /** Opaque backup retained on disk while a forced Kiro CLI login is pending persistence. */
67
+ export interface KiroCliSessionSnapshot {
68
+ readonly path: string;
69
+ readonly database: Buffer;
70
+ readonly recoveryPath: string;
71
+ }
72
+
52
73
  type JsonObject = Record<string, unknown>;
53
74
 
54
75
  function userHome(): string {
@@ -108,18 +129,72 @@ function jsonCredentialPaths(): string[] {
108
129
  .map(expandPath);
109
130
  }
110
131
 
132
+ export type KiroCliNativeLocation = "kiro-cli-data" | "kiro-cli-linux-data" | "kiro-cli-windows-data";
133
+
134
+ export interface KiroCliNativeInputs {
135
+ env: Record<string, string | undefined>;
136
+ platform: NodeJS.Platform;
137
+ home: string;
138
+ }
139
+
140
+ /**
141
+ * Native kiro-cli session stores, as a pure function of (env, platform, home).
142
+ *
143
+ * Pure + parameterized following `src/claude/desktop-3p-paths.ts`: `process.platform` is stubbable
144
+ * in this repo, but `os.platform()` does NOT follow it under Bun, so a pure resolver is the reliable
145
+ * way to exercise the win32 branch (and its env fallbacks) on a macOS/Linux host.
146
+ *
147
+ * Windows (issue #710): the official installer stores the auth DB at
148
+ * `%LOCALAPPDATA%\Kiro-Cli\data.sqlite3`. When LOCALAPPDATA is unset or blank, fall back to
149
+ * `%USERPROFILE%\AppData\Local`, then to the injected platform-native home. Deliberately NOT
150
+ * `userHome()`: that is `HOME || homedir()` and Windows shells (Git Bash / MSYS / CI) routinely
151
+ * export a POSIX-style `HOME`, which would point this at a non-native path.
152
+ *
153
+ * One entry per platform on purpose: this list also drives forced-login snapshot/rollback, so it
154
+ * must name the database the LOCAL kiro-cli actually mutates, never a foreign platform's path.
155
+ */
156
+ export function resolveKiroCliNativeSessionEntries(
157
+ inputs: KiroCliNativeInputs,
158
+ ): Array<{ location: KiroCliNativeLocation; path: string }> {
159
+ const { env, platform, home } = inputs;
160
+ if (platform === "win32") {
161
+ const base = env.LOCALAPPDATA?.trim()
162
+ || (env.USERPROFILE?.trim() ? win32.join(env.USERPROFILE.trim(), "AppData", "Local") : "")
163
+ || win32.join(home, "AppData", "Local");
164
+ return [{ location: "kiro-cli-windows-data", path: win32.join(base, "Kiro-Cli", "data.sqlite3") }];
165
+ }
166
+ if (platform === "darwin") {
167
+ return [{ location: "kiro-cli-data", path: posix.join(home, "Library", "Application Support", "kiro-cli", "data.sqlite3") }];
168
+ }
169
+ return [{ location: "kiro-cli-linux-data", path: posix.join(home, ".local", "share", "kiro-cli", "data.sqlite3") }];
170
+ }
171
+
172
+ function nativeKiroCliSessionEntries(): Array<{ location: KiroCliNativeLocation; path: string }> {
173
+ // Only the stores that `kiro-cli logout` / `kiro-cli login` themselves mutate. Import fallbacks
174
+ // (Amazon Q / SSO cache) and KIROCLI_DB_PATH selectors must not be snapshotted for rollback.
175
+ return resolveKiroCliNativeSessionEntries({
176
+ env: process.env,
177
+ platform: process.platform,
178
+ // POSIX keeps HOME-first userHome() so existing HOME-based fixtures still resolve; win32 prefers
179
+ // LOCALAPPDATA/USERPROFILE and only falls back to this platform-native home.
180
+ home: process.platform === "win32" ? homedir() : userHome(),
181
+ });
182
+ }
183
+
111
184
  function sqliteEntries(): Array<{ location: KiroImportDiagnostic["location"]; path: string }> {
112
- const home = userHome();
113
185
  const configured = process.env.KIROCLI_DB_PATH?.trim() || process.env.KIRO_CLI_DB_FILE?.trim();
114
186
  if (configured) return [{ location: "kiro-cli-db-env", path: expandPath(configured) }];
115
187
  return [
116
- { location: "kiro-cli-data", path: join(home, "Library", "Application Support", "kiro-cli", "data.sqlite3") },
117
- { location: "kiro-cli-linux-data", path: join(home, ".local", "share", "kiro-cli", "data.sqlite3") },
118
- { location: "amazon-q-data", path: join(home, ".local", "share", "amazon-q", "data.sqlite3") },
119
- { location: "kiro-sso-cache", path: join(home, ".kiro", "sso", "cache.db") },
188
+ ...nativeKiroCliSessionEntries(),
189
+ { location: "amazon-q-data", path: join(userHome(), ".local", "share", "amazon-q", "data.sqlite3") },
190
+ { location: "kiro-sso-cache", path: join(userHome(), ".kiro", "sso", "cache.db") },
120
191
  ];
121
192
  }
122
193
 
194
+ function kiroCliImportSelectorConfigured(): boolean {
195
+ return Boolean(process.env.KIROCLI_DB_PATH?.trim() || process.env.KIRO_CLI_DB_FILE?.trim());
196
+ }
197
+
123
198
  function selectTokenRow(db: Database): { value: string } | null | "ambiguous" | "selected_missing" {
124
199
  const rows = db.query("SELECT key, value FROM auth_kv WHERE key LIKE ? ORDER BY key ASC").all("%:token") as Array<{ key: string; value: string }>;
125
200
  const selectedKey = process.env.KIROCLI_TOKEN_KEY?.trim();
@@ -210,7 +285,16 @@ function readStateProfile(db: Database): { profileArn?: string; apiRegion?: stri
210
285
  }
211
286
  }
212
287
 
213
- function readSqliteCredentials(diagnostics: KiroImportDiagnostic[]): ImportedKiroCredential | undefined {
288
+ interface LocatedKiroCliCredential {
289
+ credential: ImportedKiroCredential;
290
+ path: string;
291
+ database?: Buffer;
292
+ }
293
+
294
+ function readSqliteCredentials(
295
+ diagnostics: KiroImportDiagnostic[],
296
+ includeSnapshot = false,
297
+ ): LocatedKiroCliCredential | undefined {
214
298
  for (const { location, path } of sqliteEntries()) {
215
299
  if (!existsSync(path)) {
216
300
  diagnostics.push({ location, status: "missing" });
@@ -261,7 +345,13 @@ function readSqliteCredentials(diagnostics: KiroImportDiagnostic[]): ImportedKir
261
345
  const merged = { ...registrationData, ...tokenData, ...profile };
262
346
  const credential = credentialFromJson(merged, "sqlite");
263
347
  diagnostics.push({ location, status: credential ? "token_found" : "token_missing" });
264
- if (credential) return credential;
348
+ if (credential) {
349
+ return {
350
+ credential,
351
+ path,
352
+ ...(includeSnapshot ? { database: db.serialize() } : {}),
353
+ };
354
+ }
265
355
  } catch (error) {
266
356
  if (error instanceof Error && error.message.includes("KIROCLI_TOKEN_KEY")) throw error;
267
357
  diagnostics.push({ location, status: "schema_mismatch" });
@@ -277,12 +367,12 @@ export function inspectKiroCredentialSources(): { credential: ImportedKiroCreden
277
367
  const json = readJsonCredentials(diagnostics);
278
368
  if (json) return { credential: json, diagnostics };
279
369
  const sqlite = readSqliteCredentials(diagnostics);
280
- return { credential: sqlite ?? null, diagnostics };
370
+ return { credential: sqlite?.credential ?? null, diagnostics };
281
371
  }
282
372
 
283
373
  export function inspectKiroCliSqliteSources(): { credential: ImportedKiroCredential | null; diagnostics: KiroImportDiagnostic[] } {
284
374
  const diagnostics: KiroImportDiagnostic[] = [];
285
- return { credential: readSqliteCredentials(diagnostics) ?? null, diagnostics };
375
+ return { credential: readSqliteCredentials(diagnostics)?.credential ?? null, diagnostics };
286
376
  }
287
377
 
288
378
  export function readImportedKiroCredential(): ImportedKiroCredential | null {
@@ -292,3 +382,274 @@ export function readImportedKiroCredential(): ImportedKiroCredential | null {
292
382
  export function readKiroCliSqliteCredential(): ImportedKiroCredential | null {
293
383
  return inspectKiroCliSqliteSources().credential;
294
384
  }
385
+
386
+ /** Capture the complete active CLI database so a failed account switch can restore it exactly. */
387
+ export function snapshotKiroCliSession(): KiroCliSessionSnapshot | null {
388
+ return inspectKiroCliSessionSnapshot().snapshot;
389
+ }
390
+
391
+ /**
392
+ * A store that exists but cannot be captured must never be logged out of: the recovery contract
393
+ * promises an exact restore, and without a snapshot a later failure would destroy the session for
394
+ * good. `missing`/`token_missing` are the only statuses that mean "there is nothing to lose".
395
+ */
396
+ const KIRO_UNSNAPSHOTTABLE_SESSION_STATUSES: ReadonlySet<KiroDiagnosticStatus> = new Set([
397
+ "unreadable",
398
+ "schema_mismatch",
399
+ "invalid_json",
400
+ "token_ambiguous",
401
+ "token_key_missing",
402
+ "token_found",
403
+ ]);
404
+
405
+ /**
406
+ * Capture the active CLI session and report why capture failed. `blocked` is true when a session
407
+ * store is present but could not be snapshotted (unreadable / schema-mismatched / ambiguous), so
408
+ * callers can abort before mutating it.
409
+ *
410
+ * Forced-login rollback must target the exact database `kiro-cli` mutates. Custom import selectors
411
+ * and lower-priority Amazon Q / SSO caches are never used here: snapshotting the wrong file would
412
+ * leave the real CLI session switched or lost after failure.
413
+ */
414
+ export function inspectKiroCliSessionSnapshot(): {
415
+ snapshot: KiroCliSessionSnapshot | null;
416
+ diagnostics: KiroImportDiagnostic[];
417
+ blocked: boolean;
418
+ } {
419
+ const diagnostics: KiroImportDiagnostic[] = [];
420
+ if (kiroCliImportSelectorConfigured()) {
421
+ diagnostics.push({ location: "kiro-cli-db-env", status: "token_ambiguous" });
422
+ return { snapshot: null, diagnostics, blocked: true };
423
+ }
424
+
425
+ for (const { location, path } of nativeKiroCliSessionEntries()) {
426
+ if (!existsSync(path)) {
427
+ diagnostics.push({ location, status: "missing" });
428
+ continue;
429
+ }
430
+
431
+ let db: Database | undefined;
432
+ try {
433
+ db = new Database(path, { readonly: true });
434
+ try { db.exec("PRAGMA busy_timeout = 5000"); } catch { /* read-only best effort */ }
435
+ } catch {
436
+ diagnostics.push({ location, status: "unreadable" });
437
+ return { snapshot: null, diagnostics, blocked: true };
438
+ }
439
+
440
+ try {
441
+ const row = selectTokenRow(db);
442
+ if (row === "ambiguous") {
443
+ diagnostics.push({ location, status: "token_ambiguous" });
444
+ return { snapshot: null, diagnostics, blocked: true };
445
+ }
446
+ if (row === "selected_missing") {
447
+ diagnostics.push({ location, status: "token_key_missing" });
448
+ return { snapshot: null, diagnostics, blocked: true };
449
+ }
450
+ if (!row) {
451
+ // An existing CLI database with no recognized token is still what logout mutates. Falling
452
+ // through to Amazon Q / another platform path would snapshot the wrong store.
453
+ diagnostics.push({ location, status: "token_missing" });
454
+ return { snapshot: null, diagnostics, blocked: true };
455
+ }
456
+ try {
457
+ JSON.parse(row.value);
458
+ } catch {
459
+ diagnostics.push({ location, status: "invalid_json" });
460
+ return { snapshot: null, diagnostics, blocked: true };
461
+ }
462
+ const database = db.serialize();
463
+ diagnostics.push({ location, status: "token_found" });
464
+ return {
465
+ snapshot: {
466
+ path,
467
+ database,
468
+ recoveryPath: `${path}${KIRO_CLI_RECOVERY_SUFFIX}`,
469
+ },
470
+ diagnostics,
471
+ blocked: false,
472
+ };
473
+ } catch (error) {
474
+ if (error instanceof Error && error.message.includes("KIROCLI_TOKEN_KEY")) {
475
+ diagnostics.push({ location, status: "token_key_missing" });
476
+ return { snapshot: null, diagnostics, blocked: true };
477
+ }
478
+ diagnostics.push({ location, status: "schema_mismatch" });
479
+ return { snapshot: null, diagnostics, blocked: true };
480
+ } finally {
481
+ db.close();
482
+ }
483
+ }
484
+
485
+ return {
486
+ snapshot: null,
487
+ diagnostics,
488
+ blocked: diagnostics.some(entry => KIRO_UNSNAPSHOTTABLE_SESSION_STATUSES.has(entry.status)),
489
+ };
490
+ }
491
+
492
+ /** Persist a complete, private recovery image before kiro-cli is allowed to mutate its store. */
493
+ export function persistKiroCliSessionRecovery(snapshot: KiroCliSessionSnapshot): void {
494
+ if (existsSync(snapshot.recoveryPath)) {
495
+ throw new Error("Kiro CLI session recovery is already pending.");
496
+ }
497
+ const nonce = `${process.pid}.${Date.now()}.${Math.random().toString(16).slice(2)}`;
498
+ const staged = `${snapshot.recoveryPath}.${nonce}.tmp`;
499
+ try {
500
+ writeFileSync(staged, Buffer.concat([
501
+ KIRO_CLI_RECOVERY_HEADER,
502
+ Buffer.from(`${process.pid}\n`, "utf8"),
503
+ Buffer.from(`${KIRO_CLI_RECOVERY_PROCESS_INSTANCE}\n`, "utf8"),
504
+ snapshot.database,
505
+ ]), { flag: "wx", mode: 0o600 });
506
+ try { chmodSync(staged, 0o600); } catch { /* platform may ignore chmod */ }
507
+ const fd = openSync(staged, "r+");
508
+ try { fsyncSync(fd); } finally { closeSync(fd); }
509
+ // A hard link publishes the fsynced inode atomically and, unlike rename, can never replace
510
+ // another process's live recovery transaction if both raced past the initial existence check.
511
+ linkSync(staged, snapshot.recoveryPath);
512
+ } finally {
513
+ rmSync(staged, { force: true });
514
+ }
515
+ }
516
+
517
+ /** Remove recovery data only after the corresponding login transaction has settled. */
518
+ export function discardKiroCliSessionRecovery(snapshot: KiroCliSessionSnapshot): void {
519
+ rmSync(snapshot.recoveryPath, { force: true });
520
+ }
521
+
522
+ interface ParsedKiroCliSessionRecovery {
523
+ ownerPid: number;
524
+ ownerProcessInstance?: string;
525
+ database: Buffer;
526
+ }
527
+
528
+ function parseKiroCliSessionRecovery(payload: Buffer): ParsedKiroCliSessionRecovery | null {
529
+ const isV2 = payload.subarray(0, KIRO_CLI_RECOVERY_HEADER.length).equals(KIRO_CLI_RECOVERY_HEADER);
530
+ const isV1 = payload.subarray(0, KIRO_CLI_RECOVERY_HEADER_V1.length).equals(KIRO_CLI_RECOVERY_HEADER_V1);
531
+ if (!isV2 && !isV1) return null;
532
+ const headerLength = isV2 ? KIRO_CLI_RECOVERY_HEADER.length : KIRO_CLI_RECOVERY_HEADER_V1.length;
533
+ const ownerEnd = payload.indexOf(0x0a, headerLength);
534
+ if (ownerEnd <= headerLength) return null;
535
+ const ownerPid = Number(payload.subarray(headerLength, ownerEnd).toString("utf8"));
536
+ let databaseStart = ownerEnd + 1;
537
+ let ownerProcessInstance: string | undefined;
538
+ if (isV2) {
539
+ const instanceEnd = payload.indexOf(0x0a, databaseStart);
540
+ if (instanceEnd <= databaseStart) return null;
541
+ ownerProcessInstance = payload.subarray(databaseStart, instanceEnd).toString("utf8");
542
+ if (!KIRO_CLI_RECOVERY_PROCESS_INSTANCE_PATTERN.test(ownerProcessInstance)) return null;
543
+ databaseStart = instanceEnd + 1;
544
+ }
545
+ const database = payload.subarray(databaseStart);
546
+ if (
547
+ !Number.isSafeInteger(ownerPid) || ownerPid <= 0 ||
548
+ !database.subarray(0, SQLITE_DATABASE_HEADER.length).equals(SQLITE_DATABASE_HEADER)
549
+ ) {
550
+ return null;
551
+ }
552
+ return { ownerPid, ...(ownerProcessInstance ? { ownerProcessInstance } : {}), database };
553
+ }
554
+
555
+ function isKiroRecoveryOwnerAlive(ownerPid: number, ownerProcessInstance?: string): boolean {
556
+ // A restarted supervisor may reuse the exact same PID (commonly PID 1). Only this process
557
+ // instance's nonce proves that a same-PID recovery transaction is still live.
558
+ if (ownerPid === process.pid) return ownerProcessInstance === KIRO_CLI_RECOVERY_PROCESS_INSTANCE;
559
+ try {
560
+ process.kill(ownerPid, 0);
561
+ return true;
562
+ } catch (error) {
563
+ if (error && typeof error === "object" && "code" in error) return error.code !== "ESRCH";
564
+ return true;
565
+ }
566
+ }
567
+
568
+ /** Restore a previously captured CLI database after every kiro-cli child process has exited. */
569
+ export function restoreKiroCliSession(snapshot: KiroCliSessionSnapshot): void {
570
+ const nonce = `${process.pid}.${Date.now()}.${Math.random().toString(16).slice(2)}`;
571
+ const staged = `${snapshot.path}.ocx-restore.${nonce}.tmp`;
572
+ const displacedBase = `${snapshot.path}.ocx-restore.${nonce}.new`;
573
+ const displaced: Array<{ current: string; backup: string }> = [];
574
+ let published = false;
575
+ try {
576
+ writeFileSync(staged, snapshot.database, { mode: 0o600 });
577
+ try { chmodSync(staged, 0o600); } catch { /* platform may ignore chmod */ }
578
+ for (const suffix of ["", "-wal", "-shm", "-journal"]) {
579
+ const current = `${snapshot.path}${suffix}`;
580
+ if (!existsSync(current)) continue;
581
+ const backup = `${displacedBase}${suffix || ".db"}`;
582
+ renameSync(current, backup);
583
+ displaced.push({ current, backup });
584
+ }
585
+ renameSync(staged, snapshot.path);
586
+ published = true;
587
+ } catch (error) {
588
+ const recoveryErrors: unknown[] = [];
589
+ for (const { current, backup } of [...displaced].reverse()) {
590
+ if (!existsSync(backup) || existsSync(current)) continue;
591
+ try { renameSync(backup, current); } catch (recoveryError) { recoveryErrors.push(recoveryError); }
592
+ }
593
+ if (recoveryErrors.length > 0) throw new AggregateError([error, ...recoveryErrors], "Kiro CLI session restore failed during rollback.");
594
+ throw error;
595
+ } finally {
596
+ rmSync(staged, { force: true });
597
+ if (published) {
598
+ for (const { backup } of displaced) {
599
+ try { rmSync(backup, { force: true }); } catch { /* restored database is already published */ }
600
+ }
601
+ }
602
+ }
603
+ }
604
+
605
+ /** Restore a transaction abandoned by a crashed process before starting another forced login. */
606
+ export function restoreStaleKiroCliSessionRecovery(): boolean {
607
+ for (const { path } of nativeKiroCliSessionEntries()) {
608
+ const recoveryPath = `${path}${KIRO_CLI_RECOVERY_SUFFIX}`;
609
+ if (!existsSync(recoveryPath)) continue;
610
+ const payload = readFileSync(recoveryPath);
611
+ const recovery = parseKiroCliSessionRecovery(payload);
612
+ if (!recovery) {
613
+ throw new Error(
614
+ `Kiro CLI session recovery data is invalid: ${recoveryPath}. Remove this file to continue.`,
615
+ );
616
+ }
617
+ if (isKiroRecoveryOwnerAlive(recovery.ownerPid, recovery.ownerProcessInstance)) {
618
+ throw new Error(
619
+ `Another Kiro CLI login transaction is still in progress (pid ${recovery.ownerPid}, ${recoveryPath}).`,
620
+ );
621
+ }
622
+ // Atomically claim the marker before restoring so a concurrent process cannot restore the
623
+ // same stale image (and later delete a newer recovery file published by the winner).
624
+ const claimedPath = `${recoveryPath}.claimed.${process.pid}.${Date.now()}`;
625
+ try {
626
+ renameSync(recoveryPath, claimedPath);
627
+ } catch {
628
+ continue;
629
+ }
630
+ try {
631
+ const claimed = readFileSync(claimedPath);
632
+ if (!claimed.equals(payload)) {
633
+ // Put the unexpected claim back so an operator / later process can inspect it.
634
+ try { renameSync(claimedPath, recoveryPath); } catch { /* keep claimed path for manual recovery */ }
635
+ throw new Error(
636
+ `Kiro CLI session recovery data changed while claiming ${recoveryPath}. Remove leftover claim files under the kiro-cli data directory and retry.`,
637
+ );
638
+ }
639
+ restoreKiroCliSession({
640
+ path,
641
+ database: recovery.database,
642
+ recoveryPath: claimedPath,
643
+ });
644
+ rmSync(claimedPath, { force: true });
645
+ return true;
646
+ } catch (error) {
647
+ // Preserve the only copy of the prior database when restore fails (disk full, permissions).
648
+ if (existsSync(claimedPath) && !existsSync(recoveryPath)) {
649
+ try { renameSync(claimedPath, recoveryPath); } catch { /* claimed file remains for manual recovery */ }
650
+ }
651
+ throw error;
652
+ }
653
+ }
654
+ return false;
655
+ }