Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions bin/ask
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ Usage:
ask <provider> [options] <message>

Providers:
gemini, codex, opencode, droid, claude
gemini, codex, opencode, droid, claude, copilot

Modes:
Default (async): Background task with hook callback
Expand Down Expand Up @@ -56,6 +56,7 @@ PROVIDER_DAEMONS = {
"opencode": "oask",
"droid": "dask",
"claude": "lask",
"copilot": "hask",
}

CALLER_SESSION_FILES = {
Expand All @@ -64,20 +65,23 @@ CALLER_SESSION_FILES = {
"gemini": ".gemini-session",
"opencode": ".opencode-session",
"droid": ".droid-session",
"copilot": ".copilot-session",
}

CALLER_PANE_ENV_HINTS = {
"codex": ("CODEX_TMUX_SESSION", "CODEX_WEZTERM_PANE"),
"gemini": ("GEMINI_TMUX_SESSION", "GEMINI_WEZTERM_PANE"),
"opencode": ("OPENCODE_TMUX_SESSION", "OPENCODE_WEZTERM_PANE"),
"droid": ("DROID_TMUX_SESSION", "DROID_WEZTERM_PANE"),
"copilot": ("COPILOT_TMUX_SESSION", "COPILOT_WEZTERM_PANE"),
}

CALLER_ENV_HINTS = {
"codex": ("CODEX_SESSION_ID", "CODEX_RUNTIME_DIR"),
"gemini": ("GEMINI_SESSION_ID", "GEMINI_RUNTIME_DIR"),
"opencode": ("OPENCODE_SESSION_ID", "OPENCODE_RUNTIME_DIR"),
"droid": ("DROID_SESSION_ID", "DROID_RUNTIME_DIR"),
"copilot": ("COPILOT_SESSION_ID", "COPILOT_RUNTIME_DIR"),
}

VALID_CALLERS = set(CALLER_SESSION_FILES.keys()) | {"email", "manual"}
Expand Down Expand Up @@ -471,7 +475,7 @@ def _usage() -> None:
print("Usage: ask <provider> [options] <message>", file=sys.stderr)
print("", file=sys.stderr)
print("Providers:", file=sys.stderr)
print(" gemini, codex, opencode, droid, claude", file=sys.stderr)
print(" gemini, codex, opencode, droid, claude, copilot", file=sys.stderr)
print("", file=sys.stderr)
print("Options:", file=sys.stderr)
print(" -h, --help Show this help message", file=sys.stderr)
Expand Down
6 changes: 4 additions & 2 deletions bin/askd
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"""
askd - Unified Ask Daemon for all AI providers.

Single daemon process handling codex, gemini, opencode, droid, and claude.
Single daemon process handling codex, gemini, opencode, droid, claude, and copilot.
"""
from __future__ import annotations

Expand All @@ -26,6 +26,7 @@ from askd.adapters.gemini import GeminiAdapter
from askd.adapters.opencode import OpenCodeAdapter
from askd.adapters.droid import DroidAdapter
from askd.adapters.claude import ClaudeAdapter
from askd.adapters.copilot import CopilotAdapter


def _parse_listen(value: str) -> tuple[str, int]:
Expand All @@ -38,14 +39,15 @@ def _parse_listen(value: str) -> tuple[str, int]:
return host or "127.0.0.1", int(port_s or "0")


ALL_PROVIDERS = ["codex", "gemini", "opencode", "droid", "claude"]
ALL_PROVIDERS = ["codex", "gemini", "opencode", "droid", "claude", "copilot"]

ADAPTER_CLASSES = {
"codex": CodexAdapter,
"gemini": GeminiAdapter,
"opencode": OpenCodeAdapter,
"droid": DroidAdapter,
"claude": ClaudeAdapter,
"copilot": CopilotAdapter,
}


Expand Down
217 changes: 217 additions & 0 deletions bin/hask
Original file line number Diff line number Diff line change
@@ -0,0 +1,217 @@
#!/usr/bin/env python3
"""
hask - Send message to Copilot and wait for reply (sync).

Designed to be used with Claude Code's run_in_background=true.
If --output is provided, reply is written atomically to that file and stdout stays empty.
"""

from __future__ import annotations

import os
import sys
import time
from pathlib import Path

script_dir = Path(__file__).resolve().parent
lib_dir = script_dir.parent / "lib"
sys.path.insert(0, str(lib_dir))
from compat import read_stdin_text, setup_windows_encoding

setup_windows_encoding()

from cli_output import EXIT_ERROR, EXIT_NO_REPLY, EXIT_OK, atomic_write_text
from env_utils import env_bool
from askd_client import (
state_file_from_env,
find_project_session_file,
resolve_work_dir_with_registry,
try_daemon_request,
maybe_start_daemon,
wait_for_daemon_ready,
check_background_mode,
)
from providers import HASK_CLIENT_SPEC


ASYNC_GUARDRAIL = """[CCB_ASYNC_SUBMITTED provider=copilot]
IMPORTANT: Task submitted to Copilot. You MUST:
1. Tell user "Copilot processing..."
2. END YOUR TURN IMMEDIATELY
3. Do NOT wait, poll, check status, or use any more tools
"""


def _daemon_startup_wait_s(timeout: float) -> float:
raw = (os.environ.get("CCB_HASKD_STARTUP_WAIT_S") or "").strip()
if raw:
try:
v = float(raw)
except Exception:
v = 0.0
if v > 0:
return min(max(0.2, v), max(0.2, float(timeout)))
return min(8.0, max(1.0, float(timeout)))


def _daemon_retry_wait_s(timeout: float) -> float:
raw = (os.environ.get("CCB_HASKD_RETRY_WAIT_S") or "").strip()
if raw:
try:
v = float(raw)
except Exception:
v = 0.0
if v > 0:
return min(1.0, max(0.05, v))
return min(0.3, max(0.05, float(timeout) / 50.0))


def _daemon_request_with_retries(
work_dir: Path,
message: str,
timeout: float,
quiet: bool,
output_path: Path | None,
) -> tuple[str, int] | None:
state_file = state_file_from_env(HASK_CLIENT_SPEC.state_file_env)

result = try_daemon_request(HASK_CLIENT_SPEC, work_dir, message, timeout, quiet, state_file, output_path=output_path)
if result is not None:
return result

if not env_bool(HASK_CLIENT_SPEC.enabled_env, True):
return None
if not find_project_session_file(work_dir, HASK_CLIENT_SPEC.session_filename):
return None

if state_file and state_file.exists():
try:
if not wait_for_daemon_ready(HASK_CLIENT_SPEC, 0.2, state_file):
try:
state_file.unlink()
except Exception:
pass
except Exception:
pass

started = maybe_start_daemon(HASK_CLIENT_SPEC, work_dir)
if started:
wait_for_daemon_ready(HASK_CLIENT_SPEC, _daemon_startup_wait_s(timeout), state_file)

wait_s = _daemon_retry_wait_s(timeout)
deadline = time.time() + min(3.0, max(0.2, float(timeout)))
while time.time() < deadline:
result = try_daemon_request(HASK_CLIENT_SPEC, work_dir, message, timeout, quiet, state_file, output_path=output_path)
if result is not None:
return result
time.sleep(wait_s)

return None


def _usage() -> None:
print("Usage: hask [--sync] [--session-file FILE] [--timeout SECONDS] [--output FILE] <message>", file=sys.stderr)


def main(argv: list[str]) -> int:
if len(argv) <= 1 and sys.stdin.isatty():
_usage()
return EXIT_ERROR

output_path: Path | None = None
timeout: float | None = None
quiet = False
sync_mode = False
session_file: str | None = None

parts: list[str] = []
it = iter(argv[1:])
for token in it:
if token in ("-h", "--help"):
_usage()
return EXIT_OK
if token in ("-q", "--quiet"):
quiet = True
continue
if token == "--sync":
sync_mode = True
continue
if token == "--session-file":
try:
session_file = next(it)
except StopIteration:
print("[ERROR] --session-file requires a file path", file=sys.stderr)
return EXIT_ERROR
continue
if token in ("-o", "--output"):
try:
output_path = Path(next(it)).expanduser()
except StopIteration:
print("[ERROR] --output requires a file path", file=sys.stderr)
return EXIT_ERROR
continue
if token in ("-t", "--timeout"):
try:
timeout = float(next(it))
except StopIteration:
print("[ERROR] --timeout requires a number", file=sys.stderr)
return EXIT_ERROR
except ValueError:
print("[ERROR] --timeout must be a number", file=sys.stderr)
return EXIT_ERROR
continue
parts.append(token)

message = " ".join(parts).strip()
if not message and not sys.stdin.isatty():
message = read_stdin_text().strip()
if not message:
print("[ERROR] Message cannot be empty", file=sys.stderr)
return EXIT_ERROR

if timeout is None:
try:
timeout = float(os.environ.get("CCB_SYNC_TIMEOUT", "3600.0"))
except Exception:
timeout = 3600.0

try:
work_dir, _ = resolve_work_dir_with_registry(
HASK_CLIENT_SPEC,
provider="copilot",
cli_session_file=session_file,
env_session_file=os.environ.get("CCB_SESSION_FILE"),
)

daemon_result = _daemon_request_with_retries(work_dir, message, timeout, quiet, output_path)
if daemon_result is not None:
reply, exit_code = daemon_result
if not sync_mode:
print(ASYNC_GUARDRAIL, file=sys.stderr, flush=True)
if output_path:
atomic_write_text(output_path, reply + "\n")
return exit_code
sys.stdout.write(reply)
if not reply.endswith("\n"):
sys.stdout.write("\n")
return exit_code

if not env_bool(HASK_CLIENT_SPEC.enabled_env, True):
print(f"[ERROR] {HASK_CLIENT_SPEC.enabled_env}=0: hask daemon mode disabled.", file=sys.stderr)
return EXIT_ERROR
if not find_project_session_file(work_dir, HASK_CLIENT_SPEC.session_filename):
print("[ERROR] No active Copilot session found for this directory.", file=sys.stderr)
print("Run `ccb copilot` (or add copilot to ccb.config) in this project first.", file=sys.stderr)
return EXIT_ERROR
print("[ERROR] hask daemon required but not available.", file=sys.stderr)
print("Start it with `askd` (or enable autostart via CCB_HASKD_AUTOSTART=1).", file=sys.stderr)
return EXIT_ERROR
except KeyboardInterrupt:
return 130
except Exception as exc:
print(f"[ERROR] {exc}", file=sys.stderr)
return EXIT_ERROR


if __name__ == "__main__":
sys.exit(main(sys.argv))
Loading