@swarmclawai/swarmclaw 1.5.41 → 1.5.43
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 +13 -19
- package/package.json +1 -1
- package/src/app/api/memory/[id]/route.ts +6 -1
- package/src/app/api/version/route.ts +71 -57
- package/src/app/api/version/update/route.ts +12 -0
- package/src/lib/provider-sets.ts +1 -1
- package/src/lib/providers/index.ts +13 -0
- package/src/lib/providers/opencode-web.test.ts +113 -0
- package/src/lib/providers/opencode-web.ts +307 -0
- package/src/lib/server/context-manager.ts +1 -0
- package/src/lib/server/daemon/controller.ts +24 -2
- package/src/lib/server/daemon/lease-owner.test.ts +72 -0
- package/src/lib/server/daemon/lease-owner.ts +68 -0
- package/src/lib/server/git-metadata.test.ts +45 -0
- package/src/lib/server/git-metadata.ts +42 -0
- package/src/lib/server/runtime/daemon-state/core.ts +53 -1
- package/src/lib/setup-defaults.ts +21 -0
- package/src/types/provider.ts +1 -1
- package/src/types/session.ts +1 -0
package/README.md
CHANGED
|
@@ -389,6 +389,19 @@ Operational docs: https://swarmclaw.ai/docs/observability
|
|
|
389
389
|
|
|
390
390
|
## Releases
|
|
391
391
|
|
|
392
|
+
### v1.5.43 Highlights
|
|
393
|
+
|
|
394
|
+
- **`/api/version` no longer 500s in Docker**: the route used to shell out to `git` at runtime, which fails in the production image because `.git/` is not copied. The route now returns 200 with `{ source: 'package', version }` from `package.json` when git metadata is unavailable, and `{ source: 'git', version, commit, ... }` when it is. `/api/version/update` short-circuits on Docker-style installs with a clear `no_git_metadata` reason instead of an opaque 500. ([#41](https://github.com/swarmclawai/swarmclaw/issues/41) Bug 1, reported by [@SteamedFish](https://github.com/SteamedFish).)
|
|
395
|
+
- **Daemon reclaims stale `daemon-primary` leases on container restart**: when the previous container died holding the SQLite-backed lease, the new container previously waited up to the full 120 s TTL before the daemon could start. The successor now parses the recorded owner pid, probes it with `process.kill(pid, 0)`, and reclaims the lease immediately when the prior owner is provably dead on this host. When the owner is genuinely alive (or when the recorded host is ambiguous, such as multi-pod Kubernetes), behaviour is unchanged but a single deferred retry is scheduled just past the TTL so the daemon comes up automatically rather than waiting for the next API call. ([#41](https://github.com/swarmclawai/swarmclaw/issues/41) Bug 2.)
|
|
396
|
+
- **Subprocess daemon fallback fails soft in Docker**: when `resolveDaemonRuntimeEntry()` cannot find `src/lib/server/daemon/daemon-runtime.ts` (the file is intentionally not in the standalone build), `ensureDaemonProcessRunning()` now logs a one-shot warning and returns `false` instead of throwing into the API handler. The in-process daemon path (with the Bug 2 fix) is the production path in Docker. ([#41](https://github.com/swarmclawai/swarmclaw/issues/41) Bug 3.)
|
|
397
|
+
- **`CONTRIBUTING.md`**: dropped the broken reference to `AGENTS.md`. That file is `.gitignore`'d and not visible to external contributors. The single canonical project-conventions document is `CLAUDE.md`.
|
|
398
|
+
|
|
399
|
+
### v1.5.42 Highlights
|
|
400
|
+
|
|
401
|
+
- **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.
|
|
402
|
+
- **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.
|
|
403
|
+
- **`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`.
|
|
404
|
+
|
|
392
405
|
### v1.5.41 Highlights
|
|
393
406
|
|
|
394
407
|
- **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.
|
|
@@ -411,25 +424,6 @@ Operational docs: https://swarmclaw.ai/docs/observability
|
|
|
411
424
|
- **Perf ring buffer raised to 2 000 entries**: queue/task repository events fire ~20 Hz during task processing and were evicting chat-execution/prompt perf entries out of the 200-entry buffer before they could be read. The larger buffer lets the perf viewer actually show a full turn.
|
|
412
425
|
- **Tests**: added regression tests for pre-1.5.38 stale-checkout orphan recovery and for the scoped-tool-access algorithm.
|
|
413
426
|
|
|
414
|
-
### v1.5.38 Highlights
|
|
415
|
-
|
|
416
|
-
- **Task queue: reclaim stale checkouts**: `checkoutTask()` now reclaims a lingering `checkoutRunId` on a `queued` task instead of refusing it forever. An ungraceful server exit mid-turn (crash, SIGKILL, HMR reload) previously left tasks uncheckoutable, producing a dispatch → orphan-recovery → failed-checkout spin that logged "Recovering orphaned queued task" tens of thousands of times per session. `scheduleRetryOrDeadLetter()` also clears the prior checkout when scheduling a retry or dead-lettering.
|
|
417
|
-
- **Chat: suppress duplicate parallel tool calls**: some OSS models on Ollama (notably `devstral`) emit the same tool call twice in a single turn. The LangGraph tool-event tracker now dedupes by `name + input` signature, swallowing the duplicate start and its result while allowing a genuinely later identical call once the first completes. Hardened against replayed-start events (HMR, graph retries) that previously could leak a `run_id` into both the accepted and suppressed sets and leave `pendingCount` stuck above zero.
|
|
418
|
-
- **Chat: disable `parallel_tool_calls` for Ollama**: local Ollama sessions now pass `parallel_tool_calls: false` to prevent the upstream duplicate-call behavior at the source for models that honor it.
|
|
419
|
-
- **Chat: no-progress guard for tool summary retries**: if the model produces essentially no new text on a `tool_summary` continuation, the loop stops retrying instead of streaming the same short sentence two or three times. The guard is snapshot-aware: a transient-error rollback no longer leaves a stale progress counter that silently skips a legitimate retry (`lastToolSummaryTextLen` is now round-tripped through `ChatTurnState.snapshot`/`restore`).
|
|
420
|
-
- **Task UI: distinguish retry-pending from failure**: a retrying task now renders in amber with a "Retry Pending" label in the task card and sheet, instead of the same red treatment used for dead-lettered failures.
|
|
421
|
-
- **Autonomy: dedupe reflection memories across kinds**: the supervisor reflection writer now drops notes whose normalized text has already been stored this run, eliminating near-identical memory rows classified under multiple kinds.
|
|
422
|
-
- **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.
|
|
423
|
-
- **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.
|
|
424
|
-
|
|
425
|
-
### v1.5.37 Highlights
|
|
426
|
-
|
|
427
|
-
- **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.
|
|
428
|
-
- **Desktop Release CI hardening**: v1.5.36's Electron build workflow failed on all three platforms. This release:
|
|
429
|
-
- 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.
|
|
430
|
-
- 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).
|
|
431
|
-
- 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.
|
|
432
|
-
|
|
433
427
|
Older releases: https://swarmclaw.ai/docs/release-notes
|
|
434
428
|
|
|
435
429
|
- 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.
|
|
3
|
+
"version": "1.5.43",
|
|
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,7 +1,8 @@
|
|
|
1
1
|
import { NextResponse } from 'next/server'
|
|
2
|
-
import {
|
|
3
|
-
|
|
2
|
+
import { gitAvailable, safeGit } from '@/lib/server/git-metadata'
|
|
3
|
+
import packageJson from '../../../../package.json'
|
|
4
4
|
|
|
5
|
+
export const dynamic = 'force-dynamic'
|
|
5
6
|
|
|
6
7
|
let cachedRemote: {
|
|
7
8
|
sha: string
|
|
@@ -10,74 +11,87 @@ let cachedRemote: {
|
|
|
10
11
|
remoteTag: string | null
|
|
11
12
|
checkedAt: number
|
|
12
13
|
} | null = null
|
|
13
|
-
const CACHE_TTL = 60_000
|
|
14
|
+
const CACHE_TTL = 60_000
|
|
14
15
|
const RELEASE_TAG_RE = /^v\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/
|
|
15
16
|
|
|
16
|
-
function run(cmd: string): string {
|
|
17
|
-
return execSync(cmd, { encoding: 'utf-8', cwd: process.cwd(), timeout: 15_000 }).trim()
|
|
18
|
-
}
|
|
19
|
-
|
|
20
17
|
function getLatestStableTag(): string | null {
|
|
21
|
-
const
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
.filter(Boolean)
|
|
25
|
-
return tags.find((tag) => RELEASE_TAG_RE.test(tag)) || null
|
|
18
|
+
const out = safeGit(['tag', '--list', 'v*', '--sort=-v:refname'])
|
|
19
|
+
if (!out) return null
|
|
20
|
+
return out.split('\n').map((l) => l.trim()).filter(Boolean).find((t) => RELEASE_TAG_RE.test(t)) || null
|
|
26
21
|
}
|
|
27
22
|
|
|
28
23
|
function getHeadStableTag(): string | null {
|
|
29
|
-
const
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
.filter(Boolean)
|
|
33
|
-
return tags.find((tag) => RELEASE_TAG_RE.test(tag)) || null
|
|
24
|
+
const out = safeGit(['tag', '--points-at', 'HEAD', '--list', 'v*', '--sort=-v:refname'])
|
|
25
|
+
if (!out) return null
|
|
26
|
+
return out.split('\n').map((l) => l.trim()).filter(Boolean).find((t) => RELEASE_TAG_RE.test(t)) || null
|
|
34
27
|
}
|
|
35
28
|
|
|
36
29
|
export async function GET(_req: Request) {
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
30
|
+
// Always return 200. When git metadata is unavailable (Docker production
|
|
31
|
+
// image, npm tarball install) we fall back to the static package.json
|
|
32
|
+
// version. Issue #41 reported a 500 response when `.git/` was not present
|
|
33
|
+
// in the production container; this route now degrades gracefully.
|
|
34
|
+
const packageVersion = packageJson.version
|
|
40
35
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
36
|
+
if (!gitAvailable()) {
|
|
37
|
+
return NextResponse.json({
|
|
38
|
+
source: 'package',
|
|
39
|
+
version: packageVersion,
|
|
40
|
+
localSha: null,
|
|
41
|
+
localTag: `v${packageVersion}`,
|
|
42
|
+
remoteSha: null,
|
|
43
|
+
remoteTag: null,
|
|
44
|
+
channel: 'stable',
|
|
45
|
+
updateAvailable: false,
|
|
46
|
+
behindBy: 0,
|
|
47
|
+
})
|
|
48
|
+
}
|
|
45
49
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
50
|
+
const localSha = safeGit(['rev-parse', '--short', 'HEAD'])
|
|
51
|
+
const localTag = getHeadStableTag()
|
|
52
|
+
|
|
53
|
+
let remoteSha = cachedRemote?.sha ?? localSha
|
|
54
|
+
let behindBy = cachedRemote?.behindBy ?? 0
|
|
55
|
+
let channel: 'stable' | 'main' = cachedRemote?.channel ?? 'main'
|
|
56
|
+
let remoteTag = cachedRemote?.remoteTag ?? null
|
|
57
|
+
|
|
58
|
+
if (!cachedRemote || Date.now() - cachedRemote.checkedAt > CACHE_TTL) {
|
|
59
|
+
const fetched = safeGit(['fetch', '--tags', 'origin', '--quiet'])
|
|
60
|
+
if (fetched !== null) {
|
|
61
|
+
const latestTag = getLatestStableTag()
|
|
62
|
+
if (latestTag) {
|
|
63
|
+
channel = 'stable'
|
|
64
|
+
remoteTag = latestTag
|
|
65
|
+
const sha = safeGit(['rev-parse', '--short', `${latestTag}^{commit}`])
|
|
66
|
+
if (sha) remoteSha = sha
|
|
67
|
+
const count = safeGit(['rev-list', `HEAD..${latestTag}^{commit}`, '--count'])
|
|
68
|
+
behindBy = count ? (parseInt(count, 10) || 0) : 0
|
|
69
|
+
} else {
|
|
70
|
+
channel = 'main'
|
|
71
|
+
remoteTag = null
|
|
72
|
+
safeGit(['fetch', 'origin', 'main', '--quiet'])
|
|
73
|
+
const count = safeGit(['rev-list', 'HEAD..origin/main', '--count'])
|
|
74
|
+
behindBy = count ? (parseInt(count, 10) || 0) : 0
|
|
75
|
+
if (behindBy > 0) {
|
|
76
|
+
const sha = safeGit(['rev-parse', '--short', 'origin/main'])
|
|
77
|
+
if (sha) remoteSha = sha
|
|
78
|
+
} else if (localSha) {
|
|
79
|
+
remoteSha = localSha
|
|
64
80
|
}
|
|
65
|
-
cachedRemote = { sha: remoteSha, behindBy, channel, remoteTag, checkedAt: Date.now() }
|
|
66
|
-
} catch {
|
|
67
|
-
// fetch failed (no network, no remote, etc.) — use stale cache or defaults
|
|
68
81
|
}
|
|
82
|
+
cachedRemote = { sha: remoteSha || '', behindBy, channel, remoteTag, checkedAt: Date.now() }
|
|
69
83
|
}
|
|
70
|
-
|
|
71
|
-
return NextResponse.json({
|
|
72
|
-
localSha,
|
|
73
|
-
localTag,
|
|
74
|
-
remoteSha,
|
|
75
|
-
remoteTag,
|
|
76
|
-
channel,
|
|
77
|
-
updateAvailable: behindBy > 0,
|
|
78
|
-
behindBy,
|
|
79
|
-
})
|
|
80
|
-
} catch {
|
|
81
|
-
return NextResponse.json({ error: 'Not a git repository' }, { status: 500 })
|
|
82
84
|
}
|
|
85
|
+
|
|
86
|
+
return NextResponse.json({
|
|
87
|
+
source: 'git',
|
|
88
|
+
version: packageVersion,
|
|
89
|
+
localSha,
|
|
90
|
+
localTag,
|
|
91
|
+
remoteSha,
|
|
92
|
+
remoteTag,
|
|
93
|
+
channel,
|
|
94
|
+
updateAvailable: behindBy > 0,
|
|
95
|
+
behindBy,
|
|
96
|
+
})
|
|
83
97
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { NextResponse } from 'next/server'
|
|
2
2
|
import { execSync } from 'child_process'
|
|
3
3
|
import { getDb } from '@/lib/server/storage'
|
|
4
|
+
import { gitAvailable } from '@/lib/server/git-metadata'
|
|
4
5
|
|
|
5
6
|
const RELEASE_TAG_RE = /^v\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/
|
|
6
7
|
|
|
@@ -37,6 +38,17 @@ function ensureCleanWorkingTree() {
|
|
|
37
38
|
}
|
|
38
39
|
|
|
39
40
|
export async function POST() {
|
|
41
|
+
// The git-pull update path only makes sense for source/git checkouts.
|
|
42
|
+
// Docker and packaged-app installs have their own update channels and
|
|
43
|
+
// calling this route on those installs would otherwise return a confusing
|
|
44
|
+
// 500. Surface the situation as a 200 with a clear reason instead.
|
|
45
|
+
if (!gitAvailable()) {
|
|
46
|
+
return NextResponse.json({
|
|
47
|
+
success: false,
|
|
48
|
+
reason: 'no_git_metadata',
|
|
49
|
+
error: 'Self-update is only supported for source / git checkouts. Use the npm or Docker upgrade path for this install.',
|
|
50
|
+
})
|
|
51
|
+
}
|
|
40
52
|
try {
|
|
41
53
|
const beforeSha = run('git rev-parse --short HEAD')
|
|
42
54
|
const beforeRef = run('git rev-parse HEAD')
|
package/src/lib/provider-sets.ts
CHANGED
|
@@ -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
|
+
}
|
|
@@ -31,7 +31,14 @@ import {
|
|
|
31
31
|
releaseRuntimeLock,
|
|
32
32
|
tryAcquireRuntimeLock,
|
|
33
33
|
} from '@/lib/server/runtime/runtime-lock-repository'
|
|
34
|
-
import { errorMessage } from '@/lib/shared-utils'
|
|
34
|
+
import { errorMessage, hmrSingleton } from '@/lib/shared-utils'
|
|
35
|
+
|
|
36
|
+
// HMR-safe single-shot guard so the "subprocess fallback unavailable"
|
|
37
|
+
// warning logs once per process lifetime, not per API call.
|
|
38
|
+
const subprocessFallbackUnavailableLogged = hmrSingleton<{ value: boolean }>(
|
|
39
|
+
'__swarmclaw_daemon_subprocess_fallback_warned__',
|
|
40
|
+
() => ({ value: false }),
|
|
41
|
+
)
|
|
35
42
|
|
|
36
43
|
const TAG = 'daemon-controller'
|
|
37
44
|
const LAUNCH_LOCK_NAME = 'daemon-launcher'
|
|
@@ -367,7 +374,22 @@ export async function ensureDaemonProcessRunning(
|
|
|
367
374
|
const secondCheck = await getLiveDaemonSnapshot()
|
|
368
375
|
if (secondCheck?.status.running) return false
|
|
369
376
|
|
|
370
|
-
|
|
377
|
+
let resolved: { root: string; entry: string }
|
|
378
|
+
try {
|
|
379
|
+
resolved = resolveDaemonRuntimeEntry()
|
|
380
|
+
} catch (err: unknown) {
|
|
381
|
+
// The standalone Docker image does not ship `src/` (Next.js standalone
|
|
382
|
+
// output excludes raw source files), so the subprocess fallback can
|
|
383
|
+
// never spawn there. Fail soft: log once and let callers fall back to
|
|
384
|
+
// whatever in-process daemon path is available rather than surfacing
|
|
385
|
+
// a 500 to API consumers. Reported as issue #41 (Bug 3).
|
|
386
|
+
if (!subprocessFallbackUnavailableLogged.value) {
|
|
387
|
+
subprocessFallbackUnavailableLogged.value = true
|
|
388
|
+
log.warn(TAG, `[daemon] Subprocess fallback unavailable in this build (${errorMessage(err)}). The in-process daemon will continue to be the primary path.`)
|
|
389
|
+
}
|
|
390
|
+
return false
|
|
391
|
+
}
|
|
392
|
+
const { root, entry } = resolved
|
|
371
393
|
const adminPort = await reservePort()
|
|
372
394
|
const adminToken = crypto.randomBytes(24).toString('hex')
|
|
373
395
|
fs.mkdirSync(path.dirname(DAEMON_LOG_PATH), { recursive: true })
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { describe, it } from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import { isOwnerProcessDead, parseOwnerPid } from '@/lib/server/daemon/lease-owner'
|
|
4
|
+
|
|
5
|
+
function probeThrowing(code: string) {
|
|
6
|
+
return {
|
|
7
|
+
kill: () => {
|
|
8
|
+
const err = new Error('mock probe failure') as NodeJS.ErrnoException
|
|
9
|
+
err.code = code
|
|
10
|
+
throw err
|
|
11
|
+
},
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const probeAlive = { kill: () => true as const }
|
|
16
|
+
|
|
17
|
+
describe('parseOwnerPid', () => {
|
|
18
|
+
it('returns the pid for a well-formed owner string', () => {
|
|
19
|
+
assert.equal(parseOwnerPid('pid:12345:abc'), 12345)
|
|
20
|
+
assert.equal(parseOwnerPid('pid:1:xyz'), 1)
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
it('returns null for unrecognised owner strings', () => {
|
|
24
|
+
assert.equal(parseOwnerPid(null), null)
|
|
25
|
+
assert.equal(parseOwnerPid(undefined), null)
|
|
26
|
+
assert.equal(parseOwnerPid(''), null)
|
|
27
|
+
assert.equal(parseOwnerPid('another process'), null)
|
|
28
|
+
assert.equal(parseOwnerPid('pid::abc'), null)
|
|
29
|
+
assert.equal(parseOwnerPid('pid:abc:xyz'), null)
|
|
30
|
+
assert.equal(parseOwnerPid('host:hostname:pid:1:abc'), null)
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it('rejects zero and negative pids', () => {
|
|
34
|
+
assert.equal(parseOwnerPid('pid:0:abc'), null)
|
|
35
|
+
assert.equal(parseOwnerPid('pid:-1:abc'), null)
|
|
36
|
+
})
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
describe('isOwnerProcessDead — bug #41 stale-lease recovery', () => {
|
|
40
|
+
it('returns true when the probe reports ESRCH (no such process)', () => {
|
|
41
|
+
assert.equal(isOwnerProcessDead('pid:99999:abc', probeThrowing('ESRCH')), true)
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it('returns false when the probe reports EPERM (process owned by someone else)', () => {
|
|
45
|
+
// EPERM means the process exists but signal delivery is blocked. Assume alive
|
|
46
|
+
// and do not steal the lease — bias towards waiting for TTL.
|
|
47
|
+
assert.equal(isOwnerProcessDead('pid:99999:abc', probeThrowing('EPERM')), false)
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it('returns false when the probe succeeds (process is alive)', () => {
|
|
51
|
+
assert.equal(isOwnerProcessDead('pid:99999:abc', probeAlive), false)
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it('returns false for any unknown probe error code (do not guess)', () => {
|
|
55
|
+
assert.equal(isOwnerProcessDead('pid:99999:abc', probeThrowing('EAGAIN')), false)
|
|
56
|
+
assert.equal(isOwnerProcessDead('pid:99999:abc', probeThrowing('UNKNOWN')), false)
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it('returns false for owner strings we cannot parse (different host, malformed, missing)', () => {
|
|
60
|
+
assert.equal(isOwnerProcessDead(null, probeThrowing('ESRCH')), false)
|
|
61
|
+
assert.equal(isOwnerProcessDead('another process', probeThrowing('ESRCH')), false)
|
|
62
|
+
assert.equal(isOwnerProcessDead('host:remote:pid:1:abc', probeThrowing('ESRCH')), false)
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('refuses to declare its own pid dead even if probe lies', () => {
|
|
66
|
+
// Defence in depth: the current process is obviously alive; if a
|
|
67
|
+
// pathological probe returned ESRCH for its own pid, we must not
|
|
68
|
+
// act on that.
|
|
69
|
+
const owner = `pid:${process.pid}:self`
|
|
70
|
+
assert.equal(isOwnerProcessDead(owner, probeThrowing('ESRCH')), false)
|
|
71
|
+
})
|
|
72
|
+
})
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Helpers for reasoning about who owns a runtime lease.
|
|
3
|
+
*
|
|
4
|
+
* Owner strings have the shape `pid:${pid}:${suffix}` (see
|
|
5
|
+
* `runtime/daemon-state/core.ts` where the suffix is generated). When the
|
|
6
|
+
* holding process disappears without releasing the lease (container crash,
|
|
7
|
+
* SIGKILL), a successor instance has no way to know the lease is stale
|
|
8
|
+
* other than waiting out the TTL. These helpers let the successor detect
|
|
9
|
+
* that the recorded pid is no longer alive and reclaim the lease.
|
|
10
|
+
*
|
|
11
|
+
* The reclaim path is intentionally conservative: any uncertainty (owner
|
|
12
|
+
* string format we do not recognise, probe outcome we cannot interpret,
|
|
13
|
+
* etc.) returns `false` so the caller falls back to "wait for TTL".
|
|
14
|
+
*
|
|
15
|
+
* Single-host only. If a lease was acquired on a different host (Kubernetes
|
|
16
|
+
* multi-pod), the recorded pid means nothing here. Recognising "different
|
|
17
|
+
* host" requires the owner string itself to encode a host id, which we do
|
|
18
|
+
* not currently do; for now, mixed-host deployments will continue to wait
|
|
19
|
+
* out the TTL, which is the correct behavior in the absence of a way to
|
|
20
|
+
* verify the remote process status.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
const OWNER_PATTERN = /^pid:(\d+):/
|
|
24
|
+
|
|
25
|
+
export interface ProcessProbe {
|
|
26
|
+
/** Sends signal 0 to the pid, throws on error like `process.kill`. */
|
|
27
|
+
kill: (pid: number, signal: 0) => true | void
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const realProbe: ProcessProbe = {
|
|
31
|
+
kill: (pid, signal) => {
|
|
32
|
+
process.kill(pid, signal)
|
|
33
|
+
return true
|
|
34
|
+
},
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function parseOwnerPid(owner: string | null | undefined): number | null {
|
|
38
|
+
if (typeof owner !== 'string') return null
|
|
39
|
+
const match = owner.match(OWNER_PATTERN)
|
|
40
|
+
if (!match) return null
|
|
41
|
+
const pid = Number(match[1])
|
|
42
|
+
return Number.isInteger(pid) && pid > 0 ? pid : null
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Returns true when the recorded owner pid is provably dead on this host.
|
|
47
|
+
* Returns false for any other outcome:
|
|
48
|
+
* - owner string we cannot parse
|
|
49
|
+
* - probe succeeded (the process is alive)
|
|
50
|
+
* - probe failed with EPERM (process exists but is owned by someone
|
|
51
|
+
* else; treat as "alive, do not steal")
|
|
52
|
+
* - any other unexpected failure (do not guess)
|
|
53
|
+
*
|
|
54
|
+
* `probe` is injectable for tests.
|
|
55
|
+
*/
|
|
56
|
+
export function isOwnerProcessDead(owner: string | null | undefined, probe: ProcessProbe = realProbe): boolean {
|
|
57
|
+
const pid = parseOwnerPid(owner)
|
|
58
|
+
if (pid === null) return false
|
|
59
|
+
if (pid === process.pid) return false
|
|
60
|
+
try {
|
|
61
|
+
probe.kill(pid, 0)
|
|
62
|
+
return false
|
|
63
|
+
} catch (err: unknown) {
|
|
64
|
+
const code = (err as NodeJS.ErrnoException | undefined)?.code
|
|
65
|
+
if (code === 'ESRCH') return true
|
|
66
|
+
return false
|
|
67
|
+
}
|
|
68
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { describe, it, beforeEach } from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import { gitAvailable, resetGitAvailableCache, safeGit } from '@/lib/server/git-metadata'
|
|
4
|
+
|
|
5
|
+
describe('safeGit', () => {
|
|
6
|
+
it('returns null when git is invoked with arguments that produce no useful output', () => {
|
|
7
|
+
// `git` invoked outside of a repository and asked for a missing config key
|
|
8
|
+
// is one of the few invocations guaranteed to fail on every host, while
|
|
9
|
+
// still respecting the real binary path. If git itself is not installed,
|
|
10
|
+
// `safeGit` still returns null (the catch path).
|
|
11
|
+
const out = safeGit(['config', 'this.key.does.not.exist'])
|
|
12
|
+
assert.equal(out, null)
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
it('returns a trimmed string for a successful invocation', () => {
|
|
16
|
+
const version = safeGit(['--version'])
|
|
17
|
+
if (version === null) return // git is not installed in this env; skip
|
|
18
|
+
assert.match(version, /^git version /)
|
|
19
|
+
})
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
describe('gitAvailable', () => {
|
|
23
|
+
beforeEach(() => {
|
|
24
|
+
resetGitAvailableCache()
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it('caches its result', () => {
|
|
28
|
+
const first = gitAvailable()
|
|
29
|
+
// After the first call, subsequent calls return the same value without
|
|
30
|
+
// re-probing. We cannot directly observe "did it re-probe?" without
|
|
31
|
+
// mocking `node:child_process`, so we just assert stability.
|
|
32
|
+
const second = gitAvailable()
|
|
33
|
+
const third = gitAvailable()
|
|
34
|
+
assert.equal(first, second)
|
|
35
|
+
assert.equal(second, third)
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('reflects whether the cwd is in a git checkout', () => {
|
|
39
|
+
// This test runs from inside the swarmclaw repo, so git should be
|
|
40
|
+
// available. When run from inside the published Docker image (where
|
|
41
|
+
// `.git/` is absent), the same call returns false.
|
|
42
|
+
const present = gitAvailable()
|
|
43
|
+
assert.equal(typeof present, 'boolean')
|
|
44
|
+
})
|
|
45
|
+
})
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Pure helpers for reading git metadata at runtime, with graceful degradation
|
|
5
|
+
* when the working directory is not a git checkout (Docker production image,
|
|
6
|
+
* npm tarball install, etc.).
|
|
7
|
+
*
|
|
8
|
+
* Always uses `execFileSync` with an arg array (no shell) so user input cannot
|
|
9
|
+
* influence the command line.
|
|
10
|
+
*/
|
|
11
|
+
export function safeGit(args: string[], cwd: string = process.cwd()): string | null {
|
|
12
|
+
try {
|
|
13
|
+
const out = execFileSync('git', args, {
|
|
14
|
+
cwd,
|
|
15
|
+
encoding: 'utf-8',
|
|
16
|
+
timeout: 15_000,
|
|
17
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
18
|
+
})
|
|
19
|
+
return typeof out === 'string' ? out.trim() : null
|
|
20
|
+
} catch {
|
|
21
|
+
return null
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
let cachedAvailable: boolean | null = null
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Returns true when the current working directory looks like a git checkout
|
|
29
|
+
* (i.e. `git rev-parse --git-dir` succeeds). Cached for the lifetime of the
|
|
30
|
+
* process, since the answer does not change while a server is running.
|
|
31
|
+
*
|
|
32
|
+
* Exported `resetGitAvailableCache` is for unit tests only.
|
|
33
|
+
*/
|
|
34
|
+
export function gitAvailable(): boolean {
|
|
35
|
+
if (cachedAvailable !== null) return cachedAvailable
|
|
36
|
+
cachedAvailable = safeGit(['rev-parse', '--git-dir']) !== null
|
|
37
|
+
return cachedAvailable
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function resetGitAvailableCache(): void {
|
|
41
|
+
cachedAvailable = null
|
|
42
|
+
}
|
|
@@ -4,6 +4,7 @@ import { loadConnectors, saveConnectors } from '@/lib/server/connectors/connecto
|
|
|
4
4
|
import { decryptKey, loadCredentials } from '@/lib/server/credentials/credential-repository'
|
|
5
5
|
import { loadQueue } from '@/lib/server/runtime/queue-repository'
|
|
6
6
|
import { pruneExpiredLocks, readRuntimeLock, releaseRuntimeLock, renewRuntimeLock, tryAcquireRuntimeLock } from '@/lib/server/runtime/runtime-lock-repository'
|
|
7
|
+
import { isOwnerProcessDead } from '@/lib/server/daemon/lease-owner'
|
|
7
8
|
import { loadSchedules } from '@/lib/server/schedules/schedule-repository'
|
|
8
9
|
import { loadSessions } from '@/lib/server/sessions/session-repository'
|
|
9
10
|
import { loadSettings } from '@/lib/server/settings/settings-repository'
|
|
@@ -126,6 +127,7 @@ interface DaemonState {
|
|
|
126
127
|
shuttingDown: boolean
|
|
127
128
|
providerPingCircuitBreaker: Map<string, { consecutiveFailures: number; skipUntil: number }>
|
|
128
129
|
lockRenewIntervalId: ReturnType<typeof setInterval> | null
|
|
130
|
+
leaseRetryTimeoutId: ReturnType<typeof setTimeout> | null
|
|
129
131
|
primaryLeaseHeld: boolean
|
|
130
132
|
}
|
|
131
133
|
|
|
@@ -151,6 +153,7 @@ const ds: DaemonState = hmrSingleton<DaemonState>('__swarmclaw_daemon__', () =>
|
|
|
151
153
|
shuttingDown: false,
|
|
152
154
|
providerPingCircuitBreaker: new Map<string, { consecutiveFailures: number; skipUntil: number }>(),
|
|
153
155
|
lockRenewIntervalId: null,
|
|
156
|
+
leaseRetryTimeoutId: null,
|
|
154
157
|
primaryLeaseHeld: false,
|
|
155
158
|
}))
|
|
156
159
|
|
|
@@ -180,6 +183,7 @@ if (ds.connectorHealthCheckRunning === undefined) ds.connectorHealthCheckRunning
|
|
|
180
183
|
if (ds.shuttingDown === undefined) ds.shuttingDown = false
|
|
181
184
|
if (!ds.providerPingCircuitBreaker) ds.providerPingCircuitBreaker = new Map<string, { consecutiveFailures: number; skipUntil: number }>()
|
|
182
185
|
if (ds.lockRenewIntervalId === undefined) ds.lockRenewIntervalId = null
|
|
186
|
+
if (ds.leaseRetryTimeoutId === undefined) ds.leaseRetryTimeoutId = null
|
|
183
187
|
if (ds.primaryLeaseHeld === undefined) ds.primaryLeaseHeld = false
|
|
184
188
|
|
|
185
189
|
function stopDaemonLeaseRenewal(opts?: { release?: boolean }) {
|
|
@@ -229,12 +233,60 @@ function acquireDaemonLease(source: string): boolean {
|
|
|
229
233
|
}
|
|
230
234
|
if (!acquired) {
|
|
231
235
|
let owner = 'another process'
|
|
236
|
+
let expiresAt: number | null = null
|
|
232
237
|
try {
|
|
233
|
-
|
|
238
|
+
const lease = readRuntimeLock(DAEMON_RUNTIME_LOCK_NAME)
|
|
239
|
+
if (lease) {
|
|
240
|
+
owner = lease.owner || owner
|
|
241
|
+
expiresAt = lease.expiresAt
|
|
242
|
+
}
|
|
234
243
|
} catch {
|
|
235
244
|
// Best-effort diagnostics only.
|
|
236
245
|
}
|
|
246
|
+
|
|
247
|
+
// Stale-lease recovery: when a previous container / process crashed
|
|
248
|
+
// without releasing the lease, the new instance would otherwise wait
|
|
249
|
+
// up to the full TTL (DAEMON_RUNTIME_LOCK_TTL_MS) before being able
|
|
250
|
+
// to start the daemon. If the recorded owner pid is local to this
|
|
251
|
+
// host AND is no longer alive, reclaim the lease immediately and
|
|
252
|
+
// retry. Conservative: any uncertainty (different host, malformed
|
|
253
|
+
// owner, kill probe failed for an unexpected reason) skips the
|
|
254
|
+
// reclaim path. Reported as issue #41 (Bug 2).
|
|
255
|
+
if (isOwnerProcessDead(owner)) {
|
|
256
|
+
try {
|
|
257
|
+
releaseRuntimeLock(DAEMON_RUNTIME_LOCK_NAME, owner)
|
|
258
|
+
log.info(TAG, `[daemon] Reclaimed stale daemon-primary lease from dead owner ${owner}`)
|
|
259
|
+
let retried = false
|
|
260
|
+
try {
|
|
261
|
+
retried = tryAcquireRuntimeLock(DAEMON_RUNTIME_LOCK_NAME, daemonLockOwner, DAEMON_RUNTIME_LOCK_TTL_MS)
|
|
262
|
+
} catch (err: unknown) {
|
|
263
|
+
log.warn(TAG, `[daemon] Reclaim retry failed (source=${source}): ${errorMessage(err)}`)
|
|
264
|
+
}
|
|
265
|
+
if (retried) {
|
|
266
|
+
ds.primaryLeaseHeld = true
|
|
267
|
+
startDaemonLeaseRenewal()
|
|
268
|
+
return true
|
|
269
|
+
}
|
|
270
|
+
} catch (err: unknown) {
|
|
271
|
+
log.warn(TAG, `[daemon] Failed to release stale lease (source=${source}): ${errorMessage(err)}`)
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
237
275
|
log.info(TAG, `[daemon] Skipping start (source=${source}); lease held by ${owner}`)
|
|
276
|
+
|
|
277
|
+
// Schedule one deferred retry slightly past the lease's expiry so
|
|
278
|
+
// the daemon comes up automatically once the prior owner's TTL has
|
|
279
|
+
// elapsed, instead of waiting for the next API call to nudge it.
|
|
280
|
+
if (expiresAt !== null) {
|
|
281
|
+
const delayMs = Math.max(1_000, expiresAt - Date.now() + 1_000)
|
|
282
|
+
if (ds.leaseRetryTimeoutId) clearTimeout(ds.leaseRetryTimeoutId)
|
|
283
|
+
ds.leaseRetryTimeoutId = setTimeout(() => {
|
|
284
|
+
ds.leaseRetryTimeoutId = null
|
|
285
|
+
if (ds.running || ds.primaryLeaseHeld) return
|
|
286
|
+
ensureDaemonStarted(`${source}:lease-retry`)
|
|
287
|
+
}, delayMs)
|
|
288
|
+
ds.leaseRetryTimeoutId.unref?.()
|
|
289
|
+
}
|
|
238
290
|
return false
|
|
239
291
|
}
|
|
240
292
|
ds.primaryLeaseHeld = true
|
|
@@ -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.',
|
package/src/types/provider.ts
CHANGED
|
@@ -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 {
|
package/src/types/session.ts
CHANGED
|
@@ -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
|