@swarmclawai/swarmclaw 1.5.35 → 1.5.37

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 (47) hide show
  1. package/README.md +29 -1
  2. package/package.json +18 -1
  3. package/public/provider-logos/droid-cli.svg +7 -0
  4. package/src/app/api/setup/check-provider/route.ts +4 -2
  5. package/src/app/api/setup/doctor/route.ts +1 -0
  6. package/src/components/agents/agent-sheet.tsx +3 -1
  7. package/src/components/agents/inspector-panel.tsx +1 -0
  8. package/src/components/auth/access-key-gate.tsx +0 -24
  9. package/src/components/chat/activity-moment.tsx +1 -0
  10. package/src/components/chat/chat-header.tsx +2 -1
  11. package/src/components/chat/tool-call-bubble.tsx +5 -0
  12. package/src/components/layout/sidebar-rail.tsx +0 -47
  13. package/src/lib/orchestrator-config.ts +1 -0
  14. package/src/lib/provider-sets.ts +3 -3
  15. package/src/lib/providers/cli-utils.test.ts +2 -0
  16. package/src/lib/providers/cli-utils.ts +28 -1
  17. package/src/lib/providers/droid-cli.ts +220 -0
  18. package/src/lib/providers/index.ts +11 -1
  19. package/src/lib/server/agents/agent-availability.test.ts +1 -1
  20. package/src/lib/server/agents/agent-thread-session.ts +1 -0
  21. package/src/lib/server/agents/task-session.ts +2 -0
  22. package/src/lib/server/capability-router.ts +3 -1
  23. package/src/lib/server/chat-execution/chat-execution-utils.ts +11 -0
  24. package/src/lib/server/chat-execution/chat-turn-finalization.ts +2 -0
  25. package/src/lib/server/chat-execution/chat-turn-tool-routing.ts +1 -0
  26. package/src/lib/server/chat-execution/prompt-sections.ts +2 -0
  27. package/src/lib/server/chatrooms/chatroom-helpers.ts +3 -0
  28. package/src/lib/server/chats/chat-session-service.ts +4 -0
  29. package/src/lib/server/connectors/session.ts +2 -0
  30. package/src/lib/server/context-manager.ts +1 -0
  31. package/src/lib/server/provider-health.ts +4 -2
  32. package/src/lib/server/provider-model-discovery.test.ts +1 -1
  33. package/src/lib/server/provider-model-discovery.ts +1 -1
  34. package/src/lib/server/runtime/daemon-state/core.ts +2 -2
  35. package/src/lib/server/session-reset-policy.ts +2 -0
  36. package/src/lib/server/session-tools/context.ts +2 -2
  37. package/src/lib/server/session-tools/delegate-droid.test.ts +24 -0
  38. package/src/lib/server/session-tools/delegate.ts +105 -12
  39. package/src/lib/server/session-tools/index.ts +3 -2
  40. package/src/lib/server/session-tools/session-info.ts +1 -0
  41. package/src/lib/server/storage-normalization.ts +3 -0
  42. package/src/lib/server/tool-aliases.ts +1 -1
  43. package/src/lib/server/tool-capability-policy.ts +2 -1
  44. package/src/lib/setup-defaults.ts +21 -0
  45. package/src/types/misc.ts +1 -1
  46. package/src/types/provider.ts +1 -1
  47. package/src/types/session.ts +3 -0
@@ -0,0 +1,220 @@
1
+ import fs from 'fs'
2
+ import os from 'os'
3
+ import path from 'path'
4
+ import { spawn } from 'child_process'
5
+ import type { StreamChatOptions } from './index'
6
+ import { log } from '../server/logger'
7
+ import { loadRuntimeSettings } from '@/lib/server/runtime/runtime-settings'
8
+ import { resolveCliBinary, buildCliEnv, probeCliAuth, attachAbortHandler, symlinkConfigFiles, isStderrNoise } from './cli-utils'
9
+
10
+ /**
11
+ * Factory Droid CLI provider — spawns `droid exec <message> --output-format stream-json`.
12
+ * Tracks `session.droidSessionId` from streamed events to support multi-turn continuity.
13
+ */
14
+ export function streamDroidCliChat({ session, message, imagePath, systemPrompt, write, active, signal }: StreamChatOptions): Promise<string> {
15
+ const processTimeoutMs = loadRuntimeSettings().cliProcessTimeoutMs
16
+ const binary = resolveCliBinary('droid')
17
+ if (!binary) {
18
+ const msg = 'Factory Droid CLI not found. Install it (brew install --cask droid, npm i -g droid, or https://docs.factory.ai/cli/getting-started/quickstart) and ensure it is on your PATH.'
19
+ write(`data: ${JSON.stringify({ t: 'err', text: msg })}\n\n`)
20
+ return Promise.resolve('')
21
+ }
22
+
23
+ const env = buildCliEnv()
24
+
25
+ if (session.apiKey) {
26
+ env.FACTORY_API_KEY = session.apiKey
27
+ }
28
+
29
+ if (!session.apiKey) {
30
+ const auth = probeCliAuth(binary, 'droid', env, session.cwd)
31
+ if (!auth.authenticated) {
32
+ log.error('droid-cli', auth.errorMessage || 'Auth failed')
33
+ write(`data: ${JSON.stringify({ t: 'err', text: auth.errorMessage || 'Factory Droid CLI is not authenticated.' })}\n\n`)
34
+ return Promise.resolve('')
35
+ }
36
+ }
37
+
38
+ const promptParts: string[] = []
39
+ if (imagePath) {
40
+ promptParts.push(`[The user has shared an image at: ${imagePath}]`)
41
+ }
42
+ promptParts.push(message)
43
+ const prompt = promptParts.join('\n\n')
44
+
45
+ const args = ['exec', prompt, '--output-format', 'stream-json']
46
+ if (session.droidSessionId) args.push('-s', session.droidSessionId)
47
+ if (session.model) args.push('-m', session.model)
48
+
49
+ let tempFactoryHome: string | null = null
50
+ if (systemPrompt && !session.droidSessionId) {
51
+ const realFactoryHome = process.env.FACTORY_HOME || path.join(os.homedir(), '.factory')
52
+ tempFactoryHome = path.join(os.tmpdir(), `swarmclaw-droid-${session.id}`)
53
+ fs.mkdirSync(tempFactoryHome, { recursive: true })
54
+ symlinkConfigFiles(realFactoryHome, tempFactoryHome)
55
+ fs.writeFileSync(path.join(tempFactoryHome, 'AGENTS.override.md'), systemPrompt)
56
+ env.FACTORY_HOME = tempFactoryHome
57
+ }
58
+
59
+ log.info('droid-cli', `Spawning: ${binary}`, {
60
+ args: args.map((a) => a.length > 100 ? a.slice(0, 100) + '...' : a),
61
+ cwd: session.cwd,
62
+ promptLen: prompt.length,
63
+ hasSystemPrompt: !!systemPrompt,
64
+ resumeSessionId: session.droidSessionId || null,
65
+ })
66
+
67
+ const proc = spawn(binary, args, {
68
+ cwd: session.cwd,
69
+ env,
70
+ stdio: ['ignore', 'pipe', 'pipe'],
71
+ timeout: processTimeoutMs,
72
+ })
73
+
74
+ log.info('droid-cli', `Process spawned: pid=${proc.pid}`)
75
+ active.set(session.id, proc)
76
+ attachAbortHandler(proc, signal)
77
+
78
+ let fullResponse = ''
79
+ let buf = ''
80
+ let eventCount = 0
81
+ let stderrText = ''
82
+
83
+ proc.stdout!.on('data', (chunk: Buffer) => {
84
+ const raw = chunk.toString()
85
+ buf += raw
86
+
87
+ if (eventCount === 0) {
88
+ log.debug('droid-cli', `First stdout chunk (${raw.length} bytes)`, raw.slice(0, 500))
89
+ }
90
+
91
+ const lines = buf.split('\n')
92
+ buf = lines.pop()!
93
+
94
+ for (const line of lines) {
95
+ if (!line.trim()) continue
96
+ try {
97
+ const ev = JSON.parse(line) as Record<string, unknown>
98
+ eventCount++
99
+
100
+ const data = ev.data as Record<string, unknown> | undefined
101
+
102
+ if (typeof ev.session_id === 'string') {
103
+ session.droidSessionId = ev.session_id
104
+ } else if (typeof ev.sessionId === 'string') {
105
+ session.droidSessionId = ev.sessionId
106
+ }
107
+
108
+ if (ev.type === 'assistant.message_delta' && typeof data?.deltaContent === 'string') {
109
+ fullResponse += data.deltaContent
110
+ write(`data: ${JSON.stringify({ t: 'd', text: data.deltaContent })}\n\n`)
111
+ }
112
+
113
+ else if (ev.type === 'assistant.message' && typeof data?.content === 'string') {
114
+ if (!fullResponse) {
115
+ fullResponse = data.content
116
+ write(`data: ${JSON.stringify({ t: 'r', text: data.content })}\n\n`)
117
+ }
118
+ log.debug('droid-cli', `Assistant message (${data.content.length} chars)`)
119
+ }
120
+
121
+ else if (ev.type === 'content_block_delta') {
122
+ const delta = ev.delta as Record<string, unknown> | undefined
123
+ if (typeof delta?.text === 'string') {
124
+ fullResponse += delta.text
125
+ write(`data: ${JSON.stringify({ t: 'd', text: delta.text })}\n\n`)
126
+ }
127
+ }
128
+
129
+ else if (ev.type === 'agent_message_chunk' && typeof ev.text === 'string') {
130
+ fullResponse += ev.text
131
+ write(`data: ${JSON.stringify({ t: 'd', text: ev.text })}\n\n`)
132
+ }
133
+
134
+ else if (ev.type === 'message' && ev.role === 'assistant' && typeof ev.content === 'string') {
135
+ fullResponse += ev.content
136
+ write(`data: ${JSON.stringify({ t: 'd', text: ev.content })}\n\n`)
137
+ }
138
+
139
+ else if (ev.type === 'item.completed' && (ev.item as Record<string, unknown>)?.type === 'agent_message') {
140
+ const item = ev.item as Record<string, unknown>
141
+ if (typeof item.text === 'string') {
142
+ fullResponse = item.text
143
+ write(`data: ${JSON.stringify({ t: 'r', text: item.text })}\n\n`)
144
+ log.debug('droid-cli', `Agent message (${item.text.length} chars)`)
145
+ }
146
+ }
147
+
148
+ else if (ev.type === 'result' && typeof ev.result === 'string') {
149
+ fullResponse = ev.result
150
+ write(`data: ${JSON.stringify({ t: 'r', text: ev.result })}\n\n`)
151
+ log.debug('droid-cli', `Result event (${ev.result.length} chars)`)
152
+ }
153
+
154
+ else if (ev.type === 'result' && ev.status === 'error') {
155
+ const errMsg = typeof ev.error === 'string' ? ev.error : 'Droid error'
156
+ write(`data: ${JSON.stringify({ t: 'err', text: errMsg })}\n\n`)
157
+ log.warn('droid-cli', `Error result: ${errMsg}`)
158
+ }
159
+
160
+ else if (ev.type === 'error') {
161
+ const errMsg = typeof ev.message === 'string'
162
+ ? ev.message
163
+ : typeof ev.error === 'string'
164
+ ? ev.error
165
+ : 'Unknown Droid error'
166
+ write(`data: ${JSON.stringify({ t: 'err', text: errMsg })}\n\n`)
167
+ log.warn('droid-cli', `Event error: ${errMsg}`)
168
+ }
169
+
170
+ else if (eventCount <= 10) {
171
+ log.debug('droid-cli', `Event: ${String(ev.type)}`)
172
+ }
173
+ } catch {
174
+ if (line.trim()) {
175
+ log.debug('droid-cli', `Non-JSON stdout line`, line.slice(0, 300))
176
+ fullResponse += line + '\n'
177
+ write(`data: ${JSON.stringify({ t: 'd', text: line + '\n' })}\n\n`)
178
+ }
179
+ }
180
+ }
181
+ })
182
+
183
+ proc.stderr!.on('data', (chunk: Buffer) => {
184
+ const text = chunk.toString()
185
+ stderrText += text
186
+ if (stderrText.length > 16_000) stderrText = stderrText.slice(-16_000)
187
+ if (isStderrNoise(text)) {
188
+ log.debug('droid-cli', `stderr noise [${session.id}]`, text.slice(0, 500))
189
+ } else {
190
+ log.warn('droid-cli', `stderr [${session.id}]`, text.slice(0, 500))
191
+ }
192
+ })
193
+
194
+ return new Promise((resolve) => {
195
+ proc.on('close', (code, sig) => {
196
+ log.info('droid-cli', `Process closed: code=${code} signal=${sig} events=${eventCount} response=${fullResponse.length}chars`)
197
+ active.delete(session.id)
198
+ if (tempFactoryHome) {
199
+ try { fs.rmSync(tempFactoryHome, { recursive: true }) } catch { /* ignore */ }
200
+ }
201
+ if ((code ?? 0) !== 0 && !fullResponse.trim()) {
202
+ const msg = stderrText.trim()
203
+ ? `Factory Droid CLI exited with code ${code ?? 'unknown'}${sig ? ` (${sig})` : ''}: ${stderrText.trim().slice(0, 1200)}`
204
+ : `Factory Droid CLI exited with code ${code ?? 'unknown'}${sig ? ` (${sig})` : ''} and returned no output.`
205
+ write(`data: ${JSON.stringify({ t: 'err', text: msg })}\n\n`)
206
+ }
207
+ resolve(fullResponse)
208
+ })
209
+
210
+ proc.on('error', (e) => {
211
+ log.error('droid-cli', `Process error: ${e.message}`)
212
+ active.delete(session.id)
213
+ if (tempFactoryHome) {
214
+ try { fs.rmSync(tempFactoryHome, { recursive: true }) } catch { /* ignore */ }
215
+ }
216
+ write(`data: ${JSON.stringify({ t: 'err', text: e.message })}\n\n`)
217
+ resolve(fullResponse)
218
+ })
219
+ })
220
+ }
@@ -3,6 +3,7 @@ import { streamCodexCliChat } from './codex-cli'
3
3
  import { streamOpenCodeCliChat } from './opencode-cli'
4
4
  import { streamGeminiCliChat } from './gemini-cli'
5
5
  import { streamCopilotCliChat } from './copilot-cli'
6
+ import { streamDroidCliChat } from './droid-cli'
6
7
  import { streamCursorCliChat } from './cursor-cli'
7
8
  import { streamQwenCodeCliChat } from './qwen-code-cli'
8
9
  import { streamGooseChat } from './goose'
@@ -151,6 +152,15 @@ export const PROVIDERS: Record<string, BuiltinProviderConfig> = {
151
152
  requiresEndpoint: false,
152
153
  handler: { streamChat: streamCopilotCliChat },
153
154
  },
155
+ 'droid-cli': {
156
+ id: 'droid-cli',
157
+ name: 'Factory Droid CLI',
158
+ models: ['default'],
159
+ requiresApiKey: false,
160
+ optionalApiKey: true,
161
+ requiresEndpoint: false,
162
+ handler: { streamChat: streamDroidCliChat },
163
+ },
154
164
  'cursor-cli': {
155
165
  id: 'cursor-cli',
156
166
  name: 'Cursor Agent CLI',
@@ -383,7 +393,7 @@ export function getProviderList(): ProviderInfo[] {
383
393
  ...info,
384
394
  models: overrides[info.id] || info.models,
385
395
  defaultModels: info.models,
386
- supportsModelDiscovery: !['claude-cli', 'codex-cli', 'opencode-cli', 'gemini-cli', 'copilot-cli', 'cursor-cli', 'qwen-code-cli', 'goose', 'fireworks'].includes(info.id),
396
+ supportsModelDiscovery: !['claude-cli', 'codex-cli', 'opencode-cli', 'gemini-cli', 'copilot-cli', 'droid-cli', 'cursor-cli', 'qwen-code-cli', 'goose', 'fireworks'].includes(info.id),
387
397
  }
388
398
  })
389
399
 
@@ -4,7 +4,7 @@ import type { Agent, ProviderType } from '@/types'
4
4
  import { isWorkerOnlyAgent, buildWorkerOnlyAgentMessage } from './agent-availability'
5
5
 
6
6
  describe('isWorkerOnlyAgent', () => {
7
- const CLI_PROVIDERS = ['claude-cli', 'codex-cli', 'opencode-cli', 'gemini-cli', 'copilot-cli', 'openclaw'] satisfies ProviderType[]
7
+ const CLI_PROVIDERS = ['claude-cli', 'codex-cli', 'opencode-cli', 'gemini-cli', 'copilot-cli', 'droid-cli', 'openclaw'] satisfies ProviderType[]
8
8
  const NON_CLI_PROVIDERS = ['openai', 'anthropic', 'google', 'deepseek', 'groq', 'together'] satisfies ProviderType[]
9
9
 
10
10
  function withProvider(provider: unknown): Pick<Agent, 'provider'> {
@@ -45,6 +45,7 @@ function buildThreadSession(agent: Agent, sessionId: string, user: string, creat
45
45
  opencodeSessionId: existing?.opencodeSessionId || null,
46
46
  geminiSessionId: existing?.geminiSessionId || null,
47
47
  copilotSessionId: existing?.copilotSessionId || null,
48
+ droidSessionId: existing?.droidSessionId || null,
48
49
  cursorSessionId: existing?.cursorSessionId || null,
49
50
  qwenSessionId: existing?.qwenSessionId || null,
50
51
  acpSessionId: existing?.acpSessionId || null,
@@ -42,6 +42,7 @@ export function createAgentTaskSession(
42
42
  opencodeSessionId: null,
43
43
  geminiSessionId: null,
44
44
  copilotSessionId: null,
45
+ droidSessionId: null,
45
46
  cursorSessionId: null,
46
47
  qwenSessionId: null,
47
48
  acpSessionId: null,
@@ -51,6 +52,7 @@ export function createAgentTaskSession(
51
52
  opencode: null,
52
53
  gemini: null,
53
54
  copilot: null,
55
+ droid: null,
54
56
  cursor: null,
55
57
  qwen: null,
56
58
  },
@@ -19,7 +19,7 @@ export interface CapabilityRoutingDecision {
19
19
  primaryUrl?: string
20
20
  }
21
21
 
22
- 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'
22
+ type DelegateTool = 'delegate_to_claude_code' | 'delegate_to_codex_cli' | 'delegate_to_opencode_cli' | 'delegate_to_gemini_cli' | 'delegate_to_copilot_cli' | 'delegate_to_droid_cli' | 'delegate_to_cursor_cli' | 'delegate_to_qwen_code_cli'
23
23
 
24
24
  function findFirstUrl(text: string): string | undefined {
25
25
  const m = text.match(/https?:\/\/[^\s<>"')]+/i)
@@ -42,6 +42,7 @@ function normalizeDelegateOrder(value: unknown): DelegateTool[] {
42
42
  'delegate_to_opencode_cli',
43
43
  'delegate_to_gemini_cli',
44
44
  'delegate_to_copilot_cli',
45
+ 'delegate_to_droid_cli',
45
46
  'delegate_to_cursor_cli',
46
47
  'delegate_to_qwen_code_cli',
47
48
  ]
@@ -54,6 +55,7 @@ function normalizeDelegateOrder(value: unknown): DelegateTool[] {
54
55
  else if (raw === 'opencode') mapped.push('delegate_to_opencode_cli')
55
56
  else if (raw === 'gemini') mapped.push('delegate_to_gemini_cli')
56
57
  else if (raw === 'copilot') mapped.push('delegate_to_copilot_cli')
58
+ else if (raw === 'droid') mapped.push('delegate_to_droid_cli')
57
59
  else if (raw === 'cursor') mapped.push('delegate_to_cursor_cli')
58
60
  else if (raw === 'qwen') mapped.push('delegate_to_qwen_code_cli')
59
61
  }
@@ -31,6 +31,7 @@ export type DelegateTool =
31
31
  | 'delegate_to_opencode_cli'
32
32
  | 'delegate_to_gemini_cli'
33
33
  | 'delegate_to_copilot_cli'
34
+ | 'delegate_to_droid_cli'
34
35
  | 'delegate_to_cursor_cli'
35
36
  | 'delegate_to_qwen_code_cli'
36
37
 
@@ -138,6 +139,12 @@ export function translateRequestedToolInvocation(
138
139
  if (requestedName === 'delegate_to_gemini_cli') {
139
140
  return { toolName: 'delegate', args: { ...rawArgs, backend: 'gemini' } }
140
141
  }
142
+ if (requestedName === 'delegate_to_copilot_cli') {
143
+ return { toolName: 'delegate', args: { ...rawArgs, backend: 'copilot' } }
144
+ }
145
+ if (requestedName === 'delegate_to_droid_cli') {
146
+ return { toolName: 'delegate', args: { ...rawArgs, backend: 'droid' } }
147
+ }
141
148
  if (requestedName === 'delegate_to_cursor_cli') {
142
149
  return { toolName: 'delegate', args: { ...rawArgs, backend: 'cursor' } }
143
150
  }
@@ -306,6 +313,8 @@ export function requestedToolNamesFromMessage(message: string): string[] {
306
313
  'delegate_to_codex_cli',
307
314
  'delegate_to_opencode_cli',
308
315
  'delegate_to_gemini_cli',
316
+ 'delegate_to_copilot_cli',
317
+ 'delegate_to_droid_cli',
309
318
  'delegate_to_cursor_cli',
310
319
  'delegate_to_qwen_code_cli',
311
320
  'connector_message_tool',
@@ -372,6 +381,8 @@ export function enabledDelegationTools(session: SessionWithTools): DelegateTool[
372
381
  if (hasToolEnabled(session, 'codex_cli')) tools.push('delegate_to_codex_cli')
373
382
  if (hasToolEnabled(session, 'opencode_cli')) tools.push('delegate_to_opencode_cli')
374
383
  if (hasToolEnabled(session, 'gemini_cli')) tools.push('delegate_to_gemini_cli')
384
+ if (hasToolEnabled(session, 'copilot_cli')) tools.push('delegate_to_copilot_cli')
385
+ if (hasToolEnabled(session, 'droid_cli')) tools.push('delegate_to_droid_cli')
375
386
  if (hasToolEnabled(session, 'cursor_cli')) tools.push('delegate_to_cursor_cli')
376
387
  if (hasToolEnabled(session, 'qwen_code_cli')) tools.push('delegate_to_qwen_code_cli')
377
388
  return tools
@@ -398,6 +398,7 @@ export async function finalizeChatTurn(params: {
398
398
  persistField('opencodeSessionId', session.opencodeSessionId)
399
399
  persistField('geminiSessionId', session.geminiSessionId)
400
400
  persistField('copilotSessionId', session.copilotSessionId)
401
+ persistField('droidSessionId', session.droidSessionId)
401
402
  persistField('cursorSessionId', session.cursorSessionId)
402
403
  persistField('qwenSessionId', session.qwenSessionId)
403
404
  persistField('acpSessionId', session.acpSessionId)
@@ -415,6 +416,7 @@ export async function finalizeChatTurn(params: {
415
416
  opencode: normalizeResumeId(sr.opencode ?? cr.opencode),
416
417
  gemini: normalizeResumeId(sr.gemini ?? cr.gemini),
417
418
  copilot: normalizeResumeId(sr.copilot ?? cr.copilot),
419
+ droid: normalizeResumeId(sr.droid ?? cr.droid),
418
420
  cursor: normalizeResumeId(sr.cursor ?? cr.cursor),
419
421
  qwen: normalizeResumeId(sr.qwen ?? cr.qwen),
420
422
  }
@@ -506,6 +506,7 @@ const FORCED_DELEGATION_TOOLS: DelegateTool[] = [
506
506
  'delegate_to_opencode_cli',
507
507
  'delegate_to_gemini_cli',
508
508
  'delegate_to_copilot_cli',
509
+ 'delegate_to_droid_cli',
509
510
  'delegate_to_cursor_cli',
510
511
  'delegate_to_qwen_code_cli',
511
512
  ]
@@ -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_copilot_cli' || normalized === 'copilot_cli') return 'copilot_cli'
124
+ if (normalized === 'delegate_to_droid_cli' || normalized === 'droid_cli') return 'droid_cli'
123
125
  if (normalized === 'delegate_to_cursor_cli' || normalized === 'cursor_cli') return 'cursor_cli'
124
126
  if (normalized === 'delegate_to_qwen_code_cli' || normalized === 'qwen_code_cli') return 'qwen_code_cli'
125
127
  if (['session_info', 'sessions_tool', 'whoami_tool', 'search_history_tool'].includes(normalized)) return 'manage_sessions'
@@ -282,6 +282,7 @@ function buildEmptyDelegateResumeIds(): NonNullable<Session['delegateResumeIds']
282
282
  opencode: null,
283
283
  gemini: null,
284
284
  copilot: null,
285
+ droid: null,
285
286
  cursor: null,
286
287
  qwen: null,
287
288
  }
@@ -307,6 +308,7 @@ export function buildSyntheticSession(agent: Agent, chatroomId: string): Session
307
308
  opencodeSessionId: null,
308
309
  geminiSessionId: null,
309
310
  copilotSessionId: null,
311
+ droidSessionId: null,
310
312
  cursorSessionId: null,
311
313
  qwenSessionId: null,
312
314
  acpSessionId: null,
@@ -362,6 +364,7 @@ export function ensureSyntheticSession(agent: Agent, chatroomId: string): Sessio
362
364
  if (session.opencodeSessionId === undefined) session.opencodeSessionId = null
363
365
  if (session.geminiSessionId === undefined) session.geminiSessionId = null
364
366
  if (session.copilotSessionId === undefined) session.copilotSessionId = null
367
+ if (session.droidSessionId === undefined) session.droidSessionId = null
365
368
  if (session.cursorSessionId === undefined) session.cursorSessionId = null
366
369
  if (session.qwenSessionId === undefined) session.qwenSessionId = null
367
370
  if (session.acpSessionId === undefined) session.acpSessionId = null
@@ -47,6 +47,7 @@ function emptyDelegateResumeIds() {
47
47
  opencode: null,
48
48
  gemini: null,
49
49
  copilot: null,
50
+ droid: null,
50
51
  cursor: null,
51
52
  qwen: null,
52
53
  }
@@ -129,6 +130,7 @@ export function createChatSession(input: Record<string, unknown>): ServiceResult
129
130
  opencodeSessionId: null,
130
131
  geminiSessionId: null,
131
132
  copilotSessionId: null,
133
+ droidSessionId: null,
132
134
  cursorSessionId: null,
133
135
  qwenSessionId: null,
134
136
  acpSessionId: null,
@@ -289,6 +291,7 @@ export function updateChatSession(sessionId: string, updates: Record<string, unk
289
291
  if (updates.opencodeSessionId !== undefined) session.opencodeSessionId = updates.opencodeSessionId
290
292
  if (updates.geminiSessionId !== undefined) session.geminiSessionId = updates.geminiSessionId
291
293
  if (updates.copilotSessionId !== undefined) session.copilotSessionId = updates.copilotSessionId
294
+ if (updates.droidSessionId !== undefined) session.droidSessionId = updates.droidSessionId
292
295
  if (updates.cursorSessionId !== undefined) session.cursorSessionId = updates.cursorSessionId
293
296
  if (updates.qwenSessionId !== undefined) session.qwenSessionId = updates.qwenSessionId
294
297
  if (updates.acpSessionId !== undefined) session.acpSessionId = updates.acpSessionId
@@ -375,6 +378,7 @@ export function clearChatMessages(sessionId: string): boolean {
375
378
  session.opencodeSessionId = null
376
379
  session.geminiSessionId = null
377
380
  session.copilotSessionId = null
381
+ session.droidSessionId = null
378
382
  session.cursorSessionId = null
379
383
  session.qwenSessionId = null
380
384
  session.acpSessionId = null
@@ -343,6 +343,7 @@ export function resolveDirectSession(params: {
343
343
  opencodeSessionId: null,
344
344
  geminiSessionId: null,
345
345
  copilotSessionId: null,
346
+ droidSessionId: null,
346
347
  cursorSessionId: null,
347
348
  qwenSessionId: null,
348
349
  acpSessionId: null,
@@ -352,6 +353,7 @@ export function resolveDirectSession(params: {
352
353
  opencode: null,
353
354
  gemini: null,
354
355
  copilot: null,
356
+ droid: null,
355
357
  cursor: null,
356
358
  qwen: null,
357
359
  },
@@ -79,6 +79,7 @@ const PROVIDER_DEFAULT_WINDOWS: Record<string, number> = {
79
79
  'opencode-cli': 200_000,
80
80
  'gemini-cli': 1_048_576,
81
81
  'copilot-cli': 200_000,
82
+ 'droid-cli': 200_000,
82
83
  'cursor-cli': 200_000,
83
84
  'qwen-code-cli': 1_048_576,
84
85
  google: 1_048_576,
@@ -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' | 'delegate_to_copilot_cli' | 'delegate_to_cursor_cli' | 'delegate_to_qwen_code_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_droid_cli' | 'delegate_to_cursor_cli' | 'delegate_to_qwen_code_cli'
9
9
 
10
10
  interface ProviderHealthState {
11
11
  failures: number
@@ -120,6 +120,7 @@ function delegateBinary(delegateTool: DelegateTool): string {
120
120
  if (delegateTool === 'delegate_to_codex_cli') return 'codex'
121
121
  if (delegateTool === 'delegate_to_gemini_cli') return 'gemini'
122
122
  if (delegateTool === 'delegate_to_copilot_cli') return 'copilot'
123
+ if (delegateTool === 'delegate_to_droid_cli') return 'droid'
123
124
  if (delegateTool === 'delegate_to_cursor_cli') return 'cursor-agent'
124
125
  if (delegateTool === 'delegate_to_qwen_code_cli') return 'qwen'
125
126
  return 'opencode'
@@ -164,6 +165,7 @@ function delegateProviderId(delegateTool: DelegateTool): string {
164
165
  if (delegateTool === 'delegate_to_codex_cli') return 'codex-cli'
165
166
  if (delegateTool === 'delegate_to_gemini_cli') return 'gemini-cli'
166
167
  if (delegateTool === 'delegate_to_copilot_cli') return 'copilot-cli'
168
+ if (delegateTool === 'delegate_to_droid_cli') return 'droid-cli'
167
169
  if (delegateTool === 'delegate_to_cursor_cli') return 'cursor-cli'
168
170
  if (delegateTool === 'delegate_to_qwen_code_cli') return 'qwen-code-cli'
169
171
  return 'opencode-cli'
@@ -350,7 +352,7 @@ export async function pingProvider(
350
352
  apiKey: string | undefined,
351
353
  endpoint: string | undefined,
352
354
  ): Promise<{ ok: boolean; message: string }> {
353
- const CLI_PROVIDERS = ['claude-cli', 'codex-cli', 'opencode-cli', 'gemini-cli', 'copilot-cli', 'cursor-cli', 'qwen-code-cli', 'goose']
355
+ const CLI_PROVIDERS = ['claude-cli', 'codex-cli', 'opencode-cli', 'gemini-cli', 'copilot-cli', 'droid-cli', 'cursor-cli', 'qwen-code-cli', 'goose']
354
356
  const OPTIONAL_OPENAI_COMPATIBLE_KEY_PROVIDERS = new Set(['hermes'])
355
357
  if (CLI_PROVIDERS.includes(provider)) return { ok: true, message: 'CLI provider — skipped.' }
356
358
 
@@ -229,7 +229,7 @@ test('resolveDescriptor uses Hermes as an OpenAI-compatible provider with option
229
229
  })
230
230
 
231
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']) {
232
+ for (const providerId of ['copilot-cli', 'droid-cli', 'cursor-cli', 'qwen-code-cli', 'goose']) {
233
233
  const descriptor = resolveDescriptor({ providerId })
234
234
  assert.equal(descriptor?.supportsDiscovery, false, `${providerId} should not support discovery`)
235
235
  assert.equal(descriptor?.endpoint, undefined, `${providerId} should not expose a discovery endpoint`)
@@ -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', 'gemini-cli', 'copilot-cli', 'cursor-cli', 'qwen-code-cli', 'goose'].includes(providerId)
58
+ return !['claude-cli', 'codex-cli', 'opencode-cli', 'gemini-cli', 'copilot-cli', 'droid-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', 'gemini-cli', 'copilot-cli', 'cursor-cli', 'qwen-code-cli', 'goose'].includes(provider)) continue
707
+ if (!provider || ['claude-cli', 'codex-cli', 'opencode-cli', 'gemini-cli', 'copilot-cli', 'droid-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', 'gemini-cli', 'copilot-cli', 'cursor-cli', 'qwen-code-cli', 'goose'].includes(provider)) continue
1511
+ if (!provider || ['claude-cli', 'codex-cli', 'opencode-cli', 'gemini-cli', 'copilot-cli', 'droid-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}`)
@@ -284,6 +284,7 @@ export function resetSessionRuntime(
284
284
  session.opencodeSessionId = null
285
285
  session.geminiSessionId = null
286
286
  session.copilotSessionId = null
287
+ session.droidSessionId = null
287
288
  session.cursorSessionId = null
288
289
  session.qwenSessionId = null
289
290
  session.acpSessionId = null
@@ -293,6 +294,7 @@ export function resetSessionRuntime(
293
294
  opencode: null,
294
295
  gemini: null,
295
296
  copilot: null,
297
+ droid: null,
296
298
  cursor: null,
297
299
  qwen: null,
298
300
  }
@@ -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' | 'copilot' | 'cursor' | 'qwen', id: string | null | undefined) => void
90
- readStoredDelegateResumeId: (key: 'claudeCode' | 'codex' | 'opencode' | 'gemini' | 'copilot' | 'cursor' | 'qwen') => string | null
89
+ persistDelegateResumeId: (key: 'claudeCode' | 'codex' | 'opencode' | 'gemini' | 'copilot' | 'droid' | 'cursor' | 'qwen', id: string | null | undefined) => void
90
+ readStoredDelegateResumeId: (key: 'claudeCode' | 'codex' | 'opencode' | 'gemini' | 'copilot' | 'droid' | '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 */
@@ -0,0 +1,24 @@
1
+ import assert from 'node:assert/strict'
2
+ import { describe, it } from 'node:test'
3
+
4
+ describe('droid delegate backend wiring', () => {
5
+ it('coerces Factory Droid aliases to the droid backend', async () => {
6
+ const mod = await import('./delegate')
7
+ const anyMod = mod as unknown as Record<string, unknown>
8
+ const coerceDelegateBackend = anyMod.coerceDelegateBackend as ((value: unknown) => string | null) | undefined
9
+ if (typeof coerceDelegateBackend !== 'function') return
10
+
11
+ for (const alias of ['droid', 'droid cli', 'droid-cli', 'droid_cli', 'factory', 'factory droid', 'factory-droid']) {
12
+ assert.equal(coerceDelegateBackend(alias), 'droid', `alias ${alias} should coerce to droid`)
13
+ }
14
+ })
15
+
16
+ it('includes droid in the delegation JSON-schema enum', async () => {
17
+ const { default: fs } = await import('node:fs')
18
+ const { default: path } = await import('node:path')
19
+ const delegatePath = path.resolve(process.cwd(), 'src/lib/server/session-tools/delegate.ts')
20
+ const source = fs.readFileSync(delegatePath, 'utf-8')
21
+ assert.match(source, /enum: \[[^\]]*'droid'[^\]]*\]/, 'droid must appear in the delegate backend enum')
22
+ assert.match(source, /DELEGATE_BACKEND_ORDER[\s\S]{0,200}'droid'/, 'droid must appear in DELEGATE_BACKEND_ORDER')
23
+ })
24
+ })