Skip to content

Commit 55b9fa9

Browse files
committed
Add Qwen Code support
1 parent 69eef25 commit 55b9fa9

9 files changed

Lines changed: 125 additions & 21 deletions

File tree

AGENTS.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ Read these first:
99
Current architecture:
1010

1111
- `sah` is a Go CLI that authenticates against `https://sah.borca.ai`, stores the contributor API key locally, and polls `/s@h/*` endpoints.
12-
- The CLI never hands the contributor API key to Codex CLI, Gemini CLI, or Claude Code. It fetches assignments itself, runs the local agent CLI in an empty working directory, captures stdout, and submits the parsed JSON payload itself.
12+
- The CLI never hands the contributor API key to Codex CLI, Gemini CLI, Claude Code, or Qwen Code. It fetches assignments itself, runs the local agent CLI in an empty working directory, captures stdout, and submits the parsed JSON payload itself.
1313
- Foreground mode uses `sah run`. Daemon mode uses a per-user macOS `launchd` agent installed by `sah daemon install`.
1414

1515
Note: `CLAUDE.md` is a symlink to `AGENTS.md`.

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ For always-on background work:
2929
sah daemon install
3030
```
3131

32-
`sah daemon install` installs and starts the per-user `launchd` agent immediately. It captures the current shell `PATH`, `HOME`, and the absolute paths of installed agent binaries, then runs from `~/Library/Application Support/sah` instead of your home directory. Re-run it after moving `codex`, `gemini`, or `claude`.
32+
`sah daemon install` installs and starts the per-user `launchd` agent immediately. It captures the current shell `PATH`, `HOME`, and the absolute paths of installed agent binaries, then runs from `~/Library/Application Support/sah` instead of your home directory. Re-run it after moving `codex`, `gemini`, `claude`, or `qwen`.
3333

3434
## Documentation
3535

cmd/sah/main.go

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -72,8 +72,8 @@ Commands:
7272
Examples:
7373
sah auth login
7474
sah run --rotate-installed
75-
sah run --agents codex,gemini,claude --models codex=gpt-5.4-mini,gemini=gemini-3-flash-base,claude=sonnet
76-
sah daemon install --agents codex,claude --interval 30m
75+
sah run --agents codex,gemini,claude,qwen --models codex=gpt-5.4-mini,gemini=gemini-3-flash-base,claude=sonnet
76+
sah daemon install --agents codex,claude,qwen --interval 30m
7777
sah me
7878
`)
7979
}
@@ -169,11 +169,11 @@ func authCmd(args []string) error {
169169
func runCmd(args []string) error {
170170
fs := flag.NewFlagSet("run", flag.ContinueOnError)
171171
fs.SetOutput(os.Stderr)
172-
agent := fs.String("agent", "", "Agent CLI to use: codex, gemini, claude")
173-
agents := fs.String("agents", "", "Comma-separated round-robin agent order, e.g. codex,gemini,claude")
172+
agent := fs.String("agent", "", "Agent CLI to use: codex, gemini, claude, qwen")
173+
agents := fs.String("agents", "", "Comma-separated round-robin agent order, e.g. codex,gemini,claude,qwen")
174174
rotateInstalled := fs.Bool("rotate-installed", false, "Rotate through every supported agent CLI installed on this Mac")
175175
model := fs.String("model", "", "Optional model override passed to the agent CLI")
176-
models := fs.String("models", "", "Per-agent model overrides, e.g. codex=gpt-5.4-mini,gemini=gemini-3-flash-base,claude=sonnet")
176+
models := fs.String("models", "", "Per-agent model overrides, e.g. codex=gpt-5.4-mini,gemini=gemini-3-flash-base,claude=sonnet,qwen=<name>")
177177
interval := fs.String("interval", "", "Polling interval")
178178
timeout := fs.String("timeout", "", "Per-assignment agent timeout")
179179
taskType := fs.String("task-type", "", "Optional task type filter")
@@ -351,7 +351,7 @@ func parseDaemonInstallOptions(args []string) (daemonInstallOptions, error) {
351351
fs.StringVar(&options.agents, "agents", "", "Comma-separated round-robin agent order for the daemon")
352352
fs.BoolVar(&options.rotateInstalled, "rotate-installed", false, "Rotate through every installed supported agent CLI")
353353
fs.StringVar(&options.model, "model", "", "Default model override")
354-
fs.StringVar(&options.models, "models", "", "Per-agent model overrides, e.g. codex=gpt-5.4-mini,gemini=gemini-3-flash-base,claude=sonnet")
354+
fs.StringVar(&options.models, "models", "", "Per-agent model overrides, e.g. codex=gpt-5.4-mini,gemini=gemini-3-flash-base,claude=sonnet,qwen=<name>")
355355
fs.StringVar(&options.interval, "interval", "", "Default polling interval")
356356
fs.StringVar(&options.timeout, "timeout", "", "Default per-assignment timeout")
357357
fs.StringVar(&options.baseURL, "base-url", "", "SCIENCE@home base URL")

docs/architecture.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ The browser never receives the raw contributor API key. The local CLI proves pos
1919
- `sah` calls `GET /s@h/tasks` with `X-API-Key`.
2020
- The assignment response can include a protocol version plus `_links.submit` and `_links.release`, and the same submit/release relations can also be exposed through the HTTP `Link` header.
2121
- It builds a task-only prompt from the returned assignment payload and instructions.
22-
- It runs one of the supported local agent CLIs: `codex`, `gemini`, or `claude`.
22+
- It runs one of the supported local agent CLIs: `codex`, `gemini`, `claude`, or `qwen`.
2323
- The agent receives no API key and runs in an empty temporary working directory.
2424
- The CLI parses the agent stdout as JSON and follows the assignment-scoped submit link when present, falling back to the legacy contribution endpoint when it is not.
2525
- If the local agent aborts or fails before submission, `sah` releases the assignment immediately so the API key does not stay pinned at the open-assignment limit until expiry.

docs/build.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ go build -o bin/sah ./cmd/sah
2323
For release builds, inject the version via `ldflags`.
2424

2525
```sh
26-
go build -trimpath -ldflags="-s -w -X main.version=v0.3.0" -o bin/sah ./cmd/sah
26+
go build -trimpath -ldflags="-s -w -X main.version=v0.4.0" -o bin/sah ./cmd/sah
2727
```
2828

2929
## Run
@@ -39,7 +39,7 @@ sah daemon install
3939

4040
`sah daemon install` both installs and starts the `launchd` agent. Use `sah daemon start` only after a manual `stop` or after logging back into macOS.
4141

42-
If the daemon cannot find `codex`, `gemini`, or `claude`, re-run `sah daemon install` from a shell where that agent is already on `PATH`. The install command captures the current shell environment for `launchd`, stores absolute agent binary paths, and runs the daemon from `~/Library/Application Support/sah` instead of `HOME`.
42+
If the daemon cannot find `codex`, `gemini`, `claude`, or `qwen`, re-run `sah daemon install` from a shell where that agent is already on `PATH`. The install command captures the current shell environment for `launchd`, stores absolute agent binary paths, and runs the daemon from `~/Library/Application Support/sah` instead of `HOME`.
4343

4444
## Test
4545

@@ -75,8 +75,8 @@ The hook runs:
7575
Pushing a `v*` tag triggers GitHub Actions (`.github/workflows/release.yml`), which runs GoReleaser. It builds macOS binaries, creates archives with checksums, publishes a GitHub Release, and updates the Homebrew tap.
7676

7777
```sh
78-
git tag v0.3.0
79-
git push origin v0.3.0
78+
git tag v0.4.0
79+
git push origin v0.4.0
8080
```
8181

8282
Required repository secrets:

internal/sah/agent_selection.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -144,7 +144,8 @@ func MergeAgentModels(base map[string]string, overrides map[string]string) map[s
144144
// --model is supplied. Keep these in sync with what each upstream CLI actually
145145
// accepts today: Gemini CLI 0.37.x only exposes `gemini-3-flash-base` (and
146146
// friends) -- `gemini3-flash` / `gemini-3-flash` are NOT valid ids and cause
147-
// ModelNotFoundError.
147+
// ModelNotFoundError. Qwen is intentionally omitted so Qwen Code can use the
148+
// current default model resolved by the user's local auth/provider config.
148149
var DefaultAgentModels = map[string]string{
149150
"codex": "gpt-5.4-mini",
150151
"gemini": "gemini-3-flash-base",

internal/sah/agent_selection_test.go

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,18 +3,18 @@ package sah
33
import "testing"
44

55
func TestParseAgentList(t *testing.T) {
6-
list := ParseAgentList("codex, gemini ,claude,codex")
7-
if len(list) != 3 || list[0] != "codex" || list[1] != "gemini" || list[2] != "claude" {
6+
list := ParseAgentList("codex, gemini ,claude,qwen,codex")
7+
if len(list) != 4 || list[0] != "codex" || list[1] != "gemini" || list[2] != "claude" || list[3] != "qwen" {
88
t.Fatalf("unexpected list: %#v", list)
99
}
1010
}
1111

1212
func TestParseAgentModels(t *testing.T) {
13-
models, err := ParseAgentModels("codex=gpt-5.4-mini, gemini=gemini-3-flash-base, claude=sonnet")
13+
models, err := ParseAgentModels("codex=gpt-5.4-mini, gemini=gemini-3-flash-base, claude=sonnet, qwen=coder-model")
1414
if err != nil {
1515
t.Fatalf("ParseAgentModels returned error: %v", err)
1616
}
17-
if models["codex"] != "gpt-5.4-mini" || models["gemini"] != "gemini-3-flash-base" || models["claude"] != "sonnet" {
17+
if models["codex"] != "gpt-5.4-mini" || models["gemini"] != "gemini-3-flash-base" || models["claude"] != "sonnet" || models["qwen"] != "coder-model" {
1818
t.Fatalf("unexpected models: %#v", models)
1919
}
2020
}
@@ -45,3 +45,10 @@ func TestModelForAgentUsesBuiltinDefaults(t *testing.T) {
4545
}
4646
}
4747
}
48+
49+
func TestModelForAgentLeavesQwenUnsetWithoutOverrides(t *testing.T) {
50+
model := ModelForAgent("qwen", "", nil)
51+
if model != "" {
52+
t.Fatalf("expected qwen to use upstream default model, got %q", model)
53+
}
54+
}

internal/sah/runner.go

Lines changed: 50 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ var SupportedAgents = []AgentSpec{
5151
{Name: "codex", Binary: "codex", Description: "OpenAI Codex CLI"},
5252
{Name: "gemini", Binary: "gemini", Description: "Google Gemini CLI"},
5353
{Name: "claude", Binary: "claude", Description: "Anthropic Claude Code"},
54+
{Name: "qwen", Binary: "qwen", Description: "Qwen Code CLI"},
5455
}
5556

5657
func InstalledAgents() []AgentStatus {
@@ -253,6 +254,13 @@ func buildAgentCommand(
253254
"--no-session-persistence",
254255
prompt,
255256
}
257+
case "qwen":
258+
args = []string{
259+
"--prompt", prompt,
260+
"--sandbox",
261+
"--approval-mode", "plan",
262+
"--output-format", "stream-json",
263+
}
256264
default:
257265
return nil, false, fmt.Errorf("unsupported agent %q", agent.Name)
258266
}
@@ -307,6 +315,8 @@ func parseStructuredOutput(agentName string, raw string) (*structuredAgentOutput
307315
return parseGeminiStructuredOutput(raw)
308316
case "claude":
309317
return parseClaudeStructuredOutput(raw)
318+
case "qwen":
319+
return parseQwenStructuredOutput(raw)
310320
default:
311321
return nil, fmt.Errorf("unsupported agent %q", agentName)
312322
}
@@ -369,29 +379,52 @@ func parseGeminiStructuredOutput(raw string) (*structuredAgentOutput, error) {
369379
}
370380

371381
func parseClaudeStructuredOutput(raw string) (*structuredAgentOutput, error) {
382+
return parseAssistantResultStructuredOutput(raw, parseClaudeUsage)
383+
}
384+
385+
func parseQwenStructuredOutput(raw string) (*structuredAgentOutput, error) {
386+
return parseAssistantResultStructuredOutput(raw, parseQwenUsage)
387+
}
388+
389+
func parseAssistantResultStructuredOutput(
390+
raw string,
391+
parseUsage func(map[string]any) TokenUsage,
392+
) (*structuredAgentOutput, error) {
372393
output := &structuredAgentOutput{}
394+
var assistantText strings.Builder
373395

374396
if err := parseJSONLines(raw, func(event map[string]any) {
375397
switch stringValue(event["type"]) {
376398
case "assistant":
377399
message := mapValue(event["message"])
378400
content := arrayValue(message["content"])
379401
for _, item := range content {
380-
text := strings.TrimSpace(stringValue(mapValue(item)["text"]))
402+
block := mapValue(item)
403+
if stringValue(block["type"]) != "text" {
404+
continue
405+
}
406+
text := strings.TrimSpace(stringValue(block["text"]))
381407
if text != "" {
382-
output.Text = text
408+
if assistantText.Len() > 0 {
409+
assistantText.WriteString("\n")
410+
}
411+
assistantText.WriteString(text)
383412
}
384413
}
385414
case "result":
386-
output.Usage = parseClaudeUsage(mapValue(event["usage"]))
415+
output.Usage = parseUsage(mapValue(event["usage"]))
387416
if boolValue(event["is_error"]) && output.AgentError == "" {
388417
output.AgentError = strings.TrimSpace(stringValue(event["result"]))
418+
if output.AgentError == "" {
419+
output.AgentError = strings.TrimSpace(stringValue(event["subtype"]))
420+
}
389421
}
390422
}
391423
}); err != nil {
392424
return nil, err
393425
}
394426

427+
output.Text = strings.TrimSpace(assistantText.String())
395428
return output, nil
396429
}
397430

@@ -446,6 +479,20 @@ func parseClaudeUsage(usage map[string]any) TokenUsage {
446479
}
447480
}
448481

482+
func parseQwenUsage(usage map[string]any) TokenUsage {
483+
total := int64Value(usage["total_tokens"])
484+
if total == 0 {
485+
total = int64Value(usage["input_tokens"]) + int64Value(usage["output_tokens"])
486+
}
487+
return TokenUsage{
488+
Available: len(usage) > 0,
489+
InputTokens: int64Value(usage["input_tokens"]),
490+
OutputTokens: int64Value(usage["output_tokens"]),
491+
CachedTokens: int64Value(usage["cache_read_input_tokens"]),
492+
TotalTokens: total,
493+
}
494+
}
495+
449496
func mapValue(value any) map[string]any {
450497
if mapped, ok := value.(map[string]any); ok {
451498
return mapped

internal/sah/runner_test.go

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,22 @@ func TestParseClaudeStructuredOutput(t *testing.T) {
5757
}
5858
}
5959

60+
func TestParseQwenStructuredOutput(t *testing.T) {
61+
raw := `{"type":"assistant","message":{"content":[{"type":"thinking","thinking":"internal"},{"type":"text","text":"{\"ok\":true}"}]}}
62+
{"type":"result","subtype":"success","is_error":false,"usage":{"input_tokens":21,"cache_read_input_tokens":4,"output_tokens":6,"total_tokens":27}}`
63+
64+
output, err := parseQwenStructuredOutput(raw)
65+
if err != nil {
66+
t.Fatalf("parseQwenStructuredOutput returned error: %v", err)
67+
}
68+
if output.Text != `{"ok":true}` {
69+
t.Fatalf("unexpected text: %q", output.Text)
70+
}
71+
if output.Usage.InputTokens != 21 || output.Usage.OutputTokens != 6 || output.Usage.CachedTokens != 4 || output.Usage.TotalTokens != 27 {
72+
t.Fatalf("unexpected usage: %#v", output.Usage)
73+
}
74+
}
75+
6076
func TestResolveAgentBinaryPathUsesConfiguredPath(t *testing.T) {
6177
t.Setenv("PATH", "")
6278

@@ -122,3 +138,36 @@ func TestBuildAgentCommandForClaudeAvoidsBareAuthMode(t *testing.T) {
122138
t.Fatalf("expected claude args to include --disable-slash-commands, got %q", args)
123139
}
124140
}
141+
142+
func TestBuildAgentCommandForQwenUsesHeadlessPlanMode(t *testing.T) {
143+
path := filepath.Join(t.TempDir(), "qwen")
144+
if err := os.WriteFile(path, []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil {
145+
t.Fatalf("write executable: %v", err)
146+
}
147+
148+
command, useStdin, err := buildAgentCommand(
149+
context.Background(),
150+
AgentSpec{Name: "qwen", Binary: "qwen"},
151+
"",
152+
t.TempDir(),
153+
`{"ok":true}`,
154+
map[string]string{"qwen": path},
155+
)
156+
if err != nil {
157+
t.Fatalf("buildAgentCommand returned error: %v", err)
158+
}
159+
if useStdin {
160+
t.Fatal("expected qwen prompt to be passed as an argument")
161+
}
162+
163+
args := strings.Join(command.Args[1:], " ")
164+
if !strings.Contains(args, "--output-format stream-json") {
165+
t.Fatalf("expected qwen args to include stream-json output, got %q", args)
166+
}
167+
if !strings.Contains(args, "--approval-mode plan") {
168+
t.Fatalf("expected qwen args to include plan mode, got %q", args)
169+
}
170+
if !strings.Contains(args, "--sandbox") {
171+
t.Fatalf("expected qwen args to enable sandboxing, got %q", args)
172+
}
173+
}

0 commit comments

Comments
 (0)