@yvhitxcel/opencode-remote 0.17.0 → 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.
@@ -14,7 +14,7 @@ const CONFIG_FILE = join(CONFIG_DIR, '.env');
14
14
  const threadModels = new Map();
15
15
  const recentModels = [];
16
16
  let rawDebugEnabled = false;
17
- let thinkVisibleEnabled = false;
17
+ let thinkVisibleEnabled = true;
18
18
 
19
19
  export function setRawDebug(enabled) {
20
20
  rawDebugEnabled = enabled;
@@ -332,6 +332,18 @@ export async function initOpenCode() {
332
332
  }
333
333
  return null;
334
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
+
335
347
  export async function verifyOpenCodeInstalled() {
336
348
  return new Promise((resolve) => {
337
349
  const isWindows = platform() === 'win32';
@@ -400,55 +412,58 @@ export async function createSession(_threadId, title = `Remote control session`)
400
412
  }
401
413
  // Send message - use promptAsync then poll for response
402
414
  export async function sendMessage(session, message, callbacks, threadId) {
403
- const TIMEOUT_MS = 5 * 60 * 1000;
415
+ const TIMEOUT_MS = parseInt(process.env.OPENCODE_TIMEOUT || '180', 10) * 1000;
404
416
 
417
+ // Verify session is valid first
405
418
  try {
406
- // Verify session is valid first
407
- try {
408
- const sessionCheck = await session.client.session.get({ sessionID: session.sessionId });
409
- if (sessionCheck.error) {
410
- console.error('[sendMessage] Session error:', sessionCheck.error);
411
- return '❌ 会话无效,请发送 /restart 重启';
412
- }
413
- } catch (e) {
414
- console.error('[sendMessage] Session check failed:', e.message);
415
- 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}`);
416
423
  }
424
+ } catch (e) {
425
+ console.error('[sendMessage] Session check failed:', e.message);
426
+ throw new Error(`session check failed: ${e.message}`);
427
+ }
417
428
 
418
- // Build prompt body
419
- const promptBody = {
420
- parts: [{ type: 'text', text: message }]
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,
421
443
  };
422
- // Inject local model preference if set
423
- if (threadId && threadModels.has(threadId)) {
424
- session.model = threadModels.get(threadId);
425
- pushRecent(session.model);
426
- }
427
- // Per-message model override if set on session
428
- if (session.model?.providerID && session.model?.modelID) {
429
- promptBody.model = {
430
- providerID: session.model.providerID,
431
- modelID: session.model.modelID,
432
- };
433
- }
444
+ }
434
445
 
435
- // Stream the response via session.prompt (POST /session/{sessionID}/message)
436
- const abortController = new AbortController();
437
- const timeoutId = setTimeout(() => abortController.abort(), TIMEOUT_MS);
438
-
439
- try {
440
- const response = await session.client.session.prompt({
441
- sessionID: session.sessionId,
442
- parts: promptBody.parts,
443
- ...(promptBody.model ? { model: promptBody.model } : {}),
444
- }, {
445
- parseAs: 'stream',
446
- signal: abortController.signal,
447
- });
446
+ // Stream the response via session.prompt (POST /session/{sessionID}/message)
447
+ const abortController = new AbortController();
448
+ const timeoutId = setTimeout(() => abortController.abort(), TIMEOUT_MS);
448
449
 
449
- if (response.error) {
450
- return `❌ 发送失败: ${response.error}`;
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
+ });
459
+
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');
451
464
  }
465
+ throw new Error(response.error);
466
+ }
452
467
 
453
468
  const stream = response.data;
454
469
  if (!stream) {
@@ -472,6 +487,11 @@ export async function sendMessage(session, message, callbacks, threadId) {
472
487
  if (isRawDebug()) console.log('[RAW]', rawJson);
473
488
  try {
474
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
+ }
475
495
  const t = parsed.info?.tokens || {};
476
496
  const time = parsed.info?.time || {};
477
497
  const elapsed = time.completed && time.created ? `${(time.completed - time.created) / 1000}s` : '?';
@@ -498,13 +518,23 @@ export async function sendMessage(session, message, callbacks, threadId) {
498
518
  }
499
519
  }
500
520
  }
521
+ // info.error 字段 → 透传
522
+ if (!responseText && parsed.info?.error) {
523
+ throw new Error(parsed.info.error);
524
+ }
525
+ // 非正常结束 → 透传 finish 原因
526
+ if (!responseText && parsed.info?.finish && parsed.info.finish !== 'stop') {
527
+ throw new Error(`finish=${parsed.info.finish}`);
528
+ }
501
529
  if (!responseText && parsed.info?.finish) {
502
- responseText = '[empty response]';
530
+ throw new Error(`Empty response (finish=${parsed.info.finish}, tokens=${t.total || 0})`);
503
531
  }
504
532
  } catch (e) {
533
+ if (e.message && !e.message.startsWith('Unexpected')) throw e;
505
534
  console.error('[sendMessage] Failed to parse response:', e.message);
506
535
  console.log('[RAW]', rawJson.slice(0, 1000));
507
- responseText = rawJson;
536
+ if (rawJson.trim()) responseText = rawJson;
537
+ else throw new Error('Empty response (no stream data)');
508
538
  }
509
539
 
510
540
  callbacks?.onStatusChange?.({ type: 'idle' });
@@ -513,14 +543,6 @@ export async function sendMessage(session, message, callbacks, threadId) {
513
543
  } finally {
514
544
  clearTimeout(timeoutId);
515
545
  }
516
- } catch (error) {
517
- if (error.name === 'AbortError') {
518
- console.warn('[sendMessage] 5min timeout, aborting stream');
519
- return '⏰ 请求超时,请重试';
520
- }
521
- console.error('[sendMessage] Error:', error);
522
- return `❌ Error: ${error instanceof Error ? error.message : 'Unknown error'}`;
523
- }
524
546
  }
525
547
  export async function getSession(session) {
526
548
  try {
@@ -620,8 +642,8 @@ export async function listOpenCodeSessions() {
620
642
  id: s.id,
621
643
  title: s.title || 'Untitled',
622
644
  directory: s.directory || '',
623
- createdAt: s.created_at || s.time?.created || 0,
624
- lastActivity: s.updated_at || s.time?.updated || 0,
645
+ createdAt: s.time?.created || 0,
646
+ lastActivity: s.time?.updated || 0,
625
647
  }));
626
648
  }
627
649
  catch (error) {
@@ -644,8 +666,8 @@ export async function listOpenCodeSessionsFromServer(baseUrl) {
644
666
  id: s.id,
645
667
  title: s.title || 'Untitled',
646
668
  directory: s.directory || '',
647
- createdAt: s.created_at || s.time?.created || 0,
648
- lastActivity: s.updated_at || s.time?.updated || 0,
669
+ createdAt: s.time?.created || 0,
670
+ lastActivity: s.time?.updated || 0,
649
671
  }));
650
672
  }
651
673
  catch (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';
@@ -1,6 +1,10 @@
1
- // Claude Code CLI agent adapter
1
+ // Claude Code CLI agent adapter — --print + explicit context prompt
2
+ // @ts-nocheck — spawn options type differs between @types/node versions
2
3
  import { spawn } from 'child_process';
3
4
  import { platform } from 'os';
5
+ import { tmpdir } from 'os';
6
+ import { join } from 'path';
7
+ import { registerAgentProcess, unregisterAgentProcess } from '../../../core/agent-registry.js';
4
8
 
5
9
  const LIUV_CRASH_PATTERNS = [
6
10
  'Assertion failed',
@@ -23,22 +27,13 @@ export class ClaudeCodeAgentAdapter {
23
27
  }
24
28
 
25
29
  async sendPrompt(_sessionId, prompt, history, options = {}) {
26
- const projectDir = options.projectDir;
27
- let cleanPrompt = prompt;
28
- if (prompt.startsWith('-c')) {
29
- cleanPrompt = prompt.slice(2).trim();
30
- }
31
- const contextualPrompt = this.buildContextualPrompt(cleanPrompt, history);
32
-
33
- const args = ['--print', '-c', contextualPrompt];
34
-
35
- return this.callClaude(args, projectDir);
36
- }
37
-
38
- buildContextualPrompt(prompt, history) {
39
- if (!history || history.length === 0) return prompt;
40
- const historyText = history.map(msg => `[${msg.role}]: ${msg.content}`).join('\n\n');
41
- return `Previous:\n${historyText}\n\n${prompt}`;
30
+ const threadId = options.threadId;
31
+ const contextualPrompt = buildContextualPrompt(prompt, history);
32
+ // 跑在临时目录,避免 claude 扫描项目源码崩在 misc-lib.mjs
33
+ const safeCwd = join(tmpdir(), 'opencode-remote-claude');
34
+ const { mkdirSync, existsSync } = await import('fs');
35
+ if (!existsSync(safeCwd)) mkdirSync(safeCwd, { recursive: true });
36
+ return this.callClaude(['--print', contextualPrompt], safeCwd, threadId);
42
37
  }
43
38
 
44
39
  isCrashNoise(line) {
@@ -46,70 +41,83 @@ export class ClaudeCodeAgentAdapter {
46
41
  }
47
42
 
48
43
  extractErrorMessage(stdout, stderr, code) {
49
- // 先检查 stdout:--print 模式把错误也输出到 stdout
50
44
  const stdoutLines = stdout.trim().split('\n').map(l => l.trim()).filter(Boolean);
51
45
  const stdoutErrors = stdoutLines.filter(l => !this.isCrashNoise(l));
52
- if (stdoutErrors.length > 0) {
53
- return stdoutErrors.join('\n');
54
- }
55
-
56
- // 再检查 stderr
46
+ if (stdoutErrors.length > 0) return stdoutErrors.join('\n');
57
47
  const stderrLines = stderr.trim().split('\n').map(l => l.trim()).filter(Boolean);
58
48
  const stderrReal = stderrLines.filter(l => !this.isCrashNoise(l));
59
- if (stderrReal.length > 0) {
60
- return stderrReal.join('\n');
61
- }
62
-
63
- // 如果全是崩溃噪音,尝试从任一流中找 Error 关键词
49
+ if (stderrReal.length > 0) return stderrReal.join('\n');
64
50
  const all = [...stdoutLines, ...stderrLines];
65
51
  const firstRelevant = all.find(l => /Error|error|ERROR|^\d{3}/.test(l));
66
52
  if (firstRelevant) return firstRelevant;
67
-
68
- // 兜底
69
53
  return `进程异常退出 (code: ${code})`;
70
54
  }
71
55
 
72
- callClaude(args, projectDir) {
56
+ callClaude(args, safeCwd, threadId) {
73
57
  return new Promise((resolve) => {
74
58
  const opts = {
75
59
  stdio: ['ignore', 'pipe', 'pipe'],
76
60
  shell: true,
61
+ cwd: safeCwd,
77
62
  };
78
- if (projectDir) {
79
- opts.cwd = projectDir;
80
- }
81
-
82
- console.log(`[claude-code] Spawning: claude ${args.join(' ')} in ${opts.cwd || process.cwd()}`);
83
- const proc = spawn('claude', args, opts);
63
+ // shell:true on Windows cmd.exe (and /bin/sh on POSIX) interprets several chars
64
+ // as command syntax. Strip them so user message content can't trigger command
65
+ // splitting, redirection, or quoting issues:
66
+ // \n \r — command separator (cmd.exe), treated as command end
67
+ // & | < > ^ command separator / pipe / redirect / escape
68
+ // " ' ` — quote chars (could break out of intended argument)
69
+ // We strip rather than escape to avoid platform-specific escape syntax.
70
+ const safeArgs = args.map(a => {
71
+ if (typeof a !== 'string') return a;
72
+ return a
73
+ .replace(/[\r\n]+/g, ' ')
74
+ .replace(/[&|<>^"`]/g, '')
75
+ .replace(/\s+/g, ' ')
76
+ .trim();
77
+ });
78
+ console.log(`[claude-code] ${safeArgs.join(' ')}`);
79
+ const proc = spawn('claude', safeArgs, opts);
80
+ if (threadId) registerAgentProcess(threadId, proc, 'claude-code');
84
81
  let stdout = '';
85
82
  let stderr = '';
83
+ let killed = false;
86
84
  proc.stdout?.on('data', (data) => { stdout += data.toString(); });
87
85
  proc.stderr?.on('data', (data) => { stderr += data.toString(); });
88
86
 
87
+ const TIMEOUT_MS = parseInt(process.env.OPENCODE_TIMEOUT || '600', 10) * 1000;
89
88
  const timeout = setTimeout(() => {
90
- console.log(`[claude-code] Process still running after 30s, stderr so far: ${stderr.slice(-200)}`);
91
- }, 30000);
89
+ killed = true;
90
+ console.warn(`[claude-code] Timeout ${TIMEOUT_MS / 1000}s`);
91
+ try { proc.kill('SIGKILL'); } catch {}
92
+ resolve(`⏰ Claude Code 超时 (${TIMEOUT_MS / 1000}s)`);
93
+ }, TIMEOUT_MS);
92
94
 
93
95
  proc.on('close', (code) => {
94
96
  clearTimeout(timeout);
95
- console.log(`[claude-code] Process exited with code ${code}, stdout=${stdout.length} bytes, stderr=${stderr.length} bytes`);
96
-
97
- if (code === 0) {
98
- resolve(stdout.trim());
99
- return;
100
- }
101
-
97
+ if (threadId) unregisterAgentProcess(threadId);
98
+ if (killed) return;
99
+ console.log(`[claude-code] exit ${code}, ${stdout.length} bytes`);
100
+ if (code === 0) { resolve(stdout.trim()); return; }
102
101
  const errorMsg = this.extractErrorMessage(stdout, stderr, code);
103
- console.log(`[claude-code] Process failed, raw stderr:\n${stderr.trim().slice(-1000)}`);
104
- console.log(`[claude-code] Error detail:\n${errorMsg}`);
105
-
106
- resolve(`❌ Claude Code 错误 (exit code ${code}): ${errorMsg}`);
102
+ resolve(`❌ Claude Code 错误 (exit ${code}): ${errorMsg}`);
107
103
  });
108
104
  proc.on('error', (err) => {
109
105
  clearTimeout(timeout);
110
- console.log(`[claude-code] Spawn error: ${err.message}`);
106
+ if (threadId) unregisterAgentProcess(threadId);
111
107
  resolve(`❌ Claude Code 启动失败: ${err.message}`);
112
108
  });
113
109
  });
114
110
  }
115
111
  }
112
+
113
+ function buildContextualPrompt(prompt, history) {
114
+ if (!history || history.length === 0) return prompt;
115
+ const lines = history.slice(-10).map(m => {
116
+ const label = m.role === 'user' ? 'User' : 'Assistant';
117
+ return `${label}: ${m.content}`;
118
+ }).join('\n');
119
+ return `[Previous conversation — for context only, answer the LATEST question below]\n\n${lines}\n\n[Latest question]\n${prompt}`;
120
+ }
121
+
122
+ // Exported for tests
123
+ export { buildContextualPrompt };
@@ -1,6 +1,8 @@
1
1
  // OpenAI Codex CLI agent adapter
2
+ // @ts-nocheck — spawn options type differs between @types/node versions
2
3
  import { spawn } from 'child_process';
3
4
  import { platform } from 'os';
5
+ import { registerAgentProcess, unregisterAgentProcess } from '../../../core/agent-registry.js';
4
6
 
5
7
  const CRASH_PATTERNS = [
6
8
  'Assertion failed',
@@ -22,15 +24,18 @@ export class CodexAgentAdapter {
22
24
  });
23
25
  }
24
26
 
25
- async sendPrompt(_sessionId, prompt, history) {
27
+ async sendPrompt(_sessionId, prompt, history, options = {}) {
26
28
  const contextualPrompt = this.buildContextualPrompt(prompt, history);
27
- return this.callCodex(contextualPrompt);
29
+ return this.callCodex(contextualPrompt, options.threadId);
28
30
  }
29
31
 
30
32
  buildContextualPrompt(prompt, history) {
31
33
  if (!history || history.length === 0) return prompt;
32
- const historyText = history.map(msg => `[${msg.role}]: ${msg.content}`).join('\n\n');
33
- return `Context:\n${historyText}\n\n${prompt}`;
34
+ const lines = history.slice(-10).map(msg => {
35
+ const label = msg.role === 'user' ? 'User' : 'AI';
36
+ return `${label}: ${msg.content}`;
37
+ }).join('\n');
38
+ return `[Previous conversation — for context only, answer the LATEST question below]\n\n${lines}\n\n[Latest question]\n${prompt}`;
34
39
  }
35
40
 
36
41
  extractErrorMessage(stdout, stderr) {
@@ -43,17 +48,38 @@ export class CodexAgentAdapter {
43
48
  return first || null;
44
49
  }
45
50
 
46
- callCodex(prompt) {
51
+ callCodex(prompt, threadId) {
47
52
  return new Promise((resolve) => {
48
- const proc = spawn('codex', ['--prompt', prompt], {
53
+ // shell:true on Windows cmd.exe (and /bin/sh on POSIX) interprets several chars
54
+ // as command syntax. Strip them so user message content can't trigger command
55
+ // splitting, redirection, or quoting issues.
56
+ const safePrompt = typeof prompt === 'string'
57
+ ? prompt
58
+ .replace(/[\r\n]+/g, ' ')
59
+ .replace(/[&|<>^"`]/g, '')
60
+ .replace(/\s+/g, ' ').trim()
61
+ : prompt;
62
+ const proc = spawn('codex', ['exec', safePrompt], {
49
63
  stdio: ['ignore', 'pipe', 'pipe'],
50
64
  shell: true,
51
65
  });
66
+ if (threadId) registerAgentProcess(threadId, proc, 'codex');
52
67
  let stdout = '';
53
68
  let stderr = '';
69
+ let killed = false;
54
70
  proc.stdout?.on('data', (data) => { stdout += data.toString(); });
55
71
  proc.stderr?.on('data', (data) => { stderr += data.toString(); });
72
+ const TIMEOUT_MS = parseInt(process.env.OPENCODE_TIMEOUT || '600', 10) * 1000;
73
+ const timeout = setTimeout(() => {
74
+ killed = true;
75
+ console.warn(`[codex] Timeout after ${TIMEOUT_MS / 1000}s, killing process`);
76
+ try { proc.kill('SIGKILL'); } catch {}
77
+ resolve(`⏰ Codex 超时 (${TIMEOUT_MS / 1000}s),任务已终止`);
78
+ }, TIMEOUT_MS);
56
79
  proc.on('close', (code) => {
80
+ clearTimeout(timeout);
81
+ if (threadId) unregisterAgentProcess(threadId);
82
+ if (killed) return;
57
83
  if (code === 0) {
58
84
  resolve(stdout.trim());
59
85
  } else {
@@ -1,6 +1,8 @@
1
1
  // GitHub Copilot CLI agent adapter
2
+ // @ts-nocheck — spawn options type differs between @types/node versions
2
3
  import { spawn } from 'child_process';
3
4
  import { platform } from 'os';
5
+ import { registerAgentProcess, unregisterAgentProcess } from '../../../core/agent-registry.js';
4
6
 
5
7
  const CRASH_PATTERNS = [
6
8
  'Assertion failed',
@@ -22,15 +24,18 @@ export class CopilotAgentAdapter {
22
24
  });
23
25
  }
24
26
 
25
- async sendPrompt(_sessionId, prompt, history) {
27
+ async sendPrompt(_sessionId, prompt, history, options = {}) {
26
28
  const contextualPrompt = this.buildContextualPrompt(prompt, history);
27
- return this.callCopilot(contextualPrompt);
29
+ return this.callCopilot(contextualPrompt, options.threadId);
28
30
  }
29
31
 
30
32
  buildContextualPrompt(prompt, history) {
31
33
  if (!history || history.length === 0) return prompt;
32
- const historyText = history.map(msg => `[${msg.role}]: ${msg.content}`).join('\n\n');
33
- return `Context:\n${historyText}\n\n${prompt}`;
34
+ const lines = history.slice(-10).map(msg => {
35
+ const label = msg.role === 'user' ? 'User' : 'AI';
36
+ return `${label}: ${msg.content}`;
37
+ }).join('\n');
38
+ return `[Previous conversation — for context only, answer the LATEST question below]\n\n${lines}\n\n[Latest question]\n${prompt}`;
34
39
  }
35
40
 
36
41
  extractErrorMessage(stdout, stderr) {
@@ -43,17 +48,38 @@ export class CopilotAgentAdapter {
43
48
  return first || null;
44
49
  }
45
50
 
46
- callCopilot(prompt) {
51
+ callCopilot(prompt, threadId) {
47
52
  return new Promise((resolve) => {
48
- const proc = spawn('copilot', ['suggest', '--prompt', prompt], {
53
+ // shell:true on Windows cmd.exe (and /bin/sh on POSIX) interprets several chars
54
+ // as command syntax. Strip them so user message content can't trigger command
55
+ // splitting, redirection, or quoting issues.
56
+ const safePrompt = typeof prompt === 'string'
57
+ ? prompt
58
+ .replace(/[\r\n]+/g, ' ')
59
+ .replace(/[&|<>^"`]/g, '')
60
+ .replace(/\s+/g, ' ').trim()
61
+ : prompt;
62
+ const proc = spawn('copilot', ['-p', safePrompt], {
49
63
  stdio: ['ignore', 'pipe', 'pipe'],
50
64
  shell: true,
51
65
  });
66
+ if (threadId) registerAgentProcess(threadId, proc, 'copilot');
52
67
  let stdout = '';
53
68
  let stderr = '';
69
+ let killed = false;
54
70
  proc.stdout?.on('data', (data) => { stdout += data.toString(); });
55
71
  proc.stderr?.on('data', (data) => { stderr += data.toString(); });
72
+ const TIMEOUT_MS = parseInt(process.env.OPENCODE_TIMEOUT || '600', 10) * 1000;
73
+ const timeout = setTimeout(() => {
74
+ killed = true;
75
+ console.warn(`[copilot] Timeout after ${TIMEOUT_MS / 1000}s, killing process`);
76
+ try { proc.kill('SIGKILL'); } catch {}
77
+ resolve(`⏰ Copilot 超时 (${TIMEOUT_MS / 1000}s),任务已终止`);
78
+ }, TIMEOUT_MS);
56
79
  proc.on('close', (code) => {
80
+ clearTimeout(timeout);
81
+ if (threadId) unregisterAgentProcess(threadId);
82
+ if (killed) return;
57
83
  if (code === 0) {
58
84
  resolve(stdout.trim());
59
85
  } else {
@@ -1,6 +1,8 @@
1
1
  // OpenCode CLI agent adapter
2
+ // @ts-nocheck — spawn options type differs between @types/node versions
2
3
  import { spawn } from 'child_process';
3
4
  import { platform } from 'os';
5
+ import { registerAgentProcess, unregisterAgentProcess } from '../../../core/agent-registry.js';
4
6
 
5
7
  const CRASH_PATTERNS = [
6
8
  'Assertion failed',
@@ -23,20 +25,18 @@ export class OpenCodeAgentAdapter {
23
25
  }
24
26
 
25
27
  async sendPrompt(_sessionId, prompt, history, options = {}) {
26
- let cleanPrompt = prompt;
27
- if (prompt.startsWith('-c')) {
28
- cleanPrompt = prompt.slice(2).trim();
29
- }
30
- const contextualPrompt = this.buildContextualPrompt(cleanPrompt, history);
31
- return this.callOpenCode(contextualPrompt);
28
+ const threadId = options.threadId;
29
+ const contextualPrompt = this.buildContextualPrompt(prompt, history);
30
+ return this.callOpenCode(contextualPrompt, threadId);
32
31
  }
33
32
 
34
33
  buildContextualPrompt(prompt, history) {
35
34
  if (!history || history.length === 0) return prompt;
36
- const historyText = history
37
- .map(msg => `[${msg.role === 'user' ? 'User' : 'Assistant'}]: ${msg.content}`)
38
- .join('\n\n');
39
- return `Previous conversation:\n${historyText}\n\nCurrent request: ${prompt}`;
35
+ const lines = history.slice(-10).map(msg => {
36
+ const label = msg.role === 'user' ? 'User' : 'AI';
37
+ return `${label}: ${msg.content}`;
38
+ }).join('\n');
39
+ return `[Previous conversation — for context only, answer the LATEST question below]\n\n${lines}\n\n[Latest question]\n${prompt}`;
40
40
  }
41
41
 
42
42
  extractErrorMessage(stdout, stderr) {
@@ -50,17 +50,37 @@ export class OpenCodeAgentAdapter {
50
50
  return first || null;
51
51
  }
52
52
 
53
- callOpenCode(prompt) {
53
+ callOpenCode(prompt, threadId) {
54
54
  return new Promise((resolve) => {
55
- const proc = spawn('opencode', ['run', '--format', 'json', '-c', prompt], {
55
+ // shell:true on Windows cmd.exe (and /bin/sh on POSIX) interprets several chars
56
+ // as command syntax. Strip them so user message content can't trigger command
57
+ // splitting, redirection, or quoting issues.
58
+ const safePrompt = typeof prompt === 'string'
59
+ ? prompt
60
+ .replace(/[\r\n]+/g, ' ')
61
+ .replace(/[&|<>^"`]/g, '')
62
+ .replace(/\s+/g, ' ')
63
+ .trim()
64
+ : prompt;
65
+ const proc = spawn('opencode', ['run', '--format', 'json', safePrompt], {
56
66
  stdio: ['ignore', 'pipe', 'pipe'],
57
67
  shell: true,
58
68
  });
69
+ if (threadId) registerAgentProcess(threadId, proc, 'opencode');
59
70
 
60
71
  let stdout = '';
61
72
  let stderr = '';
62
73
  let fullText = '';
63
74
  let resigned = false;
75
+ let killed = false;
76
+
77
+ const TIMEOUT_MS = parseInt(process.env.OPENCODE_TIMEOUT || '600', 10) * 1000;
78
+ const timeout = setTimeout(() => {
79
+ killed = true;
80
+ console.warn(`[opencode-agent] Timeout after ${TIMEOUT_MS / 1000}s, killing process`);
81
+ try { proc.kill('SIGKILL'); } catch {}
82
+ resolve(`⏰ OpenCode 超时 (${TIMEOUT_MS / 1000}s),任务已终止`);
83
+ }, TIMEOUT_MS);
64
84
 
65
85
  const STUCK_PATTERNS = [
66
86
  'Free usage exceeded', 'quota exceeded', 'rate limit',
@@ -92,7 +112,7 @@ export class OpenCodeAgentAdapter {
92
112
  try {
93
113
  const event = JSON.parse(line);
94
114
  if (event.text) fullText += event.text;
95
- } catch {}
115
+ } catch (e) { console.debug('[opencode-agent] stdout parse:', e.message); }
96
116
  }
97
117
  });
98
118
 
@@ -102,7 +122,9 @@ export class OpenCodeAgentAdapter {
102
122
  });
103
123
 
104
124
  proc.on('close', (code) => {
105
- if (resigned) return;
125
+ clearTimeout(timeout);
126
+ if (threadId) unregisterAgentProcess(threadId);
127
+ if (resigned || killed) return;
106
128
  if (code !== 0) {
107
129
  const detail = this.extractErrorMessage(stdout, stderr);
108
130
  const hint = detail
@@ -43,7 +43,7 @@ export class TelegramAdapter {
43
43
  ['/cx', '/copilot'],
44
44
  ];
45
45
  const keyboard = [];
46
- for (const [, cmds] of groups) {
46
+ for (const cmds of groups) {
47
47
  const row = cmds.map(cmd => ({ text: cmd, callback_data: `cmd:${cmd.slice(1)}` }));
48
48
  keyboard.push(row);
49
49
  }
@@ -52,14 +52,30 @@ export class TelegramAdapter {
52
52
  });
53
53
  }
54
54
 
55
+ // BotAdapter 接口方法
56
+ async reply(threadId, text) { return this.sendMessage(threadId, text); }
57
+ async sendTypingIndicator(threadId) { return this.sendTyping(threadId, true); }
58
+ async sendTypingEnd(threadId) { return this.sendTyping(threadId, false); }
59
+ async updateMessage(threadId, messageId, text) {
60
+ if (!this.bot || !messageId) return;
61
+ try { await this.bot.api.editMessageText(threadId, Number(messageId), text); } catch (e) { console.warn('[telegram] updateMessage failed:', e.message); }
62
+ }
63
+ async deleteMessage(threadId, messageId) {
64
+ if (!this.bot || !messageId) return;
65
+ try { await this.bot.api.deleteMessage(threadId, Number(messageId)); }
66
+ catch (e) { console.debug('[telegram] deleteMessage failed:', e.message); }
67
+ }
68
+
55
69
  async sendTyping(threadId, isTyping) {
56
70
  if (!this.bot) return;
57
71
  if (isTyping) {
58
- try { await this.bot.api.sendChatAction(threadId, 'typing'); } catch {}
72
+ try { await this.bot.api.sendChatAction(threadId, 'typing'); }
73
+ catch (e) { console.debug('[telegram] sendChatAction failed:', e.message); }
59
74
  const existing = this.typingIntervals.get(threadId);
60
75
  if (existing) clearInterval(existing);
61
76
  const interval = setInterval(async () => {
62
- try { await this.bot.api.sendChatAction(threadId, 'typing'); } catch {}
77
+ try { await this.bot.api.sendChatAction(threadId, 'typing'); }
78
+ catch (e) { console.debug('[telegram] typing-tick failed:', e.message); }
63
79
  }, 4000);
64
80
  this.typingIntervals.set(threadId, interval);
65
81
  } else {