@electerm/electerm-react 3.15.0 → 3.15.28

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.
@@ -64,6 +64,7 @@ export default {
64
64
  ],
65
65
  hideIP: false,
66
66
  dataSyncSelected: 'all',
67
+ nameAI: '',
67
68
  baseURLAI: 'https://api.atlascloud.ai/v1',
68
69
  modelAI: 'deepseek-chat',
69
70
  roleAI: '终端专家,提供不同系统下命令,简要解释用法,用markdown格式',
@@ -31,7 +31,7 @@ export const agentTools = [
31
31
  type: 'function',
32
32
  function: {
33
33
  name: 'send_terminal_command',
34
- description: 'Send a command to a terminal tab and wait for it to finish. Returns the command output.',
34
+ description: 'Send a command to a terminal tab and wait for it to finish. Returns the command output. For long-running commands (builds, deployments, installations), use run_background_command instead to avoid timeouts.',
35
35
  parameters: {
36
36
  type: 'object',
37
37
  properties: {
@@ -334,6 +334,114 @@ export const agentTools = [
334
334
  properties: {}
335
335
  }
336
336
  }
337
+ },
338
+ {
339
+ type: 'function',
340
+ function: {
341
+ name: 'get_terminal_status',
342
+ description: 'Check terminal status: running (actively receiving data), idle, or password prompt. Returns last 20 lines of output. Lightweight, non-blocking.',
343
+ parameters: {
344
+ type: 'object',
345
+ properties: {
346
+ tabId: {
347
+ type: 'string',
348
+ description: 'Tab ID. Omit for active terminal.'
349
+ }
350
+ }
351
+ }
352
+ }
353
+ },
354
+ {
355
+ type: 'function',
356
+ function: {
357
+ name: 'cancel_terminal_command',
358
+ description: 'Cancel the running command in a terminal by sending Ctrl+C.',
359
+ parameters: {
360
+ type: 'object',
361
+ properties: {
362
+ tabId: {
363
+ type: 'string',
364
+ description: 'Tab ID. Omit for active terminal.'
365
+ }
366
+ }
367
+ }
368
+ }
369
+ },
370
+ {
371
+ type: 'function',
372
+ function: {
373
+ name: 'run_background_command',
374
+ description: 'Run a command in the background using nohup. The terminal is freed immediately. Returns a taskId for monitoring. Use get_background_task_status and get_background_task_log to check progress.',
375
+ parameters: {
376
+ type: 'object',
377
+ properties: {
378
+ command: {
379
+ type: 'string',
380
+ description: 'The shell command to run in the background.'
381
+ },
382
+ tabId: {
383
+ type: 'string',
384
+ description: 'Tab ID. Omit for active terminal.'
385
+ }
386
+ },
387
+ required: ['command']
388
+ }
389
+ }
390
+ },
391
+ {
392
+ type: 'function',
393
+ function: {
394
+ name: 'get_background_task_status',
395
+ description: 'Check if a background task is running, completed (with exit code), or unknown.',
396
+ parameters: {
397
+ type: 'object',
398
+ properties: {
399
+ taskId: {
400
+ type: 'string',
401
+ description: 'Task ID from run_background_command.'
402
+ }
403
+ },
404
+ required: ['taskId']
405
+ }
406
+ }
407
+ },
408
+ {
409
+ type: 'function',
410
+ function: {
411
+ name: 'get_background_task_log',
412
+ description: 'Read the output log of a background task. Returns the last N lines.',
413
+ parameters: {
414
+ type: 'object',
415
+ properties: {
416
+ taskId: {
417
+ type: 'string',
418
+ description: 'Task ID from run_background_command.'
419
+ },
420
+ lines: {
421
+ type: 'number',
422
+ description: 'Number of recent lines to read (default 100).'
423
+ }
424
+ },
425
+ required: ['taskId']
426
+ }
427
+ }
428
+ },
429
+ {
430
+ type: 'function',
431
+ function: {
432
+ name: 'cancel_background_task',
433
+ description: 'Cancel a running background task by killing its process.',
434
+ parameters: {
435
+ type: 'object',
436
+ properties: {
437
+ taskId: {
438
+ type: 'string',
439
+ description: 'Task ID from run_background_command.'
440
+ }
441
+ },
442
+ required: ['taskId']
443
+ }
444
+ }
337
445
  }
338
446
  ]
339
447
 
@@ -391,6 +499,18 @@ export async function executeToolCall (toolName, args) {
391
499
  return JSON.stringify(store.mcpSftpTransferList())
392
500
  case 'sftp_transfer_history':
393
501
  return JSON.stringify(store.mcpSftpTransferHistory())
502
+ case 'get_terminal_status':
503
+ return JSON.stringify(store.mcpGetTerminalStatus(args))
504
+ case 'cancel_terminal_command':
505
+ return JSON.stringify(store.mcpCancelTerminalCommand(args))
506
+ case 'run_background_command':
507
+ return JSON.stringify(store.mcpRunBackgroundCommand(args))
508
+ case 'get_background_task_status':
509
+ return JSON.stringify(await store.mcpGetBackgroundTaskStatus(args))
510
+ case 'get_background_task_log':
511
+ return JSON.stringify(await store.mcpGetBackgroundTaskLog(args))
512
+ case 'cancel_background_task':
513
+ return JSON.stringify(await store.mcpCancelBackgroundTask(args))
394
514
  default:
395
515
  throw new Error(`Unknown agent tool: ${toolName}`)
396
516
  }
@@ -22,6 +22,7 @@ export default function AIChatHistoryItem ({ item }) {
22
22
  const {
23
23
  prompt,
24
24
  sessionId,
25
+ nameAI,
25
26
  modelAI,
26
27
  roleAI,
27
28
  baseURLAI,
@@ -190,6 +191,11 @@ export default function AIChatHistoryItem ({ item }) {
190
191
  function renderTitle () {
191
192
  return (
192
193
  <div>
194
+ {nameAI && (
195
+ <p>
196
+ <b>Name:</b> {nameAI}
197
+ </p>
198
+ )}
193
199
  <p>
194
200
  <b>Model:</b> {modelAI}
195
201
  </p>
@@ -52,6 +52,7 @@ export default function AIChat (props) {
52
52
  mode,
53
53
  toolCalls: [],
54
54
  ...pick(props.config, [
55
+ 'nameAI',
55
56
  'modelAI',
56
57
  'roleAI',
57
58
  'baseURLAI',
@@ -1,4 +1,5 @@
1
1
  export const aiConfigsArr = [
2
+ 'nameAI',
2
3
  'baseURLAI',
3
4
  'modelAI',
4
5
  'roleAI',
@@ -94,10 +94,13 @@ export default function AIConfigForm ({ initialValues, onSubmit, showAIConfig })
94
94
 
95
95
  function renderHistoryItem (item) {
96
96
  if (!item || typeof item !== 'object') return { label: 'Unknown', title: 'Unknown' }
97
+ const name = item.nameAI || ''
97
98
  const model = item.modelAI || 'Default Model'
98
99
  const rolePrefix = item.roleAI ? item.roleAI.substring(0, 15) + '...' : ''
99
- const label = `[${model}] ${rolePrefix}`
100
- const title = `Model: ${item.modelAI}\nRole: ${item.roleAI}\nURL: ${item.baseURLAI}`
100
+ const label = name || `[${model}] ${rolePrefix}`
101
+ const title = name
102
+ ? `${name}\nModel: ${item.modelAI}\nURL: ${item.baseURLAI}`
103
+ : `Model: ${item.modelAI}\nRole: ${item.roleAI}\nURL: ${item.baseURLAI}`
101
104
  return { label, title }
102
105
  }
103
106
 
@@ -131,6 +134,14 @@ export default function AIConfigForm ({ initialValues, onSubmit, showAIConfig })
131
134
  layout='vertical'
132
135
  className='ai-config-form'
133
136
  >
137
+ <Form.Item
138
+ label='Name'
139
+ name='nameAI'
140
+ >
141
+ <Input
142
+ placeholder='e.g. DeepSeek Relay, Local Ollama (optional)'
143
+ />
144
+ </Form.Item>
134
145
  <Form.Item label={renderApiUrlLabel()} required>
135
146
  <Space.Compact className='width-100'>
136
147
  <Form.Item
@@ -10,7 +10,9 @@ const e = window.translate
10
10
  export default function AIOutput ({ item }) {
11
11
  const {
12
12
  response,
13
- baseURLAI
13
+ baseURLAI,
14
+ nameAI,
15
+ modelAI
14
16
  } = item
15
17
  if (!response) {
16
18
  return null
@@ -82,10 +84,12 @@ export default function AIOutput ({ item }) {
82
84
  if (!brand) {
83
85
  return null
84
86
  }
87
+ const nameLabel = nameAI || modelAI
88
+ const label = nameLabel ? `${brand}:${nameLabel}` : brand
85
89
  return (
86
90
  <div className='pd1y'>
87
91
  <Link to={brandUrl}>
88
- <Tag>{brand}</Tag>
92
+ <Tag>{label}</Tag>
89
93
  </Link>
90
94
  </div>
91
95
  )
@@ -697,11 +697,22 @@ export default class FileSection extends React.Component {
697
697
  const {
698
698
  path, name
699
699
  } = this.state.file
700
- const rp = path ? resolve(path, name) : this.props[`${this.props.type}Path`]
700
+ let rp = path ? resolve(path, name) : this.props[`${this.props.type}Path`]
701
+ if (this.props.type === typeMap.remote) {
702
+ rp = this.convertSftpPathToTerminalPath(rp)
703
+ }
701
704
  this.props.tab.pane = paneMap.terminal
702
705
  refs.get('term-' + this.props.tab.id)?.cd(rp)
703
706
  }
704
707
 
708
+ convertSftpPathToTerminalPath = (p) => {
709
+ const m = p.match(/^\/([a-zA-Z]:)(.*)$/)
710
+ if (m) {
711
+ return m[1] + m[2].replace(/\//g, '\\')
712
+ }
713
+ return p
714
+ }
715
+
705
716
  fetchEditorText = async (path, type) => {
706
717
  // const sftp = sftpFunc()
707
718
  const text = typeMap.remote === type
@@ -0,0 +1,147 @@
1
+ /**
2
+ * Osc52Addon - Handles OSC 52 clipboard escape sequences
3
+ *
4
+ * OSC 52 allows terminal programs (TUI apps like vim, tmux, opencode, etc.)
5
+ * to copy and paste text via the system clipboard.
6
+ *
7
+ * Format: ESC ] 52 ; Pc ; Pd BEL (or ST)
8
+ * Pc = clipboard selection: c (clipboard), p (primary), s (secondary)
9
+ * Pd = base64-encoded content, or "?" to read
10
+ *
11
+ * Write request: ESC ] 52 ; c ; <base64data> BEL
12
+ * -> decodes base64 and writes to system clipboard
13
+ *
14
+ * Read request: ESC ] 52 ; c ; ? BEL
15
+ * -> reads system clipboard, encodes base64, sends response
16
+ */
17
+
18
+ export class Osc52Addon {
19
+ constructor () {
20
+ this.terminal = undefined
21
+ this._disposables = []
22
+ this._sendData = null
23
+ }
24
+
25
+ /**
26
+ * Set the function used to send data back to the PTY
27
+ * @param {function} sendDataFn - function(string) to send data to server
28
+ */
29
+ setSendData (sendDataFn) {
30
+ this._sendData = sendDataFn
31
+ }
32
+
33
+ activate (terminal) {
34
+ this.terminal = terminal
35
+
36
+ if (terminal.parser && terminal.parser.registerOscHandler) {
37
+ const oscHandler = terminal.parser.registerOscHandler(52, (data) => {
38
+ return this._handleOsc52(data)
39
+ })
40
+ this._disposables.push(oscHandler)
41
+ }
42
+ }
43
+
44
+ dispose () {
45
+ this.terminal = null
46
+ if (this._disposables) {
47
+ this._disposables.forEach(d => d.dispose())
48
+ this._disposables.length = 0
49
+ }
50
+ }
51
+
52
+ /**
53
+ * Handle OSC 52 clipboard sequence
54
+ * @param {string} data - The OSC data after "52;"
55
+ * @returns {boolean} Whether the sequence was handled
56
+ */
57
+ _handleOsc52 (data) {
58
+ if (!data) return false
59
+
60
+ // Parse: Pc;Pd where Pc is selection target and Pd is payload
61
+ const semicolonIdx = data.indexOf(';')
62
+ if (semicolonIdx === -1) return false
63
+
64
+ const target = data.substring(0, semicolonIdx)
65
+ const payload = data.substring(semicolonIdx + 1)
66
+
67
+ // Only handle clipboard target ('c'), also accept 'c' among multiple targets
68
+ if (!target.includes('c')) return false
69
+
70
+ if (payload === '?') {
71
+ // Read request - send clipboard content back
72
+ return this._handleReadRequest()
73
+ }
74
+
75
+ // Write request - decode and write to clipboard
76
+ return this._handleWriteRequest(payload)
77
+ }
78
+
79
+ /**
80
+ * Handle clipboard read request (ESC]52;c;? BEL)
81
+ * Reads system clipboard and sends response back to the terminal
82
+ */
83
+ _handleReadRequest () {
84
+ try {
85
+ const text = window.pre.readClipboard()
86
+ const base64 = text ? this._encodeBase64(text) : ''
87
+ this._sendResponse(base64)
88
+ } catch (e) {
89
+ console.error('OSC 52 clipboard read failed:', e)
90
+ this._sendResponse('')
91
+ }
92
+ return true
93
+ }
94
+
95
+ /**
96
+ * Handle clipboard write request (ESC]52;c;<base64> BEL)
97
+ * Decodes base64 content and writes to system clipboard
98
+ */
99
+ _handleWriteRequest (base64Data) {
100
+ try {
101
+ const text = this._decodeBase64(base64Data)
102
+ if (text) {
103
+ window.pre.writeClipboard(text)
104
+ }
105
+ } catch (e) {
106
+ console.error('OSC 52 clipboard write failed:', e)
107
+ }
108
+ return true
109
+ }
110
+
111
+ /**
112
+ * Send OSC 52 response back to the terminal (for read requests)
113
+ * @param {string} base64Data - base64-encoded clipboard content
114
+ */
115
+ _sendResponse (base64Data) {
116
+ if (!this._sendData) return
117
+ // Response format: ESC ] 52 ; c ; <base64> BEL
118
+ const response = `\x1b]52;c;${base64Data}\x07`
119
+ this._sendData(response)
120
+ }
121
+
122
+ /**
123
+ * Encode a string to base64 (handles UTF-8 properly)
124
+ */
125
+ _encodeBase64 (str) {
126
+ const encoder = new TextEncoder()
127
+ const bytes = encoder.encode(str)
128
+ let binary = ''
129
+ for (let i = 0; i < bytes.length; i++) {
130
+ binary += String.fromCharCode(bytes[i])
131
+ }
132
+ return btoa(binary)
133
+ }
134
+
135
+ /**
136
+ * Decode a base64 string (handles UTF-8 properly)
137
+ */
138
+ _decodeBase64 (base64) {
139
+ const binary = atob(base64)
140
+ const bytes = new Uint8Array(binary.length)
141
+ for (let i = 0; i < binary.length; i++) {
142
+ bytes[i] = binary.charCodeAt(i)
143
+ }
144
+ const decoder = new TextDecoder()
145
+ return decoder.decode(bytes)
146
+ }
147
+ }
@@ -38,6 +38,7 @@ import { KeywordHighlighterAddon } from './highlight-addon.js'
38
38
  import { getFilePath, isUnsafeFilename } from '../../common/file-drop-utils.js'
39
39
  import { getFolderFromFilePath } from '../sftp/file-read.js'
40
40
  import { CommandTrackerAddon } from './command-tracker-addon.js'
41
+ import { Osc52Addon } from './osc52-addon.js'
41
42
  import AIIcon from '../icons/ai-icon.jsx'
42
43
  import {
43
44
  getShellIntegrationCommand,
@@ -204,6 +205,9 @@ class Term extends Component {
204
205
  this.encode || this.props.tab.encode || 'utf-8'
205
206
  )
206
207
  await this.attachAddon.activate(this.term)
208
+ if (this.osc52Addon) {
209
+ this.osc52Addon.setSendData(this.attachAddon._sendData.bind(this.attachAddon))
210
+ }
207
211
  }
208
212
 
209
213
  getValue = (props, type, name) => {
@@ -391,7 +395,8 @@ class Term extends Component {
391
395
  if (isUnsafeFilename(p)) {
392
396
  return message.error('File name contains unsafe characters')
393
397
  }
394
- this.runQuickCommand(`cd "${p}"`)
398
+ const isWinPath = /^[a-zA-Z]:\\/.test(p)
399
+ this.runQuickCommand(isWinPath ? `cd /d "${p}"` : `cd "${p}"`)
395
400
  }
396
401
 
397
402
  onDrop = e => {
@@ -1125,6 +1130,8 @@ class Term extends Component {
1125
1130
  term.loadAddon(this.fitAddon)
1126
1131
  term.loadAddon(this.searchAddon)
1127
1132
  term.loadAddon(this.cmdAddon)
1133
+ this.osc52Addon = new Osc52Addon()
1134
+ term.loadAddon(this.osc52Addon)
1128
1135
  if (tab.enableTerminalImage) {
1129
1136
  const ImageAddon = await loadImageAddon()
1130
1137
  this.imageAddon = new ImageAddon({
@@ -66,6 +66,68 @@ export class XmodemClient extends TransferClientBase {
66
66
  case 'session-error':
67
67
  this.onError(msg.error)
68
68
  break
69
+ case 'auto-trigger-receive':
70
+ this.handleAutoReceive(msg.name)
71
+ break
72
+ case 'auto-trigger-send':
73
+ this.handleAutoSend()
74
+ break
75
+ }
76
+ }
77
+
78
+ /**
79
+ * Auto-triggered: device is sending a file, electerm should receive it.
80
+ * Opens save folder dialog then starts xmodem receive.
81
+ * @param {string} fileName - Original filename from the device
82
+ */
83
+ async handleAutoReceive (fileName) {
84
+ if (this.isActive) return
85
+
86
+ this.isActive = true
87
+ this.writeBanner('RECEIVE', null)
88
+
89
+ const savePath = await this.openSaveFolderSelect()
90
+ if (savePath) {
91
+ this.savePath = savePath
92
+ this.sendToServer({
93
+ event: 'start-receive'
94
+ })
95
+ this.sendToServer({
96
+ event: 'set-save-path',
97
+ path: savePath,
98
+ name: fileName
99
+ })
100
+ } else {
101
+ this.isActive = false
102
+ this.writeToTerminal('\r\n\x1b[33mXMODEM Receive cancelled\x1b[0m\r\n')
103
+ }
104
+ }
105
+
106
+ /**
107
+ * Auto-triggered: device wants to receive a file, electerm should send it.
108
+ * Opens file select dialog then starts xmodem send.
109
+ */
110
+ async handleAutoSend () {
111
+ if (this.isActive) return
112
+
113
+ this.isActive = true
114
+ this.writeBanner('SEND', null)
115
+
116
+ const files = await this.openFileSelect({
117
+ title: 'Choose file to send via XMODEM',
118
+ message: 'Choose file to send via XMODEM'
119
+ })
120
+ if (files && files.length > 0) {
121
+ this.sendToServer({
122
+ event: 'start-send'
123
+ })
124
+ this.sendToServer({
125
+ event: 'send-files',
126
+ files
127
+ })
128
+ } else {
129
+ this.isActive = false
130
+ this.writeToTerminal('\r\n\x1b[33mXMODEM Send cancelled\x1b[0m\r\n')
69
131
  }
70
132
  }
71
133
 
@@ -298,7 +298,7 @@ export default Store => {
298
298
  }
299
299
 
300
300
  Store.prototype.aiConfigMissing = function () {
301
- return aiConfigsArr.filter(k => k !== 'apiKeyAI' && k !== 'proxyAI').some(k => !window.store.config[k])
301
+ return aiConfigsArr.filter(k => k !== 'apiKeyAI' && k !== 'proxyAI' && k !== 'nameAI').some(k => !window.store.config[k])
302
302
  }
303
303
 
304
304
  Store.prototype.clearHistory = function () {
@@ -52,6 +52,20 @@ export async function addTabFromCommandLine (store, opts) {
52
52
  if (isHelp) {
53
53
  return store.openAbout(infoTabs.cmd)
54
54
  }
55
+ // Check if argv contains a protocol URL (e.g., ssh://user@host)
56
+ // and use parseQuickConnect for proper parsing
57
+ if (argv && argv.length) {
58
+ const protocolUrl = argv.find(arg =>
59
+ /^(ssh|telnet|rdp|vnc|serial|spice|ftp|http|https|electerm):\/\//i.test(arg)
60
+ )
61
+ if (protocolUrl) {
62
+ const parsed = parseQuickConnect(protocolUrl)
63
+ if (parsed) {
64
+ return store.ipcOpenTab(parsed)
65
+ }
66
+ }
67
+ }
68
+
55
69
  const conf = getHost(argv, options)
56
70
  const update = {
57
71
  passphrase: options.passphrase,
@@ -6,6 +6,7 @@
6
6
  import uid from '../common/uid'
7
7
  import { settingMap } from '../common/constants'
8
8
  import { refs, refsTabs } from '../components/common/ref'
9
+ import { runCmd } from '../components/terminal/terminal-apis'
9
10
  import deepCopy from 'json-deep-copy'
10
11
  import {
11
12
  getLocalFileInfo,
@@ -118,6 +119,26 @@ export default Store => {
118
119
  case 'wait_for_terminal_idle':
119
120
  result = await store.mcpWaitForTerminalIdle(args)
120
121
  break
122
+ case 'get_terminal_status':
123
+ result = store.mcpGetTerminalStatus(args)
124
+ break
125
+ case 'cancel_terminal_command':
126
+ result = store.mcpCancelTerminalCommand(args)
127
+ break
128
+
129
+ // Background task operations
130
+ case 'run_background_command':
131
+ result = store.mcpRunBackgroundCommand(args)
132
+ break
133
+ case 'get_background_task_status':
134
+ result = await store.mcpGetBackgroundTaskStatus(args)
135
+ break
136
+ case 'get_background_task_log':
137
+ result = await store.mcpGetBackgroundTaskLog(args)
138
+ break
139
+ case 'cancel_background_task':
140
+ result = await store.mcpCancelBackgroundTask(args)
141
+ break
121
142
 
122
143
  // SFTP operations
123
144
  case 'sftp_list':
@@ -652,6 +673,216 @@ export default Store => {
652
673
  }
653
674
  }
654
675
 
676
+ // ==================== Terminal Status & Cancel ====================
677
+
678
+ Store.prototype.mcpGetTerminalStatus = function (args) {
679
+ const { store } = window
680
+ const tabId = args.tabId || store.activeTabId
681
+ if (!tabId) {
682
+ throw new Error('No active terminal')
683
+ }
684
+
685
+ const tabRef = refsTabs.get('tab-' + tabId)
686
+ const onData = tabRef?.state.terminalOnData || ''
687
+ const term = refs.get('term-' + tabId)
688
+
689
+ let output = ''
690
+ let lineCount = 0
691
+ if (term && term.term) {
692
+ const buffer = term.term.buffer.active
693
+ if (buffer) {
694
+ const lines = []
695
+ const cursorY = buffer.cursorY || 0
696
+ const baseY = buffer.baseY || 0
697
+ const totalLines = buffer.length || 0
698
+ const end = baseY + cursorY + 1
699
+ const start = Math.max(0, end - 20)
700
+ for (let i = start; i < Math.min(totalLines, end); i++) {
701
+ const line = buffer.getLine(i)
702
+ if (line) {
703
+ lines.push(line.translateToString(true))
704
+ }
705
+ }
706
+ output = lines.join('\n')
707
+ lineCount = lines.length
708
+ }
709
+ }
710
+
711
+ return {
712
+ tabId,
713
+ isRunning: onData === 'feed',
714
+ hasPasswordPrompt: onData === 'password',
715
+ isIdle: !onData,
716
+ output,
717
+ lineCount
718
+ }
719
+ }
720
+
721
+ Store.prototype.mcpCancelTerminalCommand = function (args) {
722
+ const { store } = window
723
+ const tabId = args.tabId || store.activeTabId
724
+ if (!tabId) {
725
+ throw new Error('No active terminal')
726
+ }
727
+
728
+ const term = refs.get('term-' + tabId)
729
+ if (!term || !term.attachAddon) {
730
+ throw new Error('Terminal not found')
731
+ }
732
+
733
+ term.attachAddon._sendData('\x03')
734
+ return {
735
+ success: true,
736
+ message: 'Sent Ctrl+C to terminal',
737
+ tabId
738
+ }
739
+ }
740
+
741
+ // ==================== Background Task Management ====================
742
+
743
+ const backgroundTasks = new Map()
744
+ let bgTaskCounter = 0
745
+
746
+ async function runMonitorCmd (tabId, cmd) {
747
+ try {
748
+ const result = await runCmd(tabId, cmd)
749
+ return result
750
+ } catch (e) {
751
+ // Fallback: send via terminal and wait for idle
752
+ const { store } = window
753
+ store.mcpSendTerminalCommand({ command: cmd, tabId })
754
+ const idle = await store.mcpWaitForTerminalIdle({
755
+ tabId, timeout: 10000, lines: 10, minWait: 500
756
+ })
757
+ return idle.output || ''
758
+ }
759
+ }
760
+
761
+ Store.prototype.mcpRunBackgroundCommand = function (args) {
762
+ const { store } = window
763
+ const tabId = args.tabId || store.activeTabId
764
+ if (!tabId) {
765
+ throw new Error('No active terminal')
766
+ }
767
+ if (!args.command) {
768
+ throw new Error('No command provided')
769
+ }
770
+
771
+ const taskId = `bg-${Date.now()}-${++bgTaskCounter}`
772
+ const logFile = `/tmp/electerm-${taskId}.log`
773
+ const pidFile = `/tmp/electerm-${taskId}.pid`
774
+ const exitFile = `/tmp/electerm-${taskId}.exit`
775
+
776
+ // Encode command as base64 to avoid all quote-escaping issues.
777
+ // The subshell runs the user's command, captures its exit code, then cleans up the PID file.
778
+ const b64 = btoa(args.command)
779
+ const inner = `eval "$(echo ${b64} | base64 --decode)" > ${logFile} 2>&1; e=$?; echo $e > ${exitFile}; rm -f ${pidFile}`
780
+ const wrapped = `nohup bash -c '${inner}' & echo $! > ${pidFile}; disown`
781
+
782
+ store.mcpSendTerminalCommand({ command: wrapped, tabId, inputOnly: false })
783
+
784
+ const task = {
785
+ id: taskId,
786
+ command: args.command,
787
+ tabId,
788
+ startTime: Date.now(),
789
+ logFile,
790
+ pidFile,
791
+ exitFile,
792
+ status: 'started'
793
+ }
794
+ backgroundTasks.set(taskId, task)
795
+
796
+ return {
797
+ taskId,
798
+ tabId,
799
+ logFile,
800
+ pidFile,
801
+ exitFile,
802
+ message: 'Command started in background. Use get_background_task_status to check.'
803
+ }
804
+ }
805
+
806
+ Store.prototype.mcpGetBackgroundTaskStatus = async function (args) {
807
+ const task = backgroundTasks.get(args.taskId)
808
+ if (!task) {
809
+ throw new Error(`Task ${args.taskId} not found`)
810
+ }
811
+
812
+ const pidOutput = await runMonitorCmd(task.tabId,
813
+ `cat ${task.pidFile} 2>/dev/null`)
814
+ const pid = pidOutput.trim()
815
+
816
+ if (!pid) {
817
+ return { ...task, status: 'unknown', message: 'PID file not found' }
818
+ }
819
+
820
+ const aliveCheck = await runMonitorCmd(task.tabId,
821
+ `kill -0 ${pid} 2>/dev/null && echo alive || echo dead`)
822
+
823
+ if (aliveCheck.trim() === 'alive') {
824
+ task.status = 'running'
825
+ return { ...task, pid, status: 'running' }
826
+ }
827
+
828
+ // Process exited — read exit code
829
+ const exitOutput = await runMonitorCmd(task.tabId,
830
+ `cat ${task.exitFile} 2>/dev/null`)
831
+ const exitCode = exitOutput.trim()
832
+
833
+ task.status = 'completed'
834
+ task.exitCode = exitCode !== '' ? parseInt(exitCode, 10) : null
835
+ task.endTime = Date.now()
836
+ return { ...task, pid, status: 'completed', exitCode: task.exitCode }
837
+ }
838
+
839
+ Store.prototype.mcpGetBackgroundTaskLog = async function (args) {
840
+ const task = backgroundTasks.get(args.taskId)
841
+ if (!task) {
842
+ throw new Error(`Task ${args.taskId} not found`)
843
+ }
844
+
845
+ const lines = args.lines || 100
846
+ const output = await runMonitorCmd(task.tabId,
847
+ `tail -n ${lines} ${task.logFile} 2>/dev/null || echo '(no output yet)'`)
848
+
849
+ return {
850
+ taskId: task.id,
851
+ output: output.trim(),
852
+ lines
853
+ }
854
+ }
855
+
856
+ Store.prototype.mcpCancelBackgroundTask = async function (args) {
857
+ const task = backgroundTasks.get(args.taskId)
858
+ if (!task) {
859
+ throw new Error(`Task ${args.taskId} not found`)
860
+ }
861
+
862
+ const pidOutput = await runMonitorCmd(task.tabId,
863
+ `cat ${task.pidFile} 2>/dev/null`)
864
+ const pid = pidOutput.trim()
865
+
866
+ if (pid) {
867
+ await runMonitorCmd(task.tabId,
868
+ `kill ${pid} 2>/dev/null; echo $? > ${task.exitFile}`)
869
+ task.status = 'cancelled'
870
+ task.endTime = Date.now()
871
+ return {
872
+ taskId: task.id,
873
+ pid,
874
+ status: 'cancelled',
875
+ message: 'Process killed'
876
+ }
877
+ }
878
+
879
+ return {
880
+ taskId: task.id,
881
+ status: 'unknown',
882
+ message: 'PID not found, task may have already finished'
883
+ }
884
+ }
885
+
655
886
  // ==================== Settings APIs ====================
656
887
 
657
888
  Store.prototype.mcpGetSettings = function () {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@electerm/electerm-react",
3
- "version": "3.15.0",
3
+ "version": "3.15.28",
4
4
  "description": "react components src for electerm",
5
5
  "main": "./client/components/main/main.jsx",
6
6
  "license": "MIT",