Skip to content

Commit 51676d3

Browse files
committed
feat: add Qwen Code CLI as new provider (bfly123#59)
Add support for Qwen Code (qwen-code CLI by Alibaba/QwenLM) as a new provider in CCB, using pane-log based communication identical to the Copilot and CodeBuddy providers. New files: - lib/qaskd_session.py — session management (QwenProjectSession) - lib/qaskd_protocol.py — protocol helpers (wrap_qwen_prompt, QaskdRequest/Result) - lib/qwen_comm.py — communication (QwenLogReader, QwenCommunicator) - lib/askd/adapters/qwen.py — unified daemon adapter (QwenAdapter) - bin/qask, bin/qpend, bin/qping — CLI entry points - 4 test files with 32 unit tests Modified files: - lib/providers.py — QASKD_SPEC + QASK_CLIENT_SPEC - lib/askd/adapters/__init__.py — export QwenAdapter - bin/askd — register adapter - bin/ask — dispatch tables - install.sh — script symlinks - test/stubs/provider_stub.py — test stub
1 parent 2b62336 commit 51676d3

File tree

17 files changed

+1976
-5
lines changed

17 files changed

+1976
-5
lines changed

bin/ask

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ Usage:
66
ask <provider> [options] <message>
77
88
Providers:
9-
gemini, codex, opencode, droid, claude, copilot, codebuddy
9+
gemini, codex, opencode, droid, claude, copilot, codebuddy, qwen
1010
1111
Modes:
1212
Default (async): Background task with hook callback
@@ -58,6 +58,7 @@ PROVIDER_DAEMONS = {
5858
"claude": "lask",
5959
"copilot": "hask",
6060
"codebuddy": "bask",
61+
"qwen": "qask",
6162
}
6263

6364
CALLER_SESSION_FILES = {
@@ -68,6 +69,7 @@ CALLER_SESSION_FILES = {
6869
"droid": ".droid-session",
6970
"copilot": ".copilot-session",
7071
"codebuddy": ".codebuddy-session",
72+
"qwen": ".qwen-session",
7173
}
7274

7375
CALLER_PANE_ENV_HINTS = {
@@ -77,6 +79,7 @@ CALLER_PANE_ENV_HINTS = {
7779
"droid": ("DROID_TMUX_SESSION", "DROID_WEZTERM_PANE"),
7880
"copilot": ("COPILOT_TMUX_SESSION", "COPILOT_WEZTERM_PANE"),
7981
"codebuddy": ("CODEBUDDY_TMUX_SESSION", "CODEBUDDY_WEZTERM_PANE"),
82+
"qwen": ("QWEN_TMUX_SESSION", "QWEN_WEZTERM_PANE"),
8083
}
8184

8285
CALLER_ENV_HINTS = {
@@ -86,6 +89,7 @@ CALLER_ENV_HINTS = {
8689
"droid": ("DROID_SESSION_ID", "DROID_RUNTIME_DIR"),
8790
"copilot": ("COPILOT_SESSION_ID", "COPILOT_RUNTIME_DIR"),
8891
"codebuddy": ("CODEBUDDY_SESSION_ID", "CODEBUDDY_RUNTIME_DIR"),
92+
"qwen": ("QWEN_SESSION_ID", "QWEN_RUNTIME_DIR"),
8993
}
9094

9195
VALID_CALLERS = set(CALLER_SESSION_FILES.keys()) | {"email", "manual"}
@@ -479,7 +483,7 @@ def _usage() -> None:
479483
print("Usage: ask <provider> [options] <message>", file=sys.stderr)
480484
print("", file=sys.stderr)
481485
print("Providers:", file=sys.stderr)
482-
print(" gemini, codex, opencode, droid, claude, copilot, codebuddy", file=sys.stderr)
486+
print(" gemini, codex, opencode, droid, claude, copilot, codebuddy, qwen", file=sys.stderr)
483487
print("", file=sys.stderr)
484488
print("Options:", file=sys.stderr)
485489
print(" -h, --help Show this help message", file=sys.stderr)

bin/askd

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
"""
33
askd - Unified Ask Daemon for all AI providers.
44
5-
Single daemon process handling codex, gemini, opencode, droid, claude, copilot, and codebuddy.
5+
Single daemon process handling codex, gemini, opencode, droid, claude, copilot, codebuddy, and qwen.
66
"""
77
from __future__ import annotations
88

@@ -28,6 +28,7 @@ from askd.adapters.droid import DroidAdapter
2828
from askd.adapters.claude import ClaudeAdapter
2929
from askd.adapters.copilot import CopilotAdapter
3030
from askd.adapters.codebuddy import CodebuddyAdapter
31+
from askd.adapters.qwen import QwenAdapter
3132

3233

3334
def _parse_listen(value: str) -> tuple[str, int]:
@@ -40,7 +41,7 @@ def _parse_listen(value: str) -> tuple[str, int]:
4041
return host or "127.0.0.1", int(port_s or "0")
4142

4243

43-
ALL_PROVIDERS = ["codex", "gemini", "opencode", "droid", "claude", "copilot", "codebuddy"]
44+
ALL_PROVIDERS = ["codex", "gemini", "opencode", "droid", "claude", "copilot", "codebuddy", "qwen"]
4445

4546
ADAPTER_CLASSES = {
4647
"codex": CodexAdapter,
@@ -50,6 +51,7 @@ ADAPTER_CLASSES = {
5051
"claude": ClaudeAdapter,
5152
"copilot": CopilotAdapter,
5253
"codebuddy": CodebuddyAdapter,
54+
"qwen": QwenAdapter,
5355
}
5456

5557

bin/qask

Lines changed: 217 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,217 @@
1+
#!/usr/bin/env python3
2+
"""
3+
qask - Send message to Qwen and wait for reply (sync).
4+
5+
Designed to be used with Claude Code's run_in_background=true.
6+
If --output is provided, reply is written atomically to that file and stdout stays empty.
7+
"""
8+
9+
from __future__ import annotations
10+
11+
import os
12+
import sys
13+
import time
14+
from pathlib import Path
15+
16+
script_dir = Path(__file__).resolve().parent
17+
lib_dir = script_dir.parent / "lib"
18+
sys.path.insert(0, str(lib_dir))
19+
from compat import read_stdin_text, setup_windows_encoding
20+
21+
setup_windows_encoding()
22+
23+
from cli_output import EXIT_ERROR, EXIT_NO_REPLY, EXIT_OK, atomic_write_text
24+
from env_utils import env_bool
25+
from askd_client import (
26+
state_file_from_env,
27+
find_project_session_file,
28+
resolve_work_dir_with_registry,
29+
try_daemon_request,
30+
maybe_start_daemon,
31+
wait_for_daemon_ready,
32+
check_background_mode,
33+
)
34+
from providers import QASK_CLIENT_SPEC
35+
36+
37+
ASYNC_GUARDRAIL = """[CCB_ASYNC_SUBMITTED provider=qwen]
38+
IMPORTANT: Task submitted to Qwen. You MUST:
39+
1. Tell user "Qwen processing..."
40+
2. END YOUR TURN IMMEDIATELY
41+
3. Do NOT wait, poll, check status, or use any more tools
42+
"""
43+
44+
45+
def _daemon_startup_wait_s(timeout: float) -> float:
46+
raw = (os.environ.get("CCB_QASKD_STARTUP_WAIT_S") or "").strip()
47+
if raw:
48+
try:
49+
v = float(raw)
50+
except Exception:
51+
v = 0.0
52+
if v > 0:
53+
return min(max(0.2, v), max(0.2, float(timeout)))
54+
return min(8.0, max(1.0, float(timeout)))
55+
56+
57+
def _daemon_retry_wait_s(timeout: float) -> float:
58+
raw = (os.environ.get("CCB_QASKD_RETRY_WAIT_S") or "").strip()
59+
if raw:
60+
try:
61+
v = float(raw)
62+
except Exception:
63+
v = 0.0
64+
if v > 0:
65+
return min(1.0, max(0.05, v))
66+
return min(0.3, max(0.05, float(timeout) / 50.0))
67+
68+
69+
def _daemon_request_with_retries(
70+
work_dir: Path,
71+
message: str,
72+
timeout: float,
73+
quiet: bool,
74+
output_path: Path | None,
75+
) -> tuple[str, int] | None:
76+
state_file = state_file_from_env(QASK_CLIENT_SPEC.state_file_env)
77+
78+
result = try_daemon_request(QASK_CLIENT_SPEC, work_dir, message, timeout, quiet, state_file, output_path=output_path)
79+
if result is not None:
80+
return result
81+
82+
if not env_bool(QASK_CLIENT_SPEC.enabled_env, True):
83+
return None
84+
if not find_project_session_file(work_dir, QASK_CLIENT_SPEC.session_filename):
85+
return None
86+
87+
if state_file and state_file.exists():
88+
try:
89+
if not wait_for_daemon_ready(QASK_CLIENT_SPEC, 0.2, state_file):
90+
try:
91+
state_file.unlink()
92+
except Exception:
93+
pass
94+
except Exception:
95+
pass
96+
97+
started = maybe_start_daemon(QASK_CLIENT_SPEC, work_dir)
98+
if started:
99+
wait_for_daemon_ready(QASK_CLIENT_SPEC, _daemon_startup_wait_s(timeout), state_file)
100+
101+
wait_s = _daemon_retry_wait_s(timeout)
102+
deadline = time.time() + min(3.0, max(0.2, float(timeout)))
103+
while time.time() < deadline:
104+
result = try_daemon_request(QASK_CLIENT_SPEC, work_dir, message, timeout, quiet, state_file, output_path=output_path)
105+
if result is not None:
106+
return result
107+
time.sleep(wait_s)
108+
109+
return None
110+
111+
112+
def _usage() -> None:
113+
print("Usage: qask [--sync] [--session-file FILE] [--timeout SECONDS] [--output FILE] <message>", file=sys.stderr)
114+
115+
116+
def main(argv: list[str]) -> int:
117+
if len(argv) <= 1 and sys.stdin.isatty():
118+
_usage()
119+
return EXIT_ERROR
120+
121+
output_path: Path | None = None
122+
timeout: float | None = None
123+
quiet = False
124+
sync_mode = False
125+
session_file: str | None = None
126+
127+
parts: list[str] = []
128+
it = iter(argv[1:])
129+
for token in it:
130+
if token in ("-h", "--help"):
131+
_usage()
132+
return EXIT_OK
133+
if token in ("-q", "--quiet"):
134+
quiet = True
135+
continue
136+
if token == "--sync":
137+
sync_mode = True
138+
continue
139+
if token == "--session-file":
140+
try:
141+
session_file = next(it)
142+
except StopIteration:
143+
print("[ERROR] --session-file requires a file path", file=sys.stderr)
144+
return EXIT_ERROR
145+
continue
146+
if token in ("-o", "--output"):
147+
try:
148+
output_path = Path(next(it)).expanduser()
149+
except StopIteration:
150+
print("[ERROR] --output requires a file path", file=sys.stderr)
151+
return EXIT_ERROR
152+
continue
153+
if token in ("-t", "--timeout"):
154+
try:
155+
timeout = float(next(it))
156+
except StopIteration:
157+
print("[ERROR] --timeout requires a number", file=sys.stderr)
158+
return EXIT_ERROR
159+
except ValueError:
160+
print("[ERROR] --timeout must be a number", file=sys.stderr)
161+
return EXIT_ERROR
162+
continue
163+
parts.append(token)
164+
165+
message = " ".join(parts).strip()
166+
if not message and not sys.stdin.isatty():
167+
message = read_stdin_text().strip()
168+
if not message:
169+
print("[ERROR] Message cannot be empty", file=sys.stderr)
170+
return EXIT_ERROR
171+
172+
if timeout is None:
173+
try:
174+
timeout = float(os.environ.get("CCB_SYNC_TIMEOUT", "3600.0"))
175+
except Exception:
176+
timeout = 3600.0
177+
178+
try:
179+
work_dir, _ = resolve_work_dir_with_registry(
180+
QASK_CLIENT_SPEC,
181+
provider="qwen",
182+
cli_session_file=session_file,
183+
env_session_file=os.environ.get("CCB_SESSION_FILE"),
184+
)
185+
186+
daemon_result = _daemon_request_with_retries(work_dir, message, timeout, quiet, output_path)
187+
if daemon_result is not None:
188+
reply, exit_code = daemon_result
189+
if not sync_mode:
190+
print(ASYNC_GUARDRAIL, file=sys.stderr, flush=True)
191+
if output_path:
192+
atomic_write_text(output_path, reply + "\n")
193+
return exit_code
194+
sys.stdout.write(reply)
195+
if not reply.endswith("\n"):
196+
sys.stdout.write("\n")
197+
return exit_code
198+
199+
if not env_bool(QASK_CLIENT_SPEC.enabled_env, True):
200+
print(f"[ERROR] {QASK_CLIENT_SPEC.enabled_env}=0: qask daemon mode disabled.", file=sys.stderr)
201+
return EXIT_ERROR
202+
if not find_project_session_file(work_dir, QASK_CLIENT_SPEC.session_filename):
203+
print("[ERROR] No active Qwen session found for this directory.", file=sys.stderr)
204+
print("Run `ccb qwen` (or add qwen to ccb.config) in this project first.", file=sys.stderr)
205+
return EXIT_ERROR
206+
print("[ERROR] qask daemon required but not available.", file=sys.stderr)
207+
print("Start it with `qaskd` (or enable autostart via CCB_QASKD_AUTOSTART=1).", file=sys.stderr)
208+
return EXIT_ERROR
209+
except KeyboardInterrupt:
210+
return 130
211+
except Exception as exc:
212+
print(f"[ERROR] {exc}", file=sys.stderr)
213+
return EXIT_ERROR
214+
215+
216+
if __name__ == "__main__":
217+
sys.exit(main(sys.argv))

0 commit comments

Comments
 (0)