@bibobonking/opensol 3.1.5 → 3.2.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.
Files changed (53) hide show
  1. package/CHANGELOG.md +8 -2
  2. package/LICENSE +21 -0
  3. package/README.md +24 -20
  4. package/desktop/README.md +1 -1
  5. package/dist/assets/{index-C0yvs6Vl.js → index-BUVnnhRH.js} +1 -1
  6. package/dist/assets/index-CyxlAgKM.js +271 -0
  7. package/dist/assets/index-DFeF90S8.css +1 -0
  8. package/dist/index.html +2 -2
  9. package/package.json +1 -1
  10. package/server/README.md +15 -68
  11. package/server/daemon.mjs +6 -1
  12. package/server/lib/deploy.mjs +5 -10
  13. package/server/lib/shell.mjs +149 -0
  14. package/server/lib/shell.test.mjs +72 -0
  15. package/server/lib/tools.mjs +8 -44
  16. package/src/agent/adapters/anthropic.ts +2 -3
  17. package/src/agent/adapters/gemini.ts +2 -3
  18. package/src/agent/adapters/openai-responses.ts +2 -3
  19. package/src/agent/adapters/openai.ts +2 -3
  20. package/src/agent/adapters/shared.ts +3 -0
  21. package/src/components/ChatView.tsx +17 -54
  22. package/src/components/Composer.tsx +143 -135
  23. package/src/components/ContextBar.tsx +2 -2
  24. package/src/components/HomeDashboard.tsx +7 -4
  25. package/src/components/MessageItem.tsx +33 -1
  26. package/src/components/ModelDeck.tsx +5 -27
  27. package/src/components/SettingsModal.tsx +8 -0
  28. package/src/components/ToolCallCard.tsx +2 -2
  29. package/src/components/TrajectoryView.tsx +1 -1
  30. package/src/components/composer/PlusMenu.tsx +117 -0
  31. package/src/components/composer/Popover.tsx +109 -0
  32. package/src/components/composer/WorkspacePicker.tsx +102 -0
  33. package/src/core/plugins/tools/shell.ts +2 -2
  34. package/src/core/plugins/tools/subagent.ts +18 -8
  35. package/src/core/services/agent-loop.ts +45 -43
  36. package/src/core/services/compaction.ts +289 -22
  37. package/src/core/services/llm.ts +6 -2
  38. package/src/core/services/subagents.ts +191 -3
  39. package/src/core/services/system-prompt.ts +35 -7
  40. package/src/core/services/tools.ts +2 -1
  41. package/src/core/types.ts +5 -0
  42. package/src/lib/compact-plan.test.mjs +62 -0
  43. package/src/lib/compact-plan.ts +188 -0
  44. package/src/lib/subagent-types.ts +54 -0
  45. package/src/lib/tool-meta.ts +40 -0
  46. package/src/store/settings.ts +3 -0
  47. package/src/store/ui.ts +12 -0
  48. package/src/types.ts +26 -0
  49. package/shared/plugin-builder.test.mjs +0 -43
  50. package/shared/plugin-package.d.mts +0 -34
  51. package/shared/plugin-package.mjs +0 -197
  52. package/shared/plugin-package.test.mjs +0 -94
  53. package/tsconfig.tsbuildinfo +0 -1
@@ -0,0 +1,117 @@
1
+ import { useEffect, useState } from 'react'
2
+ import { Check, Globe2, Paperclip, Plug, Plus, Sparkles } from 'lucide-react'
3
+ import { useSettings } from '../../store/settings'
4
+ import { useSkills } from '../../store/skills'
5
+ import { useMcp } from '../../store/mcp'
6
+ import { useUi } from '../../store/ui'
7
+ import { Popover } from './Popover'
8
+ import { cn } from '../../lib/utils'
9
+
10
+ function Row({
11
+ icon: Icon,
12
+ label,
13
+ hint,
14
+ active,
15
+ onClick,
16
+ }: {
17
+ icon: typeof Plus
18
+ label: string
19
+ hint?: string
20
+ active?: boolean
21
+ onClick: () => void
22
+ }) {
23
+ return (
24
+ <button
25
+ type="button"
26
+ onClick={onClick}
27
+ className="flex w-full items-center gap-2.5 rounded-lg px-2 py-1.5 text-left text-[12px] text-ink transition-colors hover:bg-raised"
28
+ >
29
+ <Icon size={14} className="shrink-0 text-faint" />
30
+ <span className="min-w-0 flex-1 truncate">{label}</span>
31
+ {hint && <span className="shrink-0 text-[10.5px] text-faint">{hint}</span>}
32
+ {active !== undefined && (
33
+ <span className="flex h-4 w-4 shrink-0 items-center justify-center">
34
+ {active && <Check size={13} className="text-accent" />}
35
+ </span>
36
+ )}
37
+ </button>
38
+ )
39
+ }
40
+
41
+ /** The "+" menu: files, web search, mode, skills and MCP. */
42
+ export function PlusMenu({ onAddFiles }: { onAddFiles: () => void }) {
43
+ const settings = useSettings()
44
+ const update = useSettings((state) => state.update)
45
+ const enabledCount = useSkills((state) => state.skills.filter((skill) => skill.enabled).length)
46
+ const skillsLoaded = useSkills((state) => state.loaded)
47
+ const loadSkills = useSkills((state) => state.load)
48
+ const servers = useMcp((state) => state.servers)
49
+ const mcpLoaded = useMcp((state) => state.loaded)
50
+ const loadMcp = useMcp((state) => state.load)
51
+ const openSettings = useUi((state) => state.openSettings)
52
+ const setMcpOpen = useUi((state) => state.setMcpOpen)
53
+ const [open, setOpen] = useState(false)
54
+
55
+ useEffect(() => {
56
+ if (!open) return
57
+ if (!skillsLoaded) void loadSkills()
58
+ if (!mcpLoaded) void loadMcp()
59
+ }, [open, skillsLoaded, mcpLoaded, loadSkills, loadMcp])
60
+
61
+ const connected = servers.filter((server) => server.status === 'connected').length
62
+ const close = () => setOpen(false)
63
+
64
+ return (
65
+ <Popover
66
+ open={open}
67
+ onClose={close}
68
+ width={280}
69
+ trigger={
70
+ <button
71
+ type="button"
72
+ onClick={() => setOpen((value) => !value)}
73
+ className={cn('icon-button h-8 w-8', open && 'bg-raised text-ink')}
74
+ aria-label="Add files, skills and tools"
75
+ aria-expanded={open}
76
+ title="Add files, skills and tools"
77
+ >
78
+ <Plus size={16} className={cn('transition-transform duration-200', open && 'rotate-45')} />
79
+ </button>
80
+ }
81
+ >
82
+ <Row
83
+ icon={Paperclip}
84
+ label="Add files or photos"
85
+ onClick={() => {
86
+ close()
87
+ onAddFiles()
88
+ }}
89
+ />
90
+ <Row
91
+ icon={Globe2}
92
+ label="Web search"
93
+ active={settings.webSearchEnabled}
94
+ onClick={() => update({ webSearchEnabled: !settings.webSearchEnabled })}
95
+ />
96
+ <Row
97
+ icon={Plug}
98
+ label="MCP servers"
99
+ hint={servers.length ? `${connected}/${servers.length} connected` : 'Add'}
100
+ onClick={() => {
101
+ close()
102
+ setMcpOpen(true)
103
+ }}
104
+ />
105
+
106
+ <Row
107
+ icon={Sparkles}
108
+ label="Skills"
109
+ hint={enabledCount ? `${enabledCount} on` : undefined}
110
+ onClick={() => {
111
+ close()
112
+ openSettings('skills')
113
+ }}
114
+ />
115
+ </Popover>
116
+ )
117
+ }
@@ -0,0 +1,109 @@
1
+ import { useLayoutEffect, useRef, useState, type ReactNode } from 'react'
2
+ import { useClickOutside } from '../../lib/hooks'
3
+ import { cn } from '../../lib/utils'
4
+
5
+ const EDGE = 12
6
+ const GAP = 8
7
+
8
+ /**
9
+ * A trigger plus a floating panel. It opens on the side with room (flipping above/below, and
10
+ * left/right), never grows past `maxHeight` or the viewport, and scrolls inside when longer.
11
+ */
12
+ export function Popover({
13
+ open,
14
+ onClose,
15
+ trigger,
16
+ children,
17
+ width = 260,
18
+ placement = 'above',
19
+ maxHeight = 260,
20
+ className,
21
+ }: {
22
+ open: boolean
23
+ onClose: () => void
24
+ trigger: ReactNode
25
+ children: ReactNode
26
+ width?: number
27
+ /** Preferred side; flips when the other side has clearly more room. */
28
+ placement?: 'above' | 'below'
29
+ /** Upper bound for the panel height before it scrolls. */
30
+ maxHeight?: number
31
+ className?: string
32
+ }) {
33
+ const wrapperRef = useRef<HTMLDivElement>(null)
34
+ const panelRef = useRef<HTMLDivElement>(null)
35
+ const [layout, setLayout] = useState({ side: placement, height: maxHeight, alignRight: false })
36
+ useClickOutside(wrapperRef, onClose, open)
37
+
38
+ useLayoutEffect(() => {
39
+ if (!open) return
40
+ const measure = () => {
41
+ const wrapper = wrapperRef.current
42
+ if (!wrapper) return
43
+ const rect = wrapper.getBoundingClientRect()
44
+ const above = rect.top - EDGE - GAP
45
+ const below = window.innerHeight - rect.bottom - EDGE - GAP
46
+ const natural = Math.min(panelRef.current?.scrollHeight ?? maxHeight, maxHeight)
47
+ const fits = (room: number) => room >= natural
48
+ const preferred = placement === 'above' ? above : below
49
+ const other = placement === 'above' ? below : above
50
+ const side = fits(preferred) || preferred >= other ? placement : placement === 'above' ? 'below' : 'above'
51
+ const room = side === 'above' ? above : below
52
+ setLayout({
53
+ side,
54
+ height: Math.max(96, Math.min(maxHeight, room)),
55
+ alignRight: rect.left + width > window.innerWidth - EDGE,
56
+ })
57
+ }
58
+ measure()
59
+ window.addEventListener('resize', measure)
60
+ return () => window.removeEventListener('resize', measure)
61
+ }, [open, placement, maxHeight, width])
62
+
63
+ return (
64
+ <div ref={wrapperRef} className="relative">
65
+ {trigger}
66
+ {open && (
67
+ <div
68
+ ref={panelRef}
69
+ style={{ width, maxHeight: layout.height }}
70
+ className={cn(
71
+ 'surface-lift thin-scroll animate-scalein absolute z-40 max-w-[calc(100vw-24px)] overflow-y-auto overscroll-contain rounded-2xl border border-line p-1.5 shadow-float',
72
+ layout.alignRight ? 'right-0' : 'left-0',
73
+ layout.side === 'above' ? 'bottom-[calc(100%+8px)]' : 'top-[calc(100%+6px)]',
74
+ className,
75
+ )}
76
+ >
77
+ {children}
78
+ </div>
79
+ )}
80
+ </div>
81
+ )
82
+ }
83
+
84
+ export function MenuLabel({ children }: { children: ReactNode }) {
85
+ return <p className="px-2 pb-1 pt-1.5 text-[10px] font-medium uppercase tracking-[0.1em] text-faint">{children}</p>
86
+ }
87
+
88
+ /** A plain-text toolbar button: no border or fill until it is hovered or its panel is open. */
89
+ export function TextButton({
90
+ children,
91
+ open,
92
+ className,
93
+ ...props
94
+ }: React.ButtonHTMLAttributes<HTMLButtonElement> & { open?: boolean }) {
95
+ return (
96
+ <button
97
+ type="button"
98
+ aria-expanded={open}
99
+ {...props}
100
+ className={cn(
101
+ 'flex h-8 items-center gap-1 rounded-lg px-2 text-[12px] font-medium text-muted transition-colors hover:text-ink disabled:cursor-not-allowed disabled:opacity-45',
102
+ open && 'text-ink',
103
+ className,
104
+ )}
105
+ >
106
+ {children}
107
+ </button>
108
+ )
109
+ }
@@ -0,0 +1,102 @@
1
+ import { useState } from 'react'
2
+ import { Check, ChevronDown, FolderOpen, GitBranch } from 'lucide-react'
3
+ import { useUi } from '../../store/ui'
4
+ import { useWorkspace } from '../../store/workspace'
5
+ import { Popover, MenuLabel, TextButton } from './Popover'
6
+ import { cn } from '../../lib/utils'
7
+
8
+ /** The project folder, shown above the composer. Click to switch folders. */
9
+ export function WorkspacePicker() {
10
+ const info = useWorkspace((state) => state.info)
11
+ const git = useWorkspace((state) => state.git)
12
+ const addWorkspace = useWorkspace((state) => state.addWorkspace)
13
+ const setWorkspaceOpen = useUi((state) => state.setWorkspaceOpen)
14
+ const notify = useUi((state) => state.notify)
15
+ const [open, setOpen] = useState(false)
16
+ const [switching, setSwitching] = useState('')
17
+
18
+ const choose = async (path: string) => {
19
+ if (path === info.root) {
20
+ setOpen(false)
21
+ return
22
+ }
23
+ setSwitching(path)
24
+ try {
25
+ await addWorkspace(path)
26
+ setOpen(false)
27
+ } catch (error) {
28
+ notify(error instanceof Error ? error.message : 'Could not open that folder.', 'error')
29
+ } finally {
30
+ setSwitching('')
31
+ }
32
+ }
33
+
34
+ const recent = [...info.workspaces].sort((a, b) => b.lastOpened - a.lastOpened)
35
+
36
+ return (
37
+ <Popover
38
+ open={open}
39
+ onClose={() => setOpen(false)}
40
+ placement="below"
41
+ width={280}
42
+ maxHeight={260}
43
+ trigger={
44
+ <TextButton
45
+ open={open}
46
+ onClick={() => setOpen((value) => !value)}
47
+ title={info.available ? info.root : 'Choose a project folder'}
48
+ className="max-w-[240px]"
49
+ >
50
+ <FolderOpen size={13} className="shrink-0 text-faint" />
51
+ <span className="truncate">{info.available ? info.name : 'Select folder'}</span>
52
+ {info.available && git.available && git.branch && (
53
+ <span className="flex shrink-0 items-center gap-0.5 text-faint">
54
+ <GitBranch size={11} />
55
+ {git.branch}
56
+ </span>
57
+ )}
58
+ <ChevronDown size={11} className={cn('shrink-0 text-faint transition-transform', open && 'rotate-180')} />
59
+ </TextButton>
60
+ }
61
+ >
62
+ <MenuLabel>Folders</MenuLabel>
63
+ <div>
64
+ {recent.length === 0 && <p className="px-2 pb-2 text-[11.5px] text-faint">No folders yet.</p>}
65
+ {recent.map((workspace) => (
66
+ <button
67
+ key={workspace.path}
68
+ type="button"
69
+ onClick={() => void choose(workspace.path)}
70
+ disabled={Boolean(switching)}
71
+ className="flex w-full items-center gap-2 rounded-lg px-2 py-1.5 text-left transition-colors hover:bg-raised disabled:opacity-60"
72
+ title={workspace.path}
73
+ >
74
+ <span className="flex h-4 w-4 shrink-0 items-center justify-center">
75
+ {workspace.path === info.root ? <Check size={12} className="text-accent" /> : <FolderOpen size={12} className="text-faint" />}
76
+ </span>
77
+ <span className="min-w-0 flex-1">
78
+ <span className="block truncate text-[12px] font-medium text-ink">{workspace.name}</span>
79
+ <span className="block truncate text-[10px] text-faint">{workspace.path}</span>
80
+ </span>
81
+ {switching === workspace.path && (
82
+ <span className="h-3 w-3 shrink-0 animate-spin rounded-full border border-current border-t-transparent text-faint" />
83
+ )}
84
+ </button>
85
+ ))}
86
+ </div>
87
+ <div className="mt-1 border-t border-line pt-1">
88
+ <button
89
+ type="button"
90
+ onClick={() => {
91
+ setOpen(false)
92
+ setWorkspaceOpen(true)
93
+ }}
94
+ className="flex w-full items-center gap-2 rounded-lg px-2 py-1.5 text-left text-[12px] text-muted transition-colors hover:bg-raised hover:text-ink"
95
+ >
96
+ <FolderOpen size={12} className="text-faint" />
97
+ Open another folder…
98
+ </button>
99
+ </div>
100
+ </Popover>
101
+ )
102
+ }
@@ -55,11 +55,11 @@ export function apply(ctx: Context) {
55
55
  return ctx.tools.register({
56
56
  name: 'shell',
57
57
  description:
58
- 'Run a shell command in the workspace root. The working directory resets between calls. Prefer dedicated file and git tools for edits. In Plan mode only read-only commands are accepted.',
58
+ 'Run a command in the user\'s default shell (bash/zsh on Linux and macOS, PowerShell on Windows; see the Environment section) from the workspace root. The working directory resets between calls. Prefer dedicated file and git tools for edits. In Plan mode only read-only commands are accepted.',
59
59
  parameters: {
60
60
  type: 'object',
61
61
  properties: {
62
- command: { type: 'string', description: 'The shell command to run.' },
62
+ command: { type: 'string', description: 'The command to run, written for the shell named in the Environment section.' },
63
63
  timeoutMs: { type: 'number', description: 'Timeout in milliseconds (max 600000).' },
64
64
  },
65
65
  required: ['command'],
@@ -1,5 +1,6 @@
1
1
  import type { Context } from 'cordis'
2
2
  import type { ToolResult } from '../../types'
3
+ import { SUBAGENT_TYPES, subagentType } from '../../../lib/subagent-types'
3
4
 
4
5
  const MAX_TOOL_OUTPUT = 28_000
5
6
 
@@ -12,25 +13,34 @@ export const name = 'tool-subagent'
12
13
  export const inject = ['tools', 'subagents']
13
14
 
14
15
  export function apply(ctx: Context) {
16
+ const kinds = Object.entries(SUBAGENT_TYPES)
17
+ .map(([id, spec]) => `"${id}" (${spec.summary})`)
18
+ .join('; ')
15
19
  return ctx.tools.register({
16
20
  name: 'subagent',
17
- description:
18
- 'Delegate a focused investigation to a read-only subagent. Use it to explore a large area, trace a bug or verify assumptions without changing files. The subagent returns a concise report.',
21
+ description: `Delegate a focused, read-only investigation to a subagent that works in its own context and returns a concise report. Types: ${kinds}. Give it a complete, self-contained task (it cannot see this conversation) and any file paths or constraints as context. To investigate independent questions in parallel, call this tool several times in the same message.`,
19
22
  parameters: {
20
23
  type: 'object',
21
24
  properties: {
22
- task: { type: 'string', description: 'The specific question or investigation to perform.' },
23
- context: { type: 'string', description: 'Optional context, constraints or files to start from.' },
25
+ task: { type: 'string', description: 'The specific question or investigation. Be precise about what the report must contain.' },
26
+ context: { type: 'string', description: 'Optional context: files to start from, what is already known, constraints.' },
27
+ type: { type: 'string', enum: Object.keys(SUBAGENT_TYPES), description: 'explore (default), research or review.' },
24
28
  },
25
29
  required: ['task'],
26
30
  additionalProperties: false,
27
31
  },
28
32
  allowedInMode: () => true,
29
33
  async execute(args, exec): Promise<ToolResult> {
30
- const task = typeof args.task === 'string' ? args.task : ''
31
- const context = typeof args.context === 'string' ? args.context : ''
32
- const output = await ctx.subagents.run(task, context, exec.signal)
33
- return { output: truncate(output), isError: false }
34
+ const report = await ctx.subagents.run({
35
+ task: typeof args.task === 'string' ? args.task : '',
36
+ context: typeof args.context === 'string' ? args.context : '',
37
+ type: subagentType(args.type),
38
+ signal: exec.signal,
39
+ sessionId: exec.sessionId,
40
+ onProgress: exec.onProgress,
41
+ })
42
+ const footer = `\n\n— subagent: ${report.toolCalls} tool call${report.toolCalls === 1 ? '' : 's'}, ${report.rounds} round${report.rounds === 1 ? '' : 's'}${report.capped ? ', stopped at its round budget' : ''}`
43
+ return { output: truncate(report.output) + footer, isError: false }
34
44
  },
35
45
  })
36
46
  }
@@ -1,5 +1,5 @@
1
1
  import { Service, type Context } from 'cordis'
2
- import type { AssistantMessage, ToolMessage } from '../../types'
2
+ import type { AssistantMessage, ToolCall, ToolMessage } from '../../types'
3
3
  import { activeModelId, useSettings } from '../../store/settings'
4
4
  import { createId } from '../../store/chats'
5
5
  import { findModel } from '../../store/models'
@@ -32,11 +32,6 @@ function isAbortError(error: unknown) {
32
32
  )
33
33
  }
34
34
 
35
- function truncate(value: string, limit = 40_000) {
36
- if (value.length <= limit) return value
37
- return `${value.slice(0, limit)}\n… truncated (${value.length - limit} more characters)`
38
- }
39
-
40
35
  export class AgentLoopService extends Service {
41
36
  static provide = 'agentLoop'
42
37
  static inject = ['sessions', 'systemPrompt', 'tools', 'llm', 'approval', 'compaction', 'subagents']
@@ -116,13 +111,14 @@ export class AgentLoopService extends Service {
116
111
  let current = this.ctx.sessions.current(sessionId)
117
112
  if (!current) break
118
113
 
119
- // Auto-compaction check before round 0
120
- // A prompt queued behind this turn would be dropped by the rewrite, so wait until it is the last one.
121
- if (round === 0 && lastUserMessageId(current.messages) === userMessageId) {
122
- const compacted = await this.ctx.compaction.checkAutoCompact(sessionId)
114
+ // Auto-compaction: before the turn starts and again between tool rounds, since one long turn can
115
+ // fill the window. A prompt queued behind this turn would be dropped by the rewrite, so wait
116
+ // until this turn is the last one.
117
+ if (lastUserMessageId(current.messages) === userMessageId) {
118
+ const compacted = await this.ctx.compaction.checkAutoCompact(sessionId, { midTurn: round > 0 })
123
119
  if (compacted) {
124
- // Compaction rewrites the whole transcript, so the original user message
125
- // is gone: continue from the handoff message and the fresh session state.
120
+ // The transcript was rewritten: continue from its newest user message (the kept prompt,
121
+ // or the handoff note when compaction happened mid-turn).
126
122
  const refreshed = this.ctx.sessions.current(sessionId)
127
123
  if (!refreshed) break
128
124
  const handoffId = lastUserMessageId(refreshed.messages)
@@ -275,8 +271,7 @@ export class AgentLoopService extends Service {
275
271
 
276
272
  const toolMessages: ToolMessage[] = []
277
273
 
278
- for (const call of result.toolCalls) {
279
- if (controller.signal.aborted) break
274
+ const runToolCall = async (call: ToolCall): Promise<{ message: ToolMessage; stopped: boolean }> => {
280
275
  const toolSetting = resolveToolSetting(settings, call.name)
281
276
  if (!toolSetting?.enabled) {
282
277
  const message: ToolMessage = {
@@ -289,46 +284,39 @@ export class AgentLoopService extends Service {
289
284
  createdAt: Date.now(),
290
285
  }
291
286
  this.ctx.sessions.addMessage(sessionId, message)
292
- toolMessages.push(message)
293
- continue
287
+ return { message, stopped: false }
294
288
  }
295
289
 
296
290
  const toolMessageId = createId('tool')
297
- const awaiting = toolSetting.approval === 'ask'
298
291
  const toolMessage: ToolMessage = {
299
292
  id: toolMessageId,
300
293
  role: 'tool',
301
294
  call,
302
- status: awaiting ? 'awaiting' : 'running',
295
+ status: toolSetting.approval === 'ask' ? 'awaiting' : 'running',
303
296
  round,
304
297
  createdAt: Date.now(),
305
298
  }
306
299
  this.ctx.sessions.addMessage(sessionId, toolMessage)
307
300
 
301
+ // Long tools (subagents) stream a short live log into their card.
302
+ let progressTimer: number | undefined
303
+ let progressText = ''
304
+ const onProgress = (text: string) => {
305
+ progressText = text
306
+ if (progressTimer !== undefined) return
307
+ progressTimer = window.setTimeout(() => {
308
+ progressTimer = undefined
309
+ this.ctx.sessions.updateMessage(sessionId, toolMessageId, { result: progressText })
310
+ }, 250)
311
+ }
312
+
308
313
  const toolStarted = Date.now()
309
314
  try {
310
- const output =
311
- call.name === 'subagent'
312
- ? {
313
- output: truncate(
314
- await this.ctx.subagents.run(
315
- typeof call.args.task === 'string' ? call.args.task : '',
316
- typeof call.args.context === 'string' ? call.args.context : '',
317
- controller.signal,
318
- sessionId,
319
- ),
320
- ),
321
- isError: false,
322
- }
323
- : await this.ctx.tools.execute(
324
- call.name,
325
- call.args,
326
- settings,
327
- mode,
328
- controller.signal,
329
- call.id,
330
- userMessageId,
331
- )
315
+ const output = await this.ctx.tools.execute(call.name, call.args, settings, mode, controller.signal, call.id, userMessageId, {
316
+ sessionId,
317
+ onProgress,
318
+ })
319
+ if (progressTimer !== undefined) window.clearTimeout(progressTimer)
332
320
  const finalToolMessage: ToolMessage = {
333
321
  ...toolMessage,
334
322
  status: output.isError ? 'error' : 'done',
@@ -337,8 +325,9 @@ export class AgentLoopService extends Service {
337
325
  diff: output.diff,
338
326
  }
339
327
  this.ctx.sessions.updateMessage(sessionId, toolMessageId, finalToolMessage)
340
- toolMessages.push(finalToolMessage)
328
+ return { message: finalToolMessage, stopped: false }
341
329
  } catch (error) {
330
+ if (progressTimer !== undefined) window.clearTimeout(progressTimer)
342
331
  const stopped = isAbortError(error)
343
332
  const finalToolMessage: ToolMessage = {
344
333
  ...toolMessage,
@@ -347,9 +336,22 @@ export class AgentLoopService extends Service {
347
336
  durationMs: Date.now() - toolStarted,
348
337
  }
349
338
  this.ctx.sessions.updateMessage(sessionId, toolMessageId, finalToolMessage)
350
- toolMessages.push(finalToolMessage)
351
- if (stopped) break
339
+ return { message: finalToolMessage, stopped }
340
+ }
341
+ }
342
+
343
+ // Calls run in order, except that neighbouring subagent calls run side by side.
344
+ const calls = result.toolCalls
345
+ for (let index = 0; index < calls.length; ) {
346
+ if (controller.signal.aborted) break
347
+ let end = index + 1
348
+ if (calls[index].name === 'subagent') {
349
+ while (end < calls.length && calls[end].name === 'subagent') end += 1
352
350
  }
351
+ const outcomes = await Promise.all(calls.slice(index, end).map(runToolCall))
352
+ toolMessages.push(...outcomes.map((outcome) => outcome.message))
353
+ if (outcomes.some((outcome) => outcome.stopped)) break
354
+ index = end
353
355
  }
354
356
 
355
357
  const stepResult: StepResult = {