kimaki 0.10.1 → 0.10.2

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.
@@ -938,35 +938,16 @@ export async function run({ restartOnboarding, addChannels, useWorktrees, enable
938
938
  startCaffeinate();
939
939
  const forceRestartOnboarding = Boolean(restartOnboarding);
940
940
  const forceGateway = Boolean(gateway);
941
- // Step 0: Ensure required CLI tools are installed (OpenCode + Bun).
942
- // Run checks in parallel since they're independent `which` calls.
943
- await Promise.all([
944
- ensureCommandAvailable({
945
- name: 'opencode',
946
- envPathKey: 'OPENCODE_PATH',
947
- installUnix: 'curl -fsSL https://opencode.ai/install | bash',
948
- installWindows: 'irm https://opencode.ai/install.ps1 | iex',
949
- possiblePathsUnix: [
950
- '~/.local/bin/opencode',
951
- '~/.opencode/bin/opencode',
952
- '/usr/local/bin/opencode',
953
- '/opt/opencode/bin/opencode',
954
- ],
955
- possiblePathsWindows: [
956
- '~\\.local\\bin\\opencode.exe',
957
- '~\\AppData\\Local\\opencode\\opencode.exe',
958
- '~\\.opencode\\bin\\opencode.exe',
959
- ],
960
- }),
961
- ensureCommandAvailable({
962
- name: 'bun',
963
- envPathKey: 'BUN_PATH',
964
- installUnix: 'curl -fsSL https://bun.sh/install | bash',
965
- installWindows: 'irm bun.sh/install.ps1 | iex',
966
- possiblePathsUnix: ['~/.bun/bin/bun', '/usr/local/bin/bun'],
967
- possiblePathsWindows: ['~\\.bun\\bin\\bun.exe'],
968
- }),
969
- ]);
941
+ // Step 0: Ensure bun is installed. OpenCode is downloaded on first run
942
+ // to ~/.kimaki/bin/ (pinned version), so no install check is needed.
943
+ await ensureCommandAvailable({
944
+ name: 'bun',
945
+ envPathKey: 'BUN_PATH',
946
+ installUnix: 'curl -fsSL https://bun.sh/install | bash',
947
+ installWindows: 'irm bun.sh/install.ps1 | iex',
948
+ possiblePathsUnix: ['~/.bun/bin/bun', '/usr/local/bin/bun'],
949
+ possiblePathsWindows: ['~\\.bun\\bin\\bun.exe'],
950
+ });
970
951
  void backgroundUpgradeKimaki();
971
952
  // Start in-process Hrana server before database init. Required for the bot
972
953
  // process because it serves as both the DB server and the single-instance
@@ -47,7 +47,7 @@ test('opencode server loads plugin without errors', async () => {
47
47
  fs.mkdirSync(directory, { recursive: true });
48
48
  });
49
49
  const { command, args, windowsVerbatimArguments, } = getSpawnCommandAndArgs({
50
- resolvedCommand: resolveOpencodeCommand(),
50
+ resolvedCommand: await resolveOpencodeCommand(),
51
51
  baseArgs: ['serve', '--port', port.toString(), '--print-logs', '--log-level', 'DEBUG'],
52
52
  });
53
53
  const serverProcess = spawn(command, args, {
package/dist/opencode.js CHANGED
@@ -255,8 +255,164 @@ function ensureProcessCleanupHandlersRegistered() {
255
255
  // cleanup sends SIGTERM it only kills the shell, leaving the actual opencode
256
256
  // process orphaned (reparented to PID 1). Resolving the path upfront lets
257
257
  // us spawn the binary directly and SIGTERM reaches the right process.
258
+ //
259
+ // Resolution order:
260
+ // 1. OPENCODE_PATH env var (explicit user override)
261
+ // 2. Downloaded binary in ~/.kimaki/bin/opencode-{VERSION}
262
+ //
263
+ // The binary is downloaded from GitHub releases on first run and cached.
264
+ // Version is pinned in code so kimaki is not affected by bugged releases.
265
+ // Always stored in the default data dir (~/.kimaki) regardless of custom
266
+ // data dir settings, so one binary serves all configurations.
267
+ const OPENCODE_VERSION = '1.14.41';
268
+ // Always use ~/.kimaki/bin for the opencode binary, regardless of custom data dir.
269
+ function getOpencodeBinDir() {
270
+ const defaultDataDir = path.join(os.homedir(), '.kimaki');
271
+ return path.join(defaultDataDir, 'bin');
272
+ }
273
+ function getOpencodeBinaryPath() {
274
+ const binaryName = process.platform === 'win32' ? `opencode-${OPENCODE_VERSION}.exe` : `opencode-${OPENCODE_VERSION}`;
275
+ return path.join(getOpencodeBinDir(), binaryName);
276
+ }
277
+ // Returns candidate target names ordered by preference, matching the logic in
278
+ // the official opencode install script (musl detection, AVX2/baseline fallback).
279
+ function getOpencodeDownloadCandidates() {
280
+ const platformMap = { darwin: 'darwin', linux: 'linux', win32: 'windows' };
281
+ const archMap = { x64: 'x64', arm64: 'arm64', arm: 'arm' };
282
+ const platform = platformMap[process.platform] || process.platform;
283
+ const arch = archMap[process.arch] || process.arch;
284
+ const base = `${platform}-${arch}`;
285
+ if (platform !== 'linux') {
286
+ // macOS/Windows: no musl, just check baseline for x64
287
+ if (arch === 'x64')
288
+ return [`${base}-baseline`, base];
289
+ return [base];
290
+ }
291
+ // Linux: detect musl libc
292
+ const isMusl = (() => {
293
+ try {
294
+ if (fs.existsSync('/etc/alpine-release'))
295
+ return true;
296
+ }
297
+ catch { /* ignore */ }
298
+ try {
299
+ const out = execFileSync('ldd', ['--version'], { encoding: 'utf8', timeout: 3000, stdio: ['pipe', 'pipe', 'pipe'] });
300
+ if (out.toLowerCase().includes('musl'))
301
+ return true;
302
+ }
303
+ catch (e) {
304
+ // ldd --version writes to stderr on musl systems and exits non-zero
305
+ if (e && typeof e === 'object' && 'stderr' in e) {
306
+ const stderr = String(e.stderr);
307
+ if (stderr.toLowerCase().includes('musl'))
308
+ return true;
309
+ }
310
+ }
311
+ return false;
312
+ })();
313
+ if (arch === 'x64') {
314
+ // x64 Linux: prefer baseline (safe on all x64 CPUs) then full, with musl variants
315
+ if (isMusl)
316
+ return [`${base}-baseline-musl`, `${base}-musl`, `${base}-baseline`, base];
317
+ return [`${base}-baseline`, base, `${base}-baseline-musl`, `${base}-musl`];
318
+ }
319
+ // arm64 Linux
320
+ if (isMusl)
321
+ return [`${base}-musl`, base];
322
+ return [base, `${base}-musl`];
323
+ }
324
+ async function downloadOpencodeIfMissing() {
325
+ const binaryPath = getOpencodeBinaryPath();
326
+ if (fs.existsSync(binaryPath)) {
327
+ return binaryPath;
328
+ }
329
+ const binDir = getOpencodeBinDir();
330
+ fs.mkdirSync(binDir, { recursive: true });
331
+ const candidates = getOpencodeDownloadCandidates();
332
+ const ext = process.platform === 'linux' ? '.tar.gz' : '.zip';
333
+ const extractedName = process.platform === 'win32' ? 'opencode.exe' : 'opencode';
334
+ // Use a unique temp dir to avoid races between concurrent first-run processes
335
+ const tmpDir = fs.mkdtempSync(path.join(binDir, '.opencode-dl-'));
336
+ const tmpArchive = path.join(tmpDir, `archive${ext}`);
337
+ const { spinner } = await import('@clack/prompts');
338
+ const s = spinner();
339
+ s.start(`Downloading opencode v${OPENCODE_VERSION}...\n`);
340
+ try {
341
+ // Try each candidate URL until one succeeds (handles musl/baseline variants)
342
+ let downloaded = false;
343
+ for (const candidate of candidates) {
344
+ const url = `https://github.com/anomalyco/opencode/releases/download/v${OPENCODE_VERSION}/opencode-${candidate}${ext}`;
345
+ const response = await fetch(url, { redirect: 'follow', signal: AbortSignal.timeout(120_000) });
346
+ if (response.status === 404)
347
+ continue;
348
+ if (!response.ok) {
349
+ throw new Error(`Failed to download opencode: HTTP ${response.status} from ${url}`);
350
+ }
351
+ fs.writeFileSync(tmpArchive, Buffer.from(await response.arrayBuffer()));
352
+ downloaded = true;
353
+ break;
354
+ }
355
+ if (!downloaded) {
356
+ throw new Error(`No opencode binary found for platform ${process.platform}/${process.arch} (tried: ${candidates.join(', ')})`);
357
+ }
358
+ // Extract into the temp dir
359
+ const { execFileSync } = await import('node:child_process');
360
+ if (process.platform === 'linux') {
361
+ execFileSync('tar', ['xzf', tmpArchive, '-C', tmpDir], { timeout: 30_000 });
362
+ }
363
+ else if (process.platform === 'win32') {
364
+ execFileSync('powershell.exe', [
365
+ '-NoProfile', '-Command',
366
+ `Expand-Archive -LiteralPath '${tmpArchive.replaceAll("'", "''")}' -DestinationPath '${tmpDir.replaceAll("'", "''")}' -Force`,
367
+ ], { timeout: 30_000 });
368
+ }
369
+ else {
370
+ execFileSync('unzip', ['-o', tmpArchive, '-d', tmpDir], { timeout: 30_000 });
371
+ }
372
+ // Move extracted binary to versioned path
373
+ const extractedPath = path.join(tmpDir, extractedName);
374
+ if (!fs.existsSync(extractedPath)) {
375
+ throw new Error(`Expected binary not found after extraction: ${extractedPath}`);
376
+ }
377
+ fs.chmodSync(extractedPath, 0o755);
378
+ try {
379
+ fs.renameSync(extractedPath, binaryPath);
380
+ }
381
+ catch {
382
+ // Another concurrent process may have won the race; that's fine
383
+ if (fs.existsSync(binaryPath))
384
+ return binaryPath;
385
+ throw new Error(`Failed to move opencode binary to ${binaryPath}`);
386
+ }
387
+ // Delete old versions (match opencode-X.Y.Z or opencode-X.Y.Z.exe exactly)
388
+ const currentName = path.basename(binaryPath);
389
+ for (const entry of fs.readdirSync(binDir)) {
390
+ if (entry === currentName)
391
+ continue;
392
+ if (/^opencode-\d+\.\d+\.\d+(?:\.exe)?$/.test(entry)) {
393
+ try {
394
+ fs.unlinkSync(path.join(binDir, entry));
395
+ }
396
+ catch { /* ignore */ }
397
+ }
398
+ }
399
+ s.stop(`Downloaded opencode v${OPENCODE_VERSION}`);
400
+ return binaryPath;
401
+ }
402
+ catch (error) {
403
+ s.stop('Failed to download opencode');
404
+ throw error;
405
+ }
406
+ finally {
407
+ // Clean up temp dir
408
+ try {
409
+ fs.rmSync(tmpDir, { recursive: true, force: true });
410
+ }
411
+ catch { /* ignore */ }
412
+ }
413
+ }
258
414
  let resolvedOpencodeCommand = null;
259
- export function resolveOpencodeCommand() {
415
+ export async function resolveOpencodeCommand() {
260
416
  if (resolvedOpencodeCommand) {
261
417
  return resolvedOpencodeCommand;
262
418
  }
@@ -268,37 +424,13 @@ export function resolveOpencodeCommand() {
268
424
  });
269
425
  if (resolvedFromEnv) {
270
426
  resolvedOpencodeCommand = resolvedFromEnv;
427
+ opencodeLogger.log(`Resolved opencode binary from OPENCODE_PATH: ${resolvedFromEnv}`);
271
428
  return resolvedFromEnv;
272
429
  }
273
430
  }
274
- const isWindows = process.platform === 'win32';
275
- const whichCmd = isWindows ? 'where' : 'which';
276
- const result = errore.try({
277
- try: () => {
278
- const commandOutput = execFileSync(whichCmd, ['opencode'], {
279
- encoding: 'utf8',
280
- timeout: 5000,
281
- });
282
- const resolved = selectResolvedCommand({
283
- output: commandOutput,
284
- isWindows,
285
- });
286
- if (resolved) {
287
- return resolved;
288
- }
289
- throw new Error('opencode not found in PATH');
290
- },
291
- catch: () => new Error('opencode not found in PATH'),
292
- });
293
- if (result instanceof Error) {
294
- // Fall back to bare command name — spawn will fail with a clear error
295
- // if it can't find the binary.
296
- opencodeLogger.warn('Could not resolve opencode path via which, falling back to "opencode"');
297
- return 'opencode';
298
- }
299
- resolvedOpencodeCommand = result;
300
- opencodeLogger.log(`Resolved opencode binary: ${result}`);
301
- return result;
431
+ const downloaded = await downloadOpencodeIfMissing();
432
+ resolvedOpencodeCommand = downloaded;
433
+ return downloaded;
302
434
  }
303
435
  async function getOpenPort() {
304
436
  return new Promise((resolve, reject) => {
@@ -394,7 +526,7 @@ async function startSingleServer({ directory, } = {}) {
394
526
  'WARN',
395
527
  ];
396
528
  const { command: spawnCommand, args: spawnArgs, windowsVerbatimArguments, } = getSpawnCommandAndArgs({
397
- resolvedCommand: resolveOpencodeCommand(),
529
+ resolvedCommand: await resolveOpencodeCommand(),
398
530
  baseArgs: serveArgs,
399
531
  });
400
532
  // Server config uses permissive defaults. Per-directory external_directory
@@ -962,15 +962,15 @@ export class ThreadSessionRuntime {
962
962
  backoffMs = Math.min(backoffMs * 2, maxBackoffMs);
963
963
  continue;
964
964
  }
965
- // Reset backoff on successful connection
966
- backoffMs = 500;
967
965
  const events = subscribeResult.stream;
968
966
  logger.log(`[LISTENER] Connected to event stream for thread ${this.threadId}`);
969
967
  // Re-bootstrap sentPartIds on reconnect to prevent re-sending
970
968
  // parts that arrived while we were disconnected.
971
969
  await this.bootstrapSentPartIds();
970
+ let receivedAnyEvent = false;
972
971
  const iterResult = await errore.tryAsync(async () => {
973
972
  for await (const event of events) {
973
+ receivedAnyEvent = true;
974
974
  // Each event is dispatched through the serialized action queue
975
975
  // to prevent interleaving mutations from concurrent events.
976
976
  await this.dispatchAction(() => {
@@ -978,6 +978,13 @@ export class ThreadSessionRuntime {
978
978
  });
979
979
  }
980
980
  });
981
+ // Only reset backoff when the stream was alive long enough to
982
+ // deliver at least one event. If it closes immediately the server
983
+ // is likely not ready; keep escalating backoff to avoid a tight
984
+ // reconnect loop (GitHub issue #126).
985
+ if (receivedAnyEvent) {
986
+ backoffMs = 500;
987
+ }
981
988
  if (iterResult instanceof Error) {
982
989
  if (isAbortError(iterResult)) {
983
990
  return; // disposed
@@ -987,6 +994,16 @@ export class ThreadSessionRuntime {
987
994
  await delay(backoffMs);
988
995
  backoffMs = Math.min(backoffMs * 2, maxBackoffMs);
989
996
  }
997
+ else {
998
+ // Stream completed normally (server closed the connection).
999
+ // This can happen when the opencode server restarts, the SSE
1000
+ // endpoint times out, or there are no active sessions for this
1001
+ // directory. Without a delay here the loop reconnects immediately,
1002
+ // creating a tight infinite reconnect loop (GitHub issue #126).
1003
+ logger.log(`[LISTENER] Stream ended normally for thread ${this.threadId}, reconnecting in ${backoffMs}ms`);
1004
+ await delay(backoffMs);
1005
+ backoffMs = Math.min(backoffMs * 2, maxBackoffMs);
1006
+ }
990
1007
  }
991
1008
  }
992
1009
  // ── Session Demux Guard ─────────────────────────────────────
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "kimaki",
3
3
  "module": "index.ts",
4
4
  "type": "module",
5
- "version": "0.10.1",
5
+ "version": "0.10.2",
6
6
  "repository": "https://github.com/remorses/kimaki",
7
7
  "bin": "bin.js",
8
8
  "files": [
@@ -25,8 +25,8 @@
25
25
  "tsx": "^4.20.5",
26
26
  "undici": "^8.0.2",
27
27
  "discord-digital-twin": "^0.1.0",
28
- "db": "^0.0.0",
29
28
  "opencode-cached-provider": "^0.0.1",
29
+ "db": "^0.0.0",
30
30
  "opencode-deterministic-provider": "^0.0.1"
31
31
  },
32
32
  "dependencies": {
@@ -52,7 +52,6 @@
52
52
  "libsql": "^0.5.22",
53
53
  "marked": "^17.0.5",
54
54
  "mime": "^4.1.0",
55
- "opencode-ai": "1.14.41",
56
55
  "opusscript": "^0.0.8",
57
56
  "picocolors": "^1.1.1",
58
57
  "pretty-ms": "^9.3.0",
@@ -64,9 +63,9 @@
64
63
  "zod": "^4.3.6",
65
64
  "zustand": "^5.0.11",
66
65
  "errore": "^0.14.1",
67
- "opencode-injection-guard": "^0.2.1",
66
+ "traforo": "^0.5.0",
68
67
  "libsqlproxy": "^0.1.0",
69
- "traforo": "^0.5.0"
68
+ "opencode-injection-guard": "^0.2.1"
70
69
  },
71
70
  "optionalDependencies": {
72
71
  "@snazzah/davey": "^0.1.10",
@@ -337,3 +337,156 @@ cli
337
337
  }
338
338
  })
339
339
  ```
340
+
341
+ ## Remote Server Auth & Config
342
+
343
+ CLIs that talk to a remote server must support multiple server URLs so users can self-host, use a preview/staging environment, or point to localhost during development. Auth tokens and other per-server state live in a JSON config file keyed by API URL.
344
+
345
+ ### Config file location
346
+
347
+ Store config at `~/.cliname/config.json`. The directory is named after the CLI binary. Use `os.homedir()` to resolve `~`.
348
+
349
+ ### Config structure
350
+
351
+ The config is an object keyed by server URL. Each entry holds auth tokens and any other per-server state. The CLI reads/writes only the entry matching the current `--api-url`.
352
+
353
+ ```ts
354
+ // ~/.cliname/config.json
355
+ {
356
+ "https://api.cliname.com": {
357
+ "accessToken": "tok_abc123",
358
+ "refreshToken": "rt_xyz789",
359
+ "expiresAt": "2026-08-01T00:00:00Z"
360
+ },
361
+ "https://staging.cliname.com": {
362
+ "accessToken": "tok_staging_456"
363
+ },
364
+ "http://localhost:3000": {
365
+ "accessToken": "tok_dev_789"
366
+ }
367
+ }
368
+ ```
369
+
370
+ ### Global `--api-url` option
371
+
372
+ Register `--api-url` as a global option with a default pointing to the production hosted service. The `.use()` middleware resolves the final URL from the flag, env var, or default, then **writes it back to `process.env`**. All other code just reads `process.env.CLINAME_API_URL` instead of threading `options.apiUrl` through every function call. This avoids type-safety issues since global options aren't visible in command action types.
373
+
374
+ ```ts
375
+ import { goke } from 'goke'
376
+ import { z } from 'zod'
377
+
378
+ const DEFAULT_API_URL = 'https://api.cliname.com'
379
+
380
+ const cli = goke('cliname')
381
+
382
+ cli.option(
383
+ '--api-url [url]',
384
+ z.string().url().optional().describe('Server URL'),
385
+ )
386
+
387
+ cli.use((options) => {
388
+ const apiUrl = (
389
+ options.apiUrl
390
+ || process.env.CLINAME_API_URL
391
+ || DEFAULT_API_URL
392
+ ).replace(/\/+$/, '') // normalize: strip trailing slash so config keys are consistent
393
+
394
+ process.env.CLINAME_API_URL = apiUrl
395
+ })
396
+ ```
397
+
398
+ After this middleware runs, any module can call `getApiUrl()` without receiving it as a parameter:
399
+
400
+ ```ts
401
+ export function getApiUrl(): string {
402
+ return process.env.CLINAME_API_URL!
403
+ }
404
+ ```
405
+
406
+ ### Reading and writing config
407
+
408
+ Config helpers take the injected `fs` from the action context as an object argument. This keeps them portable across normal Node.js runs and JustBash sandboxes.
409
+
410
+ ```ts
411
+ import path from 'node:path'
412
+ import os from 'node:os'
413
+ import type { GokeFs } from 'goke'
414
+
415
+ const CONFIG_DIR = path.join(os.homedir(), '.cliname')
416
+ const CONFIG_PATH = path.join(CONFIG_DIR, 'config.json')
417
+
418
+ interface ServerConfig {
419
+ accessToken?: string
420
+ refreshToken?: string
421
+ expiresAt?: string
422
+ }
423
+
424
+ type Config = Record<string, ServerConfig>
425
+
426
+ async function loadConfig({ fs }: { fs: GokeFs }): Promise<Config> {
427
+ try {
428
+ return JSON.parse(await fs.readFile(CONFIG_PATH, 'utf-8'))
429
+ } catch {
430
+ return {}
431
+ }
432
+ }
433
+
434
+ async function saveConfig({ fs, config }: { fs: GokeFs; config: Config }) {
435
+ await fs.mkdir(CONFIG_DIR, { recursive: true })
436
+ await fs.writeFile(CONFIG_PATH, JSON.stringify(config, null, 2) + '\n')
437
+ }
438
+
439
+ async function getServerConfig({ fs }: { fs: GokeFs }): Promise<ServerConfig> {
440
+ const config = await loadConfig({ fs })
441
+ return config[getApiUrl()] ?? {}
442
+ }
443
+
444
+ async function setServerConfig({ fs, data }: { fs: GokeFs; data: ServerConfig }) {
445
+ const apiUrl = getApiUrl()
446
+ const config = await loadConfig({ fs })
447
+ config[apiUrl] = { ...config[apiUrl], ...data }
448
+ await saveConfig({ fs, config })
449
+ }
450
+ ```
451
+
452
+ ### Using it in commands
453
+
454
+ Commands pass the injected `{ fs }` to config helpers and read the API URL via `getApiUrl()`.
455
+
456
+ ```ts
457
+ cli
458
+ .command('login', 'Authenticate with the server')
459
+ .action(async (_options, { fs, console }) => {
460
+ const apiUrl = getApiUrl()
461
+ const token = await doLogin(apiUrl)
462
+ await setServerConfig({ fs, data: { accessToken: token } })
463
+ console.log(`Logged in to ${apiUrl}`)
464
+ })
465
+
466
+ cli
467
+ .command('status', 'Show current config')
468
+ .action(async (_options, { fs, console }) => {
469
+ const apiUrl = getApiUrl()
470
+ const server = await getServerConfig({ fs })
471
+ console.log(`Server: ${apiUrl}`)
472
+ console.log(`Authenticated: ${server.accessToken ? 'yes' : 'no'}`)
473
+ })
474
+
475
+ cli
476
+ .command('logout', 'Clear auth for current server')
477
+ .action(async (_options, { fs, console }) => {
478
+ const apiUrl = getApiUrl()
479
+ const config = await loadConfig({ fs })
480
+ delete config[apiUrl]
481
+ await saveConfig({ fs, config })
482
+ console.log(`Logged out from ${apiUrl}`)
483
+ })
484
+ ```
485
+
486
+ ### Why key by URL
487
+
488
+ - Users can be logged in to production and staging simultaneously
489
+ - Self-hosters get isolated auth without conflicting with the hosted service
490
+ - Developers can point to `http://localhost:3000` during development without losing their production token
491
+ - Switching servers is just `--api-url` or setting an env var; no re-login needed if the server was used before
492
+ ```
@@ -58,6 +58,9 @@ tuistory read -s dev
58
58
 
59
59
  # later, read only the new output
60
60
  tuistory read -s dev
61
+
62
+ # restart the server (sends Ctrl+C, waits, relaunches same command/cwd/env)
63
+ tuistory -s dev restart
61
64
  ```
62
65
 
63
66
  Why this is better than `tmux`:
package/src/cli-runner.ts CHANGED
@@ -1345,35 +1345,16 @@ export async function run({
1345
1345
  const forceRestartOnboarding = Boolean(restartOnboarding)
1346
1346
  const forceGateway = Boolean(gateway)
1347
1347
 
1348
- // Step 0: Ensure required CLI tools are installed (OpenCode + Bun).
1349
- // Run checks in parallel since they're independent `which` calls.
1350
- await Promise.all([
1351
- ensureCommandAvailable({
1352
- name: 'opencode',
1353
- envPathKey: 'OPENCODE_PATH',
1354
- installUnix: 'curl -fsSL https://opencode.ai/install | bash',
1355
- installWindows: 'irm https://opencode.ai/install.ps1 | iex',
1356
- possiblePathsUnix: [
1357
- '~/.local/bin/opencode',
1358
- '~/.opencode/bin/opencode',
1359
- '/usr/local/bin/opencode',
1360
- '/opt/opencode/bin/opencode',
1361
- ],
1362
- possiblePathsWindows: [
1363
- '~\\.local\\bin\\opencode.exe',
1364
- '~\\AppData\\Local\\opencode\\opencode.exe',
1365
- '~\\.opencode\\bin\\opencode.exe',
1366
- ],
1367
- }),
1368
- ensureCommandAvailable({
1369
- name: 'bun',
1370
- envPathKey: 'BUN_PATH',
1371
- installUnix: 'curl -fsSL https://bun.sh/install | bash',
1372
- installWindows: 'irm bun.sh/install.ps1 | iex',
1373
- possiblePathsUnix: ['~/.bun/bin/bun', '/usr/local/bin/bun'],
1374
- possiblePathsWindows: ['~\\.bun\\bin\\bun.exe'],
1375
- }),
1376
- ])
1348
+ // Step 0: Ensure bun is installed. OpenCode is downloaded on first run
1349
+ // to ~/.kimaki/bin/ (pinned version), so no install check is needed.
1350
+ await ensureCommandAvailable({
1351
+ name: 'bun',
1352
+ envPathKey: 'BUN_PATH',
1353
+ installUnix: 'curl -fsSL https://bun.sh/install | bash',
1354
+ installWindows: 'irm bun.sh/install.ps1 | iex',
1355
+ possiblePathsUnix: ['~/.bun/bin/bun', '/usr/local/bin/bun'],
1356
+ possiblePathsWindows: ['~\\.bun\\bin\\bun.exe'],
1357
+ })
1377
1358
 
1378
1359
 
1379
1360
  void backgroundUpgradeKimaki()
@@ -65,7 +65,7 @@ test(
65
65
  args,
66
66
  windowsVerbatimArguments,
67
67
  } = getSpawnCommandAndArgs({
68
- resolvedCommand: resolveOpencodeCommand(),
68
+ resolvedCommand: await resolveOpencodeCommand(),
69
69
  baseArgs: ['serve', '--port', port.toString(), '--print-logs', '--log-level', 'DEBUG'],
70
70
  })
71
71
 
package/src/opencode.ts CHANGED
@@ -406,9 +406,160 @@ function ensureProcessCleanupHandlersRegistered(): void {
406
406
  // cleanup sends SIGTERM it only kills the shell, leaving the actual opencode
407
407
  // process orphaned (reparented to PID 1). Resolving the path upfront lets
408
408
  // us spawn the binary directly and SIGTERM reaches the right process.
409
+ //
410
+ // Resolution order:
411
+ // 1. OPENCODE_PATH env var (explicit user override)
412
+ // 2. Downloaded binary in ~/.kimaki/bin/opencode-{VERSION}
413
+ //
414
+ // The binary is downloaded from GitHub releases on first run and cached.
415
+ // Version is pinned in code so kimaki is not affected by bugged releases.
416
+ // Always stored in the default data dir (~/.kimaki) regardless of custom
417
+ // data dir settings, so one binary serves all configurations.
418
+
419
+ const OPENCODE_VERSION = '1.14.41'
420
+
421
+ // Always use ~/.kimaki/bin for the opencode binary, regardless of custom data dir.
422
+ function getOpencodeBinDir(): string {
423
+ const defaultDataDir = path.join(os.homedir(), '.kimaki')
424
+ return path.join(defaultDataDir, 'bin')
425
+ }
426
+
427
+ function getOpencodeBinaryPath(): string {
428
+ const binaryName = process.platform === 'win32' ? `opencode-${OPENCODE_VERSION}.exe` : `opencode-${OPENCODE_VERSION}`
429
+ return path.join(getOpencodeBinDir(), binaryName)
430
+ }
431
+
432
+ // Returns candidate target names ordered by preference, matching the logic in
433
+ // the official opencode install script (musl detection, AVX2/baseline fallback).
434
+ function getOpencodeDownloadCandidates(): string[] {
435
+ const platformMap: Record<string, string> = { darwin: 'darwin', linux: 'linux', win32: 'windows' }
436
+ const archMap: Record<string, string> = { x64: 'x64', arm64: 'arm64', arm: 'arm' }
437
+ const platform = platformMap[process.platform] || process.platform
438
+ const arch = archMap[process.arch] || process.arch
439
+ const base = `${platform}-${arch}`
440
+
441
+ if (platform !== 'linux') {
442
+ // macOS/Windows: no musl, just check baseline for x64
443
+ if (arch === 'x64') return [`${base}-baseline`, base]
444
+ return [base]
445
+ }
446
+
447
+ // Linux: detect musl libc
448
+ const isMusl = (() => {
449
+ try { if (fs.existsSync('/etc/alpine-release')) return true } catch { /* ignore */ }
450
+ try {
451
+ const out = execFileSync('ldd', ['--version'], { encoding: 'utf8', timeout: 3000, stdio: ['pipe', 'pipe', 'pipe'] })
452
+ if (out.toLowerCase().includes('musl')) return true
453
+ } catch (e: unknown) {
454
+ // ldd --version writes to stderr on musl systems and exits non-zero
455
+ if (e && typeof e === 'object' && 'stderr' in e) {
456
+ const stderr = String((e as { stderr: unknown }).stderr)
457
+ if (stderr.toLowerCase().includes('musl')) return true
458
+ }
459
+ }
460
+ return false
461
+ })()
462
+
463
+ if (arch === 'x64') {
464
+ // x64 Linux: prefer baseline (safe on all x64 CPUs) then full, with musl variants
465
+ if (isMusl) return [`${base}-baseline-musl`, `${base}-musl`, `${base}-baseline`, base]
466
+ return [`${base}-baseline`, base, `${base}-baseline-musl`, `${base}-musl`]
467
+ }
468
+ // arm64 Linux
469
+ if (isMusl) return [`${base}-musl`, base]
470
+ return [base, `${base}-musl`]
471
+ }
472
+
473
+
474
+ async function downloadOpencodeIfMissing(): Promise<string> {
475
+ const binaryPath = getOpencodeBinaryPath()
476
+ if (fs.existsSync(binaryPath)) {
477
+ return binaryPath
478
+ }
479
+
480
+ const binDir = getOpencodeBinDir()
481
+ fs.mkdirSync(binDir, { recursive: true })
482
+
483
+ const candidates = getOpencodeDownloadCandidates()
484
+ const ext = process.platform === 'linux' ? '.tar.gz' : '.zip'
485
+ const extractedName = process.platform === 'win32' ? 'opencode.exe' : 'opencode'
486
+
487
+ // Use a unique temp dir to avoid races between concurrent first-run processes
488
+ const tmpDir = fs.mkdtempSync(path.join(binDir, '.opencode-dl-'))
489
+ const tmpArchive = path.join(tmpDir, `archive${ext}`)
490
+
491
+ const { spinner } = await import('@clack/prompts')
492
+ const s = spinner()
493
+ s.start(`Downloading opencode v${OPENCODE_VERSION}...\n`)
494
+
495
+ try {
496
+ // Try each candidate URL until one succeeds (handles musl/baseline variants)
497
+ let downloaded = false
498
+ for (const candidate of candidates) {
499
+ const url = `https://github.com/anomalyco/opencode/releases/download/v${OPENCODE_VERSION}/opencode-${candidate}${ext}`
500
+ const response = await fetch(url, { redirect: 'follow', signal: AbortSignal.timeout(120_000) })
501
+ if (response.status === 404) continue
502
+ if (!response.ok) {
503
+ throw new Error(`Failed to download opencode: HTTP ${response.status} from ${url}`)
504
+ }
505
+ fs.writeFileSync(tmpArchive, Buffer.from(await response.arrayBuffer()))
506
+ downloaded = true
507
+ break
508
+ }
509
+ if (!downloaded) {
510
+ throw new Error(`No opencode binary found for platform ${process.platform}/${process.arch} (tried: ${candidates.join(', ')})`)
511
+ }
512
+
513
+ // Extract into the temp dir
514
+ const { execFileSync } = await import('node:child_process')
515
+ if (process.platform === 'linux') {
516
+ execFileSync('tar', ['xzf', tmpArchive, '-C', tmpDir], { timeout: 30_000 })
517
+ } else if (process.platform === 'win32') {
518
+ execFileSync('powershell.exe', [
519
+ '-NoProfile', '-Command',
520
+ `Expand-Archive -LiteralPath '${tmpArchive.replaceAll("'", "''")}' -DestinationPath '${tmpDir.replaceAll("'", "''")}' -Force`,
521
+ ], { timeout: 30_000 })
522
+ } else {
523
+ execFileSync('unzip', ['-o', tmpArchive, '-d', tmpDir], { timeout: 30_000 })
524
+ }
525
+
526
+ // Move extracted binary to versioned path
527
+ const extractedPath = path.join(tmpDir, extractedName)
528
+ if (!fs.existsSync(extractedPath)) {
529
+ throw new Error(`Expected binary not found after extraction: ${extractedPath}`)
530
+ }
531
+ fs.chmodSync(extractedPath, 0o755)
532
+ try {
533
+ fs.renameSync(extractedPath, binaryPath)
534
+ } catch {
535
+ // Another concurrent process may have won the race; that's fine
536
+ if (fs.existsSync(binaryPath)) return binaryPath
537
+ throw new Error(`Failed to move opencode binary to ${binaryPath}`)
538
+ }
539
+
540
+ // Delete old versions (match opencode-X.Y.Z or opencode-X.Y.Z.exe exactly)
541
+ const currentName = path.basename(binaryPath)
542
+ for (const entry of fs.readdirSync(binDir)) {
543
+ if (entry === currentName) continue
544
+ if (/^opencode-\d+\.\d+\.\d+(?:\.exe)?$/.test(entry)) {
545
+ try { fs.unlinkSync(path.join(binDir, entry)) } catch { /* ignore */ }
546
+ }
547
+ }
548
+
549
+ s.stop(`Downloaded opencode v${OPENCODE_VERSION}`)
550
+ return binaryPath
551
+ } catch (error) {
552
+ s.stop('Failed to download opencode')
553
+ throw error
554
+ } finally {
555
+ // Clean up temp dir
556
+ try { fs.rmSync(tmpDir, { recursive: true, force: true }) } catch { /* ignore */ }
557
+ }
558
+ }
559
+
409
560
  let resolvedOpencodeCommand: string | null = null
410
561
 
411
- export function resolveOpencodeCommand(): string {
562
+ export async function resolveOpencodeCommand(): Promise<string> {
412
563
  if (resolvedOpencodeCommand) {
413
564
  return resolvedOpencodeCommand
414
565
  }
@@ -421,40 +572,14 @@ export function resolveOpencodeCommand(): string {
421
572
  })
422
573
  if (resolvedFromEnv) {
423
574
  resolvedOpencodeCommand = resolvedFromEnv
575
+ opencodeLogger.log(`Resolved opencode binary from OPENCODE_PATH: ${resolvedFromEnv}`)
424
576
  return resolvedFromEnv
425
577
  }
426
578
  }
427
579
 
428
- const isWindows = process.platform === 'win32'
429
- const whichCmd = isWindows ? 'where' : 'which'
430
- const result = errore.try({
431
- try: () => {
432
- const commandOutput = execFileSync(whichCmd, ['opencode'], {
433
- encoding: 'utf8',
434
- timeout: 5000,
435
- })
436
- const resolved = selectResolvedCommand({
437
- output: commandOutput,
438
- isWindows,
439
- })
440
- if (resolved) {
441
- return resolved
442
- }
443
- throw new Error('opencode not found in PATH')
444
- },
445
- catch: () => new Error('opencode not found in PATH'),
446
- })
447
-
448
- if (result instanceof Error) {
449
- // Fall back to bare command name — spawn will fail with a clear error
450
- // if it can't find the binary.
451
- opencodeLogger.warn('Could not resolve opencode path via which, falling back to "opencode"')
452
- return 'opencode'
453
- }
454
-
455
- resolvedOpencodeCommand = result
456
- opencodeLogger.log(`Resolved opencode binary: ${result}`)
457
- return result
580
+ const downloaded = await downloadOpencodeIfMissing()
581
+ resolvedOpencodeCommand = downloaded
582
+ return downloaded
458
583
  }
459
584
  async function getOpenPort(): Promise<number> {
460
585
  return new Promise((resolve, reject) => {
@@ -585,7 +710,7 @@ async function startSingleServer({
585
710
  args: spawnArgs,
586
711
  windowsVerbatimArguments,
587
712
  } = getSpawnCommandAndArgs({
588
- resolvedCommand: resolveOpencodeCommand(),
713
+ resolvedCommand: await resolveOpencodeCommand(),
589
714
  baseArgs: serveArgs,
590
715
  })
591
716
 
@@ -1417,8 +1417,6 @@ export class ThreadSessionRuntime {
1417
1417
  continue
1418
1418
  }
1419
1419
 
1420
- // Reset backoff on successful connection
1421
- backoffMs = 500
1422
1420
  const events = subscribeResult.stream
1423
1421
 
1424
1422
  logger.log(
@@ -1429,8 +1427,10 @@ export class ThreadSessionRuntime {
1429
1427
  // parts that arrived while we were disconnected.
1430
1428
  await this.bootstrapSentPartIds()
1431
1429
 
1430
+ let receivedAnyEvent = false
1432
1431
  const iterResult = await errore.tryAsync(async () => {
1433
1432
  for await (const event of events) {
1433
+ receivedAnyEvent = true
1434
1434
  // Each event is dispatched through the serialized action queue
1435
1435
  // to prevent interleaving mutations from concurrent events.
1436
1436
  await this.dispatchAction(() => {
@@ -1439,6 +1439,14 @@ export class ThreadSessionRuntime {
1439
1439
  }
1440
1440
  })
1441
1441
 
1442
+ // Only reset backoff when the stream was alive long enough to
1443
+ // deliver at least one event. If it closes immediately the server
1444
+ // is likely not ready; keep escalating backoff to avoid a tight
1445
+ // reconnect loop (GitHub issue #126).
1446
+ if (receivedAnyEvent) {
1447
+ backoffMs = 500
1448
+ }
1449
+
1442
1450
  if (iterResult instanceof Error) {
1443
1451
  if (isAbortError(iterResult)) {
1444
1452
  return // disposed
@@ -1450,6 +1458,17 @@ export class ThreadSessionRuntime {
1450
1458
  )
1451
1459
  await delay(backoffMs)
1452
1460
  backoffMs = Math.min(backoffMs * 2, maxBackoffMs)
1461
+ } else {
1462
+ // Stream completed normally (server closed the connection).
1463
+ // This can happen when the opencode server restarts, the SSE
1464
+ // endpoint times out, or there are no active sessions for this
1465
+ // directory. Without a delay here the loop reconnects immediately,
1466
+ // creating a tight infinite reconnect loop (GitHub issue #126).
1467
+ logger.log(
1468
+ `[LISTENER] Stream ended normally for thread ${this.threadId}, reconnecting in ${backoffMs}ms`,
1469
+ )
1470
+ await delay(backoffMs)
1471
+ backoffMs = Math.min(backoffMs * 2, maxBackoffMs)
1453
1472
  }
1454
1473
  }
1455
1474
  }