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.
- package/dist/cli-runner.js +10 -29
- package/dist/kimaki-opencode-plugin-loading.e2e.test.js +1 -1
- package/dist/opencode.js +162 -30
- package/dist/session-handler/thread-session-runtime.js +19 -2
- package/package.json +4 -5
- package/skills/goke/SKILL.md +153 -0
- package/skills/tuistory/SKILL.md +3 -0
- package/src/cli-runner.ts +10 -29
- package/src/kimaki-opencode-plugin-loading.e2e.test.ts +1 -1
- package/src/opencode.ts +157 -32
- package/src/session-handler/thread-session-runtime.ts +21 -2
package/dist/cli-runner.js
CHANGED
|
@@ -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
|
|
942
|
-
//
|
|
943
|
-
await
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
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
|
|
275
|
-
|
|
276
|
-
|
|
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.
|
|
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
|
-
"
|
|
66
|
+
"traforo": "^0.5.0",
|
|
68
67
|
"libsqlproxy": "^0.1.0",
|
|
69
|
-
"
|
|
68
|
+
"opencode-injection-guard": "^0.2.1"
|
|
70
69
|
},
|
|
71
70
|
"optionalDependencies": {
|
|
72
71
|
"@snazzah/davey": "^0.1.10",
|
package/skills/goke/SKILL.md
CHANGED
|
@@ -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
|
+
```
|
package/skills/tuistory/SKILL.md
CHANGED
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
|
|
1349
|
-
//
|
|
1350
|
-
await
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
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
|
|
429
|
-
|
|
430
|
-
|
|
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
|
}
|