@yvhitxcel/opencode-remote 0.16.3 → 0.18.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.
@@ -13,7 +13,23 @@ const CONFIG_FILE = join(CONFIG_DIR, '.env');
13
13
 
14
14
  const threadModels = new Map();
15
15
  const recentModels = [];
16
+ let rawDebugEnabled = false;
17
+ let thinkVisibleEnabled = true;
16
18
 
19
+ export function setRawDebug(enabled) {
20
+ rawDebugEnabled = enabled;
21
+ console.log(`[rawDebug] ${enabled ? 'ON' : 'OFF'}`);
22
+ }
23
+ export function isRawDebug() {
24
+ return rawDebugEnabled || process.env.DEBUG_RAW === '1';
25
+ }
26
+ export function setThinkVisible(enabled) {
27
+ thinkVisibleEnabled = enabled;
28
+ console.log(`[think] ${enabled ? 'ON' : 'OFF'}`);
29
+ }
30
+ export function isThinkVisible() {
31
+ return thinkVisibleEnabled;
32
+ }
17
33
  export function setThreadModel(threadId, modelStr) {
18
34
  if (!modelStr || !modelStr.includes('/')) {
19
35
  threadModels.delete(threadId);
@@ -209,7 +225,6 @@ let opencodeInstance = null;
209
225
  let opencodeServer = null;
210
226
  let lastStdoutTime = 0;
211
227
  let lastStdoutLine = '';
212
- let lastReportedStatus = '';
213
228
  const PORTS_TO_TRY = [4096, 4097, 4098];
214
229
 
215
230
  // TCP-level port probe: true = occupied, false = free
@@ -225,7 +240,7 @@ function probeTCP(port, timeoutMs = 2000) {
225
240
  }
226
241
 
227
242
  async function tryConnectPort(port, timeoutMs = 5000) {
228
- const { createOpencodeClient } = await import('@opencode-ai/sdk');
243
+ const { createOpencodeClient } = await import('@opencode-ai/sdk/v2');
229
244
  const client = createOpencodeClient({ baseUrl: `http://localhost:${port}` });
230
245
  const result = await Promise.race([
231
246
  client.session.list(),
@@ -290,7 +305,7 @@ export async function initOpenCode() {
290
305
  opencodeServer.on('exit', (code) => console.log(`[opencode] exited with code ${code}`));
291
306
 
292
307
  // Wait for server to be ready
293
- const { createOpencodeClient } = await import('@opencode-ai/sdk');
308
+ const { createOpencodeClient } = await import('@opencode-ai/sdk/v2');
294
309
  for (let i = 0; i < 15; i++) {
295
310
  await new Promise(r => setTimeout(r, 1000));
296
311
  try {
@@ -317,6 +332,18 @@ export async function initOpenCode() {
317
332
  }
318
333
  return null;
319
334
  }
335
+
336
+ /**
337
+ * Reset the cached OpenCode instance + server reference.
338
+ * Call before retrying initOpenCode() after an AbortError or fatal disconnect
339
+ * to force a fresh server spawn instead of returning the stale singleton.
340
+ */
341
+ export function resetOpenCode() {
342
+ opencodeInstance = null;
343
+ opencodeServer = null;
344
+ console.log('[opencode] reset cached instance (forced fresh start next call)');
345
+ }
346
+
320
347
  export async function verifyOpenCodeInstalled() {
321
348
  return new Promise((resolve) => {
322
349
  const isWindows = platform() === 'win32';
@@ -353,7 +380,7 @@ export async function createSession(_threadId, title = `Remote control session`)
353
380
  const opencode = await initOpenCode();
354
381
  try {
355
382
  const createResult = await opencode.client.session.create({
356
- body: { title },
383
+ title,
357
384
  });
358
385
  if (createResult.error) {
359
386
  console.error('Failed to create session:', createResult.error);
@@ -364,7 +391,7 @@ export async function createSession(_threadId, title = `Remote control session`)
364
391
  let shareUrl;
365
392
  if (process.env.SHARE_SESSIONS === 'true') {
366
393
  const shareResult = await opencode.client.session.share({
367
- path: { id: sessionId }
394
+ sessionID: sessionId,
368
395
  });
369
396
  if (!shareResult.error && shareResult.data?.share?.url) {
370
397
  shareUrl = shareResult.data.share.url;
@@ -385,167 +412,142 @@ export async function createSession(_threadId, title = `Remote control session`)
385
412
  }
386
413
  // Send message - use promptAsync then poll for response
387
414
  export async function sendMessage(session, message, callbacks, threadId) {
388
- const TIMEOUT_MS = 5 * 60 * 1000; // 5 minute timeout
389
- const POLL_INTERVAL = 2000; // 2 seconds between polls
390
-
415
+ const TIMEOUT_MS = parseInt(process.env.OPENCODE_TIMEOUT || '180', 10) * 1000;
416
+
417
+ // Verify session is valid first
391
418
  try {
392
- // Verify session is valid first
393
- try {
394
- const sessionCheck = await session.client.session.get({ path: { id: session.sessionId } });
395
- if (sessionCheck.error) {
396
- console.error('[sendMessage] Session error:', sessionCheck.error);
397
- return '❌ 会话无效,请发送 /restart 重启';
398
- }
399
- } catch (e) {
400
- console.error('[sendMessage] Session check failed:', e.message);
401
- return '❌ 会话连接失败,请发送 /restart 重启';
419
+ const sessionCheck = await session.client.session.get({ sessionID: session.sessionId });
420
+ if (sessionCheck.error) {
421
+ console.error('[sendMessage] Session error:', sessionCheck.error);
422
+ throw new Error(`session invalid: ${sessionCheck.error}`);
402
423
  }
403
-
404
- // Get last message ID and count before sending
405
- let lastMsgId = null;
406
- let msgCountBefore = 0;
407
- try {
408
- const msgsBefore = await session.client.session.messages({ path: { id: session.sessionId } });
409
- if (msgsBefore.data?.length > 0) {
410
- lastMsgId = msgsBefore.data[msgsBefore.data.length - 1].info?.id;
411
- msgCountBefore = msgsBefore.data.length;
412
- }
413
- } catch { /* ignore */ }
414
-
415
- // Send message using promptAsync (non-blocking)
416
- const promptBody = {
417
- parts: [{ type: 'text', text: message }]
424
+ } catch (e) {
425
+ console.error('[sendMessage] Session check failed:', e.message);
426
+ throw new Error(`session check failed: ${e.message}`);
427
+ }
428
+
429
+ // Build prompt body
430
+ const promptBody = {
431
+ parts: [{ type: 'text', text: message }]
432
+ };
433
+ // Inject local model preference if set
434
+ if (threadId && threadModels.has(threadId)) {
435
+ session.model = threadModels.get(threadId);
436
+ pushRecent(session.model);
437
+ }
438
+ // Per-message model override if set on session
439
+ if (session.model?.providerID && session.model?.modelID) {
440
+ promptBody.model = {
441
+ providerID: session.model.providerID,
442
+ modelID: session.model.modelID,
418
443
  };
419
- // Inject local model preference if set
420
- if (threadId && threadModels.has(threadId)) {
421
- session.model = threadModels.get(threadId);
422
- pushRecent(session.model);
423
- }
424
- // Per-message model override if set on session
425
- if (session.model?.providerID && session.model?.modelID) {
426
- promptBody.model = {
427
- providerID: session.model.providerID,
428
- modelID: session.model.modelID,
429
- };
430
- }
431
- const sendResult = await session.client.session.promptAsync({
432
- path: { id: session.sessionId },
433
- body: promptBody,
434
- });
444
+ }
435
445
 
436
- // Poll for new response - keep going as long as new content keeps arriving
437
- const startTime = Date.now();
438
- let responseText = '';
439
- let hasToolActivity = false;
440
- let idleSince = 0; // 最后一次收到新内容的时间戳
441
- let lastStatus = '';
446
+ // Stream the response via session.prompt (POST /session/{sessionID}/message)
447
+ const abortController = new AbortController();
448
+ const timeoutId = setTimeout(() => abortController.abort(), TIMEOUT_MS);
442
449
 
443
- while (Date.now() - startTime < TIMEOUT_MS) {
444
- await new Promise(r => setTimeout(r, POLL_INTERVAL));
450
+ try {
451
+ const response = await session.client.session.prompt({
452
+ sessionID: session.sessionId,
453
+ parts: promptBody.parts,
454
+ ...(promptBody.model ? { model: promptBody.model } : {}),
455
+ }, {
456
+ parseAs: 'stream',
457
+ signal: abortController.signal,
458
+ });
445
459
 
446
- try {
447
- const msgsResult = await session.client.session.messages({
448
- path: { id: session.sessionId }
449
- });
460
+ if (response.error) {
461
+ if (/abort/i.test(response.error)) {
462
+ console.warn('[sendMessage] SDK returned AbortError');
463
+ throw new Error(response.error || 'AbortError');
464
+ }
465
+ throw new Error(response.error);
466
+ }
450
467
 
451
- if (msgsResult.error) { console.error('[sendMessage] Messages error:', msgsResult.error); break; }
452
- if (!msgsResult.data?.length) continue;
468
+ const stream = response.data;
469
+ if (!stream) {
470
+ return '❌ 未收到响应流';
471
+ }
453
472
 
454
- const messages = msgsResult.data;
473
+ const reader = stream.getReader();
474
+ const decoder = new TextDecoder();
475
+ let rawJson = '';
476
+ let responseText = '';
455
477
 
456
- // 工具活动
457
- for (let i = msgCountBefore; i < messages.length; i++) {
458
- const msg = messages[i];
459
- if (msg.parts) for (const part of msg.parts) {
460
- if (part.type === 'tool_use' || part.type === 'tool_result') {
461
- hasToolActivity = true;
462
- callbacks?.onEvent?.({ type: 'tool.call', properties: { name: part.name || part.tool_name || 'unknown', input: part.input || {} } });
463
- break;
464
- }
465
- }
466
- if (hasToolActivity) break;
467
- }
478
+ while (true) {
479
+ const { done, value } = await reader.read();
480
+ if (done) break;
481
+ const chunk = decoder.decode(value, { stream: true });
482
+ if (!chunk) continue;
483
+ rawJson += chunk;
484
+ }
468
485
 
469
- // 收集所有新的 assistant 回复(累加,不丢内容)
470
- if (lastMsgId) {
471
- const idx = messages.findIndex(m => m.info?.id === lastMsgId);
472
- const startIdx = idx >= 0 ? idx + 1 : 0;
473
- const newParts = [];
474
- for (let i = startIdx; i < messages.length; i++) {
475
- const msg = messages[i];
476
- if (msg.info?.role === 'assistant' && msg.parts) {
477
- for (const p of msg.parts) {
478
- if (p.type === 'text' && p.text) newParts.push(p.text);
486
+ // Parse the full response JSON
487
+ if (isRawDebug()) console.log('[RAW]', rawJson);
488
+ try {
489
+ const parsed = JSON.parse(rawJson);
490
+ // 顶层 error → 透传真实错误
491
+ if (parsed.error) {
492
+ const errMsg = typeof parsed.error === 'string' ? parsed.error : (parsed.error.message || JSON.stringify(parsed.error));
493
+ throw new Error(errMsg);
494
+ }
495
+ const t = parsed.info?.tokens || {};
496
+ const time = parsed.info?.time || {};
497
+ const elapsed = time.completed && time.created ? `${(time.completed - time.created) / 1000}s` : '?';
498
+ const cacheRead = t.cache?.read || 0;
499
+ const cacheWrite = t.cache?.write || 0;
500
+ const cacheRate = cacheRead + cacheWrite > 0 ? `${(cacheRead / (cacheRead + cacheWrite) * 100).toFixed(0)}%` : '-';
501
+ console.log(`[RESPONSE] ${parsed.info?.providerID}/${parsed.info?.modelID} │ ${elapsed} │ tokens=${t.total || '?'} (in=${t.input} out=${t.output} rsn=${t.reasoning}) │ cache ${cacheRate} │ finish=${parsed.info?.finish || '?'}`);
502
+ const meta = { modelID: parsed.info?.modelID, providerID: parsed.info?.providerID, tokens: t, parts: parsed.parts };
503
+ callbacks?.onResponseMeta?.(meta);
504
+ if (parsed.parts) {
505
+ for (const part of parsed.parts) {
506
+ if (part.type === 'text' && part.text) {
507
+ responseText += part.text;
508
+ callbacks?.onNewContent?.(part.text);
509
+ callbacks?.onTextDelta?.(part.text);
510
+ }
511
+ if (part.type === 'reasoning' && part.text) {
512
+ const cleaned = part.text.replace(/\n/g, ' ').trim();
513
+ console.log(`[REASONING] ${cleaned.slice(0, 300)}`);
514
+ if (thinkVisibleEnabled) {
515
+ responseText += `\n🤔 思考: ${cleaned}\n━━━━━━━━━━━━━━━━━━\n`;
516
+ callbacks?.onNewContent?.(`\n🤔 思考: ${cleaned}\n━━━━━━━━━━━━━━━━━━\n`);
479
517
  }
480
518
  }
481
519
  }
482
- const fullText = newParts.join('\n');
483
- if (fullText && fullText !== responseText) {
484
- const delta = fullText.slice(responseText.length);
485
- responseText = fullText;
486
- callbacks?.onTextDelta?.(delta);
487
- callbacks?.onNewContent?.(delta);
488
- idleSince = Date.now();
489
- continue;
490
- }
491
520
  }
492
-
493
- // 检查 AI 是否还在忙(thinking/pending_tool 说明还没干完)
494
- const latestStatus = msgsResult.data?.length ? msgsResult.data[msgsResult.data.length - 1]?.info?.status : '';
495
- if (latestStatus === 'thinking' || latestStatus === 'pending_tool') {
496
- idleSince = Date.now();
521
+ // info.error 字段 → 透传
522
+ if (!responseText && parsed.info?.error) {
523
+ throw new Error(parsed.info.error);
497
524
  }
498
- if (latestStatus) lastStatus = latestStatus;
499
- if (latestStatus && latestStatus !== lastReportedStatus) {
500
- lastReportedStatus = latestStatus;
501
- console.log(`[AI状态] ${latestStatus}`);
525
+ // 非正常结束 透传 finish 原因
526
+ if (!responseText && parsed.info?.finish && parsed.info.finish !== 'stop') {
527
+ throw new Error(`finish=${parsed.info.finish}`);
502
528
  }
503
-
504
- // 有回复后:等 30 秒无新内容且 AI 不忙才退出
505
- if (responseText && Date.now() - idleSince > 30000) {
506
- break;
529
+ if (!responseText && parsed.info?.finish) {
530
+ throw new Error(`Empty response (finish=${parsed.info.finish}, tokens=${t.total || 0})`);
507
531
  }
508
532
  } catch (e) {
509
- console.warn('Poll error:', e.message);
510
- }
511
- }
512
-
513
- if (!responseText) {
514
- console.warn('⏰ Timeout waiting for response, status:', lastStatus);
515
- // Try one more time with a fresh message query
516
- try {
517
- const finalMsgs = await session.client.session.messages({ path: { id: session.sessionId }, query: { limit: 50 } });
518
- if (finalMsgs.data?.length) {
519
- for (let i = finalMsgs.data.length - 1; i >= 0; i--) {
520
- const msg = finalMsgs.data[i];
521
- if (msg.info?.role === 'assistant' && msg.parts) {
522
- const textParts = msg.parts.filter(p => p.type === 'text' && p.text).map(p => p.text);
523
- if (textParts.length > 0) {
524
- responseText = textParts.join('\n');
525
- break;
526
- }
527
- }
528
- }
529
- }
530
- } catch { /* ignore */ }
531
-
532
- if (!responseText) {
533
- return '⏰ 请求超时,请重试';
533
+ if (e.message && !e.message.startsWith('Unexpected')) throw e;
534
+ console.error('[sendMessage] Failed to parse response:', e.message);
535
+ console.log('[RAW]', rawJson.slice(0, 1000));
536
+ if (rawJson.trim()) responseText = rawJson;
537
+ else throw new Error('Empty response (no stream data)');
534
538
  }
539
+
540
+ callbacks?.onStatusChange?.({ type: 'idle' });
541
+ return responseText;
542
+
543
+ } finally {
544
+ clearTimeout(timeoutId);
535
545
  }
536
-
537
- callbacks?.onStatusChange?.({ type: 'idle', hasToolActivity });
538
- return responseText;
539
- }
540
- catch (error) {
541
- console.error('Error sending message:', error);
542
- return `❌ Error: ${error instanceof Error ? error.message : 'Unknown error'}`;
543
- }
544
546
  }
545
547
  export async function getSession(session) {
546
548
  try {
547
549
  const result = await session.client.session.get({
548
- path: { id: session.sessionId }
550
+ sessionID: session.sessionId
549
551
  });
550
552
  if (result.error) {
551
553
  return null;
@@ -559,7 +561,7 @@ export async function getSession(session) {
559
561
  export async function shareSession(session) {
560
562
  try {
561
563
  const result = await session.client.session.share({
562
- path: { id: session.sessionId }
564
+ sessionID: session.sessionId
563
565
  });
564
566
  if (result.error || !result.data?.share?.url) {
565
567
  return null;
@@ -585,7 +587,7 @@ export async function checkConnection() {
585
587
  export async function abortSession(session) {
586
588
  try {
587
589
  await session.client.session.abort({
588
- path: { id: session.sessionId }
590
+ sessionID: session.sessionId
589
591
  });
590
592
  console.log(`🛑 Aborted session: ${session.sessionId}`);
591
593
  return true;
@@ -598,7 +600,7 @@ export async function abortSession(session) {
598
600
  export async function getSessionMessages(session, limit = 20) {
599
601
  try {
600
602
  const result = await session.client.session.messages({
601
- path: { id: session.sessionId }
603
+ sessionID: session.sessionId
602
604
  });
603
605
  if (result.error) {
604
606
  return null;
@@ -614,7 +616,7 @@ export async function resumeSession(sessionId, title = 'Resumed session') {
614
616
  try {
615
617
  const opencode = await initOpenCode();
616
618
  if (!opencode) return null;
617
- const getResult = await opencode.client.session.get({ path: { id: sessionId } });
619
+ const getResult = await opencode.client.session.get({ sessionID: sessionId });
618
620
  if (getResult.error) {
619
621
  console.warn(`Session ${sessionId} not found`);
620
622
  return null;
@@ -639,10 +641,9 @@ export async function listOpenCodeSessions() {
639
641
  return sessions.map(s => ({
640
642
  id: s.id,
641
643
  title: s.title || 'Untitled',
642
- status: s.status?.type || 'unknown',
643
644
  directory: s.directory || '',
644
- createdAt: s.created_at || 0,
645
- lastActivity: s.updated_at || 0,
645
+ createdAt: s.time?.created || 0,
646
+ lastActivity: s.time?.updated || 0,
646
647
  }));
647
648
  }
648
649
  catch (error) {
@@ -652,7 +653,7 @@ export async function listOpenCodeSessions() {
652
653
  }
653
654
  export async function listOpenCodeSessionsFromServer(baseUrl) {
654
655
  try {
655
- const { createOpencodeClient } = await import('@opencode-ai/sdk');
656
+ const { createOpencodeClient } = await import('@opencode-ai/sdk/v2');
656
657
  const client = createOpencodeClient({
657
658
  baseUrl: baseUrl || 'http://localhost:4096',
658
659
  });
@@ -664,10 +665,9 @@ export async function listOpenCodeSessionsFromServer(baseUrl) {
664
665
  return sessions.map(s => ({
665
666
  id: s.id,
666
667
  title: s.title || 'Untitled',
667
- status: s.status?.type || 'unknown',
668
668
  directory: s.directory || '',
669
- createdAt: s.created_at || 0,
670
- lastActivity: s.updated_at || 0,
669
+ createdAt: s.time?.created || 0,
670
+ lastActivity: s.time?.updated || 0,
671
671
  }));
672
672
  }
673
673
  catch (error) {
@@ -680,7 +680,7 @@ export async function createOpenCodeSession(title = 'New session') {
680
680
  const opencode = await initOpenCode();
681
681
  if (!opencode) return null;
682
682
  const result = await opencode.client.session.create({
683
- body: { title }
683
+ title
684
684
  });
685
685
  if (result.error) {
686
686
  return null;
@@ -704,7 +704,7 @@ export async function deleteOpenCodeSession(sessionId) {
704
704
  const opencode = await initOpenCode();
705
705
  if (!opencode) return false;
706
706
  const result = await opencode.client.session.delete({
707
- path: { id: sessionId }
707
+ sessionID: sessionId
708
708
  });
709
709
  if (result.error) {
710
710
  return false;
@@ -719,9 +719,9 @@ export async function deleteOpenCodeSession(sessionId) {
719
719
  }
720
720
  export async function renameOpenCodeSession(session, title) {
721
721
  try {
722
- const result = await session.client.session.patch({
723
- path: { id: session.sessionId },
724
- body: { title }
722
+ const result = await session.client.session.update({
723
+ sessionID: session.sessionId,
724
+ title,
725
725
  });
726
726
  if (result.error) {
727
727
  return false;
@@ -739,9 +739,9 @@ export async function forkSession(sessionId, messageID, directory) {
739
739
  const opencode = await initOpenCode();
740
740
  if (!opencode) return null;
741
741
  const result = await opencode.client.session.fork({
742
- path: { id: sessionId },
743
- body: { messageID },
744
- query: directory ? { directory } : {}
742
+ sessionID: sessionId,
743
+ messageID,
744
+ ...(directory ? { directory } : {}),
745
745
  });
746
746
  if (result.error) {
747
747
  console.warn(`Fork failed: ${result.error}`);
@@ -766,8 +766,9 @@ export async function revertSessionMessage(sessionId, messageID, partID) {
766
766
  const opencode = await initOpenCode();
767
767
  if (!opencode) return false;
768
768
  const result = await opencode.client.session.revert({
769
- path: { id: sessionId },
770
- body: { messageID, partID }
769
+ sessionID: sessionId,
770
+ messageID,
771
+ partID,
771
772
  });
772
773
  if (result.error) {
773
774
  console.warn(`Revert failed: ${result.error}`);
@@ -786,7 +787,7 @@ export async function unrevertSession(sessionId) {
786
787
  const opencode = await initOpenCode();
787
788
  if (!opencode) return false;
788
789
  const result = await opencode.client.session.unrevert({
789
- path: { id: sessionId }
790
+ sessionID: sessionId
790
791
  });
791
792
  if (result.error) {
792
793
  console.warn(`Unrevert failed: ${result.error}`);
@@ -805,9 +806,12 @@ export async function listProviders() {
805
806
  try {
806
807
  const opencode = await initOpenCode();
807
808
  if (!opencode) return null;
808
- const result = await opencode.client.provider.list();
809
- if (result.error || !result.data?.all) return null;
810
- return result.data.all;
809
+ // Config2.providers() GET /config/providers (same as v1)
810
+ const result = await opencode.client.config.providers();
811
+ if (result.error) return null;
812
+ // v2 returns { providers: [...] }, v1 returns { all: [...] }
813
+ const data = result.data?.providers || result.data?.all || result.data || [];
814
+ return Array.isArray(data) ? data : null;
811
815
  } catch (error) {
812
816
  console.error('Failed to list providers:', error.message);
813
817
  return null;
@@ -819,7 +823,7 @@ export async function updateGlobalModel(modelStr) {
819
823
  const opencode = await initOpenCode();
820
824
  if (!opencode) return false;
821
825
  const result = await opencode.client.config.update({
822
- body: { model: modelStr },
826
+ config: { model: modelStr },
823
827
  });
824
828
  if (result.error) {
825
829
  console.error('Failed to update model:', result.error);
@@ -1,3 +1,4 @@
1
+ // @ts-nocheck — patches internal Module._load which is private API
1
2
  import { createRequire } from 'node:module';
2
3
  import Module from 'node:module';
3
4
  import { platform } from 'os';