@swarmclawai/swarmclaw 1.5.40 → 1.5.42

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 CHANGED
@@ -389,6 +389,16 @@ Operational docs: https://swarmclaw.ai/docs/observability
389
389
 
390
390
  ## Releases
391
391
 
392
+ ### v1.5.42 Highlights
393
+
394
+ - **New `opencode-web` provider — connect to remote OpenCode HTTP servers** ([#40](https://github.com/swarmclawai/swarmclaw/issues/40), requested by [@SteamedFish](https://github.com/SteamedFish)): point an agent at any host running `opencode serve` or `opencode web` (default port `4096`). Supports HTTPS endpoints, HTTP Basic Auth (encode credentials as `username:password` in the API key field; bare passwords default the username to `opencode`), automatic OpenCode session reuse across chat turns, and per-session workspace isolation via `?directory=...`. Models are entered as `providerID/modelID` (e.g. `anthropic/claude-sonnet-4-5`). The existing `opencode-cli` provider is unchanged.
395
+ - **New `CONTRIBUTING.md`**: short, scannable guide covering bug reports, feature requests, PR expectations, commit conventions, and where to look in the codebase. Models the gold-standard examples after issues #39 and #40.
396
+ - **`GET /api/memory/:id` now returns a single entry by default**: previously it eagerly traversed linked memories and returned an array, which broke naive callers that expected a single object per REST convention. Linked traversal is now opt-in via `?depth=N` or `?envelope=true`.
397
+
398
+ ### v1.5.41 Highlights
399
+
400
+ - **Moonshot / Kimi compatibility — duplicate `files` tool name fixed**: any agent with the default `files` extension was sending two tools both literally named `files` to the LLM. Most providers tolerated the duplicate; Moonshot's strict tool-schema validation rejected it with `MoonshotException - function name files is duplicated` ([#39](https://github.com/swarmclawai/swarmclaw/issues/39), reported by [@SteamedFish](https://github.com/SteamedFish)). Three fixes: the v2 file builder is now correctly gated on `files_v2` (not `files`), it registers under the matching capability key, and the session-tools assembler now shares a single dedup Set across native, CRUD, and extension phases so any future name collision is rejected with a clear warning instead of a silent double-register.
401
+
392
402
  ### v1.5.40 Highlights
393
403
 
394
404
  - **Current-thread recall routing**: the message classifier now emits four explicit flags (`isCurrentThreadRecall`, `isGreeting`, `isAcknowledgement`, `isMemoryWriteIntent`) so the chat router stops treating in-thread pronouns ("your last reply", "both answers", "what I just said") as durable-memory queries. Previously small OSS models (`devstral-small-2:24b` and similar) would run `memory_search` for these, come back empty, and truthfully report "no memories found" even when the answer was three messages up.
@@ -418,20 +428,6 @@ Operational docs: https://swarmclaw.ai/docs/observability
418
428
  - **OpenClaw gateway: fast-fail on dangling credentials**: when an agent's OpenClaw route references a deleted or missing credential, the gateway now refuses to dial the WebSocket up front instead of attempting an unauthenticated handshake and waiting the full 120 s for the agent-side timeout. The credential-missing log line is promoted from warn to error so it surfaces in routine monitoring.
419
429
  - **Prompt size profiler**: setting `SWARMCLAW_PROFILE_PROMPT=1` now logs a per-section size breakdown of the assembled system prompt (block index, first-line label, char count) on every turn, making it practical to diagnose why a specific agent is eating context budget. Off by default so production turns stay quiet.
420
430
 
421
- ### v1.5.37 Highlights
422
-
423
- - **Factory Droid CLI as a provider and delegation backend**: adds [`droid`](https://docs.factory.ai/cli/droid-exec/overview) as a first-class chat provider and `delegate` backend with streaming JSON output, session resume, and a conservative `--auto low` autonomy pin on the delegate path. Install `droid` and sign in via browser (or set `FACTORY_API_KEY`), then pick **Factory Droid CLI** in the setup wizard. Resolves #38.
424
- - **Desktop Release CI hardening**: v1.5.36's Electron build workflow failed on all three platforms. This release:
425
- - Adds a proper `author` with email to `package.json` and a `linux.maintainer` entry in `electron-builder.yml` so the Linux `.deb` target stops rejecting the build.
426
- - Pins `outputFileTracingRoot` in `next.config.ts` to the project root so the Next.js build no longer walks `C:\Users\<user>\Application Data` (a legacy NTFS junction that throws EPERM on Windows runners).
427
- - Pins Python 3.11 in the desktop-release workflow so `node-gyp` rebuilds of native modules (`node-liblzma`, etc.) succeed on Python 3.12+ runners where `distutils` was removed from the stdlib.
428
-
429
- ### v1.5.36 Highlights
430
-
431
- - **Desktop app (Electron)**: SwarmClaw now ships as a native desktop app for macOS (Apple Silicon + Intel), Windows, and Linux (AppImage + .deb). Download from [swarmclaw.ai/downloads](https://swarmclaw.ai/downloads). The app wraps the existing standalone server inside an Electron shell, stores data in the OS app-data directory, and auto-updates via GitHub Releases (notify-only on unsigned macOS builds).
432
- - **Desktop release CI**: new `desktop-release.yml` workflow builds and publishes installers for all three platforms to GitHub Releases on every version tag.
433
- - **UI cleanup**: removed sibling-product navigation links from the in-app sidebar rail and login gate so the open-source app focuses on SwarmClaw itself. Those links remain in the project README and on swarmclaw.ai.
434
-
435
431
  Older releases: https://swarmclaw.ai/docs/release-notes
436
432
 
437
433
  - GitHub releases: https://github.com/swarmclawai/swarmclaw/releases
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@swarmclawai/swarmclaw",
3
- "version": "1.5.40",
3
+ "version": "1.5.42",
4
4
  "description": "Build and run autonomous AI agents with OpenClaw, Hermes, multiple model providers, orchestration, delegation, memory, skills, schedules, and chat connectors.",
5
5
  "main": "electron-dist/main.js",
6
6
  "license": "MIT",
@@ -35,8 +35,13 @@ export async function GET(req: Request, { params }: { params: Promise<{ id: stri
35
35
  const requestedLinkedLimit = parseOptionalInt(searchParams.get('linkedLimit'))
36
36
  const db = getMemoryDb()
37
37
  const defaults = getMemoryLookupLimits()
38
+ // By default, GET /memory/:id returns a single entry (REST convention).
39
+ // Linked-traversal is opt-in via ?depth=N or ?envelope=true — previously
40
+ // this endpoint returned an array of linked entries by default, which
41
+ // broke every naive caller that expected a single object.
42
+ const linkedTraversalRequested = requestedDepth !== undefined || envelope
38
43
  const limits = resolveLookupRequest(defaults, {
39
- depth: requestedDepth,
44
+ depth: linkedTraversalRequested ? requestedDepth : 0,
40
45
  limit: requestedLimit,
41
46
  linkedLimit: requestedLinkedLimit,
42
47
  })
@@ -1,5 +1,5 @@
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', 'droid-cli', 'cursor-cli', 'qwen-code-cli'])
2
+ export const NON_LANGGRAPH_PROVIDER_IDS = new Set(['claude-cli', 'codex-cli', 'opencode-cli', 'opencode-web', 'gemini-cli', 'copilot-cli', 'droid-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
5
  export const RUNTIME_MANAGED_PROVIDER_IDS = new Set(['hermes', 'goose'])
@@ -1,6 +1,7 @@
1
1
  import { streamClaudeCliChat } from './claude-cli'
2
2
  import { streamCodexCliChat } from './codex-cli'
3
3
  import { streamOpenCodeCliChat } from './opencode-cli'
4
+ import { streamOpenCodeWebChat } from './opencode-web'
4
5
  import { streamGeminiCliChat } from './gemini-cli'
5
6
  import { streamCopilotCliChat } from './copilot-cli'
6
7
  import { streamDroidCliChat } from './droid-cli'
@@ -136,6 +137,18 @@ export const PROVIDERS: Record<string, BuiltinProviderConfig> = {
136
137
  requiresEndpoint: false,
137
138
  handler: { streamChat: streamOpenCodeCliChat },
138
139
  },
140
+ 'opencode-web': {
141
+ id: 'opencode-web',
142
+ name: 'OpenCode Web',
143
+ // OpenCode addresses models as `providerID/modelID`. Free-text entry is
144
+ // supported; these defaults seed the dropdown with common combinations.
145
+ models: ['anthropic/claude-sonnet-4-5', 'anthropic/claude-opus-4-5', 'openai/gpt-4.1', 'openai/o4-mini', 'google/gemini-2.5-pro'],
146
+ requiresApiKey: false,
147
+ optionalApiKey: true,
148
+ requiresEndpoint: true,
149
+ defaultEndpoint: 'http://localhost:4096',
150
+ handler: { streamChat: streamOpenCodeWebChat },
151
+ },
139
152
  'gemini-cli': {
140
153
  id: 'gemini-cli',
141
154
  name: 'Gemini CLI',
@@ -0,0 +1,113 @@
1
+ import { describe, it } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import {
4
+ parseBasicAuth,
5
+ buildAuthHeader,
6
+ parseModelId,
7
+ joinUrl,
8
+ SseLineParser,
9
+ } from '@/lib/providers/opencode-web'
10
+
11
+ describe('opencode-web parseBasicAuth', () => {
12
+ it('returns null for null / undefined / empty / whitespace', () => {
13
+ assert.equal(parseBasicAuth(null), null)
14
+ assert.equal(parseBasicAuth(undefined), null)
15
+ assert.equal(parseBasicAuth(''), null)
16
+ assert.equal(parseBasicAuth(' '), null)
17
+ })
18
+
19
+ it('treats a value with no colon as the password and defaults the username to "opencode"', () => {
20
+ assert.deepEqual(parseBasicAuth('mypass'), { username: 'opencode', password: 'mypass' })
21
+ })
22
+
23
+ it('splits on the first colon and preserves later colons in the password', () => {
24
+ assert.deepEqual(parseBasicAuth('bob:secret'), { username: 'bob', password: 'secret' })
25
+ assert.deepEqual(parseBasicAuth('bob:s3cr:et'), { username: 'bob', password: 's3cr:et' })
26
+ })
27
+
28
+ it('handles empty halves', () => {
29
+ assert.deepEqual(parseBasicAuth('bob:'), { username: 'bob', password: '' })
30
+ assert.deepEqual(parseBasicAuth(':secret'), { username: '', password: 'secret' })
31
+ })
32
+ })
33
+
34
+ describe('opencode-web buildAuthHeader', () => {
35
+ it('returns undefined for null', () => {
36
+ assert.equal(buildAuthHeader(null), undefined)
37
+ })
38
+
39
+ it('builds RFC-compliant Basic auth from username:password', () => {
40
+ const header = buildAuthHeader({ username: 'opencode', password: 'mypass' })
41
+ assert.equal(header, `Basic ${Buffer.from('opencode:mypass').toString('base64')}`)
42
+ })
43
+
44
+ it('round-trips with parseBasicAuth for a custom user', () => {
45
+ const parsed = parseBasicAuth('bob:secret')
46
+ assert.equal(buildAuthHeader(parsed), `Basic ${Buffer.from('bob:secret').toString('base64')}`)
47
+ })
48
+ })
49
+
50
+ describe('opencode-web parseModelId', () => {
51
+ it('splits providerID/modelID on the first slash', () => {
52
+ assert.deepEqual(parseModelId('anthropic/claude-sonnet-4-5'), { providerID: 'anthropic', modelID: 'claude-sonnet-4-5' })
53
+ assert.deepEqual(parseModelId('openai/gpt-4.1'), { providerID: 'openai', modelID: 'gpt-4.1' })
54
+ })
55
+
56
+ it('preserves slashes inside the modelID', () => {
57
+ assert.deepEqual(parseModelId('local/qwen/coder-14b'), { providerID: 'local', modelID: 'qwen/coder-14b' })
58
+ })
59
+
60
+ it('returns providerID-only when the user enters a bare string (server will reject with a real error)', () => {
61
+ assert.deepEqual(parseModelId('claude-sonnet-4-5'), { providerID: 'claude-sonnet-4-5', modelID: '' })
62
+ })
63
+
64
+ it('handles empty / whitespace input', () => {
65
+ assert.deepEqual(parseModelId(''), { providerID: '', modelID: '' })
66
+ assert.deepEqual(parseModelId(' '), { providerID: '', modelID: '' })
67
+ assert.deepEqual(parseModelId(undefined), { providerID: '', modelID: '' })
68
+ })
69
+ })
70
+
71
+ describe('opencode-web joinUrl', () => {
72
+ it('handles trailing and leading slashes idempotently', () => {
73
+ assert.equal(joinUrl('http://localhost:4096', '/session'), 'http://localhost:4096/session')
74
+ assert.equal(joinUrl('http://localhost:4096/', '/session'), 'http://localhost:4096/session')
75
+ assert.equal(joinUrl('http://localhost:4096/', 'session'), 'http://localhost:4096/session')
76
+ assert.equal(joinUrl('http://localhost:4096///', '///session'), 'http://localhost:4096///session')
77
+ })
78
+ })
79
+
80
+ describe('opencode-web SseLineParser', () => {
81
+ it('emits one event per data: line and ignores comments / event: / id:', () => {
82
+ const events: unknown[] = []
83
+ const parser = new SseLineParser()
84
+ parser.feed(
85
+ ':keepalive\nevent: message\ndata: {"type":"text-delta","text":"hi"}\nid: 1\n\n',
86
+ (ev) => events.push(ev),
87
+ )
88
+ assert.deepEqual(events, [{ type: 'text-delta', text: 'hi' }])
89
+ })
90
+
91
+ it('buffers across chunk boundaries (split mid-line)', () => {
92
+ const events: unknown[] = []
93
+ const parser = new SseLineParser()
94
+ parser.feed('data: {"type":"text-delta","text":"he', (ev) => events.push(ev))
95
+ assert.equal(events.length, 0, 'incomplete line should not emit')
96
+ parser.feed('llo"}\n', (ev) => events.push(ev))
97
+ assert.deepEqual(events, [{ type: 'text-delta', text: 'hello' }])
98
+ })
99
+
100
+ it('tolerates CRLF line endings and skips blank data: lines', () => {
101
+ const events: unknown[] = []
102
+ const parser = new SseLineParser()
103
+ parser.feed('data: {"type":"x","v":1}\r\ndata: \r\ndata: {"type":"y","v":2}\r\n', (ev) => events.push(ev))
104
+ assert.deepEqual(events, [{ type: 'x', v: 1 }, { type: 'y', v: 2 }])
105
+ })
106
+
107
+ it('silently ignores malformed JSON payloads (heartbeats, partial frames)', () => {
108
+ const events: unknown[] = []
109
+ const parser = new SseLineParser()
110
+ parser.feed('data: not json\ndata: {"type":"text-delta","text":"ok"}\n', (ev) => events.push(ev))
111
+ assert.deepEqual(events, [{ type: 'text-delta', text: 'ok' }])
112
+ })
113
+ })
@@ -0,0 +1,307 @@
1
+ import type { StreamChatOptions } from './index'
2
+ import { log } from '../server/logger'
3
+
4
+ const TAG = 'opencode-web'
5
+
6
+ const DEFAULT_ENDPOINT = 'http://localhost:4096'
7
+ const DEFAULT_USERNAME = 'opencode'
8
+
9
+ interface BasicAuth { username: string; password: string }
10
+
11
+ /**
12
+ * Parse an apiKey field into HTTP Basic Auth components. Stored as a single
13
+ * encrypted string per the project-wide credential model.
14
+ *
15
+ * - null / empty → no auth header.
16
+ * - "user:pass" → { username: 'user', password: 'pass' }
17
+ * - "pass" → { username: 'opencode', password: 'pass' } (FR-10).
18
+ *
19
+ * Mirrors RFC 3986 userinfo and `curl -u` conventions so the format is
20
+ * unsurprising and self-documenting in the credential field placeholder.
21
+ */
22
+ export function parseBasicAuth(apiKey: string | null | undefined): BasicAuth | null {
23
+ if (apiKey === null || apiKey === undefined) return null
24
+ const trimmed = apiKey.trim()
25
+ if (!trimmed) return null
26
+ const colon = trimmed.indexOf(':')
27
+ if (colon < 0) return { username: DEFAULT_USERNAME, password: trimmed }
28
+ return { username: trimmed.slice(0, colon), password: trimmed.slice(colon + 1) }
29
+ }
30
+
31
+ export function buildAuthHeader(auth: BasicAuth | null): string | undefined {
32
+ if (!auth) return undefined
33
+ const encoded = Buffer.from(`${auth.username}:${auth.password}`, 'utf8').toString('base64')
34
+ return `Basic ${encoded}`
35
+ }
36
+
37
+ /**
38
+ * Split a SwarmClaw model string into the `{ providerID, modelID }` shape
39
+ * OpenCode expects. The convention is a single forward slash:
40
+ * "anthropic/claude-sonnet-4-5" → { providerID: 'anthropic', modelID: 'claude-sonnet-4-5' }
41
+ *
42
+ * If the user enters a bare string with no slash, send providerID=value and
43
+ * an empty modelID so OpenCode rejects with a real error rather than us
44
+ * guessing wrong. Whitespace is trimmed.
45
+ */
46
+ export function parseModelId(model: string | null | undefined): { providerID: string; modelID: string } {
47
+ const trimmed = (model || '').trim()
48
+ if (!trimmed) return { providerID: '', modelID: '' }
49
+ const slash = trimmed.indexOf('/')
50
+ if (slash < 0) return { providerID: trimmed, modelID: '' }
51
+ return {
52
+ providerID: trimmed.slice(0, slash).trim(),
53
+ modelID: trimmed.slice(slash + 1).trim(),
54
+ }
55
+ }
56
+
57
+ export function joinUrl(baseUrl: string, path: string): string {
58
+ const base = baseUrl.replace(/\/+$/, '')
59
+ const suffix = path.startsWith('/') ? path : `/${path}`
60
+ return `${base}${suffix}`
61
+ }
62
+
63
+ /**
64
+ * Stateful SSE line parser. Buffers across chunk boundaries and emits
65
+ * one parsed JSON object per `data:` line. Lines that do not start with
66
+ * `data:` (comments, `event:`, `id:`, blank separators) are ignored.
67
+ */
68
+ export class SseLineParser {
69
+ private buf = ''
70
+
71
+ feed(chunk: string, onEvent: (data: unknown) => void): void {
72
+ this.buf += chunk
73
+ const lines = this.buf.split('\n')
74
+ this.buf = lines.pop() ?? ''
75
+ for (const raw of lines) {
76
+ const line = raw.replace(/\r$/, '').trim()
77
+ if (!line.startsWith('data:')) continue
78
+ const payload = line.slice(5).trim()
79
+ if (!payload) continue
80
+ try {
81
+ onEvent(JSON.parse(payload))
82
+ } catch {
83
+ // Non-JSON SSE payload (heartbeat, keep-alive). Ignore.
84
+ }
85
+ }
86
+ }
87
+ }
88
+
89
+ /**
90
+ * Best-effort extraction of streamed text out of an OpenCode SSE event.
91
+ * The shape varies a bit between versions; we accept the common variants
92
+ * and return null for everything else.
93
+ */
94
+ function extractTextDelta(ev: unknown): string | null {
95
+ if (!ev || typeof ev !== 'object') return null
96
+ const e = ev as Record<string, unknown>
97
+ if (typeof e.text === 'string' && (e.type === 'text-delta' || e.type === 'text' || e.type === 'message.update.delta')) {
98
+ return e.text
99
+ }
100
+ if (e.type === 'message.update.delta' && typeof (e.delta as Record<string, unknown>)?.text === 'string') {
101
+ return (e.delta as Record<string, unknown>).text as string
102
+ }
103
+ if (e.type === 'text' && typeof (e.part as Record<string, unknown>)?.text === 'string') {
104
+ return (e.part as Record<string, unknown>).text as string
105
+ }
106
+ return null
107
+ }
108
+
109
+ function isCompletionEvent(ev: unknown): boolean {
110
+ if (!ev || typeof ev !== 'object') return false
111
+ const t = (ev as Record<string, unknown>).type
112
+ return t === 'message.complete' || t === 'message.completed' || t === 'done' || t === 'response.completed'
113
+ }
114
+
115
+ function extractErrorMessage(ev: unknown): string | null {
116
+ if (!ev || typeof ev !== 'object') return null
117
+ const e = ev as Record<string, unknown>
118
+ if (e.type !== 'error') return null
119
+ if (typeof e.message === 'string') return e.message
120
+ if (typeof e.error === 'string') return e.error
121
+ return 'Unknown OpenCode event error'
122
+ }
123
+
124
+ interface CreateSessionResponse { id?: string; sessionID?: string; sessionId?: string }
125
+
126
+ async function createSession(opts: {
127
+ endpoint: string
128
+ cwd: string
129
+ authHeader: string | undefined
130
+ signal: AbortSignal
131
+ }): Promise<string> {
132
+ const url = `${joinUrl(opts.endpoint, '/session')}?directory=${encodeURIComponent(opts.cwd)}`
133
+ const res = await fetch(url, {
134
+ method: 'POST',
135
+ headers: {
136
+ 'Content-Type': 'application/json',
137
+ ...(opts.authHeader ? { Authorization: opts.authHeader } : {}),
138
+ },
139
+ body: '{}',
140
+ signal: opts.signal,
141
+ })
142
+ if (res.status === 401 || res.status === 403) {
143
+ throw new HttpError(res.status, 'OpenCode rejected the credentials. Check the username:password configured for this agent.')
144
+ }
145
+ if (!res.ok) {
146
+ const body = await safeReadText(res)
147
+ throw new HttpError(res.status, `OpenCode session create failed (HTTP ${res.status})${body ? `: ${body.slice(0, 200)}` : ''}`)
148
+ }
149
+ const json = (await res.json()) as CreateSessionResponse
150
+ const id = json.id || json.sessionID || json.sessionId
151
+ if (!id || typeof id !== 'string') {
152
+ throw new HttpError(0, 'OpenCode session create response did not include an id')
153
+ }
154
+ return id
155
+ }
156
+
157
+ async function postPrompt(opts: {
158
+ endpoint: string
159
+ sessionId: string
160
+ cwd: string
161
+ prompt: string
162
+ providerID: string
163
+ modelID: string
164
+ authHeader: string | undefined
165
+ signal: AbortSignal
166
+ }): Promise<{ status: number }> {
167
+ const url = `${joinUrl(opts.endpoint, `/session/${encodeURIComponent(opts.sessionId)}/prompt_async`)}?directory=${encodeURIComponent(opts.cwd)}`
168
+ const res = await fetch(url, {
169
+ method: 'POST',
170
+ headers: {
171
+ 'Content-Type': 'application/json',
172
+ ...(opts.authHeader ? { Authorization: opts.authHeader } : {}),
173
+ },
174
+ body: JSON.stringify({
175
+ providerID: opts.providerID,
176
+ modelID: opts.modelID,
177
+ prompt: opts.prompt,
178
+ }),
179
+ signal: opts.signal,
180
+ })
181
+ if (res.status === 401 || res.status === 403) {
182
+ throw new HttpError(res.status, 'OpenCode rejected the credentials. Check the username:password configured for this agent.')
183
+ }
184
+ if (res.status !== 204 && res.status !== 200 && res.status !== 202) {
185
+ if (res.status === 404) return { status: 404 }
186
+ const body = await safeReadText(res)
187
+ throw new HttpError(res.status, `OpenCode prompt_async failed (HTTP ${res.status})${body ? `: ${body.slice(0, 200)}` : ''}`)
188
+ }
189
+ return { status: res.status }
190
+ }
191
+
192
+ async function safeReadText(res: Response): Promise<string> {
193
+ try { return await res.text() } catch { return '' }
194
+ }
195
+
196
+ class HttpError extends Error {
197
+ constructor(public readonly status: number, message: string) {
198
+ super(message)
199
+ }
200
+ }
201
+
202
+ /**
203
+ * Stream an agent chat turn against a remote OpenCode HTTP server
204
+ * (`opencode serve` or `opencode web`). Talks to the same REST + SSE API
205
+ * as the official CLI. Stores the OpenCode session id on
206
+ * `session.opencodeWebSessionId` so subsequent turns reuse it.
207
+ */
208
+ export function streamOpenCodeWebChat(opts: StreamChatOptions): Promise<string> {
209
+ const { session, message, systemPrompt, apiKey, write, active, signal } = opts
210
+
211
+ const endpoint = (session.apiEndpoint as string | undefined) || DEFAULT_ENDPOINT
212
+ const cwd = (session.cwd as string | undefined) || process.cwd()
213
+ const auth = parseBasicAuth(apiKey)
214
+ const authHeader = buildAuthHeader(auth)
215
+ const { providerID, modelID } = parseModelId(session.model as string | undefined)
216
+
217
+ const controller = new AbortController()
218
+ if (signal) {
219
+ if (signal.aborted) controller.abort()
220
+ else signal.addEventListener('abort', () => controller.abort(), { once: true })
221
+ }
222
+ active.set(session.id, controller)
223
+
224
+ const promptParts: string[] = []
225
+ if (systemPrompt && !session.opencodeWebSessionId) {
226
+ promptParts.push(`[System instructions]\n${systemPrompt}`)
227
+ }
228
+ promptParts.push(message)
229
+ const prompt = promptParts.join('\n\n')
230
+
231
+ return (async () => {
232
+ let fullResponse = ''
233
+ try {
234
+ // Ensure we have a server-side session id. On HTTP 404 from prompt
235
+ // (FR-9: graceful expiry), we null this and recreate exactly once.
236
+ let sessionId = (session.opencodeWebSessionId as string | null | undefined)
237
+ || await createSession({ endpoint, cwd, authHeader, signal: controller.signal })
238
+ session.opencodeWebSessionId = sessionId
239
+
240
+ let postResult = await postPrompt({
241
+ endpoint, sessionId, cwd, prompt, providerID, modelID, authHeader, signal: controller.signal,
242
+ })
243
+ if (postResult.status === 404) {
244
+ log.info(TAG, `[${session.id}] session ${sessionId} returned 404, recreating`)
245
+ sessionId = await createSession({ endpoint, cwd, authHeader, signal: controller.signal })
246
+ session.opencodeWebSessionId = sessionId
247
+ postResult = await postPrompt({
248
+ endpoint, sessionId, cwd, prompt, providerID, modelID, authHeader, signal: controller.signal,
249
+ })
250
+ if (postResult.status === 404) {
251
+ throw new HttpError(404, 'OpenCode rejected the prompt for a freshly-created session — the server may be misconfigured.')
252
+ }
253
+ }
254
+
255
+ const eventUrl = `${joinUrl(endpoint, '/event')}?session=${encodeURIComponent(sessionId)}`
256
+ const eventRes = await fetch(eventUrl, {
257
+ method: 'GET',
258
+ headers: {
259
+ Accept: 'text/event-stream',
260
+ ...(authHeader ? { Authorization: authHeader } : {}),
261
+ },
262
+ signal: controller.signal,
263
+ })
264
+ if (!eventRes.ok || !eventRes.body) {
265
+ throw new HttpError(eventRes.status, `OpenCode event stream failed (HTTP ${eventRes.status})`)
266
+ }
267
+
268
+ const reader = eventRes.body.getReader()
269
+ const decoder = new TextDecoder()
270
+ const parser = new SseLineParser()
271
+ let completed = false
272
+
273
+ while (!completed) {
274
+ const { done, value } = await reader.read()
275
+ if (done) break
276
+ const chunk = decoder.decode(value, { stream: true })
277
+ parser.feed(chunk, (ev) => {
278
+ const text = extractTextDelta(ev)
279
+ if (text) {
280
+ fullResponse += text
281
+ write(`data: ${JSON.stringify({ t: 'd', text })}\n\n`)
282
+ return
283
+ }
284
+ const errMsg = extractErrorMessage(ev)
285
+ if (errMsg) {
286
+ write(`data: ${JSON.stringify({ t: 'err', text: errMsg })}\n\n`)
287
+ return
288
+ }
289
+ if (isCompletionEvent(ev)) completed = true
290
+ })
291
+ }
292
+
293
+ return fullResponse
294
+ } catch (err: unknown) {
295
+ const msg = err instanceof HttpError
296
+ ? err.message
297
+ : err instanceof Error
298
+ ? err.message
299
+ : String(err)
300
+ log.error(TAG, `[${session.id}] ${msg}`)
301
+ write(`data: ${JSON.stringify({ t: 'err', text: msg })}\n\n`)
302
+ return fullResponse
303
+ } finally {
304
+ active.delete(session.id)
305
+ }
306
+ })()
307
+ }
@@ -77,6 +77,7 @@ 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
+ 'opencode-web': 200_000,
80
81
  'gemini-cli': 1_048_576,
81
82
  'copilot-cli': 200_000,
82
83
  'droid-cli': 200_000,
@@ -0,0 +1,127 @@
1
+ import { describe, it } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+
4
+ // Issue #39 (Moonshot/Kimi rejecting duplicate tool names) showed that the
5
+ // Phase 1 native-tool loop in `session-tools/index.ts` was pushing tools
6
+ // without checking for duplicate names. Phase 2 already had a dedup Set; the
7
+ // fix lifts that Set above Phase 1 so all phases share it.
8
+ //
9
+ // This test mirrors the dedup algorithm in pure form so it can be verified
10
+ // without booting the full session-tools graph (which OOMs in test workers
11
+ // when run alongside the dev server).
12
+
13
+ type FakeTool = { name: string }
14
+ type Builder = () => FakeTool[]
15
+
16
+ interface DedupWarn {
17
+ toolName: string
18
+ source: 'native' | 'crud' | 'extension'
19
+ extensionId?: string
20
+ }
21
+
22
+ function dedupAssemble(
23
+ nativeBuilders: ReadonlyArray<readonly [string, Builder]>,
24
+ crudBuilder: Builder,
25
+ extensionTools: ReadonlyArray<{ extensionId: string; tool: FakeTool }>,
26
+ ): { tools: FakeTool[]; warnings: DedupWarn[] } {
27
+ const tools: FakeTool[] = []
28
+ const warnings: DedupWarn[] = []
29
+ const existingNames = new Set<string>()
30
+
31
+ for (const [extensionId, builder] of nativeBuilders) {
32
+ for (const t of builder()) {
33
+ if (existingNames.has(t.name)) {
34
+ warnings.push({ toolName: t.name, source: 'native', extensionId })
35
+ continue
36
+ }
37
+ existingNames.add(t.name)
38
+ tools.push(t)
39
+ }
40
+ }
41
+
42
+ for (const t of crudBuilder()) {
43
+ if (existingNames.has(t.name)) {
44
+ warnings.push({ toolName: t.name, source: 'crud' })
45
+ continue
46
+ }
47
+ existingNames.add(t.name)
48
+ tools.push(t)
49
+ }
50
+
51
+ for (const entry of extensionTools) {
52
+ if (existingNames.has(entry.tool.name)) {
53
+ warnings.push({ toolName: entry.tool.name, source: 'extension', extensionId: entry.extensionId })
54
+ continue
55
+ }
56
+ existingNames.add(entry.tool.name)
57
+ tools.push(entry.tool)
58
+ }
59
+
60
+ return { tools, warnings }
61
+ }
62
+
63
+ describe('session-tools assembler dedup (issue #39 regression)', () => {
64
+ it('emits a single `files` tool when two native builders both produce one (the original issue #39 scenario)', () => {
65
+ const result = dedupAssemble(
66
+ [
67
+ ['files', () => [{ name: 'files' }]],
68
+ ['files_v2', () => [{ name: 'files' }]],
69
+ ],
70
+ () => [],
71
+ [],
72
+ )
73
+
74
+ const fileTools = result.tools.filter((t) => t.name === 'files')
75
+ assert.equal(fileTools.length, 1, 'must emit exactly one tool named "files"')
76
+ assert.equal(result.warnings.length, 1)
77
+ assert.equal(result.warnings[0].toolName, 'files')
78
+ assert.equal(result.warnings[0].source, 'native')
79
+ assert.equal(result.warnings[0].extensionId, 'files_v2')
80
+ })
81
+
82
+ it('first builder wins when names collide', () => {
83
+ const t1 = { name: 'shared' }
84
+ const t2 = { name: 'shared' }
85
+ const result = dedupAssemble(
86
+ [
87
+ ['ext-a', () => [t1]],
88
+ ['ext-b', () => [t2]],
89
+ ],
90
+ () => [],
91
+ [],
92
+ )
93
+ assert.equal(result.tools.length, 1)
94
+ assert.strictEqual(result.tools[0], t1)
95
+ })
96
+
97
+ it('CRUD tools cannot collide with native tools', () => {
98
+ const result = dedupAssemble(
99
+ [['ext-a', () => [{ name: 'crud_op' }]]],
100
+ () => [{ name: 'crud_op' }],
101
+ [],
102
+ )
103
+ assert.equal(result.tools.length, 1)
104
+ assert.equal(result.warnings[0].source, 'crud')
105
+ })
106
+
107
+ it('extension tools dedup against the same shared Set', () => {
108
+ const result = dedupAssemble(
109
+ [['ext-a', () => [{ name: 'foo' }]]],
110
+ () => [],
111
+ [{ extensionId: 'ext-b', tool: { name: 'foo' } }],
112
+ )
113
+ assert.equal(result.tools.length, 1)
114
+ assert.equal(result.warnings[0].source, 'extension')
115
+ assert.equal(result.warnings[0].extensionId, 'ext-b')
116
+ })
117
+
118
+ it('lets distinct names through unchanged', () => {
119
+ const result = dedupAssemble(
120
+ [['ext-a', () => [{ name: 'a' }, { name: 'b' }]]],
121
+ () => [{ name: 'c' }],
122
+ [{ extensionId: 'ext-b', tool: { name: 'd' } }],
123
+ )
124
+ assert.deepEqual(result.tools.map((t) => t.name), ['a', 'b', 'c', 'd'])
125
+ assert.equal(result.warnings.length, 0)
126
+ })
127
+ })
@@ -0,0 +1,56 @@
1
+ import { describe, it } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import { buildFilesTools } from '@/lib/server/session-tools/files-tool'
4
+ import type { ToolBuildContext } from '@/lib/server/session-tools/context'
5
+
6
+ function makeBctx(enabled: Set<string>): ToolBuildContext {
7
+ return {
8
+ cwd: '/tmp',
9
+ ctx: undefined,
10
+ hasExtension: (name) => enabled.has(name),
11
+ hasTool: (name) => enabled.has(name),
12
+ cleanupFns: [],
13
+ commandTimeoutMs: 0,
14
+ claudeTimeoutMs: 0,
15
+ cliProcessTimeoutMs: 0,
16
+ persistDelegateResumeId: () => {},
17
+ readStoredDelegateResumeId: () => null,
18
+ resolveCurrentSession: () => null,
19
+ activeExtensions: Array.from(enabled),
20
+ filesystemScope: 'workspace',
21
+ }
22
+ }
23
+
24
+ describe('buildFilesTools (issue #39)', () => {
25
+ it('returns no tools when only the legacy `files` extension is enabled', () => {
26
+ // Pre-fix this returned a tool named "files", on top of the v1 builder
27
+ // which already produced a tool with the same name. Moonshot/Kimi rejected
28
+ // the duplicate with `function name files is duplicated`.
29
+ const bctx = makeBctx(new Set(['files']))
30
+ const out = buildFilesTools(bctx)
31
+ assert.equal(out.length, 0)
32
+ })
33
+
34
+ it('returns no tools when no relevant extension is enabled', () => {
35
+ const bctx = makeBctx(new Set(['shell', 'web']))
36
+ const out = buildFilesTools(bctx)
37
+ assert.equal(out.length, 0)
38
+ })
39
+
40
+ it('returns one `files` tool when the v2 extension is explicitly enabled', () => {
41
+ const bctx = makeBctx(new Set(['files_v2']))
42
+ const out = buildFilesTools(bctx)
43
+ assert.equal(out.length, 1)
44
+ assert.equal(out[0].name, 'files')
45
+ })
46
+
47
+ it('returns one `files` tool when both `files` and `files_v2` are enabled', () => {
48
+ // Defensive: even with both enabled, this builder emits exactly one tool.
49
+ // (The duplicate-with-v1 protection lives in the session-tools assembler
50
+ // dedup loop, covered by build-session-tools-dedup.test.ts.)
51
+ const bctx = makeBctx(new Set(['files', 'files_v2']))
52
+ const out = buildFilesTools(bctx)
53
+ assert.equal(out.length, 1)
54
+ assert.equal(out[0].name, 'files')
55
+ })
56
+ })
@@ -608,14 +608,22 @@ const FilesExtension: Extension = {
608
608
  ],
609
609
  }
610
610
 
611
- registerNativeCapability('files', FilesExtension)
611
+ // Registered under 'files_v2' to avoid colliding with the v1 FileExtension
612
+ // in `file.ts`, which also registers under the literal key 'files'. The
613
+ // builder below is wired into `session-tools/index.ts` via the same key.
614
+ registerNativeCapability('files_v2', FilesExtension)
612
615
 
613
616
  // ---------------------------------------------------------------------------
614
617
  // Tool builder (called from session-tools/index.ts)
615
618
  // ---------------------------------------------------------------------------
616
619
 
617
620
  export function buildFilesTools(bctx: ToolBuildContext) {
618
- if (!bctx.hasExtension('files')) return []
621
+ // Gate on 'files_v2' (not 'files'). Previously this checked 'files', which
622
+ // meant that enabling the v1 `files` extension activated BOTH builders and
623
+ // registered two tools literally named "files". Most providers tolerate
624
+ // duplicate tool names; Moonshot/Kimi rejects them with `function name
625
+ // files is duplicated`. Reported as issue #39.
626
+ if (!bctx.hasExtension('files_v2')) return []
619
627
 
620
628
  return [
621
629
  tool(
@@ -221,24 +221,41 @@ export async function buildSessionTools(cwd: string, enabledExtensions: string[]
221
221
  ['swarmdock', buildSwarmDockTools],
222
222
  ]
223
223
 
224
+ // Track tool names across all phases so duplicates are rejected
225
+ // consistently. Issue #39: Moonshot rejects duplicate tool names that
226
+ // most providers silently tolerate, so guarding only Phase 2 (as the
227
+ // pre-fix code did) was not enough.
228
+ const existingNames = new Set<string>()
224
229
  for (const [extensionId, builder] of nativeBuilders) {
225
230
  const builtTools = builder(bctx)
226
231
  for (const t of builtTools) {
232
+ if (existingNames.has(t.name)) {
233
+ log.warn('session-tools', 'Skipping native tool due to duplicate name', {
234
+ toolName: t.name,
235
+ extensionId,
236
+ })
237
+ continue
238
+ }
239
+ existingNames.add(t.name)
227
240
  toolToExtensionMap[t.name] = extensionId
241
+ tools.push(t)
228
242
  }
229
- tools.push(...builtTools)
230
243
  }
231
244
 
232
245
  const crudTools = buildCrudTools(bctx)
233
246
  for (const toolEntry of crudTools) {
247
+ if (existingNames.has(toolEntry.name)) {
248
+ log.warn('session-tools', 'Skipping CRUD tool due to duplicate name', { toolName: toolEntry.name })
249
+ continue
250
+ }
251
+ existingNames.add(toolEntry.name)
234
252
  toolToExtensionMap[toolEntry.name] = toolEntry.name
253
+ tools.push(toolEntry)
235
254
  }
236
- tools.push(...crudTools)
237
255
 
238
256
  // 2. Build Extension Tools (Built-in + External)
239
257
  try {
240
258
  const extensionTools = extensionManager.getTools(activeExtensions)
241
- const existingNames = new Set(tools.map((t) => t.name))
242
259
 
243
260
  for (const entry of extensionTools) {
244
261
  const pt = entry.tool
@@ -7,6 +7,7 @@ export type SetupProvider =
7
7
  | 'claude-cli'
8
8
  | 'codex-cli'
9
9
  | 'opencode-cli'
10
+ | 'opencode-web'
10
11
  | 'gemini-cli'
11
12
  | 'copilot-cli'
12
13
  | 'droid-cli'
@@ -79,6 +80,19 @@ export const SETUP_PROVIDERS: SetupProviderOption[] = [
79
80
  badge: 'CLI',
80
81
  icon: 'O',
81
82
  },
83
+ {
84
+ id: 'opencode-web',
85
+ name: 'OpenCode Web',
86
+ description: 'Connect to a remote OpenCode HTTP server (`opencode serve` or `opencode web`). Supports HTTPS and HTTP Basic Auth.',
87
+ requiresKey: false,
88
+ optionalKey: true,
89
+ supportsEndpoint: true,
90
+ defaultEndpoint: 'http://localhost:4096',
91
+ keyLabel: 'username:password (Basic Auth)',
92
+ keyPlaceholder: 'opencode:••••••• (or just the password)',
93
+ badge: 'HTTP',
94
+ icon: 'O',
95
+ },
82
96
  {
83
97
  id: 'gemini-cli',
84
98
  name: 'Gemini CLI',
@@ -743,6 +757,13 @@ export const DEFAULT_AGENTS: Record<SetupProvider, DefaultAgentConfig> = {
743
757
  model: 'claude-sonnet-4-6',
744
758
  tools: STARTER_AGENT_TOOLS,
745
759
  },
760
+ 'opencode-web': {
761
+ name: 'OpenCode Web',
762
+ description: 'A helpful assistant powered by a remote OpenCode HTTP server.',
763
+ systemPrompt: SWARMCLAW_ASSISTANT_PROMPT,
764
+ model: 'anthropic/claude-sonnet-4-5',
765
+ tools: STARTER_AGENT_TOOLS,
766
+ },
746
767
  'gemini-cli': {
747
768
  name: 'Gemini CLI',
748
769
  description: 'A helpful assistant powered by Gemini CLI.',
@@ -1,4 +1,4 @@
1
- export type ProviderType = 'claude-cli' | 'codex-cli' | 'opencode-cli' | 'gemini-cli' | 'copilot-cli' | 'droid-cli' | 'cursor-cli' | 'qwen-code-cli' | 'goose' | 'openai' | 'openrouter' | 'ollama' | 'anthropic' | 'openclaw' | 'hermes' | 'google' | 'deepseek' | 'groq' | 'together' | 'mistral' | 'xai' | 'fireworks' | 'nebius' | 'deepinfra'
1
+ export type ProviderType = 'claude-cli' | 'codex-cli' | 'opencode-cli' | 'opencode-web' | 'gemini-cli' | 'copilot-cli' | 'droid-cli' | 'cursor-cli' | 'qwen-code-cli' | 'goose' | 'openai' | 'openrouter' | 'ollama' | 'anthropic' | 'openclaw' | 'hermes' | 'google' | 'deepseek' | 'groq' | 'together' | 'mistral' | 'xai' | 'fireworks' | 'nebius' | 'deepinfra'
2
2
  export type ProviderId = ProviderType | (string & {})
3
3
 
4
4
  export interface ProviderInfo {
@@ -69,6 +69,7 @@ export interface Session {
69
69
  claudeSessionId: string | null
70
70
  codexThreadId?: string | null
71
71
  opencodeSessionId?: string | null
72
+ opencodeWebSessionId?: string | null
72
73
  geminiSessionId?: string | null
73
74
  copilotSessionId?: string | null
74
75
  droidSessionId?: string | null