@electerm/electerm-react 5.5.35 → 5.5.66

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.
Files changed (54) hide show
  1. package/client/common/bookmark-schemas.js +1 -0
  2. package/client/common/constants.js +1 -0
  3. package/client/common/dangerous-session-fields.js +39 -0
  4. package/client/common/parse-quick-connect.js +47 -6
  5. package/client/common/parse-widget-log.js +57 -0
  6. package/client/common/safe-join.js +33 -0
  7. package/client/common/sanitize-filename.js +8 -0
  8. package/client/components/ai/agent-tool-call-card.jsx +7 -3
  9. package/client/components/ai/agent.js +176 -33
  10. package/client/components/ai/ai-auto-compress.js +119 -0
  11. package/client/components/ai/ai-chat-history-item.jsx +125 -40
  12. package/client/components/ai/ai-chat.jsx +67 -19
  13. package/client/components/ai/ai-compress.js +95 -0
  14. package/client/components/ai/ai-config.jsx +29 -16
  15. package/client/components/ai/ai-config.styl +20 -0
  16. package/client/components/ai/ai-context.js +1 -1
  17. package/client/components/ai/ai-output.jsx +1 -6
  18. package/client/components/ai/ai-presets.js +0 -15
  19. package/client/components/batch-op/batch-op-runner.jsx +1 -0
  20. package/client/components/bookmark-form/bookmark-schema.js +1 -0
  21. package/client/components/bookmark-form/common/ssh-agent.jsx +31 -21
  22. package/client/components/bookmark-form/config/ssh.js +1 -0
  23. package/client/components/bookmark-form/fix-bookmark-default.js +1 -0
  24. package/client/components/file-transfer/remote2remote-handlers.jsx +8 -3
  25. package/client/components/file-transfer/transfer.jsx +3 -119
  26. package/client/components/main/main.jsx +1 -0
  27. package/client/components/session/session-control.jsx +35 -2
  28. package/client/components/session/session-control.styl +5 -0
  29. package/client/components/session/session.jsx +13 -0
  30. package/client/components/setting-panel/setting-terminal.jsx +1 -0
  31. package/client/components/setting-panel/tab-widgets.jsx +27 -7
  32. package/client/components/sftp/file-item.jsx +0 -13
  33. package/client/components/terminal/mixins/term-init.js +1 -0
  34. package/client/components/terminal/mixins/term-touch.js +18 -0
  35. package/client/components/terminal/shell.js +11 -3
  36. package/client/components/terminal/shortcut-bar-press.js +192 -0
  37. package/client/components/terminal/shortcut-bar.jsx +199 -4
  38. package/client/components/terminal/shortcut-bar.styl +40 -21
  39. package/client/components/terminal/terminal.jsx +4 -0
  40. package/client/components/widgets/widget-control.jsx +4 -2
  41. package/client/components/widgets/widget-instance-detail.jsx +370 -0
  42. package/client/components/widgets/widget-instance.jsx +30 -13
  43. package/client/components/widgets/widget-instances.jsx +3 -1
  44. package/client/components/widgets/widgets-list.jsx +8 -0
  45. package/client/components/widgets/widgets.styl +100 -0
  46. package/client/store/common.js +81 -23
  47. package/client/store/init-state.js +7 -0
  48. package/client/store/load-data.js +1 -0
  49. package/client/store/mcp-handler.js +8 -15
  50. package/client/store/sync.js +1 -0
  51. package/client/store/tab.js +2 -15
  52. package/client/store/widgets.js +23 -5
  53. package/package.json +1 -1
  54. package/client/components/file-transfer/zip.js +0 -42
@@ -62,6 +62,7 @@ export const sshBookmarkSchema = {
62
62
  enableSftp: z.boolean().optional().describe('Enable sftp, default is true'),
63
63
  useSshAgent: z.boolean().optional().describe('Use SSH agent, default is true'),
64
64
  sshAgent: z.string().optional().describe('SSH agent path'),
65
+ agentForward: z.boolean().optional().describe('Enable SSH agent forwarding (like ssh -A), requires SSH agent, default is false'),
65
66
  serverHostKey: z.array(z.string()).optional().describe('Server host key algorithms'),
66
67
  cipher: z.array(z.string()).optional().describe('Cipher list'),
67
68
  compress: z.array(z.string()).optional().describe('Compression algorithms'),
@@ -302,6 +302,7 @@ export const regexHelpLink = 'https://github.com/electerm/electerm/wiki/Terminal
302
302
  export const connectionHoppingWikiLink = 'https://github.com/electerm/electerm/wiki/Connection-Hopping-Behavior-Change-in-electerm-since-v1.50.65'
303
303
  export const aiConfigWikiLink = 'https://github.com/electerm/electerm/wiki/AI-model-config-guide'
304
304
  export const aiChatModeLsKey = 'ai-chat-mode'
305
+ export const aiAutoCompressLsKey = 'ai-auto-compress'
305
306
  export const lastAiChatSessionIdKey = 'last-ai-chat-session-id'
306
307
  export const aiTermOfUseConfirmedLsKey = 'ai-term-of-use-confirmed'
307
308
  export const syncTermOfUseConfirmedLsKey = 'sync-term-of-use-confirmed'
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Session fields that must never be settable from untrusted input: a deep link
3
+ * URL, CLI `--opts`, an MCP/AI tool call, or a shortcut file.
4
+ *
5
+ * Every entry reaches one of: child_process.spawn(), a shell, the process
6
+ * environment, or the terminal input stream.
7
+ *
8
+ * Why this is a shared module and not a local array: `proxyCommand` was added
9
+ * by a later feature and reached spawn() from a single clicked link because the
10
+ * denylists of the time were five separate copies and none of them was updated
11
+ * (follow-up to GHSA-mpm8-cx2p-626q). One list, imported everywhere, is what
12
+ * makes the guard maintainable.
13
+ *
14
+ * Why a denylist and not an allowlist: a quick connect string may legitimately
15
+ * carry any session option (encode, term, authType, agentForward, sshTunnels,
16
+ * startDirectoryRemote, ...). An allowlist would silently break every field it
17
+ * does not know about, which is a worse failure than the one it prevents. The
18
+ * cost of that choice is that a future exec-capable option must be added here.
19
+ *
20
+ * Keep in sync with the copy of this file in the other bundle - there is a unit
21
+ * test that fails when the two copies drift.
22
+ */
23
+ export default [
24
+ // spawns an external process (before any SSH authentication)
25
+ 'proxyCommand',
26
+ // run scripts after connect / override the shell binary
27
+ 'runScripts',
28
+ 'execLinux',
29
+ 'execMac',
30
+ 'execWindows',
31
+ 'execLinuxArgs',
32
+ 'execMacArgs',
33
+ 'execWindowsArgs',
34
+ // injects environment variables into the session
35
+ 'setEnv',
36
+ // injects interactive prompts / auto-answers terminal output
37
+ 'interactiveValues',
38
+ 'triggers'
39
+ ]
@@ -19,13 +19,38 @@
19
19
  * 192.168.1.100:22
20
20
  */
21
21
 
22
+ import DANGEROUS_SESSION_FIELDS from './dangerous-session-fields'
23
+
22
24
  const SUPPORTED_PROTOCOLS = ['ssh', 'telnet', 'vnc', 'rdp', 'spice', 'serial', 'ftp', 'http', 'https', 'electerm']
23
25
 
24
26
  /**
25
- * Deny list for opts keys - these are parsed from the URL itself
26
- * and should not be overridable via the opts JSON parameter for safety
27
+ * Deny list for opts keys.
28
+ *
29
+ * `type` and `host` are parsed from the URL itself, so `opts` must not be able
30
+ * to override them. Everything else is a field that reaches
31
+ * child_process.spawn(), a shell, the environment or the terminal input stream
32
+ * - see common/dangerous-session-fields.js.
33
+ *
34
+ * Deliberately a deny list rather than an allow list: a quick connect string
35
+ * may legitimately carry any session option (encode, term, authType,
36
+ * agentForward, sshTunnels, ...), so an allow list would silently break every
37
+ * field it does not know about.
27
38
  */
28
- const OPTS_DENY_LIST = ['type', 'host']
39
+ const OPTS_DENY_LIST = ['type', 'host', ...DANGEROUS_SESSION_FIELDS]
40
+
41
+ /**
42
+ * Return a copy of an untrusted object without the denied keys.
43
+ * @param {object} obj - untrusted object
44
+ * @param {string[]} denyList - keys to drop
45
+ * @returns {object} a new object with the denied keys removed
46
+ */
47
+ function withoutDenied (obj, denyList = OPTS_DENY_LIST) {
48
+ const out = { ...(obj || {}) }
49
+ denyList.forEach(key => {
50
+ delete out[key]
51
+ })
52
+ return out
53
+ }
29
54
 
30
55
  /**
31
56
  * Default ports for each protocol
@@ -53,6 +78,7 @@ const TYPE_DEFAULT_VALUES = {
53
78
  enableSsh: true,
54
79
  enableSftp: true,
55
80
  useSshAgent: true,
81
+ agentForward: false,
56
82
  authType: 'password',
57
83
  term: 'xterm-256color',
58
84
  encode: 'utf-8',
@@ -403,8 +429,22 @@ function parseQuickConnect (str) {
403
429
  if (optsStr) {
404
430
  try {
405
431
  const extraOpts = JSON.parse(optsStr)
406
- OPTS_DENY_LIST.forEach(key => delete extraOpts[key])
407
- Object.assign(opts, extraOpts)
432
+ if (
433
+ extraOpts &&
434
+ typeof extraOpts === 'object' &&
435
+ !Array.isArray(extraOpts)
436
+ ) {
437
+ Object.assign(opts, withoutDenied(extraOpts))
438
+ }
439
+ // a hop entry is spread into a sub-session (server/session-hop.js),
440
+ // so it is its own untrusted surface and needs the same filtering.
441
+ // Only the dangerous fields apply here - unlike the top level, a hop
442
+ // legitimately sets its own host and port.
443
+ if (Array.isArray(opts.connectionHoppings)) {
444
+ opts.connectionHoppings = opts.connectionHoppings
445
+ .filter(hop => hop && typeof hop === 'object' && !Array.isArray(hop))
446
+ .map(hop => withoutDenied(hop, DANGEROUS_SESSION_FIELDS))
447
+ }
408
448
  } catch (err) {
409
449
  console.error('Failed to parse opts:', err)
410
450
  }
@@ -451,5 +491,6 @@ export {
451
491
  getSupportedProtocols,
452
492
  SUPPORTED_PROTOCOLS,
453
493
  DEFAULT_PORTS,
454
- OPTS_DENY_LIST
494
+ OPTS_DENY_LIST,
495
+ DANGEROUS_SESSION_FIELDS
455
496
  }
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Parse a widget log file into the two streams the widget panel shows.
3
+ *
4
+ * The writer is src/app/widgets/instance-log.js; the format is one entry per
5
+ * line, pipe separated:
6
+ * <iso time>|log|<level>|<message>
7
+ * <iso time>|conn|<ok|fail|info>|<type>|<from>|<message>
8
+ * A message may contain pipes, so everything after the last known separator is
9
+ * taken as the message.
10
+ */
11
+
12
+ const CONN_STATE = {
13
+ ok: true,
14
+ fail: false,
15
+ info: null
16
+ }
17
+
18
+ export function parseWidgetLog (text) {
19
+ const logs = []
20
+ const events = []
21
+ for (const line of String(text || '').split('\n')) {
22
+ if (!line) {
23
+ continue
24
+ }
25
+ const parts = line.split('|')
26
+ if (parts.length < 4) {
27
+ continue
28
+ }
29
+ const ts = Date.parse(parts[0])
30
+ if (!ts) {
31
+ continue
32
+ }
33
+ if (parts[1] === 'conn') {
34
+ if (parts.length < 6) {
35
+ continue
36
+ }
37
+ events.push({
38
+ ts,
39
+ ok: Object.prototype.hasOwnProperty.call(CONN_STATE, parts[2])
40
+ ? CONN_STATE[parts[2]]
41
+ : null,
42
+ type: parts[3],
43
+ from: parts[4] === '-' ? '' : parts[4],
44
+ msg: parts.slice(5).join('|')
45
+ })
46
+ } else {
47
+ logs.push({
48
+ ts,
49
+ level: parts[2],
50
+ msg: parts.slice(3).join('|')
51
+ })
52
+ }
53
+ }
54
+ return { logs, events }
55
+ }
56
+
57
+ export default parseWidgetLog
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Join a base directory with a filename that may come from an untrusted source
3
+ * (a remote server's directory listing, a drag payload, ...) and keep the
4
+ * result inside the base.
5
+ *
6
+ * The sanitize step is load-bearing, not cosmetic. electerm's `resolve` does no
7
+ * normalisation and three of its branches discard the base outright: a leading
8
+ * `/`, a UNC `\\`, and a Windows drive prefix. The drive branch is the easy one
9
+ * to miss because it needs no separator at all — `resolve('/uploads', 'C:x')`
10
+ * returns `'C:x'`. Stripping `:` and the separators removes every one of those
11
+ * branches, leaving only the "append to base" path.
12
+ *
13
+ * @param {String} base directory the name must stay inside
14
+ * @param {String} name untrusted file name
15
+ * @return {String} base + separator + a safe name, always inside `base`
16
+ */
17
+
18
+ import resolve from './resolve.js'
19
+ import sanitizeFilename from './sanitize-filename.js'
20
+
21
+ // A single path component: no separator, and not `.` or `..`.
22
+ const SINGLE_COMPONENT = /^(?!\.\.?$)[^/\\]+$/
23
+
24
+ export default function safeJoin (base, name) {
25
+ const safeName = sanitizeFilename(name)
26
+ // sanitizeFilename already satisfies this, so the fallback is unreachable in
27
+ // practice: it is here so that a future change to its character list degrades
28
+ // to a neutral name instead of escaping the base. Deliberately not a throw —
29
+ // a bad entry in a remote listing must not take down the whole drop. It also
30
+ // stays silent on purpose: no legitimate name contains a separator, so the
31
+ // only names that can reach the fallback are hostile ones.
32
+ return resolve(base, SINGLE_COMPONENT.test(safeName) ? safeName : 'unnamed')
33
+ }
@@ -14,6 +14,14 @@
14
14
  * - Reject reserved Windows device names: CON, PRN, AUX, NUL, COM1-9, LPT1-9
15
15
  * - Limit filename length to 255 bytes (common filesystem limit)
16
16
  * - Fallback to 'unnamed' if result is empty
17
+ *
18
+ * Note for callers using this to build a path: the output is guaranteed to
19
+ * contain no path separator (`/` or `\`) and never to be `.` or `..`, because
20
+ * all of those are in ILLEGAL_CHARS or stripped as trailing dots. That is what
21
+ * makes `resolve(dir, sanitizeFilename(name))` stay inside `dir` — and it is
22
+ * load-bearing for path containment (CWE-22), not just cosmetics. Prefer
23
+ * `safeJoin(base, name)` when the name is untrusted: it depends on this
24
+ * property and asserts it.
17
25
  */
18
26
 
19
27
  // Characters illegal on Windows (and problematic on many systems)
@@ -1,4 +1,4 @@
1
- import { useState, useEffect } from 'react'
1
+ import { useState, useEffect, memo } from 'react'
2
2
  import { Tag } from 'antd'
3
3
  import {
4
4
  CaretDownOutlined,
@@ -34,7 +34,11 @@ function formatResult (result) {
34
34
  }
35
35
  }
36
36
 
37
- export default function AgentToolCallCard ({ toolCall, autoCollapse }) {
37
+ // memo()'d on the entry object: `formatResult` JSON.parses the whole tool
38
+ // result on every render, and an agent turn can hold dozens of these. The
39
+ // agent loop swaps in a new entry object when a call finishes
40
+ // (runAgentLoop), so an unchanged card keeps its identity and is skipped.
41
+ export default memo(function AgentToolCallCard ({ toolCall, autoCollapse }) {
38
42
  const [expanded, setExpanded] = useState(toolCall.status === 'running')
39
43
  const { name, args, status, result } = toolCall
40
44
  const Icon = toolIcons[name] || CodeOutlined
@@ -110,4 +114,4 @@ export default function AgentToolCallCard ({ toolCall, autoCollapse }) {
110
114
  )}
111
115
  </div>
112
116
  )
113
- }
117
+ })
@@ -1,9 +1,31 @@
1
1
  import { agentTools, executeToolCall } from './agent-tools'
2
2
  import { appendMandatoryGuardrails } from './ai-guardrails'
3
- import { buildAgentMessages, summarizeContext } from './ai-context'
3
+ import { buildAgentMessages, summarizeContext, CONTEXT_DANGER_PERCENT } from './ai-context'
4
+ import {
5
+ shouldAutoCompress,
6
+ canCompact,
7
+ applySummary
8
+ } from './ai-auto-compress'
9
+ import {
10
+ autoCompressEnabled,
11
+ summarizeMessages,
12
+ autoCompressSession
13
+ } from './ai-compress'
14
+ import uid from '../../common/uid'
4
15
 
5
16
  const MAX_ITERATIONS = 150
6
17
 
18
+ // A summary request that failed (provider hiccup, rate limit) must not be
19
+ // retried on every following iteration -- that would turn a full window into a
20
+ // call per tool result. Only try again once the estimate has grown by this
21
+ // share of the window.
22
+ const AUTO_COMPRESS_RETRY_GROWTH_PERCENT = 5
23
+
24
+ // Which loop currently owns `store.agentRunning`. A stopped run keeps
25
+ // unwinding for a moment (its last request has to settle), and it must not
26
+ // clear the flag of a run the user started in the meantime.
27
+ let activeRunId = null
28
+
7
29
  function buildAgentSystemPrompt (config) {
8
30
  const lang = config.languageAI || window.store.getLangName()
9
31
  const baseRole = config.roleAI || 'You are a helpful assistant.'
@@ -27,14 +49,10 @@ Reply in ${lang} language.`)
27
49
  }
28
50
 
29
51
  function updateChatEntry (chatEntry, updates) {
30
- const index = window.store.aiChatHistory.findIndex(i => i.id === chatEntry.id)
31
- if (index !== -1) {
32
- Object.assign(window.store.aiChatHistory[index], updates)
33
- window.store.aiChatHistory = [...window.store.aiChatHistory]
34
- }
52
+ window.store.updateAiHistoryEntry(chatEntry.id, updates)
35
53
  }
36
54
 
37
- async function callBackendAIchatWithTools (messages, config) {
55
+ async function callBackendAIchatWithTools (messages, config, requestId) {
38
56
  return window.pre.runGlobalAsync(
39
57
  'AIchatWithTools',
40
58
  messages,
@@ -44,7 +62,11 @@ async function callBackendAIchatWithTools (messages, config) {
44
62
  config.apiKeyAI,
45
63
  config.proxyAI,
46
64
  agentTools,
47
- config.authHeaderNameAI
65
+ config.authHeaderNameAI,
66
+ // `format` stays undefined (the main process detects it); `requestId` is
67
+ // the last argument so that exact request can be cancelled on stop.
68
+ undefined,
69
+ requestId
48
70
  )
49
71
  }
50
72
 
@@ -67,7 +89,16 @@ function publishContext (messages, config, usage) {
67
89
  }
68
90
 
69
91
  export async function runAgentLoop (chatEntry, config, abortRef, setIsStreaming, conversationMessages = null) {
92
+ const runId = uid()
93
+ activeRunId = runId
70
94
  window.store.agentRunning = true
95
+ const isAborted = () => !!(abortRef && abortRef.current)
96
+ // Auto compression bookkeeping, read by the finally block as well: how many
97
+ // times this run compacted itself, whether it failed on the way, and the
98
+ // size at the last summary attempt (AUTO_COMPRESS_RETRY_GROWTH_PERCENT)
99
+ let autoCompressCount = 0
100
+ let autoCompressAttemptTokens = 0
101
+ let runErrored = false
71
102
  try {
72
103
  const messages = buildAgentMessages({
73
104
  systemPrompt: buildAgentSystemPrompt(config),
@@ -78,6 +109,13 @@ export async function runAgentLoop (chatEntry, config, abortRef, setIsStreaming,
78
109
  let accumulatedContent = ''
79
110
  let lastUsage = null
80
111
 
112
+ const markStopped = () => {
113
+ setIsStreaming(false)
114
+ updateChatEntry(chatEntry, {
115
+ response: accumulatedContent + '\n\n*(Agent stopped by user)*'
116
+ })
117
+ }
118
+
81
119
  setIsStreaming(true)
82
120
  updateChatEntry(chatEntry, {
83
121
  toolCalls: [],
@@ -85,21 +123,79 @@ export async function runAgentLoop (chatEntry, config, abortRef, setIsStreaming,
85
123
  })
86
124
 
87
125
  for (let iteration = 0; iteration < MAX_ITERATIONS; iteration++) {
88
- if (abortRef && abortRef.current) {
89
- setIsStreaming(false)
90
- updateChatEntry(chatEntry, {
91
- response: accumulatedContent + '\n\n*(Agent stopped by user)*'
92
- })
126
+ if (isAborted()) {
127
+ markStopped()
93
128
  return
94
129
  }
95
130
 
96
131
  publishContext(messages, config, lastUsage)
97
- const result = await callBackendAIchatWithTools(messages, config)
132
+
133
+ // Auto compression, when the popover toggle is on: the request about to
134
+ // go out carries the whole conversation plus a tool result per step, and
135
+ // past ~90% of the window the provider rejects it outright. Replacing the
136
+ // conversation with a summary first keeps the run alive; the stored
137
+ // session is compacted at the end (autoCompressSession), because that is
138
+ // what the next turn starts from.
139
+ const info = window.store.aiContextInfo
140
+ // A failed summary is only worth retrying once the context has grown by
141
+ // this much -- but never more than the trigger itself, or a threshold
142
+ // below it would let the retry floor decide when to compress.
143
+ const growthPercent = Math.min(
144
+ AUTO_COMPRESS_RETRY_GROWTH_PERCENT,
145
+ CONTEXT_DANGER_PERCENT
146
+ )
147
+ const grewEnough = info &&
148
+ info.tokens - autoCompressAttemptTokens >=
149
+ (info.windowSize || 0) * growthPercent / 100
150
+ if (
151
+ autoCompressEnabled() &&
152
+ canCompact(messages) &&
153
+ shouldAutoCompress(info) &&
154
+ grewEnough
155
+ ) {
156
+ autoCompressAttemptTokens = info.tokens
157
+ // Shown before the request rather than after: summarizing a nearly full
158
+ // window takes a while, and this badge is the only sign of why the run
159
+ // is waiting. Taken back if the summary comes back unusable.
160
+ updateChatEntry(chatEntry, { autoCompressCount: autoCompressCount + 1 })
161
+ const summary = await summarizeMessages(messages, config)
162
+ if (isAborted()) {
163
+ markStopped()
164
+ return
165
+ }
166
+ if (summary) {
167
+ applySummary(messages, summary)
168
+ autoCompressCount++
169
+ lastUsage = null
170
+ publishContext(messages, config, null)
171
+ } else {
172
+ updateChatEntry(chatEntry, { autoCompressCount })
173
+ }
174
+ }
175
+
176
+ const requestId = uid()
177
+ if (abortRef) {
178
+ abortRef.requestId = requestId
179
+ }
180
+ const result = await callBackendAIchatWithTools(messages, config, requestId)
181
+ if (abortRef && abortRef.requestId === requestId) {
182
+ abortRef.requestId = null
183
+ }
184
+
185
+ // Re-check after the await, before anything from the answer is used:
186
+ // stopping cancels the request, and neither the partial answer nor the
187
+ // resulting error belongs in the transcript.
188
+ if (isAborted()) {
189
+ markStopped()
190
+ return
191
+ }
192
+
98
193
  if (result.usage) {
99
194
  lastUsage = result.usage
100
195
  }
101
196
 
102
197
  if (result.error) {
198
+ runErrored = true
103
199
  setIsStreaming(false)
104
200
  updateChatEntry(chatEntry, {
105
201
  response: accumulatedContent + `\n\n**Error:** ${result.error}`
@@ -109,6 +205,7 @@ export async function runAgentLoop (chatEntry, config, abortRef, setIsStreaming,
109
205
 
110
206
  const assistantMessage = result.message
111
207
  if (!assistantMessage) {
208
+ runErrored = true
112
209
  setIsStreaming(false)
113
210
  updateChatEntry(chatEntry, {
114
211
  response: accumulatedContent || 'No response from AI.'
@@ -135,11 +232,8 @@ export async function runAgentLoop (chatEntry, config, abortRef, setIsStreaming,
135
232
  }
136
233
 
137
234
  for (const toolCall of assistantMessage.tool_calls) {
138
- if (abortRef && abortRef.current) {
139
- setIsStreaming(false)
140
- updateChatEntry(chatEntry, {
141
- response: accumulatedContent + '\n\n*(Agent stopped by user)*'
142
- })
235
+ if (isAborted()) {
236
+ markStopped()
143
237
  return
144
238
  }
145
239
 
@@ -150,28 +244,32 @@ export async function runAgentLoop (chatEntry, config, abortRef, setIsStreaming,
150
244
  args = {}
151
245
  }
152
246
 
153
- const toolEntry = {
247
+ toolCallsLog.push({
154
248
  id: toolCall.id,
155
249
  name: toolCall.function.name,
156
250
  args,
157
251
  status: 'running',
158
252
  result: null
159
- }
160
- toolCallsLog.push(toolEntry)
253
+ })
161
254
  updateChatEntry(chatEntry, {
162
255
  toolCalls: [...toolCallsLog]
163
256
  })
164
257
 
165
- let toolResult
258
+ let finished
166
259
  try {
167
- toolResult = await executeToolCall(toolCall.function.name, args)
168
- toolEntry.status = 'completed'
169
- toolEntry.result = toolResult
260
+ const toolResult = await executeToolCall(toolCall.function.name, args)
261
+ finished = { status: 'completed', result: toolResult }
170
262
  } catch (err) {
171
- toolEntry.status = 'error'
172
- toolEntry.result = err.message
263
+ finished = { status: 'error', result: err.message }
173
264
  }
174
265
 
266
+ // Swap in a new entry object instead of mutating the running one: the
267
+ // tool call card is memo()'d on that object's identity, so an in-place
268
+ // mutation would leave the card showing "running" forever.
269
+ toolCallsLog[toolCallsLog.length - 1] = {
270
+ ...toolCallsLog[toolCallsLog.length - 1],
271
+ ...finished
272
+ }
175
273
  updateChatEntry(chatEntry, {
176
274
  toolCalls: [...toolCallsLog]
177
275
  })
@@ -179,7 +277,7 @@ export async function runAgentLoop (chatEntry, config, abortRef, setIsStreaming,
179
277
  messages.push({
180
278
  role: 'tool',
181
279
  tool_call_id: toolCall.id,
182
- content: toolEntry.result
280
+ content: finished.result
183
281
  })
184
282
  }
185
283
  }
@@ -189,9 +287,54 @@ export async function runAgentLoop (chatEntry, config, abortRef, setIsStreaming,
189
287
  response: accumulatedContent + '\n\n*(Agent reached maximum iterations)*'
190
288
  })
191
289
  } finally {
192
- window.store.agentRunning = false
193
- // hand the panel back to the session based estimate: tool results are not
194
- // carried into the next turn, so the agent figure stops being meaningful
195
- window.store.aiContextInfo = null
290
+ if (abortRef) {
291
+ abortRef.requestId = null
292
+ }
293
+ // Only the run that still owns the flag may clear it -- a stopped run
294
+ // unwinds a moment later and must not unlock a newer one.
295
+ const ownsRun = activeRunId === runId
296
+ if (ownsRun) {
297
+ activeRunId = null
298
+ window.store.agentRunning = false
299
+ // hand the panel back to the session based estimate: tool results are not
300
+ // carried into the next turn, so the agent figure stops being meaningful
301
+ window.store.aiContextInfo = null
302
+ }
303
+ // A run that compacted its live conversation leaves the stored session
304
+ // untouched, so the next turn would start from the full history again and
305
+ // pay for another summary. Compact the session as well -- once, after the
306
+ // run, when the turns it summarizes are complete. Skipped for a stopped or
307
+ // failed run: those transcripts are not a conversation worth keeping.
308
+ if (ownsRun && autoCompressCount > 0 && !runErrored && !isAborted()) {
309
+ await autoCompressSession(chatEntry.chatSessionId, config)
310
+ }
311
+ }
312
+ }
313
+
314
+ // Stop the running agent loop, called by the panel's stop button.
315
+ //
316
+ // The loop itself only looks at `abortRef` between iterations, so on its own
317
+ // that flag does nothing while a request is in flight -- and the panel keeps
318
+ // the composer locked for as long as the provider takes to answer (which is
319
+ // forever if it never does, these requests carry no timeout). So this also
320
+ // cancels the in-flight request and releases the composer right away; the loop
321
+ // then unwinds on its own and marks the entry as stopped.
322
+ export async function stopAgentRun (abortRef) {
323
+ if (abortRef) {
324
+ abortRef.current = true
325
+ }
326
+ const requestId = abortRef && abortRef.requestId
327
+ if (abortRef) {
328
+ abortRef.requestId = null
329
+ }
330
+ activeRunId = null
331
+ window.store.agentRunning = false
332
+ window.store.aiContextInfo = null
333
+ if (requestId) {
334
+ try {
335
+ await window.pre.runGlobalAsync('abortAIRequest', requestId)
336
+ } catch (error) {
337
+ console.error('Error aborting agent request:', error)
338
+ }
196
339
  }
197
340
  }