@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.
Files changed (78) hide show
  1. package/README.md +167 -147
  2. package/bin/swarmclaw.js +35 -1
  3. package/package.json +2 -2
  4. package/scripts/run-next-build.mjs +87 -2
  5. package/skills/swarmclaw/SKILL.md +151 -0
  6. package/src/app/api/agents/[id]/route.ts +8 -0
  7. package/src/app/api/agents/agents-route.test.ts +114 -0
  8. package/src/app/api/agents/route.ts +10 -0
  9. package/src/app/api/connectors/route.ts +25 -13
  10. package/src/app/api/credentials/[id]/route.ts +8 -1
  11. package/src/app/api/schedules/[id]/route.ts +8 -0
  12. package/src/app/api/secrets/[id]/route.ts +10 -0
  13. package/src/app/api/setup/check-provider/route.ts +45 -0
  14. package/src/app/api/setup/doctor/route.ts +5 -0
  15. package/src/cli/binary.test.js +11 -0
  16. package/src/cli/index.js +4 -4
  17. package/src/cli/index.test.js +5 -2
  18. package/src/cli/index.ts +1 -1
  19. package/src/components/agents/agent-sheet.tsx +16 -4
  20. package/src/components/agents/inspector-panel.tsx +5 -0
  21. package/src/components/auth/setup-wizard/step-agents.tsx +19 -1
  22. package/src/components/chat/activity-moment.tsx +3 -0
  23. package/src/components/chat/chat-header.tsx +23 -2
  24. package/src/components/chat/tool-call-bubble.tsx +20 -0
  25. package/src/components/layout/sidebar-rail.tsx +29 -0
  26. package/src/hooks/setup-done-detection.test.ts +4 -2
  27. package/src/hooks/setup-done-detection.ts +1 -1
  28. package/src/lib/orchestrator-config.ts +5 -0
  29. package/src/lib/provider-sets.ts +4 -4
  30. package/src/lib/providers/acp-client.ts +116 -0
  31. package/src/lib/providers/cli-utils.test.ts +9 -1
  32. package/src/lib/providers/cli-utils.ts +89 -4
  33. package/src/lib/providers/cursor-cli.ts +172 -0
  34. package/src/lib/providers/goose.ts +149 -0
  35. package/src/lib/providers/index.ts +29 -1
  36. package/src/lib/providers/qwen-code-cli.ts +152 -0
  37. package/src/lib/server/agents/agent-availability.ts +2 -2
  38. package/src/lib/server/agents/agent-thread-session.ts +8 -0
  39. package/src/lib/server/agents/task-session.ts +8 -0
  40. package/src/lib/server/capability-router.ts +8 -2
  41. package/src/lib/server/chat-execution/chat-execution-utils.ts +13 -0
  42. package/src/lib/server/chat-execution/chat-turn-finalization.ts +8 -0
  43. package/src/lib/server/chat-execution/chat-turn-preparation.ts +5 -1
  44. package/src/lib/server/chat-execution/chat-turn-tool-routing.ts +3 -0
  45. package/src/lib/server/chat-execution/iteration-timers.test.ts +84 -0
  46. package/src/lib/server/chat-execution/iteration-timers.ts +18 -1
  47. package/src/lib/server/chat-execution/prompt-sections.ts +3 -1
  48. package/src/lib/server/chat-execution/stream-agent-chat.ts +5 -0
  49. package/src/lib/server/chatrooms/chatroom-helpers.ts +13 -0
  50. package/src/lib/server/chats/chat-session-service.ts +18 -0
  51. package/src/lib/server/connectors/session.ts +8 -0
  52. package/src/lib/server/context-manager.ts +5 -0
  53. package/src/lib/server/provider-health.ts +13 -3
  54. package/src/lib/server/provider-model-discovery.test.ts +8 -0
  55. package/src/lib/server/provider-model-discovery.ts +1 -1
  56. package/src/lib/server/runtime/daemon-state/core.ts +2 -2
  57. package/src/lib/server/runtime/queue/core.ts +30 -4
  58. package/src/lib/server/runtime/session-run-manager/enqueue.ts +1 -1
  59. package/src/lib/server/session-reset-policy.test.ts +16 -0
  60. package/src/lib/server/session-reset-policy.ts +9 -1
  61. package/src/lib/server/session-tools/context.ts +2 -2
  62. package/src/lib/server/session-tools/delegate.ts +334 -14
  63. package/src/lib/server/session-tools/index.ts +5 -2
  64. package/src/lib/server/session-tools/session-info.ts +4 -1
  65. package/src/lib/server/storage-auth-docker.test.ts +244 -0
  66. package/src/lib/server/storage-auth.test.ts +150 -0
  67. package/src/lib/server/storage-auth.ts +57 -22
  68. package/src/lib/server/storage-normalization.ts +19 -0
  69. package/src/lib/server/storage.ts +3 -0
  70. package/src/lib/server/tasks/task-resume.ts +23 -1
  71. package/src/lib/server/tool-aliases.ts +1 -1
  72. package/src/lib/server/tool-capability-policy.ts +4 -1
  73. package/src/lib/setup-defaults.test.ts +6 -0
  74. package/src/lib/setup-defaults.ts +146 -0
  75. package/src/lib/tool-definitions.ts +1 -1
  76. package/src/types/misc.ts +4 -1
  77. package/src/types/provider.ts +1 -1
  78. package/src/types/session.ts +9 -0
@@ -616,7 +616,11 @@ export async function prepareChatTurn(input: ExecuteChatTurnInput): Promise<Prep
616
616
  }
617
617
 
618
618
  const turnHistory = getMessages(sessionId)
619
- const classification = !internal
619
+ // Skip message classification for local/CLI providers to avoid sending
620
+ // a blocking LLM call to the same single-request provider (e.g. Ollama)
621
+ const isLocalProvider = sessionForRun.provider === 'ollama' && sessionForRun.ollamaMode !== 'cloud'
622
+ const skipClassifier = internal || NON_LANGGRAPH_PROVIDER_IDS.has(sessionForRun.provider) || isLocalProvider
623
+ const classification = !skipClassifier
620
624
  ? await classifyMessage({
621
625
  sessionId,
622
626
  agentId: session.agentId || null,
@@ -503,6 +503,9 @@ const FORCED_DELEGATION_TOOLS: DelegateTool[] = [
503
503
  'delegate_to_codex_cli',
504
504
  'delegate_to_opencode_cli',
505
505
  'delegate_to_gemini_cli',
506
+ 'delegate_to_copilot_cli',
507
+ 'delegate_to_cursor_cli',
508
+ 'delegate_to_qwen_code_cli',
506
509
  ]
507
510
 
508
511
  export async function runPostLlmToolRouting(
@@ -0,0 +1,84 @@
1
+ import assert from 'node:assert/strict'
2
+ import { describe, it } from 'node:test'
3
+ import { IterationTimers } from './iteration-timers'
4
+
5
+ function makeTimers(opts?: { streamIdleStallMs?: number; initialPrefillStallMs?: number }) {
6
+ const controller = new AbortController()
7
+ const timers = new IterationTimers(controller, {
8
+ streamIdleStallMs: opts?.streamIdleStallMs ?? 100,
9
+ initialPrefillStallMs: opts?.initialPrefillStallMs,
10
+ requiredToolKickoffMs: 5000,
11
+ shouldEnforceEarlyRequiredToolKickoff: false,
12
+ })
13
+ return { timers, controller }
14
+ }
15
+
16
+ describe('IterationTimers', () => {
17
+ it('first arm uses initialPrefillStallMs when provided', async () => {
18
+ const { timers, controller } = makeTimers({
19
+ streamIdleStallMs: 50,
20
+ initialPrefillStallMs: 200,
21
+ })
22
+
23
+ timers.armIdleWatchdog(false)
24
+
25
+ // After 50ms (streamIdleStallMs), should NOT have timed out
26
+ await new Promise((r) => setTimeout(r, 80))
27
+ assert.equal(timers.idleTimedOut, false, 'should not time out at streamIdleStallMs')
28
+
29
+ // After 200ms (initialPrefillStallMs), should have timed out
30
+ await new Promise((r) => setTimeout(r, 150))
31
+ assert.equal(timers.idleTimedOut, true, 'should time out at initialPrefillStallMs')
32
+ assert.equal(controller.signal.aborted, true)
33
+
34
+ timers.clearAll()
35
+ })
36
+
37
+ it('second arm uses streamIdleStallMs (not prefill)', async () => {
38
+ const { timers, controller } = makeTimers({
39
+ streamIdleStallMs: 50,
40
+ initialPrefillStallMs: 500,
41
+ })
42
+
43
+ // First arm — uses prefill timeout
44
+ timers.armIdleWatchdog(false)
45
+ // Immediately re-arm (simulates receiving a stream token)
46
+ timers.armIdleWatchdog(false)
47
+
48
+ // After 80ms (> streamIdleStallMs), should have timed out
49
+ await new Promise((r) => setTimeout(r, 80))
50
+ assert.equal(timers.idleTimedOut, true, 'second arm should use streamIdleStallMs')
51
+ assert.equal(controller.signal.aborted, true)
52
+
53
+ timers.clearAll()
54
+ })
55
+
56
+ it('defaults initialPrefillStallMs to 2x streamIdleStallMs when not set', async () => {
57
+ const { timers } = makeTimers({
58
+ streamIdleStallMs: 60,
59
+ // initialPrefillStallMs not set — should default to 120
60
+ })
61
+
62
+ timers.armIdleWatchdog(false)
63
+
64
+ // After 80ms (> 60ms but < 120ms), should NOT have timed out
65
+ await new Promise((r) => setTimeout(r, 80))
66
+ assert.equal(timers.idleTimedOut, false, 'should not time out before 2x default')
67
+
68
+ // After 60 more ms (total ~140ms, > 120ms), should have timed out
69
+ await new Promise((r) => setTimeout(r, 60))
70
+ assert.equal(timers.idleTimedOut, true, 'should time out at 2x default')
71
+
72
+ timers.clearAll()
73
+ })
74
+
75
+ it('does not arm when waitingForToolResult is true', () => {
76
+ const { timers } = makeTimers({ streamIdleStallMs: 10 })
77
+
78
+ timers.armIdleWatchdog(true)
79
+
80
+ // Timer should not be set
81
+ assert.equal(timers.idleTimedOut, false)
82
+ timers.clearAll()
83
+ })
84
+ })
@@ -6,6 +6,12 @@
6
6
  export interface IterationTimerOpts {
7
7
  /** Milliseconds of idle streaming before aborting the iteration. */
8
8
  streamIdleStallMs: number
9
+ /**
10
+ * Milliseconds before aborting the initial model prefill phase (before any
11
+ * streaming token arrives). Defaults to `streamIdleStallMs * 2` when unset.
12
+ * Local models with large prompts may need extra time for the prefill pass.
13
+ */
14
+ initialPrefillStallMs?: number
9
15
  /** Milliseconds before forcing a required-tool kickoff reminder. */
10
16
  requiredToolKickoffMs: number
11
17
  /** Whether the early kickoff enforcement is enabled at all. */
@@ -17,6 +23,7 @@ export class IterationTimers {
17
23
  private requiredToolKickoffTimer: ReturnType<typeof setTimeout> | null = null
18
24
  private _idleTimedOut = false
19
25
  private _requiredToolKickoffTimedOut = false
26
+ private _receivedFirstStreamEvent = false
20
27
 
21
28
  constructor(
22
29
  private readonly iterationController: AbortController,
@@ -26,13 +33,23 @@ export class IterationTimers {
26
33
  get idleTimedOut(): boolean { return this._idleTimedOut }
27
34
  get requiredToolKickoffTimedOut(): boolean { return this._requiredToolKickoffTimedOut }
28
35
 
36
+ /**
37
+ * Arm the idle watchdog. On the first call (before any streaming tokens
38
+ * arrive), use the longer `initialPrefillStallMs` timeout to allow for
39
+ * model prefill on local providers. Subsequent re-arms use the standard
40
+ * `streamIdleStallMs` timeout.
41
+ */
29
42
  armIdleWatchdog(waitingForToolResult: boolean): void {
30
43
  this.clearIdleWatchdog()
31
44
  if (waitingForToolResult || this.iterationController.signal.aborted) return
45
+ const timeoutMs = this._receivedFirstStreamEvent
46
+ ? this.opts.streamIdleStallMs
47
+ : (this.opts.initialPrefillStallMs ?? this.opts.streamIdleStallMs * 2)
48
+ this._receivedFirstStreamEvent = true
32
49
  this.idleTimer = setTimeout(() => {
33
50
  this._idleTimedOut = true
34
51
  this.iterationController.abort()
35
- }, this.opts.streamIdleStallMs)
52
+ }, timeoutMs)
36
53
  }
37
54
 
38
55
  clearIdleWatchdog(): void {
@@ -120,6 +120,8 @@ function normalizeRuntimeExtensionId(extensionId: string): string {
120
120
  if (normalized === 'delegate_to_codex_cli' || normalized === 'codex_cli') return 'codex_cli'
121
121
  if (normalized === 'delegate_to_opencode_cli' || normalized === 'opencode_cli') return 'opencode_cli'
122
122
  if (normalized === 'delegate_to_gemini_cli' || normalized === 'gemini_cli') return 'gemini_cli'
123
+ if (normalized === 'delegate_to_cursor_cli' || normalized === 'cursor_cli') return 'cursor_cli'
124
+ if (normalized === 'delegate_to_qwen_code_cli' || normalized === 'qwen_code_cli') return 'qwen_code_cli'
123
125
  if (['session_info', 'sessions_tool', 'whoami_tool', 'search_history_tool'].includes(normalized)) return 'manage_sessions'
124
126
  return canonicalizeExtensionId(normalized)
125
127
  }
@@ -189,7 +191,7 @@ export function buildRuntimeOrientationSection(params: {
189
191
  const enabledExtensions = canonicalizeEnabledExtensions(params.sessionExtensions)
190
192
  const workspaceMarkers = collectWorkspaceMarkers(session.cwd)
191
193
  const delegationEnabled = enabledExtensions.some((extensionId) =>
192
- ['delegate', 'spawn_subagent', 'claude_code', 'codex_cli', 'opencode_cli', 'gemini_cli'].includes(extensionId),
194
+ ['delegate', 'spawn_subagent', 'claude_code', 'codex_cli', 'opencode_cli', 'gemini_cli', 'cursor_cli', 'qwen_code_cli'].includes(extensionId),
193
195
  )
194
196
 
195
197
  const lines = [
@@ -929,8 +929,13 @@ async function streamAgentChatCore(opts: StreamAgentChatOpts): Promise<StreamAge
929
929
  if (abortController.signal.aborted) iterationController.abort()
930
930
  else abortController.signal.addEventListener('abort', onParentAbort)
931
931
 
932
+ // Local models (e.g. Ollama) need extra time for prompt prefill before
933
+ // the first streaming token arrives. Use 2x the idle stall timeout for
934
+ // the initial prefill phase.
935
+ const isLocalModel = session.provider === 'ollama' && session.ollamaMode !== 'cloud'
932
936
  const timers = new IterationTimers(iterationController, {
933
937
  streamIdleStallMs: runtime.streamIdleStallMs,
938
+ initialPrefillStallMs: isLocalModel ? runtime.streamIdleStallMs * 2 : undefined,
934
939
  requiredToolKickoffMs: runtime.requiredToolKickoffMs,
935
940
  shouldEnforceEarlyRequiredToolKickoff,
936
941
  })
@@ -281,6 +281,9 @@ function buildEmptyDelegateResumeIds(): NonNullable<Session['delegateResumeIds']
281
281
  codex: null,
282
282
  opencode: null,
283
283
  gemini: null,
284
+ copilot: null,
285
+ cursor: null,
286
+ qwen: null,
284
287
  }
285
288
  }
286
289
 
@@ -302,6 +305,11 @@ export function buildSyntheticSession(agent: Agent, chatroomId: string): Session
302
305
  claudeSessionId: null,
303
306
  codexThreadId: null,
304
307
  opencodeSessionId: null,
308
+ geminiSessionId: null,
309
+ copilotSessionId: null,
310
+ cursorSessionId: null,
311
+ qwenSessionId: null,
312
+ acpSessionId: null,
305
313
  delegateResumeIds: buildEmptyDelegateResumeIds(),
306
314
  messages: [],
307
315
  createdAt: now,
@@ -352,6 +360,11 @@ export function ensureSyntheticSession(agent: Agent, chatroomId: string): Sessio
352
360
  }
353
361
  if (session.codexThreadId === undefined) session.codexThreadId = null
354
362
  if (session.opencodeSessionId === undefined) session.opencodeSessionId = null
363
+ if (session.geminiSessionId === undefined) session.geminiSessionId = null
364
+ if (session.copilotSessionId === undefined) session.copilotSessionId = null
365
+ if (session.cursorSessionId === undefined) session.cursorSessionId = null
366
+ if (session.qwenSessionId === undefined) session.qwenSessionId = null
367
+ if (session.acpSessionId === undefined) session.acpSessionId = null
355
368
  saveSession(sessionId, session)
356
369
  return session
357
370
  }
@@ -46,6 +46,9 @@ function emptyDelegateResumeIds() {
46
46
  codex: null,
47
47
  opencode: null,
48
48
  gemini: null,
49
+ copilot: null,
50
+ cursor: null,
51
+ qwen: null,
49
52
  }
50
53
  }
51
54
 
@@ -124,6 +127,11 @@ export function createChatSession(input: Record<string, unknown>): ServiceResult
124
127
  claudeSessionId: null,
125
128
  codexThreadId: null,
126
129
  opencodeSessionId: null,
130
+ geminiSessionId: null,
131
+ copilotSessionId: null,
132
+ cursorSessionId: null,
133
+ qwenSessionId: null,
134
+ acpSessionId: null,
127
135
  delegateResumeIds: emptyDelegateResumeIds(),
128
136
  messages: Array.isArray(input.messages) ? input.messages : [],
129
137
  createdAt: now,
@@ -279,6 +287,11 @@ export function updateChatSession(sessionId: string, updates: Record<string, unk
279
287
  if (updates.claudeSessionId !== undefined) session.claudeSessionId = updates.claudeSessionId
280
288
  if (updates.codexThreadId !== undefined) session.codexThreadId = updates.codexThreadId
281
289
  if (updates.opencodeSessionId !== undefined) session.opencodeSessionId = updates.opencodeSessionId
290
+ if (updates.geminiSessionId !== undefined) session.geminiSessionId = updates.geminiSessionId
291
+ if (updates.copilotSessionId !== undefined) session.copilotSessionId = updates.copilotSessionId
292
+ if (updates.cursorSessionId !== undefined) session.cursorSessionId = updates.cursorSessionId
293
+ if (updates.qwenSessionId !== undefined) session.qwenSessionId = updates.qwenSessionId
294
+ if (updates.acpSessionId !== undefined) session.acpSessionId = updates.acpSessionId
282
295
  if (updates.delegateResumeIds !== undefined) session.delegateResumeIds = updates.delegateResumeIds
283
296
  if (!Array.isArray(session.messages)) session.messages = []
284
297
 
@@ -360,6 +373,11 @@ export function clearChatMessages(sessionId: string): boolean {
360
373
  session.claudeSessionId = null
361
374
  session.codexThreadId = null
362
375
  session.opencodeSessionId = null
376
+ session.geminiSessionId = null
377
+ session.copilotSessionId = null
378
+ session.cursorSessionId = null
379
+ session.qwenSessionId = null
380
+ session.acpSessionId = null
363
381
  session.delegateResumeIds = emptyDelegateResumeIds()
364
382
  saveSession(sessionId, session)
365
383
  notify('sessions')
@@ -341,11 +341,19 @@ export function resolveDirectSession(params: {
341
341
  claudeSessionId: null,
342
342
  codexThreadId: null,
343
343
  opencodeSessionId: null,
344
+ geminiSessionId: null,
345
+ copilotSessionId: null,
346
+ cursorSessionId: null,
347
+ qwenSessionId: null,
348
+ acpSessionId: null,
344
349
  delegateResumeIds: {
345
350
  claudeCode: null,
346
351
  codex: null,
347
352
  opencode: null,
348
353
  gemini: null,
354
+ copilot: null,
355
+ cursor: null,
356
+ qwen: null,
349
357
  },
350
358
  messages: [],
351
359
  createdAt: Date.now(),
@@ -77,6 +77,10 @@ const PROVIDER_DEFAULT_WINDOWS: Record<string, number> = {
77
77
  openai: 128_000,
78
78
  'codex-cli': 1_047_576,
79
79
  'opencode-cli': 200_000,
80
+ 'gemini-cli': 1_048_576,
81
+ 'copilot-cli': 200_000,
82
+ 'cursor-cli': 200_000,
83
+ 'qwen-code-cli': 1_048_576,
80
84
  google: 1_048_576,
81
85
  deepseek: 64_000,
82
86
  groq: 32_768,
@@ -85,6 +89,7 @@ const PROVIDER_DEFAULT_WINDOWS: Record<string, number> = {
85
89
  xai: 131_072,
86
90
  fireworks: 32_768,
87
91
  ollama: 32_768,
92
+ goose: 200_000,
88
93
  openclaw: 128_000,
89
94
  }
90
95
 
@@ -5,7 +5,7 @@ import { log } from './logger'
5
5
 
6
6
  const TAG = 'provider-health'
7
7
 
8
- type DelegateTool = 'delegate_to_claude_code' | 'delegate_to_codex_cli' | 'delegate_to_opencode_cli' | 'delegate_to_gemini_cli'
8
+ type DelegateTool = 'delegate_to_claude_code' | 'delegate_to_codex_cli' | 'delegate_to_opencode_cli' | 'delegate_to_gemini_cli' | 'delegate_to_copilot_cli' | 'delegate_to_cursor_cli' | 'delegate_to_qwen_code_cli'
9
9
 
10
10
  interface ProviderHealthState {
11
11
  failures: number
@@ -119,6 +119,9 @@ function delegateBinary(delegateTool: DelegateTool): string {
119
119
  if (delegateTool === 'delegate_to_claude_code') return 'claude'
120
120
  if (delegateTool === 'delegate_to_codex_cli') return 'codex'
121
121
  if (delegateTool === 'delegate_to_gemini_cli') return 'gemini'
122
+ if (delegateTool === 'delegate_to_copilot_cli') return 'copilot'
123
+ if (delegateTool === 'delegate_to_cursor_cli') return 'cursor-agent'
124
+ if (delegateTool === 'delegate_to_qwen_code_cli') return 'qwen'
122
125
  return 'opencode'
123
126
  }
124
127
 
@@ -146,6 +149,10 @@ function delegateToolReady(delegateTool: DelegateTool): boolean {
146
149
  const probe = spawnSync(binary, ['login', 'status'], { encoding: 'utf-8', timeout: 8000 })
147
150
  const probeText = `${probe.stdout || ''}\n${probe.stderr || ''}`.toLowerCase()
148
151
  ok = (probe.status ?? 1) === 0 && probeText.includes('logged in')
152
+ } else if (ok && delegateTool === 'delegate_to_cursor_cli') {
153
+ const probe = spawnSync(binary, ['status'], { encoding: 'utf-8', timeout: 8000 })
154
+ const probeText = `${probe.stdout || ''}\n${probe.stderr || ''}`.toLowerCase()
155
+ ok = (probe.status ?? 1) === 0 || probeText.includes('authenticated') || probeText.includes('logged in')
149
156
  }
150
157
 
151
158
  delegateReadyCache.set(delegateTool, { at: now, ok })
@@ -156,6 +163,9 @@ function delegateProviderId(delegateTool: DelegateTool): string {
156
163
  if (delegateTool === 'delegate_to_claude_code') return 'claude-cli'
157
164
  if (delegateTool === 'delegate_to_codex_cli') return 'codex-cli'
158
165
  if (delegateTool === 'delegate_to_gemini_cli') return 'gemini-cli'
166
+ if (delegateTool === 'delegate_to_copilot_cli') return 'copilot-cli'
167
+ if (delegateTool === 'delegate_to_cursor_cli') return 'cursor-cli'
168
+ if (delegateTool === 'delegate_to_qwen_code_cli') return 'qwen-code-cli'
159
169
  return 'opencode-cli'
160
170
  }
161
171
 
@@ -331,14 +341,14 @@ export async function pingOpenClaw(
331
341
 
332
342
  /**
333
343
  * Ping a provider to check reachability. Returns `{ ok, message }`.
334
- * Skips CLI-based providers (claude-cli, codex-cli, opencode-cli, gemini-cli) — returns ok.
344
+ * Skips CLI-based providers — returns ok.
335
345
  */
336
346
  export async function pingProvider(
337
347
  provider: string,
338
348
  apiKey: string | undefined,
339
349
  endpoint: string | undefined,
340
350
  ): Promise<{ ok: boolean; message: string }> {
341
- const CLI_PROVIDERS = ['claude-cli', 'codex-cli', 'opencode-cli', 'gemini-cli', 'copilot-cli']
351
+ const CLI_PROVIDERS = ['claude-cli', 'codex-cli', 'opencode-cli', 'gemini-cli', 'copilot-cli', 'cursor-cli', 'qwen-code-cli', 'goose']
342
352
  const OPTIONAL_OPENAI_COMPATIBLE_KEY_PROVIDERS = new Set(['hermes'])
343
353
  if (CLI_PROVIDERS.includes(provider)) return { ok: true, message: 'CLI provider — skipped.' }
344
354
 
@@ -228,6 +228,14 @@ test('resolveDescriptor uses Hermes as an OpenAI-compatible provider with option
228
228
  assert.equal(descriptor?.optionalApiKey, true)
229
229
  })
230
230
 
231
+ test('resolveDescriptor disables model discovery for local CLI-backed providers without live model catalogs', () => {
232
+ for (const providerId of ['copilot-cli', 'cursor-cli', 'qwen-code-cli', 'goose']) {
233
+ const descriptor = resolveDescriptor({ providerId })
234
+ assert.equal(descriptor?.supportsDiscovery, false, `${providerId} should not support discovery`)
235
+ assert.equal(descriptor?.endpoint, undefined, `${providerId} should not expose a discovery endpoint`)
236
+ }
237
+ })
238
+
231
239
  // ---------------------------------------------------------------------------
232
240
  // ttlForDescriptor
233
241
  // ---------------------------------------------------------------------------
@@ -55,7 +55,7 @@ function toOpenAiCompatibleEndpoint(raw: string | null | undefined, fallback: st
55
55
  }
56
56
 
57
57
  function supportsBuiltInModelDiscovery(providerId: string): boolean {
58
- return !['claude-cli', 'codex-cli', 'opencode-cli'].includes(providerId)
58
+ return !['claude-cli', 'codex-cli', 'opencode-cli', 'gemini-cli', 'copilot-cli', 'cursor-cli', 'qwen-code-cli', 'goose'].includes(providerId)
59
59
  }
60
60
 
61
61
  function normalizeGoogleModelsEndpoint(raw: string | null | undefined): string {
@@ -704,7 +704,7 @@ async function runProviderHealthChecks() {
704
704
  if (!agent?.id || typeof agent.id !== 'string') continue
705
705
  if (shouldSuppressSyntheticAgentHealthAlert(agent.id)) continue
706
706
  const provider = typeof agent.provider === 'string' ? agent.provider : ''
707
- if (!provider || ['claude-cli', 'codex-cli', 'opencode-cli'].includes(provider)) continue
707
+ if (!provider || ['claude-cli', 'codex-cli', 'opencode-cli', 'gemini-cli', 'copilot-cli', 'cursor-cli', 'qwen-code-cli', 'goose'].includes(provider)) continue
708
708
 
709
709
  const credentialId = typeof agent.credentialId === 'string' ? agent.credentialId : ''
710
710
  const apiEndpoint = typeof agent.apiEndpoint === 'string' ? agent.apiEndpoint : ''
@@ -1508,7 +1508,7 @@ export function getDaemonHealthSummary(): {
1508
1508
  for (const agent of agentEntries) {
1509
1509
  if (!agent?.id || typeof agent.id !== 'string') continue
1510
1510
  const provider = typeof agent.provider === 'string' ? agent.provider : ''
1511
- if (!provider || ['claude-cli', 'codex-cli', 'opencode-cli'].includes(provider)) continue
1511
+ if (!provider || ['claude-cli', 'codex-cli', 'opencode-cli', 'gemini-cli', 'copilot-cli', 'cursor-cli', 'qwen-code-cli', 'goose'].includes(provider)) continue
1512
1512
  const credentialId = typeof agent.credentialId === 'string' ? agent.credentialId : ''
1513
1513
  const apiEndpoint = typeof agent.apiEndpoint === 'string' ? agent.apiEndpoint : ''
1514
1514
  providerKeys.add(`${provider}:${credentialId || 'no-cred'}:${apiEndpoint}`)
@@ -128,6 +128,8 @@ export interface TaskResumeState {
128
128
  claudeSessionId: string | null
129
129
  codexThreadId: string | null
130
130
  opencodeSessionId: string | null
131
+ cursorSessionId?: string | null
132
+ qwenSessionId?: string | null
131
133
  delegateResumeIds: NonNullable<Session['delegateResumeIds']>
132
134
  }
133
135
 
@@ -149,6 +151,9 @@ function buildEmptyDelegateResumeIds(): NonNullable<Session['delegateResumeIds']
149
151
  codex: null,
150
152
  opencode: null,
151
153
  gemini: null,
154
+ copilot: null,
155
+ cursor: null,
156
+ qwen: null,
152
157
  }
153
158
  }
154
159
 
@@ -166,6 +171,8 @@ function hasResumeState(state: TaskResumeState | null | undefined): state is Tas
166
171
  || state.delegateResumeIds.codex
167
172
  || state.delegateResumeIds.opencode
168
173
  || state.delegateResumeIds.gemini
174
+ || state.delegateResumeIds.cursor
175
+ || state.delegateResumeIds.qwen
169
176
  )
170
177
  }
171
178
 
@@ -182,16 +189,22 @@ export function extractTaskResumeState(task: Partial<BoardTask> | null | undefin
182
189
  || (legacyProvider === 'opencode-cli' ? legacyResumeId : null)
183
190
  const geminiSessionId = normalizeResumeHandle(task.geminiResumeId)
184
191
  || (legacyProvider === 'gemini-cli' ? legacyResumeId : null)
192
+ const cursorSessionId = legacyProvider === 'cursor-cli' ? legacyResumeId : null
193
+ const qwenSessionId = legacyProvider === 'qwen-code-cli' ? legacyResumeId : null
185
194
 
186
195
  const resume = {
187
196
  claudeSessionId,
188
197
  codexThreadId,
189
198
  opencodeSessionId,
199
+ cursorSessionId,
200
+ qwenSessionId,
190
201
  delegateResumeIds: {
191
202
  claudeCode: claudeSessionId,
192
203
  codex: codexThreadId,
193
204
  opencode: opencodeSessionId,
194
205
  gemini: geminiSessionId,
206
+ cursor: cursorSessionId,
207
+ qwen: qwenSessionId,
195
208
  },
196
209
  } satisfies TaskResumeState
197
210
 
@@ -204,6 +217,8 @@ export function extractSessionResumeState(session: Partial<Session> | null | und
204
217
  const claudeSessionId = normalizeResumeHandle(session.claudeSessionId)
205
218
  const codexThreadId = normalizeResumeHandle(session.codexThreadId)
206
219
  const opencodeSessionId = normalizeResumeHandle(session.opencodeSessionId)
220
+ const cursorSessionId = normalizeResumeHandle(session.cursorSessionId)
221
+ const qwenSessionId = normalizeResumeHandle(session.qwenSessionId)
207
222
  const delegateResumeIds = session.delegateResumeIds && typeof session.delegateResumeIds === 'object'
208
223
  ? { ...buildEmptyDelegateResumeIds(), ...session.delegateResumeIds }
209
224
  : buildEmptyDelegateResumeIds()
@@ -212,11 +227,16 @@ export function extractSessionResumeState(session: Partial<Session> | null | und
212
227
  claudeSessionId,
213
228
  codexThreadId,
214
229
  opencodeSessionId,
230
+ cursorSessionId,
231
+ qwenSessionId,
215
232
  delegateResumeIds: {
216
233
  claudeCode: normalizeResumeHandle(delegateResumeIds.claudeCode) || claudeSessionId,
217
234
  codex: normalizeResumeHandle(delegateResumeIds.codex) || codexThreadId,
218
235
  opencode: normalizeResumeHandle(delegateResumeIds.opencode) || opencodeSessionId,
219
236
  gemini: normalizeResumeHandle(delegateResumeIds.gemini),
237
+ cursor: normalizeResumeHandle(delegateResumeIds.cursor) || cursorSessionId,
238
+ qwen: normalizeResumeHandle(delegateResumeIds.qwen) || qwenSessionId,
239
+ copilot: normalizeResumeHandle(delegateResumeIds.copilot),
220
240
  },
221
241
  } satisfies TaskResumeState
222
242
 
@@ -263,10 +283,12 @@ export function applyTaskResumeStateToSession(session: Session, resume: TaskResu
263
283
  if (!hasResumeState(resume)) return false
264
284
 
265
285
  let changed = false
266
- const directFields: Array<['claudeSessionId' | 'codexThreadId' | 'opencodeSessionId', string | null]> = [
286
+ const directFields: Array<['claudeSessionId' | 'codexThreadId' | 'opencodeSessionId' | 'cursorSessionId' | 'qwenSessionId', string | null]> = [
267
287
  ['claudeSessionId', resume.claudeSessionId],
268
288
  ['codexThreadId', resume.codexThreadId],
269
289
  ['opencodeSessionId', resume.opencodeSessionId],
290
+ ['cursorSessionId', resume.cursorSessionId ?? null],
291
+ ['qwenSessionId', resume.qwenSessionId ?? null],
270
292
  ]
271
293
  for (const [key, value] of directFields) {
272
294
  if (!value || session[key] === value) continue
@@ -1444,27 +1466,31 @@ export async function processNext() {
1444
1466
  const execSession = execSessions[sessionId] as unknown as Record<string, unknown> | undefined
1445
1467
  if (execSession) {
1446
1468
  const delegateIds = execSession.delegateResumeIds as
1447
- | { claudeCode?: string | null; codex?: string | null; opencode?: string | null; gemini?: string | null }
1469
+ | { claudeCode?: string | null; codex?: string | null; opencode?: string | null; gemini?: string | null; cursor?: string | null; qwen?: string | null }
1448
1470
  | undefined
1449
1471
  // Store each CLI resume ID separately
1450
1472
  const claudeId = (execSession.claudeSessionId as string) || delegateIds?.claudeCode || null
1451
1473
  const codexId = (execSession.codexThreadId as string) || delegateIds?.codex || null
1452
1474
  const opencodeId = (execSession.opencodeSessionId as string) || delegateIds?.opencode || null
1453
1475
  const geminiId = delegateIds?.gemini || null
1476
+ const cursorId = (execSession.cursorSessionId as string) || delegateIds?.cursor || null
1477
+ const qwenId = (execSession.qwenSessionId as string) || delegateIds?.qwen || null
1454
1478
  if (claudeId) t2[taskId].claudeResumeId = claudeId
1455
1479
  if (codexId) t2[taskId].codexResumeId = codexId
1456
1480
  if (opencodeId) t2[taskId].opencodeResumeId = opencodeId
1457
1481
  if (geminiId) t2[taskId].geminiResumeId = geminiId
1458
1482
  // Keep backward-compat single field (first available)
1459
- const primaryId = claudeId || codexId || opencodeId || geminiId
1483
+ const primaryId = claudeId || codexId || opencodeId || geminiId || cursorId || qwenId
1460
1484
  if (primaryId) {
1461
1485
  t2[taskId].cliResumeId = primaryId
1462
1486
  if (claudeId) t2[taskId].cliProvider = 'claude-cli'
1463
1487
  else if (codexId) t2[taskId].cliProvider = 'codex-cli'
1464
1488
  else if (opencodeId) t2[taskId].cliProvider = 'opencode-cli'
1465
1489
  else if (geminiId) t2[taskId].cliProvider = 'gemini-cli'
1490
+ else if (cursorId) t2[taskId].cliProvider = 'cursor-cli'
1491
+ else if (qwenId) t2[taskId].cliProvider = 'qwen-code-cli'
1466
1492
  }
1467
- log.info(TAG, `[queue] CLI resume IDs for task ${taskId}: claude=${claudeId}, codex=${codexId}, opencode=${opencodeId}, gemini=${geminiId}`)
1493
+ log.info(TAG, `[queue] CLI resume IDs for task ${taskId}: claude=${claudeId}, codex=${codexId}, opencode=${opencodeId}, gemini=${geminiId}, cursor=${cursorId}, qwen=${qwenId}`)
1468
1494
  }
1469
1495
  } catch (e) {
1470
1496
  log.warn(TAG, `[queue] Failed to extract CLI resume IDs for task ${taskId}:`, e)
@@ -44,7 +44,7 @@ type RepairSessionRunQueueFn = (
44
44
 
45
45
  type DrainExecutionFn = (executionKey: string) => Promise<void>
46
46
 
47
- const LONG_TOOL_NAMES: ReadonlySet<string> = new Set(['claude_code', 'codex_cli', 'opencode_cli'])
47
+ const LONG_TOOL_NAMES: ReadonlySet<string> = new Set(['claude_code', 'codex_cli', 'opencode_cli', 'gemini_cli', 'cursor_cli', 'qwen_code_cli'])
48
48
 
49
49
  type SessionToolConfig = {
50
50
  tools?: string[] | null
@@ -102,6 +102,22 @@ describe('session-reset-policy', () => {
102
102
  })
103
103
  assert.equal(policy.idleTimeoutSec, 1800)
104
104
  })
105
+
106
+ it('isolated mode is preserved and not replaced by fallback', () => {
107
+ const policy = mod.resolveSessionResetPolicy({
108
+ resetType: 'direct',
109
+ session: { sessionResetMode: 'isolated' } as never,
110
+ })
111
+ assert.equal(policy.mode, 'isolated')
112
+ })
113
+
114
+ it('isolated mode from agent config is preserved', () => {
115
+ const policy = mod.resolveSessionResetPolicy({
116
+ resetType: 'direct',
117
+ agent: { sessionResetMode: 'isolated' } as never,
118
+ })
119
+ assert.equal(policy.mode, 'isolated')
120
+ })
105
121
  })
106
122
 
107
123
  // ---- evaluateSessionFreshness ----
@@ -67,7 +67,7 @@ function parseIntBounded(value: unknown, min: number, max: number): number | nul
67
67
 
68
68
  function normalizeMode(raw: unknown, fallback: SessionResetMode): SessionResetMode {
69
69
  const value = typeof raw === 'string' ? raw.trim().toLowerCase() : ''
70
- return value === 'daily' ? 'daily' : value === 'idle' ? 'idle' : fallback
70
+ return value === 'daily' ? 'daily' : value === 'idle' ? 'idle' : value === 'isolated' ? 'isolated' : fallback
71
71
  }
72
72
 
73
73
  function normalizeTimeHHMM(raw: unknown): string | null {
@@ -282,11 +282,19 @@ export function resetSessionRuntime(
282
282
  session.claudeSessionId = null
283
283
  session.codexThreadId = null
284
284
  session.opencodeSessionId = null
285
+ session.geminiSessionId = null
286
+ session.copilotSessionId = null
287
+ session.cursorSessionId = null
288
+ session.qwenSessionId = null
289
+ session.acpSessionId = null
285
290
  session.delegateResumeIds = {
286
291
  claudeCode: null,
287
292
  codex: null,
288
293
  opencode: null,
289
294
  gemini: null,
295
+ copilot: null,
296
+ cursor: null,
297
+ qwen: null,
290
298
  }
291
299
  session.createdAt = now
292
300
  session.lastActiveAt = now
@@ -86,8 +86,8 @@ export interface ToolBuildContext {
86
86
  commandTimeoutMs: number
87
87
  claudeTimeoutMs: number
88
88
  cliProcessTimeoutMs: number
89
- persistDelegateResumeId: (key: 'claudeCode' | 'codex' | 'opencode' | 'gemini', id: string | null | undefined) => void
90
- readStoredDelegateResumeId: (key: 'claudeCode' | 'codex' | 'opencode' | 'gemini') => string | null
89
+ persistDelegateResumeId: (key: 'claudeCode' | 'codex' | 'opencode' | 'gemini' | 'copilot' | 'cursor' | 'qwen', id: string | null | undefined) => void
90
+ readStoredDelegateResumeId: (key: 'claudeCode' | 'codex' | 'opencode' | 'gemini' | 'copilot' | 'cursor' | 'qwen') => string | null
91
91
  resolveCurrentSession: () => any | null
92
92
  activeExtensions: string[]
93
93
  /** Agent's file access policy — passed to shell for command-level enforcement */