@swarmclawai/swarmclaw 1.5.3 → 1.5.33
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.
- package/README.md +167 -147
- package/bin/swarmclaw.js +35 -1
- package/package.json +2 -2
- package/scripts/run-next-build.mjs +87 -2
- package/skills/swarmclaw/SKILL.md +151 -0
- package/src/app/api/agents/[id]/route.ts +8 -0
- package/src/app/api/agents/agents-route.test.ts +114 -0
- package/src/app/api/agents/route.ts +10 -0
- package/src/app/api/connectors/route.ts +25 -13
- package/src/app/api/credentials/[id]/route.ts +8 -1
- package/src/app/api/schedules/[id]/route.ts +8 -0
- package/src/app/api/secrets/[id]/route.ts +10 -0
- package/src/app/api/setup/check-provider/route.ts +45 -0
- package/src/app/api/setup/doctor/route.ts +5 -0
- package/src/cli/binary.test.js +11 -0
- package/src/cli/index.js +4 -4
- package/src/cli/index.test.js +5 -2
- package/src/cli/index.ts +1 -1
- package/src/components/agents/agent-sheet.tsx +16 -4
- package/src/components/agents/inspector-panel.tsx +5 -0
- package/src/components/auth/setup-wizard/step-agents.tsx +19 -1
- package/src/components/chat/activity-moment.tsx +3 -0
- package/src/components/chat/chat-header.tsx +23 -2
- package/src/components/chat/tool-call-bubble.tsx +20 -0
- package/src/components/layout/sidebar-rail.tsx +29 -0
- package/src/hooks/setup-done-detection.test.ts +4 -2
- package/src/hooks/setup-done-detection.ts +1 -1
- package/src/lib/orchestrator-config.ts +5 -0
- package/src/lib/provider-sets.ts +4 -4
- package/src/lib/providers/acp-client.ts +116 -0
- package/src/lib/providers/cli-utils.test.ts +9 -1
- package/src/lib/providers/cli-utils.ts +89 -4
- package/src/lib/providers/cursor-cli.ts +172 -0
- package/src/lib/providers/goose.ts +149 -0
- package/src/lib/providers/index.ts +29 -1
- package/src/lib/providers/qwen-code-cli.ts +152 -0
- package/src/lib/server/agents/agent-availability.ts +2 -2
- package/src/lib/server/agents/agent-thread-session.ts +8 -0
- package/src/lib/server/agents/task-session.ts +8 -0
- package/src/lib/server/capability-router.ts +8 -2
- package/src/lib/server/chat-execution/chat-execution-utils.ts +13 -0
- package/src/lib/server/chat-execution/chat-turn-finalization.ts +8 -0
- package/src/lib/server/chat-execution/chat-turn-preparation.ts +5 -1
- package/src/lib/server/chat-execution/chat-turn-tool-routing.ts +3 -0
- package/src/lib/server/chat-execution/iteration-timers.test.ts +84 -0
- package/src/lib/server/chat-execution/iteration-timers.ts +18 -1
- package/src/lib/server/chat-execution/prompt-sections.ts +3 -1
- package/src/lib/server/chat-execution/stream-agent-chat.ts +5 -0
- package/src/lib/server/chatrooms/chatroom-helpers.ts +13 -0
- package/src/lib/server/chats/chat-session-service.ts +18 -0
- package/src/lib/server/connectors/session.ts +8 -0
- package/src/lib/server/context-manager.ts +5 -0
- package/src/lib/server/provider-health.ts +13 -3
- package/src/lib/server/provider-model-discovery.test.ts +8 -0
- package/src/lib/server/provider-model-discovery.ts +1 -1
- package/src/lib/server/runtime/daemon-state/core.ts +2 -2
- package/src/lib/server/runtime/queue/core.ts +30 -4
- package/src/lib/server/runtime/session-run-manager/enqueue.ts +1 -1
- package/src/lib/server/session-reset-policy.test.ts +16 -0
- package/src/lib/server/session-reset-policy.ts +9 -1
- package/src/lib/server/session-tools/context.ts +2 -2
- package/src/lib/server/session-tools/delegate.ts +334 -14
- package/src/lib/server/session-tools/index.ts +5 -2
- package/src/lib/server/session-tools/session-info.ts +4 -1
- package/src/lib/server/storage-auth-docker.test.ts +244 -0
- package/src/lib/server/storage-auth.test.ts +150 -0
- package/src/lib/server/storage-auth.ts +57 -22
- package/src/lib/server/storage-normalization.ts +19 -0
- package/src/lib/server/storage.ts +3 -0
- package/src/lib/server/tasks/task-resume.ts +23 -1
- package/src/lib/server/tool-aliases.ts +1 -1
- package/src/lib/server/tool-capability-policy.ts +4 -1
- package/src/lib/setup-defaults.test.ts +6 -0
- package/src/lib/setup-defaults.ts +146 -0
- package/src/lib/tool-definitions.ts +1 -1
- package/src/types/misc.ts +4 -1
- package/src/types/provider.ts +1 -1
- package/src/types/session.ts +9 -0
|
@@ -12,6 +12,24 @@ import { AVAILABLE_TOOLS, PLATFORM_TOOLS } from '@/lib/tool-definitions'
|
|
|
12
12
|
import { AgentAvatar } from '@/components/agents/agent-avatar'
|
|
13
13
|
import { isOrchestratorProviderEligible } from '@/lib/orchestrator-config'
|
|
14
14
|
|
|
15
|
+
const SETUP_PROVIDERS_WITH_MODEL_DISCOVERY = new Set([
|
|
16
|
+
'openai',
|
|
17
|
+
'openrouter',
|
|
18
|
+
'anthropic',
|
|
19
|
+
'google',
|
|
20
|
+
'deepseek',
|
|
21
|
+
'groq',
|
|
22
|
+
'together',
|
|
23
|
+
'mistral',
|
|
24
|
+
'xai',
|
|
25
|
+
'fireworks',
|
|
26
|
+
'nebius',
|
|
27
|
+
'deepinfra',
|
|
28
|
+
'ollama',
|
|
29
|
+
'openclaw',
|
|
30
|
+
'hermes',
|
|
31
|
+
])
|
|
32
|
+
|
|
15
33
|
/* ── Model combobox: search discovered models or type a custom one ── */
|
|
16
34
|
|
|
17
35
|
function ModelCombobox({
|
|
@@ -36,7 +54,7 @@ function ModelCombobox({
|
|
|
36
54
|
const fetched = useRef<string | null>(null)
|
|
37
55
|
|
|
38
56
|
const effectiveEndpoint = endpointOverride || provider?.endpoint || null
|
|
39
|
-
const supportsModelDiscovery = Boolean(provider && provider.setupProvider
|
|
57
|
+
const supportsModelDiscovery = Boolean(provider && SETUP_PROVIDERS_WITH_MODEL_DISCOVERY.has(provider.setupProvider))
|
|
40
58
|
|
|
41
59
|
const fetchModels = useCallback(async (force?: boolean) => {
|
|
42
60
|
if (!provider || !supportsModelDiscovery) return
|
|
@@ -12,6 +12,9 @@ const NOTABLE_TOOLS: Record<string, { label: string; color: string; icon: 'brain
|
|
|
12
12
|
delegate_to_claude_code: { label: 'Delegated to Claude Code', color: '#38BDF8', icon: 'delegate' },
|
|
13
13
|
delegate_to_codex_cli: { label: 'Delegated to Codex', color: '#38BDF8', icon: 'delegate' },
|
|
14
14
|
delegate_to_opencode_cli: { label: 'Delegated to OpenCode', color: '#38BDF8', icon: 'delegate' },
|
|
15
|
+
delegate_to_gemini_cli: { label: 'Delegated to Gemini CLI', color: '#38BDF8', icon: 'delegate' },
|
|
16
|
+
delegate_to_cursor_cli: { label: 'Delegated to Cursor CLI', color: '#38BDF8', icon: 'delegate' },
|
|
17
|
+
delegate_to_qwen_code_cli: { label: 'Delegated to Qwen Code', color: '#38BDF8', icon: 'delegate' },
|
|
15
18
|
delegate_to_agent: { label: 'Delegating task', color: '#6366F1', icon: 'delegate' },
|
|
16
19
|
check_delegation_status: { label: 'Checking delegation', color: '#6366F1', icon: 'delegate' },
|
|
17
20
|
web_search: { label: 'Searched the web', color: '#22C55E', icon: 'search' },
|
|
@@ -164,6 +164,12 @@ export function ChatHeader({ session, streaming, onStop, onMenuToggle, onBack, m
|
|
|
164
164
|
const fromSessionOpenCode = session.opencodeSessionId
|
|
165
165
|
? { label: 'OpenCode', id: session.opencodeSessionId, command: `opencode run \"<task>\" --session ${session.opencodeSessionId}` }
|
|
166
166
|
: null
|
|
167
|
+
const fromSessionCursor = session.cursorSessionId
|
|
168
|
+
? { label: 'Cursor', id: session.cursorSessionId, command: `cursor-agent --resume ${session.cursorSessionId} --print \"<task>\"` }
|
|
169
|
+
: null
|
|
170
|
+
const fromSessionQwen = session.qwenSessionId
|
|
171
|
+
? { label: 'Qwen Code', id: session.qwenSessionId, command: `qwen --resume ${session.qwenSessionId} -p \"<task>\"` }
|
|
172
|
+
: null
|
|
167
173
|
const fromDelegateClaude = session.delegateResumeIds?.claudeCode
|
|
168
174
|
? { label: 'Claude', id: session.delegateResumeIds.claudeCode, command: `claude --resume ${session.delegateResumeIds.claudeCode}` }
|
|
169
175
|
: null
|
|
@@ -176,15 +182,25 @@ export function ChatHeader({ session, streaming, onStop, onMenuToggle, onBack, m
|
|
|
176
182
|
const fromDelegateGemini = session.delegateResumeIds?.gemini
|
|
177
183
|
? { label: 'Gemini', id: session.delegateResumeIds.gemini, command: `gemini --resume ${session.delegateResumeIds.gemini} --prompt \"<task>\"` }
|
|
178
184
|
: null
|
|
185
|
+
const fromDelegateCursor = session.delegateResumeIds?.cursor
|
|
186
|
+
? { label: 'Cursor', id: session.delegateResumeIds.cursor, command: `cursor-agent --resume ${session.delegateResumeIds.cursor} --print \"<task>\"` }
|
|
187
|
+
: null
|
|
188
|
+
const fromDelegateQwen = session.delegateResumeIds?.qwen
|
|
189
|
+
? { label: 'Qwen Code', id: session.delegateResumeIds.qwen, command: `qwen --resume ${session.delegateResumeIds.qwen} -p \"<task>\"` }
|
|
190
|
+
: null
|
|
179
191
|
return fromSessionClaude
|
|
180
192
|
|| fromSessionCodex
|
|
181
193
|
|| fromSessionOpenCode
|
|
194
|
+
|| fromSessionCursor
|
|
195
|
+
|| fromSessionQwen
|
|
182
196
|
|| fromDelegateClaude
|
|
183
197
|
|| fromDelegateCodex
|
|
184
198
|
|| fromDelegateOpenCode
|
|
185
199
|
|| fromDelegateGemini
|
|
200
|
+
|| fromDelegateCursor
|
|
201
|
+
|| fromDelegateQwen
|
|
186
202
|
|| null
|
|
187
|
-
}, [session.claudeSessionId, session.codexThreadId, session.opencodeSessionId, session.delegateResumeIds])
|
|
203
|
+
}, [session.claudeSessionId, session.codexThreadId, session.opencodeSessionId, session.cursorSessionId, session.qwenSessionId, session.delegateResumeIds])
|
|
188
204
|
|
|
189
205
|
const handleCopySessionId = () => {
|
|
190
206
|
if (!resumeHandle) return
|
|
@@ -202,7 +218,12 @@ export function ChatHeader({ session, streaming, onStop, onMenuToggle, onBack, m
|
|
|
202
218
|
claudeSessionId: null,
|
|
203
219
|
codexThreadId: null,
|
|
204
220
|
opencodeSessionId: null,
|
|
205
|
-
|
|
221
|
+
geminiSessionId: null,
|
|
222
|
+
copilotSessionId: null,
|
|
223
|
+
cursorSessionId: null,
|
|
224
|
+
qwenSessionId: null,
|
|
225
|
+
acpSessionId: null,
|
|
226
|
+
delegateResumeIds: { claudeCode: null, codex: null, opencode: null, gemini: null, copilot: null, cursor: null, qwen: null },
|
|
206
227
|
})
|
|
207
228
|
await refreshSession(session.id)
|
|
208
229
|
} catch { /* best-effort */ }
|
|
@@ -26,6 +26,10 @@ const TOOL_COLORS: Record<string, string> = {
|
|
|
26
26
|
delegate_to_claude_code: '#6366F1',
|
|
27
27
|
delegate_to_codex_cli: '#0EA5E9',
|
|
28
28
|
delegate_to_opencode_cli: '#14B8A6',
|
|
29
|
+
delegate_to_gemini_cli: '#2563EB',
|
|
30
|
+
delegate_to_copilot_cli: '#6366F1',
|
|
31
|
+
delegate_to_cursor_cli: '#F97316',
|
|
32
|
+
delegate_to_qwen_code_cli: '#22C55E',
|
|
29
33
|
whoami_tool: '#8B5CF6',
|
|
30
34
|
connector_message_tool: '#EC4899',
|
|
31
35
|
search_history_tool: '#8B5CF6',
|
|
@@ -75,12 +79,20 @@ export const TOOL_LABELS: Record<string, string> = {
|
|
|
75
79
|
claude_code: 'Claude Code',
|
|
76
80
|
codex_cli: 'Codex CLI',
|
|
77
81
|
opencode_cli: 'OpenCode CLI',
|
|
82
|
+
gemini_cli: 'Gemini CLI',
|
|
83
|
+
copilot_cli: 'Copilot CLI',
|
|
84
|
+
cursor_cli: 'Cursor CLI',
|
|
85
|
+
qwen_code_cli: 'Qwen Code CLI',
|
|
78
86
|
spawn_subagent: 'Subagent',
|
|
79
87
|
delegate_to_agent: 'Agent Delegation',
|
|
80
88
|
check_delegation_status: 'Check Delegation',
|
|
81
89
|
delegate_to_claude_code: 'Claude Code',
|
|
82
90
|
delegate_to_codex_cli: 'Codex CLI',
|
|
83
91
|
delegate_to_opencode_cli: 'OpenCode CLI',
|
|
92
|
+
delegate_to_gemini_cli: 'Gemini CLI',
|
|
93
|
+
delegate_to_copilot_cli: 'Copilot CLI',
|
|
94
|
+
delegate_to_cursor_cli: 'Cursor CLI',
|
|
95
|
+
delegate_to_qwen_code_cli: 'Qwen Code CLI',
|
|
84
96
|
whoami_tool: 'Who Am I',
|
|
85
97
|
connector_message_tool: 'Connector Message',
|
|
86
98
|
search_history_tool: 'Search History',
|
|
@@ -115,12 +127,20 @@ export const TOOL_DESCRIPTIONS: Record<string, string> = {
|
|
|
115
127
|
claude_code: 'Enable delegation to Claude Code CLI',
|
|
116
128
|
codex_cli: 'Enable delegation to OpenAI Codex CLI',
|
|
117
129
|
opencode_cli: 'Enable delegation to OpenCode CLI',
|
|
130
|
+
gemini_cli: 'Enable delegation to Gemini CLI',
|
|
131
|
+
copilot_cli: 'Enable delegation to GitHub Copilot CLI',
|
|
132
|
+
cursor_cli: 'Enable delegation to Cursor Agent CLI',
|
|
133
|
+
qwen_code_cli: 'Enable delegation to Qwen Code CLI',
|
|
118
134
|
spawn_subagent: 'Spawn native subagents with lineage tracking and batch support',
|
|
119
135
|
delegate_to_agent: 'Delegate a task to another agent',
|
|
120
136
|
check_delegation_status: 'Check the status of a delegated task',
|
|
121
137
|
delegate_to_claude_code: 'Delegate complex coding tasks to Claude Code',
|
|
122
138
|
delegate_to_codex_cli: 'Delegate complex coding tasks to Codex CLI',
|
|
123
139
|
delegate_to_opencode_cli: 'Delegate complex coding tasks to OpenCode CLI',
|
|
140
|
+
delegate_to_gemini_cli: 'Delegate complex coding tasks to Gemini CLI',
|
|
141
|
+
delegate_to_copilot_cli: 'Delegate complex coding tasks to GitHub Copilot CLI',
|
|
142
|
+
delegate_to_cursor_cli: 'Delegate complex coding tasks to Cursor Agent CLI',
|
|
143
|
+
delegate_to_qwen_code_cli: 'Delegate complex coding tasks to Qwen Code CLI',
|
|
124
144
|
whoami_tool: 'Reveal the current agent and chat context',
|
|
125
145
|
connector_message_tool: 'Send proactive outbound messages via running connectors',
|
|
126
146
|
search_history_tool: 'Search chat history for relevant prior context',
|
|
@@ -16,6 +16,7 @@ import type { AppView } from '@/types'
|
|
|
16
16
|
|
|
17
17
|
const RAIL_EXPANDED_KEY = 'sc_rail_expanded'
|
|
18
18
|
const GITHUB_REPO_URL = 'https://github.com/swarmclawai/swarmclaw'
|
|
19
|
+
const DISCORD_URL = 'https://discord.gg/sbEavS8cPV'
|
|
19
20
|
const NETWORK_LINKS = [
|
|
20
21
|
{ href: 'https://www.swarmdock.ai', label: 'SwarmDock', abbr: 'DO' },
|
|
21
22
|
{ href: 'https://swarmrecall.ai', label: 'SwarmRecall', abbr: 'RE' },
|
|
@@ -487,6 +488,34 @@ export function SidebarRail({
|
|
|
487
488
|
</a>
|
|
488
489
|
</RailTooltip>
|
|
489
490
|
)}
|
|
491
|
+
{railExpanded ? (
|
|
492
|
+
<a
|
|
493
|
+
href={DISCORD_URL}
|
|
494
|
+
target="_blank"
|
|
495
|
+
rel="noopener noreferrer"
|
|
496
|
+
className="w-full flex items-center gap-2.5 px-3 py-2 rounded-[10px] text-[13px] font-500 cursor-pointer transition-all
|
|
497
|
+
bg-transparent text-text-3 hover:text-text hover:bg-white/[0.04] no-underline"
|
|
498
|
+
style={{ fontFamily: 'inherit' }}
|
|
499
|
+
>
|
|
500
|
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" className="shrink-0">
|
|
501
|
+
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
|
|
502
|
+
<path d="M8 10h.01M12 10h.01M16 10h.01" />
|
|
503
|
+
</svg>
|
|
504
|
+
Join Discord
|
|
505
|
+
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" className="ml-auto opacity-40">
|
|
506
|
+
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" /><polyline points="15 3 21 3 21 9" /><line x1="10" y1="14" x2="21" y2="3" />
|
|
507
|
+
</svg>
|
|
508
|
+
</a>
|
|
509
|
+
) : (
|
|
510
|
+
<RailTooltip label="Join Discord" description="Open the SwarmClaw community">
|
|
511
|
+
<a href={DISCORD_URL} target="_blank" rel="noopener noreferrer" className="rail-btn">
|
|
512
|
+
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
|
513
|
+
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
|
|
514
|
+
<path d="M8 10h.01M12 10h.01M16 10h.01" />
|
|
515
|
+
</svg>
|
|
516
|
+
</a>
|
|
517
|
+
</RailTooltip>
|
|
518
|
+
)}
|
|
490
519
|
{railExpanded && <DaemonIndicator />}
|
|
491
520
|
{railExpanded ? (
|
|
492
521
|
<NotificationCenter variant="row" align="left" direction="up" />
|
|
@@ -2,8 +2,10 @@ import assert from 'node:assert/strict'
|
|
|
2
2
|
import { test } from 'node:test'
|
|
3
3
|
import { resolveSetupDone } from './setup-done-detection'
|
|
4
4
|
|
|
5
|
-
test('resolveSetupDone returns
|
|
6
|
-
|
|
5
|
+
test('resolveSetupDone returns false when both fetches failed so the wizard is shown', () => {
|
|
6
|
+
// When both API calls fail we cannot determine setup state — default to
|
|
7
|
+
// showing the wizard so the user doesn't land in a broken app.
|
|
8
|
+
assert.equal(resolveSetupDone({}, {}, true), false)
|
|
7
9
|
})
|
|
8
10
|
|
|
9
11
|
test('resolveSetupDone returns true when setupCompleted is true', () => {
|
|
@@ -7,7 +7,7 @@ export function resolveSetupDone(
|
|
|
7
7
|
creds: Record<string, unknown>,
|
|
8
8
|
bothFailed: boolean,
|
|
9
9
|
): boolean {
|
|
10
|
-
if (bothFailed) return
|
|
10
|
+
if (bothFailed) return false
|
|
11
11
|
const hasCreds = Object.keys(creds).length > 0
|
|
12
12
|
return settings.setupCompleted === true || hasCreds
|
|
13
13
|
}
|
package/src/lib/provider-sets.ts
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
/** CLI providers that use their own tool execution outside the shared tool-runtime path. */
|
|
2
|
-
export const NON_LANGGRAPH_PROVIDER_IDS = new Set(['claude-cli', 'codex-cli', 'opencode-cli', 'gemini-cli', 'copilot-cli'])
|
|
2
|
+
export const NON_LANGGRAPH_PROVIDER_IDS = new Set(['claude-cli', 'codex-cli', 'opencode-cli', 'gemini-cli', 'copilot-cli', 'cursor-cli', 'qwen-code-cli'])
|
|
3
3
|
|
|
4
4
|
/** Providers that manage their own runtime/tool loop even when reached over an API endpoint. */
|
|
5
|
-
export const RUNTIME_MANAGED_PROVIDER_IDS = new Set(['hermes'])
|
|
5
|
+
export const RUNTIME_MANAGED_PROVIDER_IDS = new Set(['hermes', 'goose'])
|
|
6
6
|
|
|
7
7
|
/** Providers with native tool/capability support (CLI providers + OpenClaw + Hermes). */
|
|
8
|
-
export const NATIVE_CAPABILITY_PROVIDER_IDS = new Set(['claude-cli', 'codex-cli', 'opencode-cli', 'gemini-cli', 'copilot-cli', 'openclaw', 'hermes'])
|
|
8
|
+
export const NATIVE_CAPABILITY_PROVIDER_IDS = new Set(['claude-cli', 'codex-cli', 'opencode-cli', 'gemini-cli', 'copilot-cli', 'cursor-cli', 'qwen-code-cli', 'goose', 'openclaw', 'hermes'])
|
|
9
9
|
|
|
10
10
|
/** Providers that can only act as workers — no coordinator role, no heartbeat, no advanced settings. */
|
|
11
|
-
export const WORKER_ONLY_PROVIDER_IDS = new Set(['claude-cli', 'codex-cli', 'opencode-cli', 'gemini-cli', 'copilot-cli', 'openclaw', 'hermes'])
|
|
11
|
+
export const WORKER_ONLY_PROVIDER_IDS = new Set(['claude-cli', 'codex-cli', 'opencode-cli', 'gemini-cli', 'copilot-cli', 'cursor-cli', 'qwen-code-cli', 'goose', 'openclaw', 'hermes'])
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { spawn, type ChildProcess } from 'child_process'
|
|
2
|
+
import { EventEmitter } from 'events'
|
|
3
|
+
|
|
4
|
+
interface JsonRpcRequest {
|
|
5
|
+
jsonrpc: '2.0'
|
|
6
|
+
id: number
|
|
7
|
+
method: string
|
|
8
|
+
params?: unknown
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
interface JsonRpcResponse {
|
|
12
|
+
jsonrpc: '2.0'
|
|
13
|
+
id?: number | string | null
|
|
14
|
+
result?: unknown
|
|
15
|
+
error?: {
|
|
16
|
+
code?: number
|
|
17
|
+
message?: string
|
|
18
|
+
data?: unknown
|
|
19
|
+
}
|
|
20
|
+
method?: string
|
|
21
|
+
params?: unknown
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface AcpClientOptions {
|
|
25
|
+
command: string
|
|
26
|
+
args?: string[]
|
|
27
|
+
cwd?: string
|
|
28
|
+
env?: NodeJS.ProcessEnv
|
|
29
|
+
timeoutMs?: number
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export class AcpClient extends EventEmitter {
|
|
33
|
+
private readonly proc: ChildProcess
|
|
34
|
+
private readonly timeoutMs: number
|
|
35
|
+
private readonly pending = new Map<number, { resolve: (value: unknown) => void; reject: (error: Error) => void; timer: NodeJS.Timeout }>()
|
|
36
|
+
private nextId = 1
|
|
37
|
+
private stdoutBuf = ''
|
|
38
|
+
|
|
39
|
+
constructor(options: AcpClientOptions) {
|
|
40
|
+
super()
|
|
41
|
+
this.timeoutMs = Math.max(1_000, options.timeoutMs || 30_000)
|
|
42
|
+
this.proc = spawn(options.command, options.args || [], {
|
|
43
|
+
cwd: options.cwd,
|
|
44
|
+
env: options.env,
|
|
45
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
this.proc.stdout?.on('data', (chunk: Buffer) => this.handleStdout(chunk.toString()))
|
|
49
|
+
this.proc.stderr?.on('data', (chunk: Buffer) => {
|
|
50
|
+
this.emit('stderr', chunk.toString())
|
|
51
|
+
})
|
|
52
|
+
this.proc.on('error', (err) => this.failPending(err))
|
|
53
|
+
this.proc.on('close', () => this.failPending(new Error('ACP process closed')))
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async request(method: string, params?: unknown): Promise<unknown> {
|
|
57
|
+
const id = this.nextId++
|
|
58
|
+
const payload: JsonRpcRequest = { jsonrpc: '2.0', id, method }
|
|
59
|
+
if (params !== undefined) payload.params = params
|
|
60
|
+
|
|
61
|
+
const response = await new Promise<unknown>((resolve, reject) => {
|
|
62
|
+
const timer = setTimeout(() => {
|
|
63
|
+
this.pending.delete(id)
|
|
64
|
+
reject(new Error(`ACP request timed out: ${method}`))
|
|
65
|
+
}, this.timeoutMs)
|
|
66
|
+
this.pending.set(id, { resolve, reject, timer })
|
|
67
|
+
this.proc.stdin?.write(`${JSON.stringify(payload)}\n`)
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
return response
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
notify(method: string, params?: unknown): void {
|
|
74
|
+
const payload = params === undefined
|
|
75
|
+
? { jsonrpc: '2.0', method }
|
|
76
|
+
: { jsonrpc: '2.0', method, params }
|
|
77
|
+
this.proc.stdin?.write(`${JSON.stringify(payload)}\n`)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
close(): void {
|
|
81
|
+
this.failPending(new Error('ACP client closed'))
|
|
82
|
+
try { this.proc.kill('SIGTERM') } catch { /* ignore */ }
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
private handleStdout(raw: string): void {
|
|
86
|
+
this.stdoutBuf += raw
|
|
87
|
+
const lines = this.stdoutBuf.split('\n')
|
|
88
|
+
this.stdoutBuf = lines.pop() || ''
|
|
89
|
+
|
|
90
|
+
for (const line of lines) {
|
|
91
|
+
if (!line.trim()) continue
|
|
92
|
+
try {
|
|
93
|
+
const message = JSON.parse(line) as JsonRpcResponse
|
|
94
|
+
if (typeof message.id === 'number' && this.pending.has(message.id)) {
|
|
95
|
+
const entry = this.pending.get(message.id)!
|
|
96
|
+
clearTimeout(entry.timer)
|
|
97
|
+
this.pending.delete(message.id)
|
|
98
|
+
if (message.error?.message) entry.reject(new Error(message.error.message))
|
|
99
|
+
else entry.resolve(message.result)
|
|
100
|
+
continue
|
|
101
|
+
}
|
|
102
|
+
this.emit('notification', message)
|
|
103
|
+
} catch {
|
|
104
|
+
this.emit('raw', line)
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
private failPending(err: Error): void {
|
|
110
|
+
for (const [, entry] of this.pending) {
|
|
111
|
+
clearTimeout(entry.timer)
|
|
112
|
+
entry.reject(err)
|
|
113
|
+
}
|
|
114
|
+
this.pending.clear()
|
|
115
|
+
}
|
|
116
|
+
}
|
|
@@ -93,6 +93,10 @@ describe('isCliProvider', () => {
|
|
|
93
93
|
assert.equal(isCliProvider('codex-cli'), true)
|
|
94
94
|
assert.equal(isCliProvider('opencode-cli'), true)
|
|
95
95
|
assert.equal(isCliProvider('gemini-cli'), true)
|
|
96
|
+
assert.equal(isCliProvider('copilot-cli'), true)
|
|
97
|
+
assert.equal(isCliProvider('cursor-cli'), true)
|
|
98
|
+
assert.equal(isCliProvider('qwen-code-cli'), true)
|
|
99
|
+
assert.equal(isCliProvider('goose'), true)
|
|
96
100
|
})
|
|
97
101
|
|
|
98
102
|
it('returns false for non-CLI providers', () => {
|
|
@@ -108,11 +112,15 @@ describe('isCliProvider', () => {
|
|
|
108
112
|
// ---------------------------------------------------------------------------
|
|
109
113
|
|
|
110
114
|
describe('CLI_PROVIDER_CAPABILITIES', () => {
|
|
111
|
-
it('has entries for all
|
|
115
|
+
it('has entries for all supported local CLI-backed providers', () => {
|
|
112
116
|
assert.ok('claude-cli' in CLI_PROVIDER_CAPABILITIES)
|
|
113
117
|
assert.ok('codex-cli' in CLI_PROVIDER_CAPABILITIES)
|
|
114
118
|
assert.ok('opencode-cli' in CLI_PROVIDER_CAPABILITIES)
|
|
115
119
|
assert.ok('gemini-cli' in CLI_PROVIDER_CAPABILITIES)
|
|
120
|
+
assert.ok('copilot-cli' in CLI_PROVIDER_CAPABILITIES)
|
|
121
|
+
assert.ok('cursor-cli' in CLI_PROVIDER_CAPABILITIES)
|
|
122
|
+
assert.ok('qwen-code-cli' in CLI_PROVIDER_CAPABILITIES)
|
|
123
|
+
assert.ok('goose' in CLI_PROVIDER_CAPABILITIES)
|
|
116
124
|
})
|
|
117
125
|
|
|
118
126
|
it('each entry is a non-empty string', () => {
|
|
@@ -2,8 +2,7 @@
|
|
|
2
2
|
* Shared CLI utility module for CLI-based providers and delegation backends.
|
|
3
3
|
*
|
|
4
4
|
* Consolidates environment building, binary discovery, auth probing,
|
|
5
|
-
* abort handling, and config forwarding used by
|
|
6
|
-
* opencode-cli, and gemini-cli providers.
|
|
5
|
+
* abort handling, and config forwarding used by CLI-backed providers.
|
|
7
6
|
*/
|
|
8
7
|
|
|
9
8
|
import fs from 'fs'
|
|
@@ -46,6 +45,24 @@ const KNOWN_BINARY_PATHS: Record<string, string[]> = {
|
|
|
46
45
|
'/opt/homebrew/bin/copilot',
|
|
47
46
|
path.join(os.homedir(), '.npm-global/bin/copilot'),
|
|
48
47
|
],
|
|
48
|
+
'cursor-agent': [
|
|
49
|
+
path.join(os.homedir(), '.local/bin/cursor-agent'),
|
|
50
|
+
'/usr/local/bin/cursor-agent',
|
|
51
|
+
'/opt/homebrew/bin/cursor-agent',
|
|
52
|
+
path.join(os.homedir(), '.npm-global/bin/cursor-agent'),
|
|
53
|
+
],
|
|
54
|
+
qwen: [
|
|
55
|
+
path.join(os.homedir(), '.local/bin/qwen'),
|
|
56
|
+
'/usr/local/bin/qwen',
|
|
57
|
+
'/opt/homebrew/bin/qwen',
|
|
58
|
+
path.join(os.homedir(), '.npm-global/bin/qwen'),
|
|
59
|
+
],
|
|
60
|
+
goose: [
|
|
61
|
+
path.join(os.homedir(), '.local/bin/goose'),
|
|
62
|
+
'/usr/local/bin/goose',
|
|
63
|
+
'/opt/homebrew/bin/goose',
|
|
64
|
+
path.join(os.homedir(), '.cargo/bin/goose'),
|
|
65
|
+
],
|
|
49
66
|
}
|
|
50
67
|
|
|
51
68
|
function getNvmBinaryPaths(name: string): string[] {
|
|
@@ -145,11 +162,11 @@ export interface AuthProbeResult {
|
|
|
145
162
|
}
|
|
146
163
|
|
|
147
164
|
/**
|
|
148
|
-
* Unified auth check for
|
|
165
|
+
* Unified auth check for supported CLI-backed providers.
|
|
149
166
|
*/
|
|
150
167
|
export function probeCliAuth(
|
|
151
168
|
binary: string,
|
|
152
|
-
backend: 'claude' | 'codex' | 'opencode' | 'gemini' | 'copilot',
|
|
169
|
+
backend: 'claude' | 'codex' | 'opencode' | 'gemini' | 'copilot' | 'cursor' | 'qwen' | 'goose',
|
|
153
170
|
env: NodeJS.ProcessEnv,
|
|
154
171
|
cwd?: string,
|
|
155
172
|
): AuthProbeResult {
|
|
@@ -260,6 +277,71 @@ export function probeCliAuth(
|
|
|
260
277
|
return { authenticated: true }
|
|
261
278
|
}
|
|
262
279
|
|
|
280
|
+
if (backend === 'cursor') {
|
|
281
|
+
try {
|
|
282
|
+
const probe = spawnSync(binary, ['status'], {
|
|
283
|
+
cwd, env, encoding: 'utf-8', timeout: 8_000,
|
|
284
|
+
})
|
|
285
|
+
const probeText = `${probe.stdout || ''}\n${probe.stderr || ''}`.toLowerCase()
|
|
286
|
+
if ((probe.status ?? 1) === 0 || probeText.includes('authenticated') || probeText.includes('logged in')) {
|
|
287
|
+
return { authenticated: true }
|
|
288
|
+
}
|
|
289
|
+
} catch { /* ignore */ }
|
|
290
|
+
|
|
291
|
+
const configPaths = [
|
|
292
|
+
path.join(os.homedir(), '.cursor', 'config.json'),
|
|
293
|
+
path.join(os.homedir(), '.config', 'cursor', 'config.json'),
|
|
294
|
+
]
|
|
295
|
+
const hasConfig = configPaths.some((p) => fs.existsSync(p))
|
|
296
|
+
if (!hasConfig) {
|
|
297
|
+
return {
|
|
298
|
+
authenticated: false,
|
|
299
|
+
errorMessage: 'Cursor Agent CLI is not authenticated. Run `cursor-agent login` and try again.',
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
return { authenticated: true }
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
if (backend === 'qwen') {
|
|
306
|
+
const envKeys = ['QWEN_API_KEY', 'DASHSCOPE_API_KEY', 'OPENAI_API_KEY', 'ANTHROPIC_API_KEY', 'GEMINI_API_KEY']
|
|
307
|
+
if (envKeys.some((key) => typeof env[key] === 'string' && env[key]?.trim())) {
|
|
308
|
+
return { authenticated: true }
|
|
309
|
+
}
|
|
310
|
+
const configPaths = [
|
|
311
|
+
path.join(os.homedir(), '.qwen', 'settings.json'),
|
|
312
|
+
path.join(os.homedir(), '.config', 'qwen', 'settings.json'),
|
|
313
|
+
]
|
|
314
|
+
const hasConfig = configPaths.some((p) => fs.existsSync(p))
|
|
315
|
+
if (!hasConfig) {
|
|
316
|
+
return {
|
|
317
|
+
authenticated: false,
|
|
318
|
+
errorMessage: 'Qwen Code CLI is not configured. Run `qwen`, finish `/auth`, or configure ~/.qwen/settings.json and try again.',
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
return { authenticated: true }
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
if (backend === 'goose') {
|
|
325
|
+
const envKeys = ['GOOSE_API_KEY', 'OPENAI_API_KEY', 'ANTHROPIC_API_KEY', 'GEMINI_API_KEY']
|
|
326
|
+
if (envKeys.some((key) => typeof env[key] === 'string' && env[key]?.trim())) {
|
|
327
|
+
return { authenticated: true }
|
|
328
|
+
}
|
|
329
|
+
const configPaths = [
|
|
330
|
+
path.join(os.homedir(), '.config', 'goose', 'config.yaml'),
|
|
331
|
+
path.join(os.homedir(), '.config', 'goose', 'config.yml'),
|
|
332
|
+
path.join(os.homedir(), '.goose', 'config.yaml'),
|
|
333
|
+
path.join(os.homedir(), '.goose', 'config.yml'),
|
|
334
|
+
]
|
|
335
|
+
const hasConfig = configPaths.some((p) => fs.existsSync(p))
|
|
336
|
+
if (!hasConfig) {
|
|
337
|
+
return {
|
|
338
|
+
authenticated: false,
|
|
339
|
+
errorMessage: 'Goose CLI is not configured. Run `goose configure` and try again.',
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
return { authenticated: true }
|
|
343
|
+
}
|
|
344
|
+
|
|
263
345
|
return { authenticated: true }
|
|
264
346
|
}
|
|
265
347
|
|
|
@@ -345,6 +427,9 @@ export const CLI_PROVIDER_CAPABILITIES: Record<string, string> = {
|
|
|
345
427
|
'opencode-cli': 'code analysis, generation across multiple LLM backends',
|
|
346
428
|
'gemini-cli': 'code generation, analysis with Gemini models',
|
|
347
429
|
'copilot-cli': 'code generation, analysis, multi-model support via GitHub Copilot',
|
|
430
|
+
'cursor-cli': 'full-agent coding workflows, multi-file edits, project-aware code changes',
|
|
431
|
+
'qwen-code-cli': 'terminal-native coding workflows, code generation, review, and automation',
|
|
432
|
+
goose: 'agentic coding workflows with extensions, tools, and runtime-managed execution',
|
|
348
433
|
}
|
|
349
434
|
|
|
350
435
|
/** Check if a provider ID is a CLI-based provider. */
|