Compare commits
32 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 17d1fcad77 | |||
| d6ed87d4b9 | |||
| 860f4f610d | |||
| eff7d60df3 | |||
| 31c3bc6425 | |||
| 0a4b232635 | |||
| 84f1bdad52 | |||
| 05acd0611e | |||
| 2e7c72c8e6 | |||
| 1e55f36b2b | |||
| 1323072735 | |||
| f244c37c63 | |||
| 67ef849b5b | |||
| c366cdc714 | |||
| fe0f2e87e4 | |||
| 5733a472a8 | |||
| 15fb4ffc3a | |||
| 05a1cff51f | |||
| ad99247c44 | |||
| ef561cd1b2 | |||
| 96403f29e8 | |||
| cf5b47660a | |||
| 0e88cfd3d2 | |||
| 9db75cd1a8 | |||
| b9f1151ee6 | |||
| 2bfc389db8 | |||
| 5b031003da | |||
| 15bdac5c66 | |||
| 5849f8a7ec | |||
| 81b635994b | |||
| dff40d764b | |||
| 34ef32870a |
@@ -1 +0,0 @@
|
||||
../.claude/skills
|
||||
@@ -1,193 +0,0 @@
|
||||
---
|
||||
name: harness-eval
|
||||
description: This skill should be used when the user asks to "test the harness", "run integration tests", "validate features with real API", "test with real model calls", "run agent loop tests", "verify end-to-end", or needs to verify OpenHarness features on a real codebase with actual LLM calls.
|
||||
version: 0.2.0
|
||||
---
|
||||
|
||||
# Harness Eval — End-to-End Feature Validation
|
||||
|
||||
Validate OpenHarness features by running real agent loops against an unfamiliar codebase with actual LLM API calls. Every test exercises the full stack: API client → model → tool calls → execution → result.
|
||||
|
||||
## Core Principles
|
||||
|
||||
1. **Test on an unfamiliar project** — never test on OpenHarness itself (the agent modifies its own code). Clone a real project as the workspace.
|
||||
2. **Use real API calls** — no mocks. Configure a real LLM endpoint.
|
||||
3. **Multi-turn conversations** — always test 2+ turns where the model needs prior context.
|
||||
4. **Combine features** — test hooks+skills+agent loop together, not in isolation.
|
||||
5. **Verify tool execution** — inspect tool call lists and output files, not just model text.
|
||||
|
||||
## Workflow
|
||||
|
||||
### 1. Prepare Workspace
|
||||
|
||||
Clone an unfamiliar project (do not use OpenHarness):
|
||||
|
||||
```bash
|
||||
git clone https://github.com/HKUDS/AutoAgent /tmp/eval-workspace
|
||||
```
|
||||
|
||||
### 2. Configure Environment
|
||||
|
||||
```bash
|
||||
export ANTHROPIC_API_KEY=sk-xxx
|
||||
export ANTHROPIC_BASE_URL=https://api.moonshot.cn/anthropic # or any provider
|
||||
export ANTHROPIC_MODEL=kimi-k2.5
|
||||
```
|
||||
|
||||
For long-running real evals, do not artificially lower `max_turns`. Use the product default (`200`) unless the user explicitly wants a tighter bound.
|
||||
|
||||
### 3. Prepare Real Sandbox Runtime When Relevant
|
||||
|
||||
If the task is validating sandbox behavior, install and verify the actual runtime before running agent loops:
|
||||
|
||||
```bash
|
||||
npm install -g @anthropic-ai/sandbox-runtime
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y bubblewrap ripgrep
|
||||
which srt
|
||||
which bwrap
|
||||
which rg
|
||||
srt --version
|
||||
```
|
||||
|
||||
Then run a minimal smoke check through OpenHarness, not just raw `srt`, so you verify the real adapter path:
|
||||
|
||||
```python
|
||||
from pathlib import Path
|
||||
from openharness.config.settings import Settings, SandboxSettings, save_settings
|
||||
from openharness.tools.bash_tool import BashTool
|
||||
|
||||
cfg = Path("/tmp/openharness-sandbox-settings.json")
|
||||
save_settings(Settings(sandbox=SandboxSettings(enabled=True, fail_if_unavailable=True)), cfg)
|
||||
# Point config loader at this file, then run BashTool on a tiny command such as `pwd`.
|
||||
```
|
||||
|
||||
If sandbox dependencies are missing, treat that as an environment/setup failure, not a feature regression.
|
||||
|
||||
### 4. Design Tests
|
||||
|
||||
Each test follows this pattern:
|
||||
|
||||
```python
|
||||
engine = make_engine(system_prompt="...", cwd=UNFAMILIAR_PROJECT)
|
||||
evs1 = [ev async for ev in engine.submit_message("Read X, analyze Y")]
|
||||
r1 = collect(evs1) # text, tools, turns, tokens
|
||||
evs2 = [ev async for ev in engine.submit_message("Based on what you found...")]
|
||||
r2 = collect(evs2)
|
||||
assert "grep" in r1["tools"] # verify tools ran
|
||||
```
|
||||
|
||||
For detailed code templates and the `make_engine`/`collect` helpers, consult `references/test-patterns.md`.
|
||||
|
||||
### 5. Prefer Long-Horizon, Real Agent Loops
|
||||
|
||||
For meaningful end-to-end validation, prefer unfamiliar-repo tasks that force multiple turns, context reuse, and mixed tool usage.
|
||||
|
||||
Recommended pattern:
|
||||
|
||||
- Use a real external workspace such as `AutoAgent`
|
||||
- Use real provider credentials and the actual target model
|
||||
- Keep `max_turns=200`
|
||||
- Use per-prompt timeouts large enough for real exploration, such as `240-600s`
|
||||
- Require at least 2 turns per scenario
|
||||
- Verify both text quality and tool traces
|
||||
- Keep polling long-running sessions until they finish; do not abandon a run after the first long pause
|
||||
|
||||
Recommended long-horizon scenarios:
|
||||
|
||||
- `architecture_multiturn`
|
||||
- Turn 1: map architecture, shell/subprocess surfaces, and test entrypoints
|
||||
- Turn 2: identify top risks and propose refactors
|
||||
- Turn 3: condense into onboarding or remediation actions
|
||||
- Success: `bash`, `glob`, `grep`, `read_file` all appear; no timeout; no `MaxTurnsExceeded`
|
||||
|
||||
- `hook_block_and_recover`
|
||||
- Force the model to try `bash`
|
||||
- Block it with a real pre-tool hook
|
||||
- Verify the model adapts with `glob`/`grep`/`read_file`
|
||||
|
||||
- `sandbox_multiturn`
|
||||
- Enable real sandbox settings with `fail_if_unavailable=true`
|
||||
- First prompt must start with exactly one shell command such as `pwd && ls -la`
|
||||
- Second prompt must explicitly reuse the prior shell findings
|
||||
- Success: `bash` executes via sandbox, non-shell tools continue the task, and the agent recovers from incidental repo errors
|
||||
|
||||
When a scenario fails, classify it before changing code:
|
||||
|
||||
- `MaxTurnsExceeded`: likely eval harness misconfiguration if `max_turns` was manually lowered
|
||||
- `timeout`: task is too broad or per-prompt timeout is too small
|
||||
- sandbox unavailable: environment missing `srt`, `bwrap`, or `rg`
|
||||
- tool error with task still completed: feature may still be healthy; inspect recovery behavior
|
||||
|
||||
### 6. Run Tests
|
||||
|
||||
```bash
|
||||
python tests/test_merged_prs_on_autoagent.py # PR feature tests
|
||||
python tests/test_real_large_tasks.py # large multi-step tasks
|
||||
python tests/test_hooks_skills_plugins_real.py # hooks/skills/plugins
|
||||
python -m pytest tests/ -q -k "not autoagent" # unit tests (no API)
|
||||
```
|
||||
|
||||
For ad hoc long-horizon validation, it is acceptable to run a temporary Python driver script as long as it:
|
||||
|
||||
- uses real OpenHarness engine/tool objects
|
||||
- targets an unfamiliar repository
|
||||
- prints per-scenario JSON summaries
|
||||
- records tools, errors, turns, and token usage
|
||||
- stays attached until completion
|
||||
|
||||
### 7. Interpret Results
|
||||
|
||||
| Result | Meaning | Action |
|
||||
|--------|---------|--------|
|
||||
| PASS with tool calls | Feature works end-to-end | Done |
|
||||
| PASS without tool calls | Model answered from knowledge | Rewrite prompt to force tool use |
|
||||
| FAIL with exception | Code bug | Read traceback |
|
||||
| FAIL with wrong output | Model behavior issue | Check system prompt and tool schemas |
|
||||
| Timeout | Task too complex | Increase `max_turns` or simplify prompt |
|
||||
|
||||
For long-running real evals, refine the timeout guidance:
|
||||
|
||||
- First check whether `max_turns` was manually set too low
|
||||
- If `max_turns=200` and the run still fails, the next suspect is wall-clock timeout, not turn count
|
||||
- Distinguish environment failures from product failures
|
||||
- Example: missing dependency in the unfamiliar target repo is not automatically an OpenHarness regression
|
||||
- Example: missing `srt`/`bwrap`/`rg` is an eval environment issue
|
||||
|
||||
## Feature Coverage Checklist
|
||||
|
||||
- [ ] Engine: multi-turn memory, tool chaining, parallel tools, error recovery, auto-compaction
|
||||
- [ ] Swarm: InProcessBackend lifecycle, concurrent teammates, coordinator+notifications
|
||||
- [ ] Hooks: pre_tool_use blocking → model adapts, post_tool_use firing
|
||||
- [ ] Skills: skill tool invocation → model follows instructions
|
||||
- [ ] Plugins: plugin-provided skill loaded and used in agent loop
|
||||
- [ ] Memory: YAML frontmatter parsing, body content search, context injection
|
||||
- [ ] Session: save → load → resume with context preserved
|
||||
- [ ] Providers: Anthropic client, OpenAI client (with reasoning_content), multi-turn
|
||||
- [ ] Cost: token accumulation across turns
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
- Testing on OpenHarness itself — agent modifies its own running code
|
||||
- Using mocks — misses serialization and API compatibility bugs
|
||||
- Single-turn only — misses context accumulation and compaction bugs
|
||||
- Artificially lowering `max_turns` during real evals — can create false failures that do not reflect product defaults
|
||||
- Not checking tool call list — model may claim tool use without calling it
|
||||
- Hardcoding paths — use `WORKSPACE` variable, skip in CI with `pytest.mark.skipif`
|
||||
- Declaring sandbox “tested” after only checking raw `srt` — verify the OpenHarness adapter path too
|
||||
- Abandoning long tasks too early — some real tasks pause for minutes before the next event arrives
|
||||
|
||||
## Additional Resources
|
||||
|
||||
### Reference Files
|
||||
|
||||
- **`references/test-patterns.md`** — Complete code templates for `make_engine`, `collect`, and each feature category
|
||||
- **`references/feature-matrix.md`** — Detailed test cases for every OpenHarness module
|
||||
|
||||
### Existing Test Files
|
||||
|
||||
Working test suites in the repo:
|
||||
- `tests/test_merged_prs_on_autoagent.py` — PR feature validation
|
||||
- `tests/test_real_large_tasks.py` — Large multi-step tasks
|
||||
- `tests/test_hooks_skills_plugins_real.py` — Hooks/skills/plugins in agent loops
|
||||
- `tests/test_untested_features.py` — Module-level integration tests
|
||||
@@ -1,47 +0,0 @@
|
||||
# Feature Test Matrix — Detailed Test Cases
|
||||
|
||||
## Engine & Tools
|
||||
|
||||
| Test | What to Verify | Key Assertion |
|
||||
|------|---------------|---------------|
|
||||
| Multi-turn memory | Set fact turn 1, recall turn 3 | Fact appears in turn 3 response |
|
||||
| Tool chaining | glob → grep → read in one task | All 3 tools in tool list |
|
||||
| Write→Edit→Read | Create, modify, verify file | File content matches expected |
|
||||
| Parallel tools | 3+ tool calls in one response | 3+ tools in single turn |
|
||||
| Error recovery | Tool fails, model adapts | Alternative tool used after error |
|
||||
| Auto-compaction | 5+ tasks on shared engine | No context overflow crash |
|
||||
|
||||
## Swarm & Coordinator
|
||||
|
||||
| Test | What to Verify | Key Assertion |
|
||||
|------|---------------|---------------|
|
||||
| InProcessBackend | spawn → active → status → shutdown | All states transition correctly |
|
||||
| Concurrent teammates | 2+ agents running simultaneously | Both complete, total time < 2x single |
|
||||
| Coordinator + notifications | Multi-turn delegation with XML | Coordinator synthesizes worker results |
|
||||
| Permission sync | request → pending → resolve | Pending count goes to 0 after resolve |
|
||||
|
||||
## Hooks, Skills, Plugins
|
||||
|
||||
| Test | What to Verify | Key Assertion |
|
||||
|------|---------------|---------------|
|
||||
| Hook blocks → adapt | pre_tool_use blocks bash | "bash" in errors, "glob" in tools |
|
||||
| Skill invocation | Model calls skill tool | "skill" in tools, content drives next action |
|
||||
| Plugin skill | Plugin provides skill | Loaded via skill tool, model follows it |
|
||||
| Hook + skill combined | Hook gates writes, skill guides | Protected file untouched, new file created |
|
||||
|
||||
## Memory, Session, Config
|
||||
|
||||
| Test | What to Verify | Key Assertion |
|
||||
|------|---------------|---------------|
|
||||
| Memory frontmatter | YAML parsed, not "---" | description != "---", body searchable |
|
||||
| Session resume | Save → load → continue | Model remembers prior context |
|
||||
| Cost tracking | Tokens accumulate | in_tokens strictly increasing |
|
||||
| Cron CRUD | Create, toggle, mark_run, delete | Job count correct at each step |
|
||||
|
||||
## Provider Compatibility
|
||||
|
||||
| Test | What to Verify | Key Assertion |
|
||||
|------|---------------|---------------|
|
||||
| Anthropic client | Standard tool calling | Tools execute, response coherent |
|
||||
| OpenAI client | Tool calling + reasoning_content | No 400 error on tool call round-trip |
|
||||
| OpenAI multi-turn | reasoning_content persists | 3+ turns without API error |
|
||||
@@ -1,150 +0,0 @@
|
||||
# Test Patterns — Code Templates for Harness Eval
|
||||
|
||||
## Engine Setup Helpers
|
||||
|
||||
```python
|
||||
import asyncio, sys, os
|
||||
from pathlib import Path
|
||||
sys.path.insert(0, "src")
|
||||
|
||||
API_KEY = os.environ.get("ANTHROPIC_API_KEY", "your-key")
|
||||
BASE_URL = os.environ.get("ANTHROPIC_BASE_URL", "https://api.moonshot.cn/anthropic")
|
||||
OPENAI_BASE = os.environ.get("OPENAI_BASE_URL", "https://api.moonshot.cn/v1")
|
||||
MODEL = os.environ.get("ANTHROPIC_MODEL", "kimi-k2.5")
|
||||
WORKSPACE = Path("/tmp/eval-workspace") # unfamiliar project
|
||||
|
||||
|
||||
def make_anthropic_engine(system_prompt, cwd=None, extra_tools=None):
|
||||
from openharness.api.client import AnthropicApiClient
|
||||
from openharness.config.settings import PermissionSettings
|
||||
from openharness.engine.query_engine import QueryEngine
|
||||
from openharness.permissions.checker import PermissionChecker
|
||||
from openharness.permissions.modes import PermissionMode
|
||||
from openharness.tools.base import ToolRegistry
|
||||
from openharness.tools.bash_tool import BashTool
|
||||
from openharness.tools.file_read_tool import FileReadTool
|
||||
from openharness.tools.file_write_tool import FileWriteTool
|
||||
from openharness.tools.file_edit_tool import FileEditTool
|
||||
from openharness.tools.glob_tool import GlobTool
|
||||
from openharness.tools.grep_tool import GrepTool
|
||||
|
||||
api = AnthropicApiClient(api_key=API_KEY, base_url=BASE_URL)
|
||||
reg = ToolRegistry()
|
||||
for t in [BashTool(), FileReadTool(), FileWriteTool(), FileEditTool(), GlobTool(), GrepTool()]:
|
||||
reg.register(t)
|
||||
for t in (extra_tools or []):
|
||||
reg.register(t)
|
||||
checker = PermissionChecker(PermissionSettings(mode=PermissionMode.FULL_AUTO))
|
||||
return QueryEngine(
|
||||
api_client=api, tool_registry=reg, permission_checker=checker,
|
||||
cwd=Path(cwd or WORKSPACE), model=MODEL, system_prompt=system_prompt, max_tokens=4096,
|
||||
)
|
||||
|
||||
|
||||
def make_openai_engine(system_prompt, cwd=None, extra_tools=None):
|
||||
from openharness.api.openai_client import OpenAICompatibleClient
|
||||
# Same structure as above, but with:
|
||||
api = OpenAICompatibleClient(api_key=API_KEY, base_url=OPENAI_BASE)
|
||||
# ... rest identical
|
||||
|
||||
|
||||
def collect(events):
|
||||
from openharness.engine.stream_events import (
|
||||
AssistantTextDelta, AssistantTurnComplete,
|
||||
ToolExecutionStarted, ToolExecutionCompleted,
|
||||
)
|
||||
r = {"text": "", "tools": [], "turns": 0, "in_tok": 0, "out_tok": 0}
|
||||
for ev in events:
|
||||
if isinstance(ev, AssistantTextDelta):
|
||||
r["text"] += ev.text
|
||||
elif isinstance(ev, ToolExecutionStarted):
|
||||
r["tools"].append(ev.tool_name)
|
||||
elif isinstance(ev, AssistantTurnComplete):
|
||||
r["turns"] += 1
|
||||
r["in_tok"] += ev.usage.input_tokens
|
||||
r["out_tok"] += ev.usage.output_tokens
|
||||
return r
|
||||
```
|
||||
|
||||
## Multi-Turn Memory Test
|
||||
|
||||
```python
|
||||
async def test_multi_turn_memory():
|
||||
engine = make_anthropic_engine("Remember what the user tells you.")
|
||||
[ev async for ev in engine.submit_message("My favorite number is 42.")]
|
||||
[ev async for ev in engine.submit_message("What is 2+2?")]
|
||||
evs = [ev async for ev in engine.submit_message("What is my favorite number?")]
|
||||
r = collect(evs)
|
||||
assert "42" in r["text"]
|
||||
```
|
||||
|
||||
## Hook Blocks Tool → Model Adapts
|
||||
|
||||
```python
|
||||
async def test_hook_blocks():
|
||||
from openharness.hooks.events import HookEvent
|
||||
from openharness.hooks.loader import HookRegistry
|
||||
from openharness.hooks.schemas import CommandHookDefinition
|
||||
from openharness.hooks.executor import HookExecutor, HookExecutionContext
|
||||
|
||||
hook_reg = HookRegistry()
|
||||
hook_reg.register(HookEvent.PRE_TOOL_USE, CommandHookDefinition(
|
||||
type="command", command="exit 1",
|
||||
matcher="bash", block_on_failure=True, timeout_seconds=5,
|
||||
))
|
||||
# ... create engine with hook_executor, model tries bash, gets blocked, adapts to glob
|
||||
```
|
||||
|
||||
## Skill Tool Invocation
|
||||
|
||||
```python
|
||||
async def test_skill_invocation():
|
||||
from openharness.tools.skill_tool import SkillTool
|
||||
engine = make_anthropic_engine(
|
||||
"Use the 'skill' tool to load instructions before working.",
|
||||
extra_tools=[SkillTool()],
|
||||
)
|
||||
evs = [ev async for ev in engine.submit_message(
|
||||
"Load the 'diagnose' skill, then investigate the codebase."
|
||||
)]
|
||||
r = collect(evs)
|
||||
assert "skill" in r["tools"]
|
||||
```
|
||||
|
||||
## InProcess Concurrent Teammates
|
||||
|
||||
```python
|
||||
async def test_concurrent_teammates():
|
||||
from openharness.swarm.in_process import start_in_process_teammate, TeammateAbortController
|
||||
from openharness.swarm.types import TeammateSpawnConfig
|
||||
from openharness.engine.query import QueryContext
|
||||
|
||||
async def run_one(name, prompt):
|
||||
ctx = QueryContext(api_client=api, tool_registry=reg, ...)
|
||||
config = TeammateSpawnConfig(name=name, team="test", prompt=prompt, ...)
|
||||
abort = TeammateAbortController()
|
||||
await start_in_process_teammate(config=config, agent_id=f"{name}@test", abort_controller=abort, query_context=ctx)
|
||||
|
||||
await asyncio.gather(
|
||||
asyncio.wait_for(run_one("worker-a", "Count .py files"), timeout=30),
|
||||
asyncio.wait_for(run_one("worker-b", "Find main class"), timeout=30),
|
||||
)
|
||||
```
|
||||
|
||||
## Session Save → Resume
|
||||
|
||||
```python
|
||||
async def test_session_resume():
|
||||
from openharness.services.session_storage import save_session_snapshot, load_session_snapshot
|
||||
from openharness.engine.messages import ConversationMessage
|
||||
|
||||
engine1 = make_anthropic_engine("Remember context.")
|
||||
[ev async for ev in engine1.submit_message("Project uses FastAPI + React.")]
|
||||
save_session_snapshot(cwd=tmpdir, model=MODEL, system_prompt="...", messages=engine1.messages, usage=engine1.total_usage)
|
||||
|
||||
loaded = load_session_snapshot(tmpdir)
|
||||
engine2 = make_anthropic_engine("Continue analysis.")
|
||||
engine2.load_messages([ConversationMessage.model_validate(m) for m in loaded["messages"]])
|
||||
evs = [ev async for ev in engine2.submit_message("What tech stack did I mention?")]
|
||||
assert "fastapi" in collect(evs)["text"].lower()
|
||||
```
|
||||
@@ -1,99 +0,0 @@
|
||||
---
|
||||
name: pr-merge
|
||||
description: This skill should be used when the user asks to "merge a PR", "review and merge pull requests", "integrate external contributions", "handle PR conflicts", "cherry-pick from a PR", or needs to merge GitHub PRs while maximizing contributor attribution.
|
||||
version: 0.1.0
|
||||
---
|
||||
|
||||
# PR Merge — Contributor-First Pull Request Integration
|
||||
|
||||
Merge external pull requests while maximizing original author attribution. Core principle: **merge first, resolve conflicts after** — never rewrite a contributor's work from scratch.
|
||||
|
||||
## Core Principles
|
||||
|
||||
1. **Preserve authorship** — use `gh pr merge --squash` for clean PRs. For manual merges, use `--author="Name <email>"`.
|
||||
2. **Merge first, fix after** — accept the PR's approach even if it differs from local style. Fix conflicts in a separate commit.
|
||||
3. **Selective merge is OK** — exclude files with `--exclude` when a PR contains features already implemented locally. Document what was excluded.
|
||||
4. **Never `git apply` + self-commit** — this loses author attribution entirely.
|
||||
|
||||
## Workflow
|
||||
|
||||
### 1. Triage Open PRs
|
||||
|
||||
```bash
|
||||
gh pr list --repo OWNER/REPO --state open \
|
||||
--json number,title,author,additions,deletions,mergeable
|
||||
```
|
||||
|
||||
Classify each PR: merge directly, merge with conflict resolution, selective merge, or close.
|
||||
|
||||
### 2. Merge Clean PRs via GitHub
|
||||
|
||||
Prefer `gh pr merge` — preserves author automatically:
|
||||
|
||||
```bash
|
||||
gh pr merge NUMBER --repo OWNER/REPO --squash \
|
||||
--subject "feat: description (#NUMBER)"
|
||||
```
|
||||
|
||||
### 3. Handle Conflicting PRs Locally
|
||||
|
||||
```bash
|
||||
git fetch origin pull/NUMBER/head:pr-NUMBER
|
||||
git merge pr-NUMBER --no-edit
|
||||
# Resolve conflicts keeping both sides where possible
|
||||
git add -A && git commit --no-edit
|
||||
```
|
||||
|
||||
### 4. Selective Merge (Skip Some Files)
|
||||
|
||||
When a PR contains features already implemented locally:
|
||||
|
||||
```bash
|
||||
gh pr diff NUMBER --repo OWNER/REPO | \
|
||||
git apply --exclude='path/to/skip.py' --exclude='CHANGELOG.md'
|
||||
git commit --author="Author Name <author@email>" \
|
||||
-m "feat: description (#NUMBER)
|
||||
|
||||
Cherry-picked from PR #NUMBER. Excluded: file.py (already implemented)."
|
||||
```
|
||||
|
||||
### 5. Close Duplicate/Superseded PRs
|
||||
|
||||
```bash
|
||||
gh pr close NUMBER --repo OWNER/REPO \
|
||||
--comment "Fixed via PR #OTHER. Thank you for the contribution!"
|
||||
```
|
||||
|
||||
### 6. Post-Merge Verification
|
||||
|
||||
```bash
|
||||
python -m ruff check src/ tests/ # lint
|
||||
python -m pytest tests/ -q # unit tests
|
||||
git push origin main # push
|
||||
```
|
||||
|
||||
Then run `harness-eval` for end-to-end verification on an unfamiliar codebase.
|
||||
|
||||
## Attribution Checklist
|
||||
|
||||
Before pushing a merged PR:
|
||||
|
||||
- [ ] Original author appears in `git log` (via `--author` or GitHub squash merge)
|
||||
- [ ] Commit message references PR number (`#NUMBER`)
|
||||
- [ ] If selectively merged, commit body explains exclusions
|
||||
- [ ] Closed PRs have a comment thanking the contributor
|
||||
- [ ] Duplicate PRs acknowledge the contributor's investigation
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
- `git apply` + self-commit loses author
|
||||
- Rewriting from scratch instead of merging — merge their code, fix style after
|
||||
- Force-pushing main after merge — may remove contributor commits
|
||||
- Forgetting CHANGELOG conflicts — always exclude and handle manually
|
||||
- Not testing after merge — clean merge doesn't mean working code
|
||||
|
||||
## Additional Resources
|
||||
|
||||
### Reference Files
|
||||
|
||||
- **`references/merge-scenarios.md`** — Detailed examples for each merge scenario (clean, conflicting, selective, duplicate)
|
||||
@@ -1,131 +0,0 @@
|
||||
# Merge Scenarios — Detailed Examples
|
||||
|
||||
## Scenario 1: Clean Merge (No Conflicts)
|
||||
|
||||
PR adds a new feature, no files overlap with local changes.
|
||||
|
||||
```bash
|
||||
# Preferred: squash merge via GitHub (preserves author)
|
||||
gh pr merge 17 --repo HKUDS/OpenHarness --squash \
|
||||
--subject "feat(skills): add diagnose skill (#17)"
|
||||
|
||||
# Result: author "Qu Zhi" appears in git log
|
||||
```
|
||||
|
||||
## Scenario 2: CHANGELOG Conflict Only
|
||||
|
||||
Almost every PR touches CHANGELOG.md. Handle by excluding it:
|
||||
|
||||
```bash
|
||||
gh pr diff 14 --repo OWNER/REPO | \
|
||||
git apply --exclude='CHANGELOG.md'
|
||||
git add -A
|
||||
|
||||
# Manually merge CHANGELOG sections (keep both)
|
||||
# Then commit with original author
|
||||
git commit --author="washi4 <washi4@users.noreply.github.com>" \
|
||||
-m "feat: add OpenAI-compatible client (#14)"
|
||||
```
|
||||
|
||||
## Scenario 3: Conflicting UI Files
|
||||
|
||||
PR #14 modified ui/app.py and ui/backend_host.py which were also changed by PR #13.
|
||||
|
||||
```bash
|
||||
# Apply excluding conflicting files
|
||||
gh pr diff 14 --repo OWNER/REPO | \
|
||||
git apply --exclude='src/ui/app.py' --exclude='src/ui/backend_host.py' --exclude='CHANGELOG.md'
|
||||
|
||||
# Manually add the PR's changes to conflicting files
|
||||
# Read the PR diff to understand what they added, apply by hand
|
||||
# Commit with author
|
||||
git commit --author="Author <email>" -m "feat: description (#14)"
|
||||
```
|
||||
|
||||
## Scenario 4: Selective Merge (Skip Features)
|
||||
|
||||
PR #16 has auto-compact (already implemented locally) + --resume (wanted) + cron (wanted).
|
||||
|
||||
```bash
|
||||
# Exclude the file containing the unwanted feature
|
||||
gh pr diff 16 --repo OWNER/REPO | git apply \
|
||||
--exclude='src/engine/query_engine.py' \ # skip auto-compact
|
||||
--exclude='CHANGELOG.md' \
|
||||
--exclude='README.md'
|
||||
|
||||
# Commit with explanation
|
||||
git commit --author="Chao Qin <win4r@users.noreply.github.com>" \
|
||||
-m "feat: wire --resume/--continue and cron scheduler (#16)
|
||||
|
||||
Cherry-picked from PR #16. Excluded auto-compact (already implemented
|
||||
with LLM-based approach from reference source)."
|
||||
```
|
||||
|
||||
## Scenario 5: Duplicate PRs (Same Bug, Different Fix)
|
||||
|
||||
PR #11 and #13 both fix the double-Enter bug. #13 is smaller and cleaner.
|
||||
|
||||
```bash
|
||||
# Merge #13 (the better fix)
|
||||
gh pr merge 13 --repo OWNER/REPO --squash
|
||||
|
||||
# Close #11 with acknowledgment
|
||||
gh pr close 11 --repo OWNER/REPO --comment \
|
||||
"Fixed via PR #13 (smaller patch for the same bug). \
|
||||
Thank you for the detailed investigation!"
|
||||
```
|
||||
|
||||
## Scenario 6: PR From a Fork
|
||||
|
||||
The contributor pushed to their fork, not a branch on the repo.
|
||||
|
||||
```bash
|
||||
# Fetch via PR ref (works for any PR regardless of source)
|
||||
git fetch origin pull/14/head:pr-14
|
||||
git merge pr-14 --no-edit
|
||||
|
||||
# If merge conflict:
|
||||
git mergetool # or manually resolve
|
||||
git add -A && git commit --no-edit
|
||||
|
||||
git push origin main
|
||||
```
|
||||
|
||||
## Scenario 7: Batch Merge (Multiple PRs)
|
||||
|
||||
Merge in dependency order, test after each:
|
||||
|
||||
```bash
|
||||
# 1. Small fixes first (least risk)
|
||||
gh pr merge 17 --squash # skill file
|
||||
gh pr merge 13 --squash # 1-file bug fix
|
||||
|
||||
# 2. Medium changes
|
||||
gh pr merge 12 --squash # memory improvement
|
||||
|
||||
# 3. Large features last (most conflict risk)
|
||||
# PR #14 needs manual conflict resolution
|
||||
git fetch origin pull/14/head:pr-14
|
||||
git merge pr-14
|
||||
# resolve CHANGELOG conflict
|
||||
git push origin main
|
||||
|
||||
# 4. Test after all merges
|
||||
python -m ruff check src/ tests/
|
||||
python -m pytest tests/ -q
|
||||
```
|
||||
|
||||
## Post-Merge Fix Commit Pattern
|
||||
|
||||
When the merged PR introduces a compatibility issue (e.g., API mismatch with a provider):
|
||||
|
||||
```bash
|
||||
# Fix in a SEPARATE commit (don't amend the author's commit)
|
||||
git commit -m "fix(api): handle Kimi reasoning_content in OpenAI client
|
||||
|
||||
Kimi k2.5 requires reasoning_content on assistant tool_call messages.
|
||||
Fix: capture during streaming, replay when converting back.
|
||||
Found during post-merge testing with harness-eval."
|
||||
```
|
||||
|
||||
This preserves the original author's commit intact while documenting the fix separately.
|
||||
@@ -1,59 +0,0 @@
|
||||
name: Autopilot Pages
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- "docs/autopilot/**"
|
||||
- "autopilot-dashboard/**"
|
||||
- ".github/workflows/autopilot-pages.yml"
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pages: write
|
||||
id-token: write
|
||||
|
||||
concurrency:
|
||||
group: autopilot-pages
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
environment:
|
||||
name: github-pages
|
||||
url: ${{ steps.deployment.outputs.page_url }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20"
|
||||
cache: "npm"
|
||||
cache-dependency-path: autopilot-dashboard/package-lock.json
|
||||
|
||||
- name: Install dependencies
|
||||
working-directory: autopilot-dashboard
|
||||
run: npm ci
|
||||
|
||||
- name: Build dashboard
|
||||
working-directory: autopilot-dashboard
|
||||
run: npm run build
|
||||
|
||||
- name: Configure Pages
|
||||
uses: actions/configure-pages@v5
|
||||
with:
|
||||
enablement: true
|
||||
|
||||
- name: Upload autopilot dashboard artifact
|
||||
uses: actions/upload-pages-artifact@v3
|
||||
with:
|
||||
path: docs/autopilot
|
||||
|
||||
- name: Deploy to GitHub Pages
|
||||
id: deployment
|
||||
uses: actions/deploy-pages@v4
|
||||
@@ -1,53 +0,0 @@
|
||||
name: Autopilot Run Next
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: "0 */2 * * *"
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
issues: write
|
||||
pull-requests: write
|
||||
|
||||
concurrency:
|
||||
group: autopilot-run-next
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
run-next:
|
||||
runs-on: self-hosted
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
clean: false
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.11"
|
||||
|
||||
- name: Set up uv
|
||||
uses: astral-sh/setup-uv@v6
|
||||
with:
|
||||
enable-cache: true
|
||||
|
||||
- name: Install dependencies
|
||||
run: uv sync --extra dev
|
||||
|
||||
- name: Execute autopilot tick
|
||||
run: uv run python -m openharness autopilot tick --cwd "$GITHUB_WORKSPACE"
|
||||
|
||||
- name: Export dashboard
|
||||
run: uv run python -m openharness autopilot export-dashboard --cwd "$GITHUB_WORKSPACE"
|
||||
|
||||
- name: Commit dashboard updates
|
||||
run: |
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
git add docs/autopilot
|
||||
git diff --cached --quiet && exit 0
|
||||
git commit -m "autopilot: refresh dashboard after run"
|
||||
git push
|
||||
@@ -1,53 +0,0 @@
|
||||
name: Autopilot Scan
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: "*/30 * * * *"
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
issues: write
|
||||
pull-requests: write
|
||||
|
||||
concurrency:
|
||||
group: autopilot-scan
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
scan:
|
||||
runs-on: self-hosted
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
clean: false
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.11"
|
||||
|
||||
- name: Set up uv
|
||||
uses: astral-sh/setup-uv@v6
|
||||
with:
|
||||
enable-cache: true
|
||||
|
||||
- name: Install dependencies
|
||||
run: uv sync --extra dev
|
||||
|
||||
- name: Scan autopilot intake
|
||||
run: uv run python -m openharness autopilot scan all --cwd "$GITHUB_WORKSPACE"
|
||||
|
||||
- name: Export dashboard
|
||||
run: uv run python -m openharness autopilot export-dashboard --cwd "$GITHUB_WORKSPACE"
|
||||
|
||||
- name: Commit dashboard updates
|
||||
run: |
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
git add docs/autopilot
|
||||
git diff --cached --quiet && exit 0
|
||||
git commit -m "autopilot: refresh dashboard after scan"
|
||||
git push
|
||||
@@ -10,7 +10,6 @@ build/
|
||||
.mypy_cache/
|
||||
.ruff_cache/
|
||||
.venv/
|
||||
.openharness-venv/
|
||||
venv/
|
||||
env/
|
||||
.env
|
||||
|
||||
@@ -8,94 +8,15 @@ The format is based on Keep a Changelog, and this project currently tracks chang
|
||||
|
||||
### Added
|
||||
|
||||
- Hooks now support a `priority` field (default `0`). Within an event, hooks run highest-priority first, and hooks sharing a priority keep their registration order. This lets users order, for example, a security-check hook ahead of a logging hook regardless of where each is declared in settings or contributed by plugins.
|
||||
- `edit_file` and `write_file` in the React TUI now preview a unified diff before applying file changes, let users approve once or for the rest of the session, and skip the extra prompt automatically in `full_auto` mode.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Codex subscription requests now pass reasoning effort separately, enabling `gpt-5.5` with `xhigh` effort instead of treating `gpt-5.5 xhigh` as an unsupported model name.
|
||||
- Telegram channel now delivers replies again under `ohmo init --no-interactive` and other configs that do not write a `reply_to_message` field. `TelegramConfig` declares `reply_to_message: bool = True` so the attribute access in `TelegramChannel.send` no longer raises `AttributeError` and outbound progress/tool-hint/final messages are sent as expected. See issue #243.
|
||||
|
||||
## [0.1.9] - 2026-05-07
|
||||
|
||||
### Added
|
||||
|
||||
- Added a bundled `skill-creator` skill for creating, improving, and verifying OpenHarness/ohmo skills.
|
||||
- User-invocable skills can now be triggered directly as slash commands, with support for skill-specific arguments and model override metadata.
|
||||
|
||||
### Fixed
|
||||
|
||||
- `oh setup` can now update the API key for an already-configured API-key provider profile instead of only changing the model.
|
||||
- `oh provider edit <profile> --api-key <key>` can now replace a saved profile API key, and `oh provider add ... --api-key <key>` can store one during profile creation.
|
||||
|
||||
## [0.1.8] - 2026-05-06
|
||||
|
||||
### Added
|
||||
|
||||
- Built-in `nvidia` provider profile so `oh setup` offers NVIDIA NIM as a first-class OpenAI-compatible provider choice, with `NVIDIA_API_KEY` auth source, `openai/gpt-oss-120b` as the default model, and the NVIDIA NIM endpoint.
|
||||
- Built-in `qwen` provider profile so `oh setup` offers Qwen (DashScope) as a first-class provider choice, with `dashscope_api_key` auth source, `qwen-plus` as the default model, and the DashScope OpenAI-compatible endpoint.
|
||||
- Plugin tool discovery: plugins can now provide `BaseTool` subclasses in a `<plugin>/tools/` directory and they are auto-discovered, instantiated, and registered in the tool registry at runtime. Add `tools_dir` to `plugin.json` (defaults to `"tools"`).
|
||||
- `oh --dry-run` safe preview mode for inspecting resolved runtime settings, auth state, prompt assembly, commands, skills, tools, and configured MCP servers without executing the model or tools.
|
||||
- Built-in `minimax` provider profile so `oh setup` offers MiniMax as a first-class provider choice, with `MINIMAX_API_KEY` auth source, `MiniMax-M2.7` as the default model, and `MiniMax-M2.7-highspeed` in the model picker.
|
||||
- Docker as an alternative sandbox backend (`sandbox.backend = "docker"`) for stronger execution isolation with configurable resource limits, network isolation, and automatic image management.
|
||||
- Built-in `gemini` provider profile so `oh setup` offers Google Gemini as a first-class provider choice, with `gemini_api_key` auth source and `gemini-2.5-flash` as the default model.
|
||||
- `diagnose` skill: trace agent run failures and regressions using structured evidence from run artifacts.
|
||||
- OpenAI-compatible API client (`--api-format openai`) supporting any provider that implements the OpenAI `/v1/chat/completions` format, including Alibaba DashScope, DeepSeek, GitHub Models, Groq, Together AI, Ollama, and more.
|
||||
- `OPENHARNESS_API_FORMAT` environment variable for selecting the API format.
|
||||
- `OPENAI_API_KEY` fallback when using OpenAI-format providers.
|
||||
- GitHub Actions CI workflow for Python linting, tests, and frontend TypeScript checks.
|
||||
- `CONTRIBUTING.md` with local setup, validation commands, and PR expectations.
|
||||
- `docs/SHOWCASE.md` with concrete OpenHarness usage patterns and demo commands.
|
||||
- GitHub issue templates and a pull request template.
|
||||
- React TUI assistant messages now render structured Markdown blocks, including headings, lists, code fences, blockquotes, links, and tables.
|
||||
- Built-in `codex` output style for compact, low-noise transcript rendering in React TUI.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Subprocess teammate spawn (`agent` tool, `task_create`) now works on Windows under Git Bash. `subprocess_backend.spawn` builds a direct-exec `argv` list and passes it through new `argv=` and `env=` kwargs on `BackgroundTaskManager.create_agent_task` / `create_shell_task`; `_start_process` then runs the executable via `asyncio.create_subprocess_exec(*argv)` with no shell in between. Previously the spawn command was a single string interpreted by `bash -lc`, which on Windows could not reliably exec a Windows-pathed Python interpreter (e.g. `C:\Users\...\python.exe`) — Git Bash's escape parser consumed the backslashes from the embedded env-prefix and, even with proper quoting, bash launched via `asyncio.create_subprocess_exec` returned `command not found` for Windows-pathed binaries that worked perfectly when invoked interactively. Bypassing the shell sidesteps the entire class of cross-platform quoting and path-translation hazard. The legacy shell-evaluated `command=` path is preserved for callers (e.g. `BashTool`) that legitimately want shell semantics. See issue #230.
|
||||
- Bundled skill loader now uses `yaml.safe_load` for SKILL.md frontmatter, matching the user-skill loader. The shared parser is extracted to `openharness.skills._frontmatter` so bundled and user skills handle YAML block scalars (`>`, `|`), quoted values, and other standard YAML constructs the same way.
|
||||
- Compaction now detects llama.cpp/OpenAI-compatible context overflow errors, accounts for image blocks in auto-compact token estimates, and strips image payloads from summarizer-only compaction requests.
|
||||
- Large tool results are now bounded in conversation history: oversized outputs are saved under `tool_artifacts`, old MCP results become microcompactable, and context collapse trims stale tool-result payloads.
|
||||
- ohmo now keeps personal memory isolated from OpenHarness project memory: `/memory` in ohmo sessions targets the ohmo workspace memory store, and ohmo runtime prompt refreshes no longer inject project memory unless explicitly requested.
|
||||
- Fixed `glob` and `grep` tools hanging indefinitely when the `rg` subprocess produced enough stderr output to fill the OS pipe buffer. `stderr` is now redirected to `DEVNULL` so it is discarded rather than blocking the child process.
|
||||
- Fixed `bash_tool` hanging after a timed-out command when the subprocess stdout stream stayed open. `_read_remaining_output` now applies a 2-second `asyncio.wait_for` timeout so the tool always returns promptly.
|
||||
- Fixed `session_runner` background task deadlock caused by an unread `stderr=PIPE` stream. The subprocess now uses `stderr=STDOUT` so all output merges into the single readable stdout pipe.
|
||||
- React TUI prompt input now treats the raw DEL byte (`0x7f`) as backward delete while preserving true forward-delete escape sequences, fixing backspace failures seen in some macOS terminal environments.
|
||||
- `todo_write` tool now updates an existing unchecked item in-place when `checked=True` instead of appending a duplicate `[x]` line.
|
||||
|
||||
- Built-in `Explore` and `claude-code-guide` agents no longer hard-code `model="haiku"`, which caused them to fail for users on non-Anthropic providers (OpenAI, Bedrock, custom base URLs, etc.). Both agents now use `model="inherit"` so they run with whatever model the parent session is using. `build_inherited_cli_flags` is also fixed to skip the `--model` flag entirely when the value is `"inherit"`, letting the subprocess correctly inherit the parent model via the `OPENHARNESS_MODEL` environment variable instead of receiving the literal string `"inherit"` as a model name.
|
||||
|
||||
- React TUI spinner now stays visible throughout the entire agent turn: `assistant_complete` no longer resets `busy` state prematurely, and `tool_started` explicitly sets `busy=true` so the status bar remains active even when tool calls follow an assistant message. `line_complete` is the sole signal that ends the turn and clears the spinner.
|
||||
- Skill loader now uses `yaml.safe_load` to parse SKILL.md frontmatter, correctly handling YAML block scalars (`>`, `|`), quoted values, and other standard YAML constructs instead of naive line-by-line splitting.
|
||||
- `BackendHostConfig` was missing the `cwd` field, causing `AttributeError: 'BackendHostConfig' object has no attribute 'cwd'` on startup when `oh` was run after the runtime refactor that added `cwd` support to `build_runtime`.
|
||||
- Shell-escape `$ARGUMENTS` substitution in command hooks to prevent shell injection from payload values containing metacharacters like `$(...)` or backticks.
|
||||
- Swarm `_READ_ONLY_TOOLS` now uses actual registered tool names (snake_case) instead of PascalCase, fixing read-only auto-approval in `handle_permission_request`.
|
||||
- Memory scanner now parses YAML frontmatter (`name`, `description`, `type`) instead of returning raw `---` as description.
|
||||
- Memory search matches against body content in addition to metadata, with metadata weighted higher for relevance.
|
||||
- Memory search tokenizer handles Han characters for multilingual queries.
|
||||
- Fixed duplicate response in React TUI caused by double Enter key submission in the input handler.
|
||||
- Fixed concurrent permission modals overwriting each other in TUI default mode when the LLM returns multiple tool calls in one response; `_ask_permission` now serialises callers via an `asyncio.Lock` so each modal is shown and resolved before the next one is emitted.
|
||||
- Fixed React TUI Markdown tables to size columns from rendered cell text so inline formatting like code spans and bold text no longer breaks alignment.
|
||||
- Fixed grep tool crashing with `ValueError` / `LimitOverrunError` when ripgrep outputs a line longer than 64 KB (e.g. minified assets or lock files). The asyncio subprocess stream limit is now 8 MB and oversized lines are skipped rather than terminating the session.
|
||||
- Fixed React TUI exit leaving the shell prompt concatenated with the last TUI line. The terminal cleanup handler now writes a trailing newline (`\n`) alongside the cursor-show escape sequence so the shell prompt always starts on a fresh line.
|
||||
- Reduced React TUI redraw pressure when `output_style=codex` by avoiding token-level assistant buffer flushes during streaming.
|
||||
|
||||
### Changed
|
||||
|
||||
- ohmo Feishu group routing now supports managed group creation, gateway-scoped provider/model commands, and stricter group mention handling so group conversations only wake ohmo when explicitly addressed.
|
||||
- Dry-run output now reports a `ready` / `warning` / `blocked` readiness verdict, concrete `next_actions`, likely matching skills/tools for normal prompts, and richer slash-command previews for read-only vs stateful command paths.
|
||||
- React TUI now groups consecutive `tool` + `tool_result` transcript rows into a single compound row: success shows the result line count inline (e.g. `→ 24L`), errors show a red icon and up to 5 lines of error detail beneath the tool row. Standalone successful tool results are suppressed to reduce transcript noise; standalone errors are still surfaced.
|
||||
- README now links to contribution docs, changelog, showcase material, and provider compatibility guidance.
|
||||
- README quick start now includes a one-command demo and clearer provider compatibility notes.
|
||||
- README provider compatibility section updated to include OpenAI-format providers.
|
||||
|
||||
## [0.1.7] - 2026-04-18
|
||||
|
||||
### Fixed
|
||||
|
||||
- Install script now links `oh`, `ohmo`, and `openharness` into `~/.local/bin` instead of prepending the virtualenv `bin` directory to `PATH`, which avoids overriding Conda-managed shells while preserving global command discovery.
|
||||
- React TUI prompt now supports `Shift+Enter` for inserting a newline without submitting the current prompt.
|
||||
- React TUI busy-state animation is less error-prone on Windows terminals: the extra pseudo-animation line was removed, Windows now uses conservative ASCII spinner frames, and the spinner interval was slightly slowed to reduce flashing.
|
||||
|
||||
## [0.1.0] - 2026-04-01
|
||||
|
||||
|
||||
@@ -1,20 +1,7 @@
|
||||
<h1 align="center">
|
||||
<img src="assets/logo.png" alt="OpenHarness" width="64" style="vertical-align: middle;">
|
||||
|
||||
<img src="assets/ohmo.png" alt="ohmo" width="64" style="vertical-align: middle;">
|
||||
<br>
|
||||
<code>oh</code> — OpenHarness & <code>ohmo</code>
|
||||
</h1>
|
||||
|
||||
<p align="center">
|
||||
<a href="README.md"><strong>English</strong></a> ·
|
||||
<a href="README.zh-CN.md"><strong>简体中文</strong></a>
|
||||
</p>
|
||||
<h1 align="center"><img src="assets/logo.png" alt="OpenHarness" width="64" style="vertical-align: middle;"> <code>oh</code> — OpenHarness: Open Agent Harness</h1>
|
||||
|
||||
**OpenHarness** delivers core lightweight agent infrastructure: tool-use, skills, memory, and multi-agent coordination.
|
||||
|
||||
**ohmo** is a personal AI agent built on OpenHarness — not another chatbot, but an assistant that actually works for you over long sessions. Chat with ohmo in Feishu / Slack / Telegram / Discord, and it forks branches, writes code, runs tests, and opens PRs on its own. ohmo runs on your existing Claude Code or Codex subscription — no extra API key needed.
|
||||
|
||||
**Join the community**: contribute **Harness** for open agent development.
|
||||
|
||||
<p align="center">
|
||||
@@ -44,6 +31,10 @@ Supports CLI agent integration including OpenClaw, nanobot, Cursor, and more.
|
||||
<img src="assets/cli-typing.gif" alt="OpenHarness Terminal Demo" width="800">
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="assets/architecture-comic.png" alt="How Agent Harness Works" width="800">
|
||||
</p>
|
||||
|
||||
---
|
||||
## ✨ OpenHarness's Key Harness Features
|
||||
|
||||
@@ -134,6 +125,24 @@ Supports CLI agent integration including OpenClaw, nanobot, Cursor, and more.
|
||||
|
||||
---
|
||||
|
||||
## 🚀 44x Lighter Than Claude Code
|
||||
|
||||
<table>
|
||||
<tr><th></th><th>Claude Code</th><th>OpenHarness</th></tr>
|
||||
<tr><td><strong>Lines of Code</strong></td><td>512,664</td><td><strong>11,733</strong> (44x lighter)</td></tr>
|
||||
<tr><td><strong>Files</strong></td><td>1,884</td><td><strong>163</strong></td></tr>
|
||||
<tr><td><strong>Language</strong></td><td>TypeScript</td><td>Python</td></tr>
|
||||
<tr><td><strong>Tools</strong></td><td>~44</td><td>43 (98%)</td></tr>
|
||||
<tr><td><strong>Commands</strong></td><td>~88</td><td>54 (61%)</td></tr>
|
||||
<tr><td><strong>Skills Compatible</strong></td><td>✅</td><td>✅ anthropics/skills</td></tr>
|
||||
<tr><td><strong>Plugin Compatible</strong></td><td>✅</td><td>✅ claude-code/plugins</td></tr>
|
||||
<tr><td><strong>Tests</strong></td><td>—</td><td>114 unit + 6 E2E suites</td></tr>
|
||||
</table>
|
||||
|
||||
Leverages Python's power with pure focus on Harness architecture—stripped of enterprise overhead like telemetry, OAuth complexity, and hundreds of React components.
|
||||
|
||||
---
|
||||
|
||||
## 🤔 What is an Agent Harness?
|
||||
|
||||
An **Agent Harness** is the complete infrastructure that wraps around an LLM to make it a functional agent. The model provides intelligence; the harness provides **hands, eyes, memory, and safety boundaries**.
|
||||
@@ -153,34 +162,6 @@ OpenHarness is an open-source Python implementation designed for **researchers,
|
||||
|
||||
## 📰 What's New
|
||||
|
||||
- **Unreleased** 🔍 **Dry-run safe preview**:
|
||||
- `oh --dry-run` previews resolved runtime settings, auth state, skills, commands, tools, and configured MCP servers without executing the model, tools, or subagents.
|
||||
- Dry-run now reports a `ready` / `warning` / `blocked` readiness verdict with concrete next-step suggestions such as fixing auth, fixing MCP config, or running the prompt directly.
|
||||
- Prompt previews include likely matching skills and tools, while slash-command previews show whether the command is mostly read-only or stateful.
|
||||
- **2026-04-18** ⚙️ **v0.1.7** — Packaging & TUI polish:
|
||||
- Install script now links `oh`, `ohmo`, and `openharness` into `~/.local/bin` instead of prepending the virtualenv `bin` directory to `PATH`, which avoids clobbering Conda-managed shells.
|
||||
- React TUI now supports `Shift+Enter` to insert a newline while keeping plain `Enter` as submit.
|
||||
- Busy-state animation in the React TUI is quieter and less error-prone on Windows terminals, with conservative spinner frames and reduced flashing.
|
||||
- **2026-04-10** 🧠 **v0.1.6** — Auto-Compaction & Markdown TUI:
|
||||
- Auto-Compaction preserves task state and channel logs across context compression — agents can run multi-day sessions without manual compact/clear
|
||||
- Subprocess teammates run in headless worker mode; agent team creation stabilized
|
||||
- Assistant messages now render full Markdown in the React TUI
|
||||
- `ohmo` gains channel slash commands and multimodal attachment support
|
||||
- **2026-04-08** 🔌 **v0.1.5** — MCP HTTP transport & Swarm polling:
|
||||
- MCP protocol adds HTTP transport, auto-reconnect on disconnect, and tool-only server compatibility
|
||||
- JSON Schema types inferred for MCP tool inputs — no manual type mapping needed
|
||||
- `ohmo` channels support file attachments and multimodal gateway messages
|
||||
- Subprocess agents are now pollable in real runs; permission modals serialized to prevent input swallowing
|
||||
- **2026-04-08** 🌙 **v0.1.4** — Multi-provider auth & Moonshot/Kimi:
|
||||
- Native Moonshot/Kimi provider with `reasoning_content` support for thinking models
|
||||
- Auth overhaul: fixed provider-switching key mismatch, `OPENAI_BASE_URL` env override, profile-scoped credential priority
|
||||
- MCP gracefully handles disconnected servers in `call_tool` / `read_resource`
|
||||
- Security: built-in sensitive-path protection in PermissionChecker, hardened `web_fetch` URL validation
|
||||
- Stability: EIO crash recovery in Ink TUI, `--debug` logging, Windows cmd flash fix
|
||||
- **2026-04-06** 🚀 **v0.1.2** — Unified setup flows and `ohmo` personal-agent app:
|
||||
- `oh setup` now guides provider selection as workflows instead of exposing raw auth/provider internals
|
||||
- Compatible API setup is now profile-scoped, so Anthropic/OpenAI-compatible endpoints can keep separate keys
|
||||
- `ohmo` ships as a packaged app with `~/.ohmo` workspace, gateway, bootstrap prompts, and channel config flow
|
||||
- **2026-04-01** 🎨 **v0.1.0** — Initial **OpenHarness** open-source release featuring complete Harness architecture:
|
||||
|
||||
<p align="center">
|
||||
@@ -196,62 +177,40 @@ OpenHarness is an open-source Python implementation designed for **researchers,
|
||||
|
||||
## 🚀 Quick Start
|
||||
|
||||
### 1. Install
|
||||
### Prerequisites
|
||||
|
||||
#### Linux / macOS / WSL
|
||||
- **Python 3.10+** and [uv](https://docs.astral.sh/uv/)
|
||||
- **Node.js 18+** (optional, for the React terminal UI)
|
||||
- An LLM API key
|
||||
|
||||
### One-Command Demo
|
||||
|
||||
```bash
|
||||
# One-click install
|
||||
curl -fsSL https://raw.githubusercontent.com/HKUDS/OpenHarness/main/scripts/install.sh | bash
|
||||
|
||||
# Or via pip
|
||||
pip install openharness-ai
|
||||
ANTHROPIC_API_KEY=your_key uv run oh -p "Inspect this repository and list the top 3 refactors"
|
||||
```
|
||||
|
||||
#### Windows (Native)
|
||||
|
||||
```powershell
|
||||
# One-click install (PowerShell)
|
||||
iex (Invoke-WebRequest -Uri 'https://raw.githubusercontent.com/HKUDS/OpenHarness/main/scripts/install.ps1')
|
||||
|
||||
# Or via pip
|
||||
pip install openharness-ai
|
||||
```
|
||||
|
||||
**Note**: Windows support is now native. In PowerShell, use `openh` instead of `oh` because `oh` can resolve to the built-in `Out-Host` alias.
|
||||
|
||||
### 2. Configure
|
||||
### Install & Run
|
||||
|
||||
```bash
|
||||
oh setup # interactive wizard — pick a provider, authenticate, done
|
||||
# On Windows PowerShell, use: openh setup
|
||||
```
|
||||
# Clone and install
|
||||
git clone https://github.com/HKUDS/OpenHarness.git
|
||||
cd OpenHarness
|
||||
uv sync --extra dev
|
||||
|
||||
Supports **Claude / OpenAI / Copilot / Codex / Moonshot(Kimi) / GLM / MiniMax / NVIDIA NIM** and any compatible endpoint.
|
||||
# Example: use Kimi as the backend
|
||||
export ANTHROPIC_BASE_URL=https://api.moonshot.cn/anthropic
|
||||
export ANTHROPIC_API_KEY=your_kimi_api_key
|
||||
export ANTHROPIC_MODEL=kimi-k2.5
|
||||
|
||||
### 3. Run
|
||||
|
||||
```bash
|
||||
oh
|
||||
# On Windows PowerShell, use: openh
|
||||
# Launch
|
||||
oh # if venv is activated
|
||||
uv run oh # without activating venv
|
||||
```
|
||||
|
||||
<p align="center">
|
||||
<img src="assets/landing.png" alt="OpenHarness Landing Screen" width="700">
|
||||
</p>
|
||||
|
||||
### 4. Set up ohmo (Personal Agent)
|
||||
|
||||
Want an AI agent that works for you from Feishu / Slack / Telegram / Discord?
|
||||
|
||||
```bash
|
||||
ohmo init # initialize ~/.ohmo workspace
|
||||
ohmo config # configure channels and provider
|
||||
ohmo gateway start # start the gateway — ohmo is now live in your chat app
|
||||
```
|
||||
|
||||
ohmo runs on your existing **Claude Code subscription** or **Codex subscription** — no extra API key needed.
|
||||
|
||||
### Non-Interactive Mode (Pipes & Scripts)
|
||||
|
||||
```bash
|
||||
@@ -265,181 +224,19 @@ oh -p "List all functions in main.py" --output-format json
|
||||
oh -p "Fix the bug" --output-format stream-json
|
||||
```
|
||||
|
||||
### Dry Run (Safe Preview)
|
||||
|
||||
Use `--dry-run` when you want to inspect what OpenHarness would use before any live execution starts.
|
||||
|
||||
```bash
|
||||
# Preview an interactive session setup
|
||||
oh --dry-run
|
||||
|
||||
# Preview one prompt without executing the model or tools
|
||||
oh --dry-run -p "Review this bug fix and grep for failing tests"
|
||||
|
||||
# Preview a slash command path
|
||||
oh --dry-run -p "/plugin list"
|
||||
|
||||
# Get structured output for scripts or channels
|
||||
oh --dry-run -p "Explain this repository" --output-format json
|
||||
```
|
||||
|
||||
Dry-run is intentionally static:
|
||||
|
||||
- It does **not** call the model
|
||||
- It does **not** execute tools or spawn subagents
|
||||
- It does **not** connect to MCP servers
|
||||
- It **does** resolve settings, auth status, prompt assembly, skills, commands, tools, and obvious MCP config problems
|
||||
|
||||
Readiness levels:
|
||||
|
||||
- `ready`: configuration looks usable; the next suggested action is usually to run the prompt directly
|
||||
- `warning`: OpenHarness can resolve the session, but something important still looks wrong, such as broken MCP config or missing auth for later model work
|
||||
- `blocked`: the requested path will not run successfully as-is, for example an unknown slash command or a prompt that cannot resolve a runtime client
|
||||
|
||||
`next actions` in the dry-run output tell you the shortest fix or follow-up step, such as:
|
||||
|
||||
- run `oh auth login`
|
||||
- fix or disable broken MCP configuration
|
||||
- run the prompt directly with `oh -p "..."` or open the interactive UI with `oh`
|
||||
|
||||
## 🔌 Provider Compatibility
|
||||
|
||||
OpenHarness treats providers as **workflows** backed by named profiles. In day-to-day use, prefer:
|
||||
OpenHarness currently detects and adapts to a small set of provider profiles in code. The table below is intentionally conservative and reflects the profiles implemented in `src/openharness/api/provider.py`.
|
||||
|
||||
```bash
|
||||
oh setup
|
||||
oh provider list
|
||||
oh provider use <profile>
|
||||
```
|
||||
| Provider profile | Detection signal | Auth kind | Voice mode | Notes |
|
||||
|------------------|------------------|-----------|------------|-------|
|
||||
| **Anthropic** | Default when no custom `ANTHROPIC_BASE_URL` is set | API key | Not wired in current build | Default Claude-oriented setup |
|
||||
| **Moonshot / Kimi** | `ANTHROPIC_BASE_URL` contains `moonshot` or model starts with `kimi` | API key | Not wired in current build | Works through an Anthropic-compatible endpoint |
|
||||
| **Vertex-compatible** | Base URL contains `vertex` or `aiplatform` | GCP | Not wired in current build | Good fit for Anthropic-style gateways on Vertex |
|
||||
| **Bedrock-compatible** | Base URL contains `bedrock` | AWS | Not wired in current build | Intended for Bedrock-style deployments |
|
||||
| **Generic Anthropic-compatible** | Any other explicit `ANTHROPIC_BASE_URL` | API key | Not wired in current build | Useful for proxies and internal gateways |
|
||||
|
||||
### Built-in Workflows
|
||||
|
||||
| Workflow | What it is | Typical backends |
|
||||
|----------|------------|------------------|
|
||||
| **Anthropic-Compatible API** | Anthropic-style request format | Claude official, Kimi, GLM, MiniMax, internal Anthropic-compatible gateways |
|
||||
| **Claude Subscription** | Claude CLI subscription bridge | Local `~/.claude/.credentials.json` |
|
||||
| **OpenAI-Compatible API** | OpenAI-style request format | OpenAI official, OpenRouter, DashScope, DeepSeek, SiliconFlow, Groq, Ollama, GitHub Models |
|
||||
| **Codex Subscription** | Codex CLI subscription bridge | Local `~/.codex/auth.json` |
|
||||
| **GitHub Copilot** | Copilot OAuth workflow | GitHub Copilot device-flow login |
|
||||
|
||||
### Compatible API Families
|
||||
|
||||
#### Anthropic-Compatible API
|
||||
|
||||
Typical examples:
|
||||
|
||||
| Backend | Base URL | Example models |
|
||||
|---------|----------|----------------|
|
||||
| **Claude official** | `https://api.anthropic.com` | `claude-sonnet-4-6`, `claude-opus-4-6` |
|
||||
| **Moonshot / Kimi** | `https://api.moonshot.cn/anthropic` | `kimi-k2.5` |
|
||||
| **Zhipu / GLM** | custom Anthropic-compatible endpoint | `glm-4.5` |
|
||||
| **MiniMax** | custom Anthropic-compatible endpoint | `minimax-m1` |
|
||||
|
||||
#### OpenAI-Compatible API
|
||||
|
||||
Any provider implementing the OpenAI `/v1/chat/completions` style API works:
|
||||
|
||||
| Backend | Base URL | Example models |
|
||||
|---------|----------|----------------|
|
||||
| **OpenAI** | `https://api.openai.com/v1` | `gpt-5.4`, `gpt-4.1` |
|
||||
| **OpenRouter** | `https://openrouter.ai/api/v1` | provider-specific |
|
||||
| **Alibaba DashScope** | `https://dashscope.aliyuncs.com/compatible-mode/v1` | `qwen3.5-flash`, `qwen3-max`, `deepseek-r1` |
|
||||
| **DeepSeek** | `https://api.deepseek.com` | `deepseek-chat`, `deepseek-reasoner` |
|
||||
| **GitHub Models** | `https://models.inference.ai.azure.com` | `gpt-4o`, `Meta-Llama-3.1-405B-Instruct` |
|
||||
| **SiliconFlow** | `https://api.siliconflow.cn/v1` | `deepseek-ai/DeepSeek-V3` |
|
||||
| **NVIDIA NIM** | `https://integrate.api.nvidia.com/v1` | `openai/gpt-oss-120b`, `nvidia/llama-3.3-nemotron-super-49b-v1` |
|
||||
| **Google Gemini** | `https://generativelanguage.googleapis.com/v1beta/openai` | `gemini-2.5-flash`, `gemini-2.5-pro` |
|
||||
| **Groq** | `https://api.groq.com/openai/v1` | `llama-3.3-70b-versatile` |
|
||||
| **Ollama (local)** | `http://localhost:11434/v1` | any local model |
|
||||
|
||||
### Advanced Profile Management
|
||||
|
||||
```bash
|
||||
# List saved workflows
|
||||
oh provider list
|
||||
|
||||
# Switch the active workflow
|
||||
oh provider use codex
|
||||
|
||||
# Add your own compatible endpoint
|
||||
oh provider add my-endpoint \
|
||||
--label "My Endpoint" \
|
||||
--provider openai \
|
||||
--api-format openai \
|
||||
--auth-source openai_api_key \
|
||||
--model my-model \
|
||||
--base-url https://example.com/v1
|
||||
```
|
||||
|
||||
For custom compatible endpoints, OpenHarness can bind credentials per profile instead of forcing every Anthropic-compatible or OpenAI-compatible backend to share the same API key.
|
||||
|
||||
### Ollama (Local Models)
|
||||
|
||||
Run local models through Ollama's OpenAI-compatible endpoint:
|
||||
|
||||
```bash
|
||||
# Add an Ollama provider profile
|
||||
oh provider add ollama \
|
||||
--label "Ollama" \
|
||||
--provider Ollama \
|
||||
--api-format openai \
|
||||
--auth-source openai_api_key \
|
||||
--model glm-4.7-flash:q8_0 \
|
||||
--base-url http://localhost:11434/v1
|
||||
```
|
||||
```
|
||||
Saved provider profile: ollama
|
||||
```
|
||||
|
||||
```bash
|
||||
# Activate and verify
|
||||
oh provider use ollama
|
||||
```
|
||||
```
|
||||
Activated provider profile: ollama
|
||||
```
|
||||
|
||||
```bash
|
||||
oh provider list
|
||||
```
|
||||
```
|
||||
claude-api: Anthropic-Compatible API [ready]
|
||||
...
|
||||
moonshot: Moonshot (Kimi) [missing auth]
|
||||
auth=moonshot_api_key model=kimi-k2.5 base_url=https://api.moonshot.cn/v1
|
||||
* ollama: Ollama [ready]
|
||||
auth=openai_api_key model=glm-4.7-flash:q8_0 base_url=http://localhost:11434/v1
|
||||
```
|
||||
|
||||
### GitHub Copilot Format (`--api-format copilot`)
|
||||
|
||||
Use your existing GitHub Copilot subscription as the LLM backend. Authentication uses GitHub's OAuth device flow — no API keys needed.
|
||||
|
||||
```bash
|
||||
# One-time login (opens browser for GitHub authorization)
|
||||
oh auth copilot-login
|
||||
|
||||
# Then launch with Copilot as the provider
|
||||
uv run oh --api-format copilot
|
||||
|
||||
# Or via environment variable
|
||||
export OPENHARNESS_API_FORMAT=copilot
|
||||
uv run oh
|
||||
|
||||
# Check auth status
|
||||
oh auth status
|
||||
|
||||
# Remove stored credentials
|
||||
oh auth copilot-logout
|
||||
```
|
||||
|
||||
| Feature | Details |
|
||||
|---------|---------|
|
||||
| **Auth method** | GitHub OAuth device flow (no API key needed) |
|
||||
| **Token management** | Automatic refresh of short-lived session tokens |
|
||||
| **Enterprise** | Supports GitHub Enterprise via `--github-domain` flag |
|
||||
| **Models** | Uses Copilot's default model selection |
|
||||
| **API** | OpenAI-compatible chat completions under the hood |
|
||||
If you are evaluating cross-provider workflows or want a concrete demo path, start with Anthropic or the Kimi example above, then compare behavior against your own compatible endpoint.
|
||||
|
||||
---
|
||||
|
||||
@@ -541,47 +338,7 @@ Available Skills:
|
||||
- ... 40+ more
|
||||
```
|
||||
|
||||
Skills can live in bundled, user, ohmo, project, or plugin locations. User-level skills are loaded from:
|
||||
|
||||
```text
|
||||
~/.openharness/skills/<skill>/SKILL.md
|
||||
~/.claude/skills/<skill>/SKILL.md
|
||||
~/.agents/skills/<skill>/SKILL.md
|
||||
```
|
||||
|
||||
Project-level skills are enabled by default and are discovered from the current working directory up to the git root:
|
||||
|
||||
```text
|
||||
<project>/.openharness/skills/<skill>/SKILL.md
|
||||
<project>/.agents/skills/<skill>/SKILL.md
|
||||
<project>/.claude/skills/<skill>/SKILL.md
|
||||
```
|
||||
|
||||
Disable project skills for untrusted repositories with:
|
||||
|
||||
```bash
|
||||
oh config set allow_project_skills false
|
||||
```
|
||||
|
||||
Use `/skills` to list loaded skills with their source and path. User-invocable skills can be run directly as slash commands, for example `/deploy staging`.
|
||||
|
||||
**Compatible with [anthropics/skills](https://github.com/anthropics/skills)** — use the `SKILL.md` directory layout above.
|
||||
|
||||
### 🌐 Web search and proxy settings
|
||||
|
||||
Built-in `web_search` uses DuckDuckGo HTML search by default. In regions where that endpoint is unreachable, point OpenHarness at a trusted public HTML search endpoint or your own SearXNG instance:
|
||||
|
||||
```bash
|
||||
export OPENHARNESS_WEB_SEARCH_URL="https://your-searxng.example/search"
|
||||
```
|
||||
|
||||
`web_search` and `web_fetch` keep `trust_env=False` for SSRF safety, so they do not automatically inherit `HTTP_PROXY` / `HTTPS_PROXY`. If you need a proxy, opt in with an OpenHarness-specific variable:
|
||||
|
||||
```bash
|
||||
export OPENHARNESS_WEB_PROXY="http://127.0.0.1:7890"
|
||||
```
|
||||
|
||||
The proxy URL must be HTTP/HTTPS and cannot contain embedded credentials.
|
||||
**Compatible with [anthropics/skills](https://github.com/anthropics/skills)** — just copy `.md` files to `~/.openharness/skills/`.
|
||||
|
||||
### 🔌 Plugin System
|
||||
|
||||
@@ -657,65 +414,9 @@ Permissions: --permission-mode, --dangerously-skip-permissions
|
||||
Context: -s/--system-prompt, --append-system-prompt, --settings
|
||||
Advanced: -d/--debug, --mcp-config, --bare
|
||||
|
||||
Subcommands: oh setup | oh provider | oh auth | oh mcp | oh plugin
|
||||
Subcommands: oh mcp | oh plugin | oh auth
|
||||
```
|
||||
|
||||
### 🧑💼 ohmo Personal Agent
|
||||
|
||||
`ohmo` is a personal-agent app built on top of OpenHarness. It is packaged alongside `oh`, with its own workspace and gateway:
|
||||
|
||||
```bash
|
||||
# Initialize personal workspace
|
||||
ohmo init
|
||||
|
||||
# Configure gateway channels and pick a provider profile
|
||||
ohmo config
|
||||
|
||||
# Run the personal agent
|
||||
ohmo
|
||||
|
||||
# Run the gateway in foreground
|
||||
ohmo gateway run
|
||||
|
||||
# Check or restart the gateway
|
||||
ohmo gateway status
|
||||
ohmo gateway restart
|
||||
```
|
||||
|
||||
Key concepts:
|
||||
|
||||
- `~/.ohmo/`
|
||||
- personal workspace root
|
||||
- `soul.md`
|
||||
- long-term agent personality and behavior
|
||||
- `identity.md`
|
||||
- who `ohmo` is
|
||||
- `user.md`
|
||||
- user profile and preferences
|
||||
- `BOOTSTRAP.md`
|
||||
- first-run landing ritual
|
||||
- `memory/`
|
||||
- personal memory
|
||||
- `gateway.json`
|
||||
- selected provider profile and channel configuration
|
||||
|
||||
`ohmo config` uses the same workflow language as `oh setup`, so you can point the personal-agent gateway at:
|
||||
|
||||
- `Anthropic-Compatible API`
|
||||
- `Claude Subscription`
|
||||
- `OpenAI-Compatible API`
|
||||
- `Codex Subscription`
|
||||
- `GitHub Copilot`
|
||||
|
||||
`ohmo init` creates the home workspace once. After that, use `ohmo config` to update provider and channel settings; if the gateway is already running, the config flow can restart it for you.
|
||||
|
||||
Currently `ohmo init` / `ohmo config` can guide channel setup for:
|
||||
|
||||
- Telegram
|
||||
- Slack
|
||||
- Discord
|
||||
- Feishu
|
||||
|
||||
---
|
||||
|
||||
## 📊 Test Results
|
||||
@@ -839,16 +540,6 @@ Useful contributor entry points:
|
||||
|
||||
---
|
||||
|
||||
## 🔧 Troubleshooting
|
||||
|
||||
### Backspace key in macOS Terminal.app
|
||||
|
||||
OpenHarness handles both common terminal delete sequences, including the raw `DEL` byte (`0x7f`) that macOS Terminal.app sends for Backspace. If Backspace inserts spaces or visible control characters instead of deleting text, upgrade OpenHarness first.
|
||||
|
||||
For older versions that do not include this fix, use a terminal that sends a standard Backspace sequence or adjust your terminal keyboard profile as a temporary workaround.
|
||||
|
||||
---
|
||||
|
||||
## 📄 License
|
||||
|
||||
MIT — see [LICENSE](LICENSE).
|
||||
@@ -863,16 +554,6 @@ MIT — see [LICENSE](LICENSE).
|
||||
<em>The model is the agent. The code is the harness.</em>
|
||||
</p>
|
||||
|
||||
<div align="center">
|
||||
<a href="https://star-history.com/#HKUDS/OpenHarness&Date">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=HKUDS/OpenHarness&type=Date&theme=dark" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=HKUDS/OpenHarness&type=Date" />
|
||||
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=HKUDS/OpenHarness&type=Date" style="border-radius: 15px; box-shadow: 0 0 30px rgba(0, 217, 255, 0.3);" />
|
||||
</picture>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<p align="center">
|
||||
<em> Thanks for visiting ✨ OpenHarness!</em><br><br>
|
||||
<img src="https://visitor-badge.laobi.icu/badge?page_id=HKUDS.OpenHarness&style=for-the-badge&color=00d4ff" alt="Views">
|
||||
|
||||
-417
@@ -1,417 +0,0 @@
|
||||
# <img src="assets/logo.png" alt="OpenHarness" width="40" style="vertical-align: middle;"> `oh` — OpenHarness 中文说明
|
||||
|
||||
<p align="center">
|
||||
<a href="README.md"><strong>English</strong></a> ·
|
||||
<a href="README.zh-CN.md"><strong>简体中文</strong></a>
|
||||
</p>
|
||||
|
||||
**OpenHarness** 是一个面向开源社区的 Agent Harness。它提供轻量、可扩展、可检查的 Agent 基础设施,包括:
|
||||
|
||||
- Agent loop
|
||||
- tools / skills / plugins
|
||||
- memory / session resume
|
||||
- permissions / hooks
|
||||
- multi-agent coordination
|
||||
- provider workflows
|
||||
- React TUI
|
||||
- `ohmo` personal-agent app
|
||||
|
||||
---
|
||||
|
||||
## 最新更新
|
||||
|
||||
### Unreleased · Dry-run 安全预览
|
||||
|
||||
- 新增 `oh --dry-run`,可以在**不执行模型、不执行工具、不 spawn subagent** 的前提下,预览当前会话会使用的配置、skills、commands、tools 和 MCP 配置。
|
||||
- Dry-run 会给出 `ready / warning / blocked` 结论,并直接告诉你下一步该做什么,例如先修认证、先修 MCP 配置,或者可以直接运行。
|
||||
- 对普通 prompt,会给出可能命中的 skills / tools;对 slash command,会展示它更偏只读还是会改本地状态。
|
||||
|
||||
### 2026-04-06 · v0.1.2
|
||||
|
||||
- 新增统一配置入口 `oh setup`
|
||||
- provider 配置从“auth -> provider -> model”收敛成 workflow 视角
|
||||
- Anthropic/OpenAI 兼容接口支持 profile 级凭据,不再强制共用一把全局 key
|
||||
- 新增 `ohmo` personal-agent app
|
||||
- `ohmo` 使用 `~/.ohmo` 作为 home workspace,支持 gateway、bootstrap prompts 和交互式 channel 配置
|
||||
|
||||
---
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 一键安装
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/HKUDS/OpenHarness/main/scripts/install.sh | bash
|
||||
```
|
||||
|
||||
常用安装参数:
|
||||
|
||||
- `--from-source`:从源码安装,适合贡献者
|
||||
- `--with-channels`:一并安装 IM channel 依赖
|
||||
|
||||
例如:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/HKUDS/OpenHarness/main/scripts/install.sh | bash -s -- --from-source --with-channels
|
||||
```
|
||||
|
||||
### 本地运行
|
||||
|
||||
```bash
|
||||
git clone https://github.com/HKUDS/OpenHarness.git
|
||||
cd OpenHarness
|
||||
uv sync --extra dev
|
||||
uv run oh
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 配置模型与 Provider
|
||||
|
||||
现在最推荐的入口是:
|
||||
|
||||
```bash
|
||||
oh setup
|
||||
```
|
||||
|
||||
`oh setup` 会按下面的顺序引导:
|
||||
|
||||
1. 选择一个 workflow
|
||||
2. 如果需要,完成认证
|
||||
3. 选择具体后端 preset
|
||||
4. 确认模型
|
||||
5. 保存并激活 profile
|
||||
|
||||
当前内置 workflow 包括:
|
||||
|
||||
- `Anthropic-Compatible API`
|
||||
- `Claude Subscription`
|
||||
- `OpenAI-Compatible API`
|
||||
- `Codex Subscription`
|
||||
- `GitHub Copilot`
|
||||
|
||||
### Anthropic-Compatible API
|
||||
|
||||
适合这类后端:
|
||||
|
||||
- Claude 官方 API
|
||||
- Moonshot / Kimi
|
||||
- Zhipu / GLM
|
||||
- MiniMax
|
||||
- 其他 Anthropic-compatible endpoint
|
||||
|
||||
### OpenAI-Compatible API
|
||||
|
||||
适合这类后端:
|
||||
|
||||
- OpenAI 官方 API
|
||||
- OpenRouter
|
||||
- DashScope
|
||||
- DeepSeek
|
||||
- GitHub Models
|
||||
- SiliconFlow
|
||||
- Google Gemini
|
||||
- Groq
|
||||
- Ollama
|
||||
- 其他 OpenAI-compatible endpoint
|
||||
|
||||
### 常用命令
|
||||
|
||||
```bash
|
||||
# 统一配置入口
|
||||
oh setup
|
||||
|
||||
# 查看已有 workflow/profile
|
||||
oh provider list
|
||||
|
||||
# 切换当前 workflow
|
||||
oh provider use codex
|
||||
|
||||
# 查看认证状态
|
||||
oh auth status
|
||||
```
|
||||
|
||||
### 高级:添加自定义兼容接口
|
||||
|
||||
如果内置 preset 不够,可以直接新增 profile:
|
||||
|
||||
```bash
|
||||
oh provider add my-endpoint \
|
||||
--label "My Endpoint" \
|
||||
--provider anthropic \
|
||||
--api-format anthropic \
|
||||
--auth-source anthropic_api_key \
|
||||
--model my-model \
|
||||
--base-url https://example.com/anthropic
|
||||
```
|
||||
|
||||
这一版开始,兼容接口可以按 profile 绑定凭据。
|
||||
也就是说,`Kimi`、`GLM`、`MiniMax` 这类 Anthropic-compatible 后端,不需要再共用一把全局 `anthropic` key。
|
||||
|
||||
---
|
||||
|
||||
## 交互模式与 TUI
|
||||
|
||||
运行:
|
||||
|
||||
```bash
|
||||
oh
|
||||
```
|
||||
|
||||
你会得到 React/Ink TUI,支持:
|
||||
|
||||
- `/` 命令选择器
|
||||
- 交互式权限确认
|
||||
- `/model` 模型切换
|
||||
- `/permissions` 权限模式切换
|
||||
- `/resume` 会话恢复
|
||||
- `/provider` workflow 选择
|
||||
|
||||
非交互模式也支持:
|
||||
|
||||
```bash
|
||||
oh -p "Explain this repository"
|
||||
oh -p "List all functions in main.py" --output-format json
|
||||
oh -p "Fix the bug" --output-format stream-json
|
||||
```
|
||||
|
||||
### Dry-run 安全预览
|
||||
|
||||
如果你想先看 OpenHarness **会怎么跑**,但又不想真的执行模型或工具,可以用:
|
||||
|
||||
```bash
|
||||
# 预览交互会话本身
|
||||
oh --dry-run
|
||||
|
||||
# 预览一个普通 prompt
|
||||
oh --dry-run -p "Review this bug fix and grep for failing tests"
|
||||
|
||||
# 预览 slash command
|
||||
oh --dry-run -p "/plugin list"
|
||||
|
||||
# 输出结构化 JSON,方便脚本或 channel 使用
|
||||
oh --dry-run -p "Explain this repository" --output-format json
|
||||
```
|
||||
|
||||
Dry-run 的边界是明确的:
|
||||
|
||||
- **不会**调用模型
|
||||
- **不会**执行 tools
|
||||
- **不会**启动 subagent
|
||||
- **不会**连接 MCP server
|
||||
- **会**解析 settings、auth 状态、system prompt、skills、commands、tools,以及明显错误的 MCP 配置
|
||||
|
||||
Readiness 结论说明:
|
||||
|
||||
- `ready`:当前配置基本可直接运行
|
||||
- `warning`:能解析会话,但仍有重要问题需要先处理,比如 MCP 配置错误或后续模型调用缺认证
|
||||
- `blocked`:按当前状态直接运行会失败,比如 slash command 不存在,或者普通 prompt 无法解析 runtime client
|
||||
|
||||
Dry-run 输出里的 `next actions` 会直接给出下一步建议,例如:
|
||||
|
||||
- 先执行 `oh auth login`
|
||||
- 先修或禁用坏掉的 MCP 配置
|
||||
- 直接运行 `oh -p "..."` 或进入 `oh`
|
||||
|
||||
---
|
||||
|
||||
## Provider 兼容性概览
|
||||
|
||||
OpenHarness 现在把 provider 视为 **workflow + profile**,而不是只暴露底层协议名。
|
||||
|
||||
| Workflow | 说明 |
|
||||
|----------|------|
|
||||
| `Anthropic-Compatible API` | Anthropic 风格接口,适合 Claude/Kimi/GLM/MiniMax 等 |
|
||||
| `Claude Subscription` | 复用本地 `~/.claude/.credentials.json` |
|
||||
| `OpenAI-Compatible API` | OpenAI 风格接口,适合 OpenAI/OpenRouter/各种兼容网关 |
|
||||
| `Codex Subscription` | 复用本地 `~/.codex/auth.json` |
|
||||
| `GitHub Copilot` | GitHub Copilot OAuth workflow |
|
||||
|
||||
日常推荐用法:
|
||||
|
||||
```bash
|
||||
oh setup
|
||||
oh provider list
|
||||
oh provider use <profile>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## `ohmo` Personal Agent
|
||||
|
||||
`ohmo` 是基于 OpenHarness 的 personal-agent app,不是 core 的一个 mode。
|
||||
|
||||
### 初始化
|
||||
|
||||
```bash
|
||||
ohmo init
|
||||
```
|
||||
|
||||
这会创建:
|
||||
|
||||
- `~/.ohmo/soul.md`
|
||||
- `~/.ohmo/identity.md`
|
||||
- `~/.ohmo/user.md`
|
||||
- `~/.ohmo/BOOTSTRAP.md`
|
||||
- `~/.ohmo/memory/`
|
||||
- `~/.ohmo/gateway.json`
|
||||
|
||||
其中:
|
||||
|
||||
- `soul.md`:长期人格与行为原则
|
||||
- `identity.md`:`ohmo` 自己是谁
|
||||
- `user.md`:用户画像、偏好、关系信息
|
||||
- `BOOTSTRAP.md`:首轮 landing / onboarding ritual
|
||||
- `memory/`:personal memory
|
||||
- `gateway.json`:gateway 的 profile 和 channel 配置
|
||||
|
||||
### 配置
|
||||
|
||||
```bash
|
||||
ohmo config
|
||||
```
|
||||
|
||||
`ohmo config` 会用和 `oh setup` 一致的 workflow 语言来配置 gateway,例如:
|
||||
|
||||
- `Anthropic-Compatible API`
|
||||
- `Claude Subscription`
|
||||
- `OpenAI-Compatible API`
|
||||
- `Codex Subscription`
|
||||
- `GitHub Copilot`
|
||||
|
||||
目前 `ohmo init` / `ohmo config` 已支持引导式配置这些 channel:
|
||||
|
||||
- Telegram
|
||||
- Slack
|
||||
- Discord
|
||||
- Feishu
|
||||
|
||||
如果 gateway 已经在运行,配置完成后也可以直接选择是否重启。
|
||||
|
||||
### 运行
|
||||
|
||||
```bash
|
||||
# 运行 personal agent
|
||||
ohmo
|
||||
|
||||
# 前台运行 gateway
|
||||
ohmo gateway run
|
||||
|
||||
# 查看 gateway 状态
|
||||
ohmo gateway status
|
||||
|
||||
# 重启 gateway
|
||||
ohmo gateway restart
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## OpenHarness 的核心能力
|
||||
|
||||
### Agent Loop
|
||||
|
||||
- streaming tool-call cycle
|
||||
- tool execution / observation / loop
|
||||
- retry + exponential backoff
|
||||
- token counting 与成本跟踪
|
||||
|
||||
### Tools / Skills / Plugins
|
||||
|
||||
- 43+ tools
|
||||
- Markdown skills 按需加载
|
||||
- 插件生态
|
||||
- 兼容 `anthropics/skills`
|
||||
- 兼容 Claude-style plugins
|
||||
|
||||
### Memory / Session
|
||||
|
||||
- `CLAUDE.md` 自动发现与注入
|
||||
- `MEMORY.md` 持久记忆
|
||||
- session resume
|
||||
- auto-compact
|
||||
|
||||
### Governance
|
||||
|
||||
- 多级 permission mode
|
||||
- path rules
|
||||
- denied commands
|
||||
- hooks
|
||||
- interactive approval
|
||||
|
||||
### Multi-Agent
|
||||
|
||||
- subagent spawning
|
||||
- team registry
|
||||
- task lifecycle
|
||||
- background task execution
|
||||
|
||||
---
|
||||
|
||||
## 常见命令
|
||||
|
||||
### `oh`
|
||||
|
||||
```bash
|
||||
oh setup
|
||||
oh provider list
|
||||
oh provider use codex
|
||||
oh auth status
|
||||
oh -p "Explain this codebase"
|
||||
oh
|
||||
```
|
||||
|
||||
### `ohmo`
|
||||
|
||||
```bash
|
||||
ohmo init
|
||||
ohmo config
|
||||
ohmo
|
||||
ohmo gateway run
|
||||
ohmo gateway status
|
||||
ohmo gateway restart
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 测试
|
||||
|
||||
```bash
|
||||
uv run pytest -q
|
||||
python scripts/test_harness_features.py
|
||||
python scripts/test_real_skills_plugins.py
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 贡献
|
||||
|
||||
欢迎贡献:
|
||||
|
||||
- tools
|
||||
- skills
|
||||
- plugins
|
||||
- providers
|
||||
- multi-agent coordination
|
||||
- tests
|
||||
- 文档与中文翻译
|
||||
|
||||
开发环境:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/HKUDS/OpenHarness.git
|
||||
cd OpenHarness
|
||||
uv sync --extra dev
|
||||
uv run pytest -q
|
||||
```
|
||||
|
||||
更多信息:
|
||||
|
||||
- [贡献指南](CONTRIBUTING.md)
|
||||
- [更新日志](CHANGELOG.md)
|
||||
- [Showcase](docs/SHOWCASE.md)
|
||||
|
||||
---
|
||||
|
||||
## License
|
||||
|
||||
MIT,见 [LICENSE](LICENSE)。
|
||||
@@ -1,66 +0,0 @@
|
||||
# v0.1.8 — Providers, ohmo Feishu Groups, and Safer Remotes
|
||||
|
||||
OpenHarness v0.1.8 is a stabilization and provider-expansion release. It adds more first-class provider workflows, improves ohmo's Feishu group experience, hardens remote-channel security, and fixes several Windows/MCP reliability issues.
|
||||
|
||||
## Highlights
|
||||
|
||||
- **New provider workflows**
|
||||
- Added NVIDIA NIM as a built-in OpenAI-compatible provider using `NVIDIA_API_KEY`.
|
||||
- Added ModelScope Inference API support.
|
||||
- Added Qwen (DashScope), MiniMax, and Gemini provider profiles.
|
||||
- Improved OpenAI-compatible API behavior, including explicit bearer authorization headers and `<think>` block filtering for compatible streaming responses.
|
||||
|
||||
- **ohmo Feishu group support**
|
||||
- Added Feishu managed group creation flow for ohmo.
|
||||
- Added gateway-scoped provider/model commands for chat-based operation.
|
||||
- Improved group routing and mention policy so ohmo responds in shared Feishu groups only when explicitly addressed.
|
||||
- Hardened Feishu attachment filename handling.
|
||||
|
||||
- **Security and remote-channel hardening**
|
||||
- Kept sensitive config/auth/provider/model/ship commands local-only by default in remote channels.
|
||||
- Kept bridge commands local-only by default.
|
||||
- Added coverage for bridge spawn blocking and remote gateway security behavior.
|
||||
- Rejected path traversal names during plugin uninstall.
|
||||
|
||||
- **Windows and shell reliability**
|
||||
- Fixed Windows agent/subagent spawning by direct-executing teammate argv instead of shell-wrapping Python paths.
|
||||
- Windows shell resolution now skips discovered `bash.exe` binaries that cannot actually run commands, falling back to PowerShell/cmd.
|
||||
- Improved Windows gateway process lifecycle handling.
|
||||
- Avoided shell execution when opening browsers on Windows.
|
||||
|
||||
- **MCP, tools, and stability**
|
||||
- MCP startup now isolates failed servers instead of aborting the whole OpenHarness startup.
|
||||
- Fixed subprocess stderr pipe deadlocks in grep/glob/bash/session runner paths.
|
||||
- Bounded large tool results in conversation history and improved compaction under large/vision contexts.
|
||||
- Improved skill frontmatter parsing with YAML `safe_load` for bundled and user skills.
|
||||
|
||||
- **TUI and UX fixes**
|
||||
- Restored raw `DEL` backspace handling for macOS Terminal-style environments.
|
||||
- Added better slash-command completion, markdown table rendering, spinner behavior, and escape interruption.
|
||||
- Added image-to-text fallback support for text-only models and `--vision-model` override.
|
||||
|
||||
## External contributors
|
||||
|
||||
Thanks to the external contributors whose PRs are included in this release:
|
||||
|
||||
- @Litianhui888 — MCP startup isolation (#237)
|
||||
- @Hinotoi-agent — remote command security hardening (#232, #209, #208, #198, #197)
|
||||
- @nsxdavid — Windows agent spawn fix (#231)
|
||||
- @voidborne-d — bundled skill frontmatter parsing (#229)
|
||||
- @Mcy0618 — image-to-text fallback and ModelScope support (#227, #224)
|
||||
- @WANG-Guangxin — invalid regex fallback fix (#219)
|
||||
- @glitch-ux — Windows browser auth safety, async coordinator draining, autopilot shell safety, hook events (#217, #200, #188, #170)
|
||||
- @Escapingbug — Windows gateway lifecycle and Telegram proxy config fixes (#193, #192)
|
||||
- @ZevGit — Qwen provider profile (#207)
|
||||
- @yl-jiang — subprocess stderr deadlock fixes and OpenAI-compatible think-block filtering (#205, #174)
|
||||
- @he-yufeng — TUI slash-command completion polish (#185)
|
||||
- @powAu3 — raw DEL backspace fix (#182)
|
||||
- @flobo3 — shell subprocess stdin default fix (#179)
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
pip install --upgrade openharness-ai==0.1.8
|
||||
```
|
||||
|
||||
Or run the installer from the repository docs.
|
||||
@@ -1,32 +0,0 @@
|
||||
# v0.1.9 — Skill Workflows and Provider Key Updates
|
||||
|
||||
OpenHarness v0.1.9 is a small follow-up release after v0.1.8 focused on making skills easier to create and invoke, plus fixing provider API key updates.
|
||||
|
||||
## Highlights
|
||||
|
||||
- **Bundled skill creator**
|
||||
- Added a bundled `skill-creator` skill for creating, improving, and verifying OpenHarness/ohmo skills.
|
||||
- This makes repeatable workflows easier to capture as first-class skills.
|
||||
|
||||
- **User-invocable skill slash commands**
|
||||
- Skills marked as user-invocable can now be triggered directly with slash commands.
|
||||
- Slash-invoked skills support user arguments and can request a model override through skill metadata.
|
||||
|
||||
- **Provider API key update fix**
|
||||
- `oh setup` now lets users update the API key for an already-configured API-key provider profile.
|
||||
- `oh provider edit <profile> --api-key <key>` can replace a saved profile key directly.
|
||||
- `oh provider add ... --api-key <key>` can store a key while creating a provider profile.
|
||||
|
||||
## Fixes
|
||||
|
||||
- Fixed issue #238, where users could change a configured provider model but had no supported path to update the saved API key.
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
pip install --upgrade openharness-ai==0.1.9
|
||||
```
|
||||
|
||||
## Contributors
|
||||
|
||||
This release is primarily a maintainer follow-up release. Thanks to the users and contributors who reported and validated the provider key update workflow, especially the reporter of #238.
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 994 KiB |
@@ -1,2 +0,0 @@
|
||||
node_modules
|
||||
dist
|
||||
@@ -1,15 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Autopilot Kanban</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&display=swap" rel="stylesheet" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
-1859
File diff suppressed because it is too large
Load Diff
@@ -1,22 +0,0 @@
|
||||
{
|
||||
"name": "autopilot-dashboard",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^19.1.2",
|
||||
"@types/react-dom": "^19.1.2",
|
||||
"@vitejs/plugin-react": "^4.4.1",
|
||||
"typescript": "~5.8.3",
|
||||
"vite": "^6.3.2"
|
||||
}
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
{
|
||||
"generated_at": 1776338766.0050209,
|
||||
"repo_name": "OpenHarness-new",
|
||||
"repo_path": "/home/tangjiabin/OpenHarness-new",
|
||||
"focus": null,
|
||||
"counts": {
|
||||
"queued": 0,
|
||||
"accepted": 0,
|
||||
"preparing": 0,
|
||||
"running": 0,
|
||||
"verifying": 0,
|
||||
"pr_open": 0,
|
||||
"waiting_ci": 0,
|
||||
"repairing": 0,
|
||||
"completed": 0,
|
||||
"merged": 0,
|
||||
"failed": 0,
|
||||
"rejected": 0,
|
||||
"superseded": 0
|
||||
},
|
||||
"status_order": [
|
||||
"queued",
|
||||
"accepted",
|
||||
"preparing",
|
||||
"running",
|
||||
"verifying",
|
||||
"pr_open",
|
||||
"waiting_ci",
|
||||
"repairing",
|
||||
"completed",
|
||||
"merged",
|
||||
"failed",
|
||||
"rejected",
|
||||
"superseded"
|
||||
],
|
||||
"columns": {
|
||||
"queued": [],
|
||||
"accepted": [],
|
||||
"preparing": [],
|
||||
"running": [],
|
||||
"verifying": [],
|
||||
"pr_open": [],
|
||||
"waiting_ci": [],
|
||||
"repairing": [],
|
||||
"completed": [],
|
||||
"merged": [],
|
||||
"failed": [],
|
||||
"rejected": [],
|
||||
"superseded": []
|
||||
},
|
||||
"cards": [],
|
||||
"journal": [],
|
||||
"policies": {
|
||||
"autopilot": "/home/tangjiabin/OpenHarness-new/.openharness/autopilot/autopilot_policy.yaml",
|
||||
"verification": "/home/tangjiabin/OpenHarness-new/.openharness/autopilot/verification_policy.yaml",
|
||||
"release": "/home/tangjiabin/OpenHarness-new/.openharness/autopilot/release_policy.yaml"
|
||||
},
|
||||
"active_context": "# Active Repo Context\n\nGenerated at: 2026-04-16 11:05:49 UTC\n\n## Current Task Focus\n- No active repo task focus yet.\n\n## In Progress\n- None.\n\n## Next Up\n- No queued items.\n\n## Recently Completed\n- None yet.\n\n## Recent Failures\n- None.\n\n## Recent Repo Journal\n- Journal is empty.\n\n## Policies\n- Autopilot: /home/tangjiabin/OpenHarness-new/.openharness/autopilot/autopilot_policy.yaml\n- Verification: /home/tangjiabin/OpenHarness-new/.openharness/autopilot/verification_policy.yaml\n- Release: /home/tangjiabin/OpenHarness-new/.openharness/autopilot/release_policy.yaml"
|
||||
}
|
||||
@@ -1,290 +0,0 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { HeroBackground } from "./components/HeroBackground";
|
||||
import { PipelineAnimation } from "./components/PipelineAnimation";
|
||||
import type { Snapshot, TaskCard, JournalEntry } from "./types";
|
||||
import { STATUS_LABELS, STATUS_COLORS, KANBAN_GROUPS } from "./types";
|
||||
|
||||
/* ── Helpers ─────────────────────────────────── */
|
||||
|
||||
function fmtAgo(ts?: number): string {
|
||||
if (!ts) return "-";
|
||||
const delta = Math.max(0, Math.floor(Date.now() / 1000 - ts));
|
||||
if (delta < 60) return `${delta}s ago`;
|
||||
if (delta < 3600) return `${Math.floor(delta / 60)}m ago`;
|
||||
if (delta < 86400) return `${Math.floor(delta / 3600)}h ago`;
|
||||
return `${Math.floor(delta / 86400)}d ago`;
|
||||
}
|
||||
|
||||
function statusBadgeClass(status: string): string {
|
||||
if (["running", "completed", "merged", "preparing"].includes(status)) return "badge-teal";
|
||||
if (["repairing"].includes(status)) return "badge-orange";
|
||||
if (["accepted", "pr_open"].includes(status)) return "badge-violet";
|
||||
if (["failed", "rejected"].includes(status)) return "badge-red";
|
||||
if (["verifying", "waiting_ci"].includes(status)) return "badge-blue";
|
||||
if (["superseded"].includes(status)) return "badge-amber";
|
||||
return "badge-gray";
|
||||
}
|
||||
|
||||
/* ── Card Component ──────────────────────────── */
|
||||
|
||||
function CardView({ card }: { card: TaskCard }) {
|
||||
const labels = [...(card.labels || []), card.source_kind].filter(Boolean);
|
||||
const verification = (card.metadata?.verification_steps || [])
|
||||
.map((step) => `${step.status} · ${step.command}`)
|
||||
.slice(0, 2)
|
||||
.join(" | ");
|
||||
const borderColor = STATUS_COLORS[card.status] || "#333";
|
||||
|
||||
return (
|
||||
<article
|
||||
className="card"
|
||||
style={{ "--card-accent": borderColor } as React.CSSProperties}
|
||||
>
|
||||
<div className="card-meta">
|
||||
<span>{card.id}</span>
|
||||
<span className={`badge ${statusBadgeClass(card.status)}`}>
|
||||
{STATUS_LABELS[card.status] || card.status}
|
||||
</span>
|
||||
</div>
|
||||
<h3>{card.title}</h3>
|
||||
{card.body && (
|
||||
<p className="card-body">{card.body.slice(0, 260)}</p>
|
||||
)}
|
||||
{labels.length > 0 && (
|
||||
<div className="card-tags">
|
||||
{labels.map((tag, i) => (
|
||||
<span key={i} className="tag">{tag}</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="card-footer">
|
||||
<div>
|
||||
score {card.score} · updated {fmtAgo(card.updated_at)}
|
||||
{card.source_ref ? ` · ref ${card.source_ref}` : ""}
|
||||
</div>
|
||||
<div>{card.metadata?.last_note || "no status note yet"}</div>
|
||||
{(verification || card.metadata?.last_ci_summary || card.metadata?.last_failure_summary || card.metadata?.human_gate_pending) && (
|
||||
<div>
|
||||
{verification || card.metadata?.last_ci_summary || card.metadata?.last_failure_summary ||
|
||||
(card.metadata?.human_gate_pending ? "verification passed; human gate pending" : "")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Grouped Column Component ────────────────── */
|
||||
|
||||
function GroupColumnView({ label, color, cards }: {
|
||||
label: string;
|
||||
color: string;
|
||||
cards: TaskCard[];
|
||||
}) {
|
||||
return (
|
||||
<section className="column">
|
||||
<div className="column-header">
|
||||
<div className="column-title-row">
|
||||
<span className="column-dot" style={{ background: color }} />
|
||||
<h2>{label}</h2>
|
||||
</div>
|
||||
<span className="column-count">{cards.length}</span>
|
||||
</div>
|
||||
<div className="cards">
|
||||
{cards.length > 0
|
||||
? cards.map((card) => <CardView key={card.id} card={card} />)
|
||||
: <div className="empty">No cards.</div>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Journal Component ───────────────────────── */
|
||||
|
||||
function JournalView({ entries }: { entries: JournalEntry[] }) {
|
||||
return (
|
||||
<section className="journal">
|
||||
<div className="journal-header">
|
||||
<span style={{ color: "var(--accent)", fontSize: 10, letterSpacing: 2, fontWeight: 700 }}>
|
||||
//
|
||||
</span>
|
||||
<h2>RECENT JOURNAL</h2>
|
||||
</div>
|
||||
<div className="journal-list">
|
||||
{entries.length > 0
|
||||
? entries.slice().reverse().map((entry, i) => (
|
||||
<article key={i} className="journal-item">
|
||||
<time>
|
||||
{new Date(entry.timestamp * 1000)
|
||||
.toISOString()
|
||||
.replace("T", " ")
|
||||
.replace(".000Z", " UTC")}
|
||||
</time>
|
||||
<div>
|
||||
<span className="kind">{entry.kind}</span>
|
||||
{entry.task_id && <span className="task-ref"> [{entry.task_id}]</span>}
|
||||
</div>
|
||||
<div className="summary">{entry.summary}</div>
|
||||
</article>
|
||||
))
|
||||
: <div className="empty">Journal is empty.</div>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Main App ────────────────────────────────── */
|
||||
|
||||
export function App() {
|
||||
const [snapshot, setSnapshot] = useState<Snapshot | null>(null);
|
||||
const [filter, setFilter] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetch("./snapshot.json", { cache: "no-store" })
|
||||
.then((r) => r.json())
|
||||
.then(setSnapshot)
|
||||
.catch((e) => setError(String(e)));
|
||||
}, []);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="shell" style={{ paddingTop: 80 }}>
|
||||
<div className="empty">Failed to load snapshot.json: {error}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!snapshot) {
|
||||
return (
|
||||
<div className="shell" style={{ paddingTop: 80, textAlign: "center" }}>
|
||||
<div style={{ color: "var(--accent)", fontSize: 12, letterSpacing: 2 }}>
|
||||
LOADING SNAPSHOT...
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const counts = snapshot.counts || {};
|
||||
const normalizedFilter = filter.trim().toLowerCase();
|
||||
|
||||
// Group cards into 4 kanban columns
|
||||
const groupedColumns = KANBAN_GROUPS.map((group) => {
|
||||
const allCards = group.statuses.flatMap((s) => snapshot.columns?.[s] || []);
|
||||
const cards = allCards.filter((card) => {
|
||||
if (!normalizedFilter) return true;
|
||||
const haystack = [
|
||||
card.id, card.title, card.body, card.source_kind, card.source_ref,
|
||||
...(card.labels || []), ...(card.score_reasons || []),
|
||||
].join(" ").toLowerCase();
|
||||
return haystack.includes(normalizedFilter);
|
||||
});
|
||||
return { ...group, cards };
|
||||
});
|
||||
|
||||
const inProgress = (counts.preparing || 0) + (counts.running || 0) +
|
||||
(counts.verifying || 0) + (counts.waiting_ci || 0) +
|
||||
(counts.repairing || 0) + (counts.accepted || 0) + (counts.pr_open || 0);
|
||||
const completed = (counts.completed || 0) + (counts.merged || 0);
|
||||
const failed = (counts.failed || 0) + (counts.rejected || 0);
|
||||
|
||||
const generated = new Date((snapshot.generated_at || 0) * 1000)
|
||||
.toISOString().replace("T", " ").replace(".000Z", " UTC");
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* ── Hero ─────────────────────────── */}
|
||||
<section className="hero">
|
||||
<div className="hero-bg">
|
||||
<HeroBackground />
|
||||
</div>
|
||||
<div className="hero-content">
|
||||
<div className="hero-main">
|
||||
<div className="eyebrow">// AUTOPILOT_KANBAN</div>
|
||||
<h1>
|
||||
OpenHarness<br />
|
||||
<span className="accent">SELF-EVOLUTION</span>
|
||||
</h1>
|
||||
<p className="hero-sub">
|
||||
Kanban for OpenHarness self-evolution.
|
||||
</p>
|
||||
<div className="focus-box">
|
||||
<div className="focus-label">// CURRENT_FOCUS</div>
|
||||
<div className="focus-text">
|
||||
{snapshot.focus
|
||||
? `[${snapshot.focus.status}] ${snapshot.focus.title} · score=${snapshot.focus.score} · ${snapshot.focus.source_kind}`
|
||||
: "No active task focus yet."}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="hero-side">
|
||||
<div className="hero-timestamp">
|
||||
Generated from repo state at {generated}
|
||||
</div>
|
||||
<div className="pipeline-viz">
|
||||
<PipelineAnimation />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="shell">
|
||||
{/* ── Stats Bar ──────────────────── */}
|
||||
<section className="stats-bar">
|
||||
<div className="stat">
|
||||
<div className="stat-label" style={{ color: "#64748b" }}>TO DO</div>
|
||||
<div className="stat-value">{counts.queued || 0}</div>
|
||||
<div className="stat-sub">queued + accepted</div>
|
||||
</div>
|
||||
<div className="stat">
|
||||
<div className="stat-label teal">IN PROGRESS</div>
|
||||
<div className="stat-value">{inProgress}</div>
|
||||
<div className="stat-sub">active pipeline</div>
|
||||
</div>
|
||||
<div className="stat">
|
||||
<div className="stat-label" style={{ color: "#3b82f6" }}>IN REVIEW</div>
|
||||
<div className="stat-value">
|
||||
{(counts.verifying || 0) + (counts.pr_open || 0) + (counts.waiting_ci || 0)}
|
||||
</div>
|
||||
<div className="stat-sub">verify + PR + CI</div>
|
||||
</div>
|
||||
<div className="stat">
|
||||
<div className="stat-label violet">DONE</div>
|
||||
<div className="stat-value">{completed + failed}</div>
|
||||
<div className="stat-sub">merged + completed + failed</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ── Toolbar ────────────────────── */}
|
||||
<section className="toolbar">
|
||||
<input
|
||||
type="search"
|
||||
placeholder="Filter by title, body, source, label, or task id..."
|
||||
value={filter}
|
||||
onChange={(e) => setFilter(e.target.value)}
|
||||
/>
|
||||
<div className="hint">
|
||||
Reads <code>snapshot.json</code> — no backend required
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ── Kanban Board ───────────────── */}
|
||||
<section className="board">
|
||||
{groupedColumns.map((group) => (
|
||||
<GroupColumnView
|
||||
key={group.key}
|
||||
label={group.label}
|
||||
color={group.color}
|
||||
cards={group.cards}
|
||||
/>
|
||||
))}
|
||||
</section>
|
||||
|
||||
{/* ── Journal ────────────────────── */}
|
||||
<JournalView entries={snapshot.journal || []} />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,181 +0,0 @@
|
||||
/**
|
||||
* CyberHeroBackground — full-bleed animated SVG background for the kanban hero.
|
||||
*
|
||||
* Layers (back → front):
|
||||
* 1. Radial teal glow
|
||||
* 2. Perspective grid floor
|
||||
* 3. Horizontal data-stream lines
|
||||
* 4. Constellation nodes + edges
|
||||
* 5. Data fragment segments
|
||||
* 6. Binary / hex rain
|
||||
* 7. Horizontal scan-line
|
||||
*
|
||||
* All SMIL-based — no JS animation loops needed.
|
||||
*/
|
||||
export function HeroBackground() {
|
||||
const glyphs = [
|
||||
{ x: 45, ch: "0", sz: 11, dur: 14, d: 0 },
|
||||
{ x: 120, ch: "1", sz: 9, dur: 18, d: 3 },
|
||||
{ x: 195, ch: "A", sz: 10, dur: 12, d: 7 },
|
||||
{ x: 290, ch: "F", sz: 8, dur: 16, d: 1 },
|
||||
{ x: 365, ch: "0", sz: 12, dur: 20, d: 5 },
|
||||
{ x: 430, ch: "1", sz: 9, dur: 13, d: 9 },
|
||||
{ x: 510, ch: "D", sz: 10, dur: 17, d: 2 },
|
||||
{ x: 580, ch: "0", sz: 11, dur: 15, d: 6 },
|
||||
{ x: 650, ch: "1", sz: 8, dur: 11, d: 4 },
|
||||
{ x: 720, ch: "B", sz: 10, dur: 19, d: 8 },
|
||||
{ x: 790, ch: "0", sz: 9, dur: 14, d: 1 },
|
||||
{ x: 860, ch: "E", sz: 11, dur: 16, d: 10 },
|
||||
{ x: 930, ch: "1", sz: 10, dur: 12, d: 3 },
|
||||
{ x: 1000, ch: "C", sz: 8, dur: 18, d: 7 },
|
||||
{ x: 1070, ch: "0", sz: 12, dur: 15, d: 0 },
|
||||
{ x: 1140, ch: "7", sz: 9, dur: 13, d: 5 },
|
||||
{ x: 260, ch: "3", sz: 8, dur: 22, d: 11 },
|
||||
{ x: 475, ch: "F", sz: 10, dur: 16, d: 4 },
|
||||
{ x: 690, ch: "8", sz: 9, dur: 20, d: 6 },
|
||||
{ x: 1020, ch: "1", sz: 11, dur: 14, d: 8 },
|
||||
];
|
||||
|
||||
const cn = [
|
||||
{ x: 80, y: 55 },
|
||||
{ x: 210, y: 30 },
|
||||
{ x: 355, y: 80 },
|
||||
{ x: 500, y: 45 },
|
||||
{ x: 700, y: 68 },
|
||||
{ x: 850, y: 28 },
|
||||
{ x: 980, y: 60 },
|
||||
{ x: 1120, y: 42 },
|
||||
{ x: 145, y: 140 },
|
||||
{ x: 420, y: 155 },
|
||||
{ x: 780, y: 145 },
|
||||
{ x: 1050, y: 130 },
|
||||
];
|
||||
|
||||
const ce: [number, number][] = [
|
||||
[0, 1], [1, 2], [2, 3], [3, 4], [4, 5], [5, 6], [6, 7],
|
||||
[8, 9], [9, 10], [10, 11],
|
||||
[0, 8], [3, 9], [4, 10], [7, 11],
|
||||
];
|
||||
|
||||
const streams = [
|
||||
{ y: 90, dash: "4 18", tot: 22, spd: 2.0, op: 0.07 },
|
||||
{ y: 160, dash: "2 22", tot: 24, spd: 2.8, op: 0.05 },
|
||||
{ y: 230, dash: "6 14", tot: 20, spd: 1.5, op: 0.08 },
|
||||
{ y: 300, dash: "3 20", tot: 23, spd: 2.2, op: 0.06 },
|
||||
{ y: 370, dash: "5 15", tot: 20, spd: 1.8, op: 0.07 },
|
||||
];
|
||||
|
||||
const frags = [
|
||||
{ x: 95, y: 120, w: 40 },
|
||||
{ x: 310, y: 200, w: 30 },
|
||||
{ x: 540, y: 280, w: 50 },
|
||||
{ x: 760, y: 130, w: 35 },
|
||||
{ x: 990, y: 250, w: 45 },
|
||||
{ x: 180, y: 350, w: 30 },
|
||||
{ x: 640, y: 380, w: 40 },
|
||||
{ x: 1080, y: 330, w: 35 },
|
||||
];
|
||||
|
||||
const gridY = [300, 325, 355, 390, 430];
|
||||
const vx = 600;
|
||||
const vy = 240;
|
||||
const radials = [-5, -4, -3, -2, -1, 0, 1, 2, 3, 4, 5];
|
||||
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 1200 460"
|
||||
preserveAspectRatio="xMidYMid slice"
|
||||
style={{ width: "100%", height: "100%" }}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<defs>
|
||||
<radialGradient id="bg-glow" cx="50%" cy="35%" r="45%">
|
||||
<stop offset="0%" stopColor="#00d4aa" stopOpacity="0.12" />
|
||||
<stop offset="50%" stopColor="#00d4aa" stopOpacity="0.03" />
|
||||
<stop offset="100%" stopColor="#00d4aa" stopOpacity="0" />
|
||||
</radialGradient>
|
||||
<linearGradient id="bg-vfade" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor="white" stopOpacity="0" />
|
||||
<stop offset="8%" stopColor="white" stopOpacity="1" />
|
||||
<stop offset="88%" stopColor="white" stopOpacity="1" />
|
||||
<stop offset="100%" stopColor="white" stopOpacity="0" />
|
||||
</linearGradient>
|
||||
<mask id="bg-mask">
|
||||
<rect width="1200" height="460" fill="url(#bg-vfade)" />
|
||||
</mask>
|
||||
</defs>
|
||||
|
||||
{/* Central glow */}
|
||||
<rect width="1200" height="460" fill="url(#bg-glow)" />
|
||||
|
||||
<g mask="url(#bg-mask)">
|
||||
{/* 1. Perspective grid */}
|
||||
{gridY.map((y, i) => (
|
||||
<line key={`hg${i}`} x1="50" y1={y} x2="1150" y2={y}
|
||||
stroke="#00d4aa" strokeOpacity={0.025 + i * 0.012} strokeWidth="1" />
|
||||
))}
|
||||
{radials.map((n) => (
|
||||
<line key={`vg${n}`} x1={vx} y1={vy} x2={vx + n * 130} y2="460"
|
||||
stroke="#00d4aa" strokeOpacity="0.025" strokeWidth="1" />
|
||||
))}
|
||||
|
||||
{/* 2. Data streams */}
|
||||
{streams.map((s, i) => (
|
||||
<line key={`ds${i}`} x1="0" y1={s.y} x2="1200" y2={s.y}
|
||||
stroke="#00d4aa" strokeOpacity={s.op} strokeWidth="1" strokeDasharray={s.dash}>
|
||||
<animate attributeName="stroke-dashoffset" from="0" to={`-${s.tot}`}
|
||||
dur={`${s.spd}s`} repeatCount="indefinite" />
|
||||
</line>
|
||||
))}
|
||||
|
||||
{/* 3. Constellation edges */}
|
||||
{ce.map(([a, b], i) => (
|
||||
<line key={`ce${i}`} x1={cn[a].x} y1={cn[a].y} x2={cn[b].x} y2={cn[b].y}
|
||||
stroke="#00d4aa" strokeOpacity="0.06" strokeWidth="1" />
|
||||
))}
|
||||
|
||||
{/* 4. Constellation nodes */}
|
||||
{cn.map((n, i) => (
|
||||
<circle key={`cn${i}`} cx={n.x} cy={n.y} r="2" fill="#00d4aa">
|
||||
<animate attributeName="fill-opacity" values="0.1;0.35;0.1"
|
||||
dur={`${2 + (i % 4) * 0.5}s`} begin={`${i * 0.3}s`} repeatCount="indefinite" />
|
||||
<animate attributeName="r" values="1.5;3.5;1.5"
|
||||
dur={`${2 + (i % 4) * 0.5}s`} begin={`${i * 0.3}s`} repeatCount="indefinite" />
|
||||
</circle>
|
||||
))}
|
||||
|
||||
{/* 5. Data fragments */}
|
||||
{frags.map((f, i) => (
|
||||
<line key={`fr${i}`} x1={f.x} y1={f.y} x2={f.x + f.w} y2={f.y}
|
||||
stroke={i % 3 === 0 ? "#8b5cf6" : "#00d4aa"} strokeOpacity="0.08"
|
||||
strokeWidth="1" strokeLinecap="round">
|
||||
<animate attributeName="stroke-opacity" values="0.04;0.16;0.04"
|
||||
dur={`${2.5 + (i % 3) * 0.7}s`} begin={`${i * 0.4}s`} repeatCount="indefinite" />
|
||||
</line>
|
||||
))}
|
||||
|
||||
{/* 6. Binary / hex rain */}
|
||||
{glyphs.map((g, i) => (
|
||||
<text key={`gl${i}`} x={g.x} y={-10} fontSize={g.sz}
|
||||
fill={i % 7 === 0 ? "#8b5cf6" : i % 11 === 0 ? "#ff6b35" : "#00d4aa"}
|
||||
fillOpacity="0" fontFamily="JetBrains Mono, monospace" fontWeight="600" letterSpacing="0.6">
|
||||
{g.ch}
|
||||
<animateTransform attributeName="transform" type="translate"
|
||||
from="0 0" to="0 480" dur={`${g.dur}s`} begin={`${g.d}s`} repeatCount="indefinite" />
|
||||
<animate attributeName="fill-opacity" values="0;0.2;0.2;0"
|
||||
keyTimes="0;0.08;0.85;1" dur={`${g.dur}s`} begin={`${g.d}s`} repeatCount="indefinite" />
|
||||
</text>
|
||||
))}
|
||||
|
||||
{/* 7. Scan-line */}
|
||||
<line x1="0" y1="0" x2="1200" y2="0" stroke="#00d4aa" strokeOpacity="0" strokeWidth="1">
|
||||
<animateTransform attributeName="transform" type="translate"
|
||||
from="0 0" to="0 460" dur="6s" repeatCount="indefinite" />
|
||||
<animate attributeName="stroke-opacity" values="0;0.16;0.16;0"
|
||||
keyTimes="0;0.04;0.96;1" dur="6s" repeatCount="indefinite" />
|
||||
</line>
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -1,223 +0,0 @@
|
||||
/**
|
||||
* PipelineAnimation — a rich, orbital visualization of the autopilot pipeline.
|
||||
*
|
||||
* Central hub surrounded by 5 stage nodes in a circuit layout, with
|
||||
* data packets flowing through connecting paths, background grid,
|
||||
* ambient particles, and pulsing energy rings. Fills the entire
|
||||
* container with minimal dead space.
|
||||
*/
|
||||
export function PipelineAnimation() {
|
||||
const cx = 200;
|
||||
const cy = 105;
|
||||
const rx = 145;
|
||||
const ry = 65;
|
||||
|
||||
const stages = [
|
||||
{ label: "QUEUE", angle: Math.PI },
|
||||
{ label: "PREP", angle: Math.PI + Math.PI * 2 / 5 },
|
||||
{ label: "RUN", angle: Math.PI + (Math.PI * 2 / 5) * 2 },
|
||||
{ label: "CHECK", angle: Math.PI + (Math.PI * 2 / 5) * 3 },
|
||||
{ label: "MERGE", angle: Math.PI + (Math.PI * 2 / 5) * 4 },
|
||||
].map((s) => ({
|
||||
...s,
|
||||
x: cx + Math.cos(s.angle) * rx,
|
||||
y: cy + Math.sin(s.angle) * ry,
|
||||
}));
|
||||
|
||||
// Build the orbital path for traveling packets
|
||||
const orbitPath = stages
|
||||
.map((s, i) => `${i === 0 ? "M" : "L"} ${s.x.toFixed(1)} ${s.y.toFixed(1)}`)
|
||||
.join(" ") + " Z";
|
||||
|
||||
// Ambient floating particles
|
||||
const particles = [
|
||||
{ x: 40, y: 30, r: 1.2, dur: 3, d: 0 },
|
||||
{ x: 340, y: 50, r: 0.8, dur: 4, d: 1 },
|
||||
{ x: 80, y: 170, r: 1, dur: 3.5, d: 2 },
|
||||
{ x: 320, y: 160, r: 1.3, dur: 2.8, d: 0.5 },
|
||||
{ x: 170, y: 25, r: 0.7, dur: 4.2, d: 1.5 },
|
||||
{ x: 250, y: 185, r: 0.9, dur: 3.2, d: 3 },
|
||||
{ x: 120, y: 100, r: 0.6, dur: 5, d: 2.5 },
|
||||
{ x: 300, y: 110, r: 1.1, dur: 3.8, d: 0.8 },
|
||||
{ x: 50, y: 120, r: 0.8, dur: 4.5, d: 1.2 },
|
||||
{ x: 370, y: 90, r: 0.7, dur: 3.6, d: 2.2 },
|
||||
];
|
||||
|
||||
// Background grid lines
|
||||
const hLines = [35, 70, 105, 140, 175];
|
||||
const vLines = [40, 80, 120, 160, 200, 240, 280, 320, 360];
|
||||
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 400 210"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
aria-label="Autopilot pipeline"
|
||||
style={{ width: "100%", height: "100%" }}
|
||||
preserveAspectRatio="xMidYMid meet"
|
||||
>
|
||||
<defs>
|
||||
<radialGradient id="pipe-glow" cx="50%" cy="50%" r="50%">
|
||||
<stop offset="0%" stopColor="#00d4aa" stopOpacity="0.85" />
|
||||
<stop offset="40%" stopColor="#00d4aa" stopOpacity="0.3" />
|
||||
<stop offset="100%" stopColor="#00d4aa" stopOpacity="0" />
|
||||
</radialGradient>
|
||||
<radialGradient id="hub-glow" cx="50%" cy="50%" r="50%">
|
||||
<stop offset="0%" stopColor="#00d4aa" stopOpacity="0.2" />
|
||||
<stop offset="100%" stopColor="#00d4aa" stopOpacity="0" />
|
||||
</radialGradient>
|
||||
<radialGradient id="node-glow" cx="50%" cy="50%" r="50%">
|
||||
<stop offset="0%" stopColor="#00d4aa" stopOpacity="0.15" />
|
||||
<stop offset="100%" stopColor="#00d4aa" stopOpacity="0" />
|
||||
</radialGradient>
|
||||
<linearGradient id="edge-fade" x1="0" y1="0" x2="1" y2="0">
|
||||
<stop offset="0%" stopColor="#00d4aa" stopOpacity="0" />
|
||||
<stop offset="50%" stopColor="#00d4aa" stopOpacity="0.25" />
|
||||
<stop offset="100%" stopColor="#00d4aa" stopOpacity="0" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
{/* Background grid */}
|
||||
{hLines.map((y, i) => (
|
||||
<line key={`h${i}`} x1="0" y1={y} x2="400" y2={y}
|
||||
stroke="#00d4aa" strokeOpacity="0.04" strokeWidth="1" />
|
||||
))}
|
||||
{vLines.map((x, i) => (
|
||||
<line key={`v${i}`} x1={x} y1="0" x2={x} y2="210"
|
||||
stroke="#00d4aa" strokeOpacity="0.04" strokeWidth="1" />
|
||||
))}
|
||||
|
||||
{/* Central radial glow */}
|
||||
<circle cx={cx} cy={cy} r="80" fill="url(#hub-glow)" />
|
||||
|
||||
{/* Orbital ring (background) */}
|
||||
<path d={orbitPath} fill="none" stroke="#00d4aa" strokeOpacity="0.08" strokeWidth="1" />
|
||||
|
||||
{/* Animated dashed orbital ring */}
|
||||
<path d={orbitPath} fill="none" stroke="#00d4aa" strokeOpacity="0.18"
|
||||
strokeWidth="1" strokeDasharray="4 8">
|
||||
<animate attributeName="stroke-dashoffset" values="0;-24" dur="2s" repeatCount="indefinite" />
|
||||
</path>
|
||||
|
||||
{/* Spokes from center to each node */}
|
||||
{stages.map((s, i) => (
|
||||
<g key={`spoke-${i}`}>
|
||||
<line x1={cx} y1={cy} x2={s.x} y2={s.y}
|
||||
stroke="#00d4aa" strokeOpacity="0.06" strokeWidth="1" />
|
||||
{/* Data pulse along spoke */}
|
||||
<circle r="1.5" fill="#00d4aa">
|
||||
<animateMotion
|
||||
dur="2s"
|
||||
begin={`${i * 0.4}s`}
|
||||
repeatCount="indefinite"
|
||||
path={`M ${cx} ${cy} L ${s.x} ${s.y}`}
|
||||
/>
|
||||
<animate attributeName="opacity" values="0;0.8;0.8;0"
|
||||
keyTimes="0;0.1;0.8;1" dur="2s" begin={`${i * 0.4}s`} repeatCount="indefinite" />
|
||||
</circle>
|
||||
</g>
|
||||
))}
|
||||
|
||||
{/* Stage nodes */}
|
||||
{stages.map((s, i) => {
|
||||
const colors = ["#64748b", "#0f766e", "#00d4aa", "#3b82f6", "#8b5cf6"];
|
||||
const c = colors[i];
|
||||
return (
|
||||
<g key={s.label}>
|
||||
{/* Node glow */}
|
||||
<circle cx={s.x} cy={s.y} r="28" fill="url(#node-glow)" />
|
||||
|
||||
{/* Outer ring */}
|
||||
<circle cx={s.x} cy={s.y} r="18" fill="#0a0a0a"
|
||||
stroke={c} strokeOpacity="0.5" strokeWidth="1" />
|
||||
|
||||
{/* Corner brackets */}
|
||||
<g stroke={c} strokeOpacity="0.7" strokeWidth="1" strokeLinecap="round">
|
||||
<path d={`M ${s.x-13} ${s.y-13} l 5 0 M ${s.x-13} ${s.y-13} l 0 5`} />
|
||||
<path d={`M ${s.x+13} ${s.y-13} l -5 0 M ${s.x+13} ${s.y-13} l 0 5`} />
|
||||
<path d={`M ${s.x-13} ${s.y+13} l 5 0 M ${s.x-13} ${s.y+13} l 0 -5`} />
|
||||
<path d={`M ${s.x+13} ${s.y+13} l -5 0 M ${s.x+13} ${s.y+13} l 0 -5`} />
|
||||
</g>
|
||||
|
||||
{/* Inner pulsing dot */}
|
||||
<circle cx={s.x} cy={s.y} r="4" fill={c}>
|
||||
<animate attributeName="r" values="4;6;4" dur="2.4s"
|
||||
begin={`${i * 0.5}s`} repeatCount="indefinite" />
|
||||
<animate attributeName="opacity" values="0.5;1;0.5" dur="2.4s"
|
||||
begin={`${i * 0.5}s`} repeatCount="indefinite" />
|
||||
</circle>
|
||||
|
||||
{/* Expanding pulse ring */}
|
||||
<circle cx={s.x} cy={s.y} r="18" fill="none" stroke={c} strokeWidth="1">
|
||||
<animate attributeName="r" values="18;30" dur="3s"
|
||||
begin={`${i * 0.6}s`} repeatCount="indefinite" />
|
||||
<animate attributeName="opacity" values="0.45;0" dur="3s"
|
||||
begin={`${i * 0.6}s`} repeatCount="indefinite" />
|
||||
</circle>
|
||||
|
||||
{/* Label */}
|
||||
<text x={s.x} y={s.y + 30} fontSize="7" fill={c} fillOpacity="0.7"
|
||||
textAnchor="middle" fontFamily="JetBrains Mono, monospace"
|
||||
letterSpacing="1.5" fontWeight="700">
|
||||
{s.label}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Central hub */}
|
||||
<circle cx={cx} cy={cy} r="14" fill="#0a0a0a" stroke="#00d4aa" strokeOpacity="0.6" strokeWidth="1.2" />
|
||||
<circle cx={cx} cy={cy} r="14" fill="none" stroke="#00d4aa" strokeWidth="1">
|
||||
<animate attributeName="r" values="14;22" dur="2.4s" repeatCount="indefinite" />
|
||||
<animate attributeName="opacity" values="0.4;0" dur="2.4s" repeatCount="indefinite" />
|
||||
</circle>
|
||||
{/* Hub icon — infinity / loop symbol */}
|
||||
<text x={cx} y={cy + 4} fontSize="11" fill="#00d4aa" fillOpacity="0.9"
|
||||
textAnchor="middle" fontFamily="JetBrains Mono, monospace" fontWeight="700">
|
||||
∞
|
||||
</text>
|
||||
|
||||
{/* Traveling packet 1 — clockwise */}
|
||||
<circle r="16" fill="url(#pipe-glow)" opacity="0.6">
|
||||
<animateMotion dur="5s" repeatCount="indefinite" path={orbitPath} rotate="auto" />
|
||||
</circle>
|
||||
<circle r="3.5" fill="#00d4aa">
|
||||
<animateMotion dur="5s" repeatCount="indefinite" path={orbitPath} rotate="auto" />
|
||||
<animate attributeName="r" values="3.5;2.5;3.5" dur="1s" repeatCount="indefinite" />
|
||||
</circle>
|
||||
|
||||
{/* Traveling packet 2 — offset, different speed */}
|
||||
<circle r="10" fill="url(#pipe-glow)" opacity="0.35">
|
||||
<animateMotion dur="7s" begin="2.5s" repeatCount="indefinite" path={orbitPath} rotate="auto" />
|
||||
</circle>
|
||||
<circle r="2" fill="#8b5cf6">
|
||||
<animateMotion dur="7s" begin="2.5s" repeatCount="indefinite" path={orbitPath} rotate="auto" />
|
||||
<animate attributeName="opacity" values="0.4;1;0.4" dur="2s" repeatCount="indefinite" />
|
||||
</circle>
|
||||
|
||||
{/* Ambient particles */}
|
||||
{particles.map((p, i) => (
|
||||
<circle key={`p${i}`} cx={p.x} cy={p.y} r={p.r}
|
||||
fill={i % 3 === 0 ? "#8b5cf6" : "#00d4aa"}>
|
||||
<animate attributeName="opacity" values="0.05;0.25;0.05"
|
||||
dur={`${p.dur}s`} begin={`${p.d}s`} repeatCount="indefinite" />
|
||||
<animate attributeName="r" values={`${p.r};${p.r * 1.5};${p.r}`}
|
||||
dur={`${p.dur}s`} begin={`${p.d}s`} repeatCount="indefinite" />
|
||||
</circle>
|
||||
))}
|
||||
|
||||
{/* Scan line */}
|
||||
<line x1="0" y1="0" x2="400" y2="0" stroke="#00d4aa" strokeOpacity="0" strokeWidth="1">
|
||||
<animateTransform attributeName="transform" type="translate"
|
||||
from="0 0" to="0 210" dur="4s" repeatCount="indefinite" />
|
||||
<animate attributeName="stroke-opacity" values="0;0.12;0.12;0"
|
||||
keyTimes="0;0.05;0.95;1" dur="4s" repeatCount="indefinite" />
|
||||
</line>
|
||||
|
||||
{/* Bottom caption */}
|
||||
<text x={cx} y="204" fontSize="7" fill="#00d4aa" fillOpacity="0.4"
|
||||
textAnchor="middle" fontFamily="JetBrains Mono, monospace" letterSpacing="2.5">
|
||||
AUTOPILOT · PIPELINE
|
||||
</text>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -1,546 +0,0 @@
|
||||
/* ─── AnyFS-inspired dark cyberpunk theme ─────────── */
|
||||
:root {
|
||||
--bg: #0a0a0a;
|
||||
--bg-surface: #111111;
|
||||
--bg-elevated: #1a1a1a;
|
||||
--ink: #ffffff;
|
||||
--ink-secondary: #888888;
|
||||
--accent: #00d4aa;
|
||||
--accent-light: #00f0c0;
|
||||
--accent-orange: #ff6b35;
|
||||
--accent-violet: #8b5cf6;
|
||||
--muted: #666666;
|
||||
--line: #222222;
|
||||
--line-bright: #333333;
|
||||
--success: #00d4aa;
|
||||
--error: #ff4444;
|
||||
--warning: #ffaa00;
|
||||
--mono: "JetBrains Mono", "Fira Code", ui-monospace, "Cascadia Code", monospace;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
html { scroll-behavior: smooth; }
|
||||
|
||||
body {
|
||||
background: var(--bg);
|
||||
color: var(--ink);
|
||||
font-family: var(--mono);
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
::selection {
|
||||
background: rgba(0, 212, 170, 0.25);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* ─── Scrollbar ──────────────────────────────── */
|
||||
::-webkit-scrollbar { width: 4px; height: 4px; }
|
||||
::-webkit-scrollbar-track { background: transparent; }
|
||||
::-webkit-scrollbar-thumb { background: #333; border-radius: 2px; }
|
||||
::-webkit-scrollbar-thumb:hover { background: #555; }
|
||||
|
||||
/* ─── Shell ──────────────────────────────────── */
|
||||
.shell {
|
||||
max-width: 1600px;
|
||||
margin: 0 auto;
|
||||
padding: 0 20px 56px;
|
||||
}
|
||||
|
||||
/* ─── Hero Section ───────────────────────────── */
|
||||
.hero {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
margin: 0 -20px;
|
||||
padding: 60px 20px 40px;
|
||||
min-height: 340px;
|
||||
}
|
||||
|
||||
.hero-bg {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.hero-content {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: grid;
|
||||
grid-template-columns: 1.4fr 1fr;
|
||||
gap: 24px;
|
||||
max-width: 1600px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.hero-main { padding: 8px; }
|
||||
|
||||
.eyebrow {
|
||||
display: inline-flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
font-size: 10px;
|
||||
letter-spacing: 3px;
|
||||
text-transform: uppercase;
|
||||
color: var(--accent);
|
||||
font-weight: 700;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.hero h1 {
|
||||
font-size: clamp(32px, 4.5vw, 56px);
|
||||
font-weight: 700;
|
||||
letter-spacing: 2px;
|
||||
line-height: 1.1;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.hero h1 .accent { color: var(--accent); }
|
||||
|
||||
.hero-sub {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
line-height: 1.8;
|
||||
max-width: 52ch;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.focus-box {
|
||||
padding: 16px;
|
||||
border-radius: 6px;
|
||||
background: rgba(0, 212, 170, 0.06);
|
||||
border: 1px solid rgba(0, 212, 170, 0.15);
|
||||
}
|
||||
|
||||
.focus-box .focus-label {
|
||||
font-size: 10px;
|
||||
letter-spacing: 2px;
|
||||
text-transform: uppercase;
|
||||
color: var(--accent);
|
||||
font-weight: 700;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.focus-box .focus-text {
|
||||
font-size: 12px;
|
||||
color: var(--ink-secondary);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.hero-side {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.hero-timestamp {
|
||||
font-size: 10px;
|
||||
letter-spacing: 1px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
/* ─── Stats Bar ──────────────────────────────── */
|
||||
.stats-bar {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 1px;
|
||||
background: var(--line);
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.stat {
|
||||
background: var(--bg-surface);
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
transition: background 0.2s ease;
|
||||
}
|
||||
|
||||
.stat:hover {
|
||||
background: var(--bg-elevated);
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 10px;
|
||||
letter-spacing: 2px;
|
||||
text-transform: uppercase;
|
||||
font-weight: 700;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.stat-label.teal { color: var(--accent); }
|
||||
.stat-label.orange { color: var(--accent-orange); }
|
||||
.stat-label.violet { color: var(--accent-violet); }
|
||||
|
||||
.stat-value {
|
||||
font-size: 32px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -1px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.stat-sub {
|
||||
font-size: 10px;
|
||||
color: #444;
|
||||
margin-top: 6px;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
/* ─── Toolbar ────────────────────────────────── */
|
||||
.toolbar {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px 20px;
|
||||
margin-bottom: 20px;
|
||||
background: var(--bg-elevated);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.toolbar input {
|
||||
flex: 1;
|
||||
min-width: min(400px, 100%);
|
||||
height: 2.5rem;
|
||||
padding: 0 14px;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--line-bright);
|
||||
border-radius: 4px;
|
||||
font-family: var(--mono);
|
||||
font-size: 12px;
|
||||
color: var(--ink);
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.toolbar input::placeholder { color: #444; }
|
||||
|
||||
.toolbar input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 2px rgba(0, 212, 170, 0.1);
|
||||
}
|
||||
|
||||
.toolbar .hint {
|
||||
font-size: 11px;
|
||||
color: var(--muted);
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.toolbar .hint code {
|
||||
color: var(--accent);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
/* ─── Board ──────────────────────────────────── */
|
||||
.board {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 16px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
/* ─── Column ─────────────────────────────────── */
|
||||
.column {
|
||||
background: var(--bg-elevated);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
padding: 16px;
|
||||
min-height: 200px;
|
||||
}
|
||||
|
||||
.column-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 14px;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.column-title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.column-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.column-header h2 {
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 1.5px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.column-count {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 1px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 3px;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border: 1px solid var(--line);
|
||||
color: var(--ink-secondary);
|
||||
}
|
||||
|
||||
.cards { display: grid; gap: 10px; }
|
||||
|
||||
/* ─── Card ───────────────────────────────────── */
|
||||
.card {
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
padding: 14px;
|
||||
transition: all 0.2s ease;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.card::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 3px;
|
||||
height: 100%;
|
||||
background: var(--card-accent, var(--line-bright));
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
border-color: var(--line-bright);
|
||||
box-shadow: 0 0 20px color-mix(in srgb, var(--card-accent, var(--accent)) 12%, transparent);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.card-meta {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
font-size: 10px;
|
||||
color: var(--muted);
|
||||
margin-bottom: 8px;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.card h3 {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
line-height: 1.4;
|
||||
margin-bottom: 8px;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
|
||||
.card-body {
|
||||
font-size: 11px;
|
||||
color: var(--muted);
|
||||
line-height: 1.6;
|
||||
margin-bottom: 10px;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.card-tags {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 2px 8px;
|
||||
font-size: 9px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 1px;
|
||||
text-transform: uppercase;
|
||||
border-radius: 3px;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
color: var(--ink-secondary);
|
||||
border: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.card-footer {
|
||||
border-top: 1px solid var(--line);
|
||||
padding-top: 10px;
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
font-size: 10px;
|
||||
color: #555;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
|
||||
/* ─── Status badges for column headers ────── */
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 2px 10px;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 1px;
|
||||
text-transform: uppercase;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.badge-teal {
|
||||
background: rgba(0, 212, 170, 0.1);
|
||||
color: var(--accent);
|
||||
border: 1px solid rgba(0, 212, 170, 0.2);
|
||||
}
|
||||
|
||||
.badge-orange {
|
||||
background: rgba(255, 107, 53, 0.1);
|
||||
color: var(--accent-orange);
|
||||
border: 1px solid rgba(255, 107, 53, 0.2);
|
||||
}
|
||||
|
||||
.badge-violet {
|
||||
background: rgba(139, 92, 246, 0.1);
|
||||
color: var(--accent-violet);
|
||||
border: 1px solid rgba(139, 92, 246, 0.2);
|
||||
}
|
||||
|
||||
.badge-red {
|
||||
background: rgba(255, 68, 68, 0.1);
|
||||
color: #ff6666;
|
||||
border: 1px solid rgba(255, 68, 68, 0.2);
|
||||
}
|
||||
|
||||
.badge-blue {
|
||||
background: rgba(59, 130, 246, 0.1);
|
||||
color: #60a5fa;
|
||||
border: 1px solid rgba(59, 130, 246, 0.2);
|
||||
}
|
||||
|
||||
.badge-amber {
|
||||
background: rgba(255, 170, 0, 0.1);
|
||||
color: #ffaa00;
|
||||
border: 1px solid rgba(255, 170, 0, 0.2);
|
||||
}
|
||||
|
||||
.badge-gray {
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
color: #888;
|
||||
border: 1px solid var(--line);
|
||||
}
|
||||
|
||||
/* ─── Journal ────────────────────────────────── */
|
||||
.journal {
|
||||
background: var(--bg-elevated);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.journal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
padding-bottom: 14px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.journal-header h2 {
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 2px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.journal-list { display: grid; gap: 8px; }
|
||||
|
||||
.journal-item {
|
||||
padding: 12px 14px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--line);
|
||||
background: var(--bg-surface);
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
|
||||
.journal-item:hover { background: var(--bg-elevated); }
|
||||
|
||||
.journal-item time {
|
||||
font-size: 10px;
|
||||
color: var(--muted);
|
||||
letter-spacing: 0.5px;
|
||||
display: inline-block;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.journal-item .kind {
|
||||
font-weight: 700;
|
||||
color: var(--accent);
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.journal-item .task-ref {
|
||||
color: var(--accent-violet);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.journal-item .summary {
|
||||
font-size: 12px;
|
||||
color: var(--ink-secondary);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.empty {
|
||||
color: var(--muted);
|
||||
font-style: italic;
|
||||
padding: 16px 4px;
|
||||
font-size: 12px;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
/* ─── Pipeline Animation (hero side) ─────────── */
|
||||
.pipeline-viz {
|
||||
background: var(--bg-elevated);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* ─── Animations ─────────────────────────────── */
|
||||
@keyframes fade-in {
|
||||
from { opacity: 0; transform: translateY(8px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
@keyframes glow-pulse {
|
||||
0%, 100% { box-shadow: 0 0 12px rgba(0, 212, 170, 0.08); }
|
||||
50% { box-shadow: 0 0 24px rgba(0, 212, 170, 0.16); }
|
||||
}
|
||||
|
||||
.animate-fade-in {
|
||||
animation: fade-in 0.4s ease-out forwards;
|
||||
}
|
||||
|
||||
/* ─── Responsive ─────────────────────────────── */
|
||||
@media (max-width: 1100px) {
|
||||
.hero-content { grid-template-columns: 1fr; }
|
||||
.stats-bar { grid-template-columns: repeat(2, 1fr); }
|
||||
.board { grid-template-columns: repeat(2, 1fr); }
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.shell { padding: 0 12px 36px; }
|
||||
.hero { padding: 40px 12px 28px; margin: 0 -12px; }
|
||||
.board { grid-template-columns: 1fr; }
|
||||
.stats-bar { grid-template-columns: 1fr 1fr; }
|
||||
.toolbar input { min-width: 100%; }
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import "./index.css";
|
||||
import { App } from "./App";
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
);
|
||||
@@ -1,84 +0,0 @@
|
||||
export interface TaskCard {
|
||||
id: string;
|
||||
title: string;
|
||||
body?: string;
|
||||
status: string;
|
||||
score: number;
|
||||
score_reasons?: string[];
|
||||
source_kind?: string;
|
||||
source_ref?: string;
|
||||
labels?: string[];
|
||||
updated_at?: number;
|
||||
metadata?: {
|
||||
last_note?: string;
|
||||
last_ci_summary?: string;
|
||||
last_failure_summary?: string;
|
||||
human_gate_pending?: boolean;
|
||||
verification_steps?: { status: string; command: string }[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface JournalEntry {
|
||||
timestamp: number;
|
||||
kind: string;
|
||||
task_id?: string;
|
||||
summary: string;
|
||||
}
|
||||
|
||||
export interface Snapshot {
|
||||
generated_at: number;
|
||||
repo_name: string;
|
||||
focus?: TaskCard;
|
||||
counts: Record<string, number>;
|
||||
status_order: string[];
|
||||
columns: Record<string, TaskCard[]>;
|
||||
cards: TaskCard[];
|
||||
journal: JournalEntry[];
|
||||
}
|
||||
|
||||
export const STATUS_LABELS: Record<string, string> = {
|
||||
queued: "Queued",
|
||||
accepted: "Accepted",
|
||||
preparing: "Preparing",
|
||||
running: "Running",
|
||||
verifying: "Verifying",
|
||||
pr_open: "PR Open",
|
||||
waiting_ci: "Waiting CI",
|
||||
repairing: "Repairing",
|
||||
completed: "Completed",
|
||||
merged: "Merged",
|
||||
failed: "Failed",
|
||||
rejected: "Rejected",
|
||||
superseded: "Superseded",
|
||||
};
|
||||
|
||||
export const STATUS_COLORS: Record<string, string> = {
|
||||
queued: "#64748b",
|
||||
accepted: "#8b5cf6",
|
||||
preparing: "#0f766e",
|
||||
running: "#00d4aa",
|
||||
verifying: "#3b82f6",
|
||||
pr_open: "#8b5cf6",
|
||||
waiting_ci: "#3b82f6",
|
||||
repairing: "#ff6b35",
|
||||
completed: "#00d4aa",
|
||||
merged: "#00d4aa",
|
||||
failed: "#ff4444",
|
||||
rejected: "#ff4444",
|
||||
superseded: "#ffaa00",
|
||||
};
|
||||
|
||||
/** Grouped kanban columns — vibe-kanban style */
|
||||
export interface KanbanGroup {
|
||||
key: string;
|
||||
label: string;
|
||||
color: string;
|
||||
statuses: string[];
|
||||
}
|
||||
|
||||
export const KANBAN_GROUPS: KanbanGroup[] = [
|
||||
{ key: "todo", label: "To Do", color: "#64748b", statuses: ["queued", "accepted"] },
|
||||
{ key: "in_progress", label: "In Progress", color: "#00d4aa", statuses: ["preparing", "running", "repairing"] },
|
||||
{ key: "in_review", label: "In Review", color: "#3b82f6", statuses: ["verifying", "pr_open", "waiting_ci"] },
|
||||
{ key: "done", label: "Done", color: "#8b5cf6", statuses: ["completed", "merged", "failed", "rejected", "superseded"] },
|
||||
];
|
||||
-1
@@ -1 +0,0 @@
|
||||
/// <reference types="vite/client" />
|
||||
@@ -1,22 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsBuildInfo",
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [{ "path": "./tsconfig.app.json" }]
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
base: "./",
|
||||
build: {
|
||||
outDir: "../docs/autopilot",
|
||||
emptyOutDir: false,
|
||||
},
|
||||
});
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,16 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Autopilot Kanban</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&display=swap" rel="stylesheet" />
|
||||
<script type="module" crossorigin src="./assets/index-gQnrxco6.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="./assets/index-CENkxP5l.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,59 +0,0 @@
|
||||
{
|
||||
"generated_at": 1776338766.0050209,
|
||||
"repo_name": "OpenHarness-new",
|
||||
"repo_path": "/home/tangjiabin/OpenHarness-new",
|
||||
"focus": null,
|
||||
"counts": {
|
||||
"queued": 0,
|
||||
"accepted": 0,
|
||||
"preparing": 0,
|
||||
"running": 0,
|
||||
"verifying": 0,
|
||||
"pr_open": 0,
|
||||
"waiting_ci": 0,
|
||||
"repairing": 0,
|
||||
"completed": 0,
|
||||
"merged": 0,
|
||||
"failed": 0,
|
||||
"rejected": 0,
|
||||
"superseded": 0
|
||||
},
|
||||
"status_order": [
|
||||
"queued",
|
||||
"accepted",
|
||||
"preparing",
|
||||
"running",
|
||||
"verifying",
|
||||
"pr_open",
|
||||
"waiting_ci",
|
||||
"repairing",
|
||||
"completed",
|
||||
"merged",
|
||||
"failed",
|
||||
"rejected",
|
||||
"superseded"
|
||||
],
|
||||
"columns": {
|
||||
"queued": [],
|
||||
"accepted": [],
|
||||
"preparing": [],
|
||||
"running": [],
|
||||
"verifying": [],
|
||||
"pr_open": [],
|
||||
"waiting_ci": [],
|
||||
"repairing": [],
|
||||
"completed": [],
|
||||
"merged": [],
|
||||
"failed": [],
|
||||
"rejected": [],
|
||||
"superseded": []
|
||||
},
|
||||
"cards": [],
|
||||
"journal": [],
|
||||
"policies": {
|
||||
"autopilot": "/home/tangjiabin/OpenHarness-new/.openharness/autopilot/autopilot_policy.yaml",
|
||||
"verification": "/home/tangjiabin/OpenHarness-new/.openharness/autopilot/verification_policy.yaml",
|
||||
"release": "/home/tangjiabin/OpenHarness-new/.openharness/autopilot/release_policy.yaml"
|
||||
},
|
||||
"active_context": "# Active Repo Context\n\nGenerated at: 2026-04-16 11:05:49 UTC\n\n## Current Task Focus\n- No active repo task focus yet.\n\n## In Progress\n- None.\n\n## Next Up\n- No queued items.\n\n## Recently Completed\n- None yet.\n\n## Recent Failures\n- None.\n\n## Recent Repo Journal\n- Journal is empty.\n\n## Policies\n- Autopilot: /home/tangjiabin/OpenHarness-new/.openharness/autopilot/autopilot_policy.yaml\n- Verification: /home/tangjiabin/OpenHarness-new/.openharness/autopilot/verification_policy.yaml\n- Release: /home/tangjiabin/OpenHarness-new/.openharness/autopilot/release_policy.yaml"
|
||||
}
|
||||
Generated
+4
-15
@@ -8,9 +8,7 @@
|
||||
"dependencies": {
|
||||
"ink": "^5.1.0",
|
||||
"ink-text-input": "^6.0.0",
|
||||
"marked": "^18.0.0",
|
||||
"react": "^18.3.1",
|
||||
"string-width": "^7.2.0"
|
||||
"react": "^18.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.13.10",
|
||||
@@ -497,6 +495,7 @@
|
||||
"integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/prop-types": "*",
|
||||
"csstype": "^3.2.2"
|
||||
@@ -788,6 +787,7 @@
|
||||
"resolved": "https://registry.npmjs.org/ink/-/ink-5.2.1.tgz",
|
||||
"integrity": "sha512-BqcUyWrG9zq5HIwW6JcfFHsIYebJkWWb4fczNah1goUO0vv5vneIlfwuS85twyJ5hYR/y18FlAYUxrO9ChIWVg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@alcalzone/ansi-tokenize": "^0.1.3",
|
||||
"ansi-escapes": "^7.0.0",
|
||||
@@ -893,18 +893,6 @@
|
||||
"loose-envify": "cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/marked": {
|
||||
"version": "18.0.0",
|
||||
"resolved": "https://registry.npmjs.org/marked/-/marked-18.0.0.tgz",
|
||||
"integrity": "sha512-2e7Qiv/HJSXj8rDEpgTvGKsP8yYtI9xXHKDnrftrmnrJPaFNM7VRb2YCzWaX4BP1iCJ/XPduzDJZMFoqTCcIMA==",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"marked": "bin/marked.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 20"
|
||||
}
|
||||
},
|
||||
"node_modules/mimic-fn": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz",
|
||||
@@ -943,6 +931,7 @@
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
|
||||
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.1.0"
|
||||
},
|
||||
|
||||
@@ -8,9 +8,7 @@
|
||||
"dependencies": {
|
||||
"ink": "^5.1.0",
|
||||
"ink-text-input": "^6.0.0",
|
||||
"marked": "^18.0.0",
|
||||
"react": "^18.3.1",
|
||||
"string-width": "^7.2.0"
|
||||
"react": "^18.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.13.10",
|
||||
|
||||
+57
-247
@@ -1,18 +1,14 @@
|
||||
import React, {useDeferredValue, useEffect, useMemo, useRef, useState} from 'react';
|
||||
import React, {useEffect, useMemo, useState} from 'react';
|
||||
import {Box, Text, useApp, useInput} from 'ink';
|
||||
|
||||
import {readClipboardImage, type ImageAttachment} from './clipboardImage.js';
|
||||
import {CommandPicker} from './components/CommandPicker.js';
|
||||
import {ConversationView} from './components/ConversationView.js';
|
||||
import {ModalHost} from './components/ModalHost.js';
|
||||
import {PromptInput} from './components/PromptInput.js';
|
||||
import {SelectModal, type SelectOption} from './components/SelectModal.js';
|
||||
import {StatusBar} from './components/StatusBar.js';
|
||||
import {SwarmPanel} from './components/SwarmPanel.js';
|
||||
import {TodoPanel} from './components/TodoPanel.js';
|
||||
import {useBackendSession} from './hooks/useBackendSession.js';
|
||||
import {ThemeProvider, useTheme} from './theme/ThemeContext.js';
|
||||
import type {FrontendConfig, ImageAttachmentPayload} from './types.js';
|
||||
import type {FrontendConfig} from './types.js';
|
||||
|
||||
const rawReturnSubmit = process.env.OPENHARNESS_FRONTEND_RAW_RETURN === '1';
|
||||
const scriptedSteps = (() => {
|
||||
@@ -28,20 +24,11 @@ const scriptedSteps = (() => {
|
||||
}
|
||||
})();
|
||||
|
||||
const SELECTABLE_COMMANDS = new Set([
|
||||
'/provider',
|
||||
'/model',
|
||||
'/theme',
|
||||
'/output-style',
|
||||
'/permissions',
|
||||
'/resume',
|
||||
'/effort',
|
||||
'/passes',
|
||||
'/turns',
|
||||
'/fast',
|
||||
'/vim',
|
||||
'/voice',
|
||||
]);
|
||||
const PERMISSION_MODES: SelectOption[] = [
|
||||
{value: 'default', label: 'Default', description: 'Ask before write/execute operations'},
|
||||
{value: 'full_auto', label: 'Auto', description: 'Allow all tools automatically'},
|
||||
{value: 'plan', label: 'Plan Mode', description: 'Block all write operations'},
|
||||
];
|
||||
|
||||
type SelectModalState = {
|
||||
title: string;
|
||||
@@ -50,90 +37,21 @@ type SelectModalState = {
|
||||
} | null;
|
||||
|
||||
export function App({config}: {config: FrontendConfig}): React.JSX.Element {
|
||||
const initialTheme = String((config as Record<string, unknown>).theme ?? 'default');
|
||||
return (
|
||||
<ThemeProvider initialTheme={initialTheme}>
|
||||
<AppInner config={config} />
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function AppInner({config}: {config: FrontendConfig}): React.JSX.Element {
|
||||
const {exit} = useApp();
|
||||
const {theme, setThemeName} = useTheme();
|
||||
const [input, setInput] = useState('');
|
||||
const [modalInput, setModalInput] = useState('');
|
||||
const [history, setHistory] = useState<string[]>([]);
|
||||
const [historyIndex, setHistoryIndex] = useState(-1);
|
||||
const [imageAttachments, setImageAttachments] = useState<ImageAttachment[]>([]);
|
||||
const [clipboardStatus, setClipboardStatus] = useState<string | null>(null);
|
||||
const [lastEscapeAt, setLastEscapeAt] = useState(0);
|
||||
const [scriptIndex, setScriptIndex] = useState(0);
|
||||
const [pickerIndex, setPickerIndex] = useState(0);
|
||||
const [selectModal, setSelectModal] = useState<SelectModalState>(null);
|
||||
const [selectIndex, setSelectIndex] = useState(0);
|
||||
const session = useBackendSession(config, () => exit());
|
||||
const deferredTranscript = useDeferredValue(session.transcript);
|
||||
const deferredAssistantBuffer = useDeferredValue(session.assistantBuffer);
|
||||
const deferredStatus = useDeferredValue(session.status);
|
||||
const deferredTasks = useDeferredValue(session.tasks);
|
||||
const deferredTodoMarkdown = useDeferredValue(session.todoMarkdown);
|
||||
const deferredSwarmTeammates = useDeferredValue(session.swarmTeammates);
|
||||
const deferredSwarmNotifications = useDeferredValue(session.swarmNotifications);
|
||||
const clipboardStatusTimerRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const nextTheme = session.status.theme;
|
||||
if (typeof nextTheme === 'string' && nextTheme) {
|
||||
setThemeName(nextTheme);
|
||||
}
|
||||
}, [session.status.theme, setThemeName]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (clipboardStatusTimerRef.current) {
|
||||
clearTimeout(clipboardStatusTimerRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const setTemporaryClipboardStatus = (message: string): void => {
|
||||
setClipboardStatus(message);
|
||||
if (clipboardStatusTimerRef.current) {
|
||||
clearTimeout(clipboardStatusTimerRef.current);
|
||||
}
|
||||
clipboardStatusTimerRef.current = setTimeout(() => {
|
||||
setClipboardStatus(null);
|
||||
clipboardStatusTimerRef.current = null;
|
||||
}, 2500);
|
||||
};
|
||||
|
||||
const attachClipboardImage = (): void => {
|
||||
void (async () => {
|
||||
const image = await readClipboardImage();
|
||||
if (!image) {
|
||||
setTemporaryClipboardStatus('No image found in clipboard');
|
||||
return;
|
||||
}
|
||||
setImageAttachments((items) => [...items, image]);
|
||||
setTemporaryClipboardStatus(`Attached ${image.label}`);
|
||||
})().catch((error: unknown) => {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
setTemporaryClipboardStatus(`Clipboard image unavailable: ${message}`);
|
||||
});
|
||||
};
|
||||
|
||||
const imagePayloads = (): ImageAttachmentPayload[] =>
|
||||
imageAttachments.map((image) => ({
|
||||
media_type: image.media_type,
|
||||
data: image.data,
|
||||
source_path: image.source_path,
|
||||
}));
|
||||
|
||||
// Current tool name for spinner
|
||||
const currentToolName = useMemo(() => {
|
||||
for (let i = deferredTranscript.length - 1; i >= 0; i--) {
|
||||
const item = deferredTranscript[i];
|
||||
for (let i = session.transcript.length - 1; i >= 0; i--) {
|
||||
const item = session.transcript[i];
|
||||
if (item.role === 'tool') {
|
||||
return item.tool_name ?? 'tool';
|
||||
}
|
||||
@@ -142,7 +60,7 @@ function AppInner({config}: {config: FrontendConfig}): React.JSX.Element {
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}, [deferredTranscript]);
|
||||
}, [session.transcript]);
|
||||
|
||||
// Command hints
|
||||
const commandHints = useMemo(() => {
|
||||
@@ -154,7 +72,6 @@ function AppInner({config}: {config: FrontendConfig}): React.JSX.Element {
|
||||
}, [session.commands, input]);
|
||||
|
||||
const showPicker = commandHints.length > 0 && !session.busy && !session.modal && !selectModal;
|
||||
const outputStyle = String(session.status.output_style ?? 'default');
|
||||
|
||||
useEffect(() => {
|
||||
setPickerIndex(0);
|
||||
@@ -170,13 +87,12 @@ function AppInner({config}: {config: FrontendConfig}): React.JSX.Element {
|
||||
session.setSelectRequest(null);
|
||||
return;
|
||||
}
|
||||
const initialIndex = req.options.findIndex((option) => option.active);
|
||||
setSelectIndex(initialIndex >= 0 ? initialIndex : 0);
|
||||
setSelectIndex(0);
|
||||
setSelectModal({
|
||||
title: req.title,
|
||||
options: req.options.map((o) => ({value: o.value, label: o.label, description: o.description, active: o.active})),
|
||||
options: req.options.map((o) => ({value: o.value, label: o.label, description: o.description})),
|
||||
onSelect: (value) => {
|
||||
session.sendRequest({type: 'apply_select_command', command: req.command, value});
|
||||
session.sendRequest({type: 'submit_line', line: `${req.submitPrefix}${value}`});
|
||||
session.setBusy(true);
|
||||
setSelectModal(null);
|
||||
},
|
||||
@@ -188,14 +104,24 @@ function AppInner({config}: {config: FrontendConfig}): React.JSX.Element {
|
||||
const handleCommand = (cmd: string): boolean => {
|
||||
const trimmed = cmd.trim();
|
||||
|
||||
if (SELECTABLE_COMMANDS.has(trimmed)) {
|
||||
session.sendRequest({type: 'select_command', command: trimmed.slice(1)});
|
||||
return true;
|
||||
}
|
||||
|
||||
// /permissions → show mode picker
|
||||
if (trimmed === '/permissions' || trimmed === '/permissions show') {
|
||||
session.sendRequest({type: 'select_command', command: 'permissions'});
|
||||
const currentMode = String(session.status.permission_mode ?? 'default');
|
||||
const options = PERMISSION_MODES.map((opt) => ({
|
||||
...opt,
|
||||
active: opt.value === currentMode,
|
||||
}));
|
||||
const initialIndex = options.findIndex((o) => o.active);
|
||||
setSelectIndex(initialIndex >= 0 ? initialIndex : 0);
|
||||
setSelectModal({
|
||||
title: 'Permission Mode',
|
||||
options,
|
||||
onSelect: (value) => {
|
||||
session.sendRequest({type: 'submit_line', line: `/permissions set ${value}`});
|
||||
session.setBusy(true);
|
||||
setSelectModal(null);
|
||||
},
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -213,7 +139,7 @@ function AppInner({config}: {config: FrontendConfig}): React.JSX.Element {
|
||||
|
||||
// /resume → request session list from backend (will trigger select_request)
|
||||
if (trimmed === '/resume') {
|
||||
session.sendRequest({type: 'select_command', command: 'resume'});
|
||||
session.sendRequest({type: 'list_sessions'});
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -221,31 +147,13 @@ function AppInner({config}: {config: FrontendConfig}): React.JSX.Element {
|
||||
};
|
||||
|
||||
useInput((chunk, key) => {
|
||||
const isPaste = chunk.length > 1 && !key.ctrl && !key.meta;
|
||||
const isEscape = key.escape || chunk === '\u001B';
|
||||
|
||||
// Ctrl+C interrupts a running turn; when idle it exits the TUI.
|
||||
// Ctrl+C → exit
|
||||
if (key.ctrl && chunk === 'c') {
|
||||
if (session.busy) {
|
||||
session.sendRequest({type: 'interrupt'});
|
||||
session.setBusyLabel('Stopping current operation...');
|
||||
return;
|
||||
}
|
||||
session.sendRequest({type: 'shutdown'});
|
||||
exit();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!session.busy && key.ctrl && chunk === 'v') {
|
||||
attachClipboardImage();
|
||||
return;
|
||||
}
|
||||
|
||||
// Let ink-text-input handle pasted text directly.
|
||||
if (isPaste) {
|
||||
return;
|
||||
}
|
||||
|
||||
// --- Select modal (permissions picker etc.) ---
|
||||
if (selectModal) {
|
||||
if (key.upArrow) {
|
||||
@@ -297,7 +205,7 @@ function AppInner({config}: {config: FrontendConfig}): React.JSX.Element {
|
||||
}
|
||||
}
|
||||
|
||||
// --- Permission modal (MUST be before busy check — modal appears while busy) ---
|
||||
// --- Permission modal ---
|
||||
if (session.modal?.kind === 'permission') {
|
||||
if (chunk.toLowerCase() === 'y') {
|
||||
session.sendRequest({
|
||||
@@ -308,7 +216,7 @@ function AppInner({config}: {config: FrontendConfig}): React.JSX.Element {
|
||||
session.setModal(null);
|
||||
return;
|
||||
}
|
||||
if (chunk.toLowerCase() === 'n' || isEscape) {
|
||||
if (chunk.toLowerCase() === 'n' || key.escape) {
|
||||
session.sendRequest({
|
||||
type: 'permission_response',
|
||||
request_id: session.modal.request_id,
|
||||
@@ -320,64 +228,11 @@ function AppInner({config}: {config: FrontendConfig}): React.JSX.Element {
|
||||
return;
|
||||
}
|
||||
|
||||
// --- Edit diff modal (also appears while busy) ---
|
||||
if (session.modal?.kind === 'edit_diff') {
|
||||
if (chunk.toLowerCase() === 'y') {
|
||||
session.sendRequest({
|
||||
type: 'permission_response',
|
||||
request_id: session.modal.request_id,
|
||||
allowed: true,
|
||||
permission_reply: 'once',
|
||||
});
|
||||
session.setModal(null);
|
||||
return;
|
||||
}
|
||||
if (chunk.toLowerCase() === 'a') {
|
||||
session.sendRequest({
|
||||
type: 'permission_response',
|
||||
request_id: session.modal.request_id,
|
||||
allowed: true,
|
||||
permission_reply: 'always',
|
||||
});
|
||||
session.setModal(null);
|
||||
return;
|
||||
}
|
||||
if (chunk.toLowerCase() === 'n' || isEscape) {
|
||||
session.sendRequest({
|
||||
type: 'permission_response',
|
||||
request_id: session.modal.request_id,
|
||||
allowed: false,
|
||||
permission_reply: 'reject',
|
||||
});
|
||||
session.setModal(null);
|
||||
return;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// --- Question modal (also appears while busy) ---
|
||||
if (session.modal?.kind === 'question') {
|
||||
return; // Let TextInput in ModalHost handle input
|
||||
}
|
||||
|
||||
if (session.busy && isEscape) {
|
||||
session.sendRequest({type: 'interrupt'});
|
||||
session.setBusyLabel('Stopping current operation...');
|
||||
return;
|
||||
}
|
||||
|
||||
// --- Ignore input while busy ---
|
||||
if (session.busy) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Empty-input Tab opens the permission mode picker. This makes leaving
|
||||
// plan mode explicit without requiring users to remember /permissions.
|
||||
if (!showPicker && key.tab && input.trim() === '') {
|
||||
session.sendRequest({type: 'select_command', command: 'permissions'});
|
||||
return;
|
||||
}
|
||||
|
||||
// --- Command picker ---
|
||||
if (showPicker) {
|
||||
if (key.upArrow) {
|
||||
@@ -401,33 +256,16 @@ function AppInner({config}: {config: FrontendConfig}): React.JSX.Element {
|
||||
if (key.tab) {
|
||||
const selected = commandHints[pickerIndex];
|
||||
if (selected) {
|
||||
// Complete to the selected command with no trailing space —
|
||||
// the user can hit Enter immediately to run it, or keep
|
||||
// typing to add args. The trailing space made it look like
|
||||
// Tab was "committing" with a token, which broke the flow.
|
||||
setInput(selected);
|
||||
setInput(selected + ' ');
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (isEscape) {
|
||||
if (key.escape) {
|
||||
setInput('');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (isEscape) {
|
||||
const now = Date.now();
|
||||
if ((input || imageAttachments.length > 0) && now - lastEscapeAt < 500) {
|
||||
setInput('');
|
||||
setImageAttachments([]);
|
||||
setHistoryIndex(-1);
|
||||
setLastEscapeAt(0);
|
||||
return;
|
||||
}
|
||||
setLastEscapeAt(now);
|
||||
return;
|
||||
}
|
||||
|
||||
// --- History navigation ---
|
||||
if (!showPicker && key.upArrow) {
|
||||
const nextIndex = Math.min(history.length - 1, historyIndex + 1);
|
||||
@@ -444,8 +282,11 @@ function AppInner({config}: {config: FrontendConfig}): React.JSX.Element {
|
||||
return;
|
||||
}
|
||||
|
||||
// Note: normal Enter submission is handled by TextInput's onSubmit in
|
||||
// PromptInput. Do NOT duplicate it here — that causes double requests.
|
||||
// --- Submit on Enter ---
|
||||
if (!showPicker && key.return && input.trim()) {
|
||||
onSubmit(input);
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
const onSubmit = (value: string): void => {
|
||||
@@ -459,28 +300,20 @@ function AppInner({config}: {config: FrontendConfig}): React.JSX.Element {
|
||||
setModalInput('');
|
||||
return;
|
||||
}
|
||||
if ((!value.trim() && imageAttachments.length === 0) || session.busy || !session.ready) {
|
||||
if (session.busy && value.trim() === '/stop') {
|
||||
session.sendRequest({type: 'interrupt'});
|
||||
session.setBusyLabel('Stopping current operation...');
|
||||
setInput('');
|
||||
}
|
||||
if (!value.trim() || session.busy) {
|
||||
return;
|
||||
}
|
||||
// Check if it's an interactive command
|
||||
if (imageAttachments.length === 0 && handleCommand(value)) {
|
||||
if (handleCommand(value)) {
|
||||
setHistory((items) => [...items, value]);
|
||||
setHistoryIndex(-1);
|
||||
setInput('');
|
||||
return;
|
||||
}
|
||||
session.sendRequest({type: 'submit_line', line: value, images: imagePayloads()});
|
||||
if (value.trim()) {
|
||||
setHistory((items) => [...items, value]);
|
||||
}
|
||||
session.sendRequest({type: 'submit_line', line: value});
|
||||
setHistory((items) => [...items, value]);
|
||||
setHistoryIndex(-1);
|
||||
setInput('');
|
||||
setImageAttachments([]);
|
||||
session.setBusy(true);
|
||||
};
|
||||
|
||||
@@ -505,10 +338,9 @@ function AppInner({config}: {config: FrontendConfig}): React.JSX.Element {
|
||||
{/* Conversation area */}
|
||||
<Box flexDirection="column" flexGrow={1}>
|
||||
<ConversationView
|
||||
items={deferredTranscript}
|
||||
assistantBuffer={deferredAssistantBuffer}
|
||||
showWelcome={session.ready && outputStyle !== 'codex'}
|
||||
outputStyle={outputStyle}
|
||||
items={session.transcript}
|
||||
assistantBuffer={session.assistantBuffer}
|
||||
showWelcome={true}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
@@ -536,51 +368,29 @@ function AppInner({config}: {config: FrontendConfig}): React.JSX.Element {
|
||||
<CommandPicker hints={commandHints} selectedIndex={pickerIndex} />
|
||||
) : null}
|
||||
|
||||
{/* Todo panel */}
|
||||
{session.ready && deferredTodoMarkdown ? (
|
||||
<TodoPanel markdown={deferredTodoMarkdown} />
|
||||
) : null}
|
||||
{/* Status bar */}
|
||||
<StatusBar status={session.status} tasks={session.tasks} />
|
||||
|
||||
{/* Swarm panel */}
|
||||
{session.ready && (deferredSwarmTeammates.length > 0 || deferredSwarmNotifications.length > 0) ? (
|
||||
<SwarmPanel teammates={deferredSwarmTeammates} notifications={deferredSwarmNotifications} />
|
||||
) : null}
|
||||
|
||||
{/* Status bar (only after backend is ready) */}
|
||||
{session.ready ? (
|
||||
<StatusBar status={deferredStatus} tasks={deferredTasks} activeToolName={session.busy ? currentToolName : undefined} />
|
||||
) : null}
|
||||
|
||||
{/* Input — show loading indicator until backend is ready */}
|
||||
{!session.ready ? (
|
||||
<Box>
|
||||
<Text color={theme.colors.warning}>Connecting to backend...</Text>
|
||||
</Box>
|
||||
) : session.modal || selectModal ? null : (
|
||||
{/* Input */}
|
||||
{session.modal || selectModal ? null : (
|
||||
<PromptInput
|
||||
busy={session.busy}
|
||||
input={input}
|
||||
setInput={setInput}
|
||||
onSubmit={onSubmit}
|
||||
toolName={session.busy ? currentToolName : undefined}
|
||||
statusLabel={session.busy ? (session.busyLabel ?? (currentToolName ? `Running ${currentToolName}...` : 'Running agent loop...')) : undefined}
|
||||
suppressSubmit={showPicker}
|
||||
imageAttachmentLabels={imageAttachments.map((image) => image.label)}
|
||||
clipboardStatus={clipboardStatus}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Keyboard hints (only after backend is ready) */}
|
||||
{session.ready && !session.modal && !selectModal ? (
|
||||
{/* Keyboard hints */}
|
||||
{!session.modal && !session.busy && !selectModal ? (
|
||||
<Box>
|
||||
<Text dimColor>
|
||||
<Text color={theme.colors.primary}>shift+enter</Text> newline{' '}
|
||||
<Text color={theme.colors.primary}>enter</Text> send{' '}
|
||||
<Text color={theme.colors.primary}>/</Text> commands{' '}
|
||||
<Text color={theme.colors.primary}>tab</Text> mode{' '}
|
||||
<Text color={theme.colors.primary}>{'\u2191\u2193'}</Text> history{' '}
|
||||
<Text color={theme.colors.primary}>{session.busy ? '/stop' : 'esc'}</Text> stop{' '}
|
||||
<Text color={theme.colors.primary}>ctrl+c</Text> {session.busy ? 'stop' : 'exit'}
|
||||
<Text color="cyan">enter</Text> send{' '}
|
||||
<Text color="cyan">/</Text> commands{' '}
|
||||
<Text color="cyan">{'\u2191\u2193'}</Text> history{' '}
|
||||
<Text color="cyan">ctrl+c</Text> exit
|
||||
</Text>
|
||||
</Box>
|
||||
) : null}
|
||||
|
||||
@@ -1,173 +0,0 @@
|
||||
import {execFile} from 'node:child_process';
|
||||
import {mkdtemp, readFile, rm, stat} from 'node:fs/promises';
|
||||
import {tmpdir} from 'node:os';
|
||||
import {join} from 'node:path';
|
||||
|
||||
export type ImageAttachment = {
|
||||
id: string;
|
||||
label: string;
|
||||
media_type: string;
|
||||
data: string;
|
||||
source_path?: string;
|
||||
size_bytes?: number;
|
||||
};
|
||||
|
||||
const MAX_CLIPBOARD_IMAGE_BYTES = 15 * 1024 * 1024;
|
||||
const EXEC_TIMEOUT_MS = 2500;
|
||||
|
||||
type ClipboardImageRead = {
|
||||
data: Buffer;
|
||||
mediaType: string;
|
||||
label: string;
|
||||
};
|
||||
|
||||
export async function readClipboardImage(): Promise<ImageAttachment | null> {
|
||||
const image = await readClipboardImageData();
|
||||
if (!image) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
id: `clipboard-${Date.now()}-${Math.random().toString(16).slice(2)}`,
|
||||
label: image.label,
|
||||
media_type: image.mediaType,
|
||||
data: image.data.toString('base64'),
|
||||
source_path: `clipboard:${image.label}`,
|
||||
size_bytes: image.data.length,
|
||||
};
|
||||
}
|
||||
|
||||
async function readClipboardImageData(): Promise<ClipboardImageRead | null> {
|
||||
if (process.platform === 'darwin') {
|
||||
return readMacClipboardImage();
|
||||
}
|
||||
if (process.platform === 'win32') {
|
||||
return readWindowsClipboardImage();
|
||||
}
|
||||
return readLinuxClipboardImage();
|
||||
}
|
||||
|
||||
async function readMacClipboardImage(): Promise<ClipboardImageRead | null> {
|
||||
const tempDir = await mkdtemp(join(tmpdir(), 'openharness-clipboard-'));
|
||||
try {
|
||||
const pngPath = join(tempDir, 'clipboard.png');
|
||||
if (await runFileCommand('pngpaste', [pngPath])) {
|
||||
return await readImageFile(pngPath, 'image/png', 'clipboard.png');
|
||||
}
|
||||
if (await writeMacClipboardClass('PNGf', pngPath)) {
|
||||
return await readImageFile(pngPath, 'image/png', 'clipboard.png');
|
||||
}
|
||||
|
||||
const tiffPath = join(tempDir, 'clipboard.tiff');
|
||||
if (await writeMacClipboardClass('TIFF', tiffPath)) {
|
||||
if (await runFileCommand('sips', ['-s', 'format', 'png', tiffPath, '--out', pngPath])) {
|
||||
return await readImageFile(pngPath, 'image/png', 'clipboard.png');
|
||||
}
|
||||
return await readImageFile(tiffPath, 'image/tiff', 'clipboard.tiff');
|
||||
}
|
||||
return null;
|
||||
} finally {
|
||||
await rm(tempDir, {recursive: true, force: true});
|
||||
}
|
||||
}
|
||||
|
||||
async function writeMacClipboardClass(classCode: 'PNGf' | 'TIFF', outputPath: string): Promise<boolean> {
|
||||
const appleClass = `${String.fromCharCode(0xab)}class ${classCode}${String.fromCharCode(0xbb)}`;
|
||||
const escapedPath = outputPath.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
||||
return runFileCommand('osascript', [
|
||||
'-e',
|
||||
`set clipboardData to the clipboard as ${appleClass}`,
|
||||
'-e',
|
||||
`set outFile to open for access POSIX file "${escapedPath}" with write permission`,
|
||||
'-e',
|
||||
'set eof outFile to 0',
|
||||
'-e',
|
||||
'write clipboardData to outFile',
|
||||
'-e',
|
||||
'close access outFile',
|
||||
]);
|
||||
}
|
||||
|
||||
async function readWindowsClipboardImage(): Promise<ClipboardImageRead | null> {
|
||||
const tempDir = await mkdtemp(join(tmpdir(), 'openharness-clipboard-'));
|
||||
try {
|
||||
const pngPath = join(tempDir, 'clipboard.png');
|
||||
const escapedPath = pngPath.replace(/'/g, "''");
|
||||
const powershell = join(
|
||||
process.env.SystemRoot ?? 'C:\\Windows',
|
||||
'System32',
|
||||
'WindowsPowerShell',
|
||||
'v1.0',
|
||||
'powershell.exe',
|
||||
);
|
||||
const script = [
|
||||
'Add-Type -AssemblyName System.Windows.Forms',
|
||||
'Add-Type -AssemblyName System.Drawing',
|
||||
'if (-not [Windows.Forms.Clipboard]::ContainsImage()) { exit 2 }',
|
||||
'$image = [Windows.Forms.Clipboard]::GetImage()',
|
||||
`$image.Save('${escapedPath}', [Drawing.Imaging.ImageFormat]::Png)`,
|
||||
].join('; ');
|
||||
if (!(await runFileCommand(powershell, ['-NoProfile', '-STA', '-Command', script]))) {
|
||||
return null;
|
||||
}
|
||||
return await readImageFile(pngPath, 'image/png', 'clipboard.png');
|
||||
} finally {
|
||||
await rm(tempDir, {recursive: true, force: true});
|
||||
}
|
||||
}
|
||||
|
||||
async function readLinuxClipboardImage(): Promise<ClipboardImageRead | null> {
|
||||
const attempts: Array<[string, string[], string, string]> = [
|
||||
['wl-paste', ['--no-newline', '--type', 'image/png'], 'image/png', 'clipboard.png'],
|
||||
['wl-paste', ['--no-newline', '--type', 'image/jpeg'], 'image/jpeg', 'clipboard.jpg'],
|
||||
['xclip', ['-selection', 'clipboard', '-target', 'image/png', '-out'], 'image/png', 'clipboard.png'],
|
||||
['xclip', ['-selection', 'clipboard', '-target', 'image/jpeg', '-out'], 'image/jpeg', 'clipboard.jpg'],
|
||||
['xsel', ['--clipboard', '--output', '--mime-type', 'image/png'], 'image/png', 'clipboard.png'],
|
||||
['xsel', ['--clipboard', '--output', '--mime-type', 'image/jpeg'], 'image/jpeg', 'clipboard.jpg'],
|
||||
];
|
||||
for (const [command, args, mediaType, label] of attempts) {
|
||||
const data = await runBufferCommand(command, args);
|
||||
if (data && data.length > 0) {
|
||||
return {data, mediaType, label};
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function readImageFile(path: string, mediaType: string, label: string): Promise<ClipboardImageRead | null> {
|
||||
const fileStat = await stat(path).catch(() => null);
|
||||
if (!fileStat || fileStat.size <= 0 || fileStat.size > MAX_CLIPBOARD_IMAGE_BYTES) {
|
||||
return null;
|
||||
}
|
||||
const data = await readFile(path);
|
||||
return {data, mediaType, label};
|
||||
}
|
||||
|
||||
async function runFileCommand(command: string, args: string[]): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
execFile(command, args, {timeout: EXEC_TIMEOUT_MS, windowsHide: true}, (error) => {
|
||||
resolve(!error);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function runBufferCommand(command: string, args: string[]): Promise<Buffer | null> {
|
||||
return new Promise((resolve) => {
|
||||
execFile(
|
||||
command,
|
||||
args,
|
||||
{
|
||||
encoding: 'buffer',
|
||||
maxBuffer: MAX_CLIPBOARD_IMAGE_BYTES + 1024,
|
||||
timeout: EXEC_TIMEOUT_MS,
|
||||
windowsHide: true,
|
||||
},
|
||||
(error, stdout) => {
|
||||
if (error || !Buffer.isBuffer(stdout) || stdout.length > MAX_CLIPBOARD_IMAGE_BYTES) {
|
||||
resolve(null);
|
||||
return;
|
||||
}
|
||||
resolve(stdout);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from 'react';
|
||||
import {Box, Text} from 'ink';
|
||||
|
||||
function CommandPickerInner({
|
||||
export function CommandPicker({
|
||||
hints,
|
||||
selectedIndex,
|
||||
}: {
|
||||
@@ -31,5 +31,3 @@ function CommandPickerInner({
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export const CommandPicker = React.memo(CommandPickerInner);
|
||||
|
||||
@@ -24,7 +24,7 @@ export function Composer({
|
||||
</Box>
|
||||
<Box marginTop={1}>
|
||||
<Text dimColor>
|
||||
shift+enter=newline enter=submit tab=complete ctrl-p/ctrl-n=history history_index={String(historyIndex)}
|
||||
enter=submit tab=complete ctrl-p/ctrl-n=history history_index={String(historyIndex)}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
@@ -1,178 +1,76 @@
|
||||
import React from 'react';
|
||||
import {Box, Text} from 'ink';
|
||||
|
||||
import {useTheme} from '../theme/ThemeContext.js';
|
||||
import type {TranscriptItem} from '../types.js';
|
||||
import {MarkdownText} from './MarkdownText.js';
|
||||
import {ToolCallDisplay} from './ToolCallDisplay.js';
|
||||
import {WelcomeBanner} from './WelcomeBanner.js';
|
||||
|
||||
type ToolPair = readonly [TranscriptItem, TranscriptItem];
|
||||
type GroupedItem = TranscriptItem | ToolPair;
|
||||
|
||||
function groupToolPairs(items: TranscriptItem[]): GroupedItem[] {
|
||||
const result: GroupedItem[] = [];
|
||||
let i = 0;
|
||||
while (i < items.length) {
|
||||
const cur = items[i];
|
||||
const next = items[i + 1];
|
||||
if (cur.role === 'tool' && next?.role === 'tool_result') {
|
||||
result.push([cur, next] as const);
|
||||
i += 2;
|
||||
} else {
|
||||
result.push(cur);
|
||||
i++;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function ConversationViewInner({
|
||||
export function ConversationView({
|
||||
items,
|
||||
assistantBuffer,
|
||||
showWelcome,
|
||||
outputStyle,
|
||||
}: {
|
||||
items: TranscriptItem[];
|
||||
assistantBuffer: string;
|
||||
showWelcome: boolean;
|
||||
outputStyle: string;
|
||||
}): React.JSX.Element {
|
||||
const {theme} = useTheme();
|
||||
const isCodexStyle = outputStyle === 'codex';
|
||||
// Show the most recent items that fit the viewport
|
||||
const visible = items.slice(-40);
|
||||
const grouped = groupToolPairs(visible);
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" flexGrow={1}>
|
||||
{showWelcome && items.length === 0 ? <WelcomeBanner /> : null}
|
||||
|
||||
{grouped.map((group, index) => {
|
||||
if (Array.isArray(group)) {
|
||||
const [toolItem, resultItem] = group as [TranscriptItem, TranscriptItem];
|
||||
return (
|
||||
<ToolCallDisplay
|
||||
key={index}
|
||||
item={toolItem}
|
||||
resultItem={resultItem}
|
||||
outputStyle={outputStyle}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<MessageRow
|
||||
key={index}
|
||||
item={group as TranscriptItem}
|
||||
theme={theme}
|
||||
outputStyle={outputStyle}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{visible.map((item, index) => (
|
||||
<MessageRow key={index} item={item} />
|
||||
))}
|
||||
|
||||
{assistantBuffer ? (
|
||||
isCodexStyle ? (
|
||||
<Box flexDirection="row" marginTop={0}>
|
||||
<Text>{assistantBuffer}</Text>
|
||||
</Box>
|
||||
) : (
|
||||
<Box marginTop={1} marginBottom={0} flexDirection="column">
|
||||
<Text>
|
||||
<Text color={theme.colors.success} bold>{theme.icons.assistant}</Text>
|
||||
</Text>
|
||||
<Box marginLeft={2} flexDirection="column">
|
||||
<MarkdownText content={assistantBuffer} />
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
<Box flexDirection="row" marginTop={0}>
|
||||
<Text color="green" bold>{'\u23FA '}</Text>
|
||||
<Text>{assistantBuffer}</Text>
|
||||
</Box>
|
||||
) : null}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export const ConversationView = React.memo(ConversationViewInner);
|
||||
|
||||
function MessageRow({
|
||||
item,
|
||||
theme,
|
||||
outputStyle,
|
||||
}: {
|
||||
item: TranscriptItem;
|
||||
theme: ReturnType<typeof useTheme>['theme'];
|
||||
outputStyle: string;
|
||||
}): React.JSX.Element {
|
||||
const isCodexStyle = outputStyle === 'codex';
|
||||
|
||||
function MessageRow({item}: {item: TranscriptItem}): React.JSX.Element {
|
||||
switch (item.role) {
|
||||
case 'user':
|
||||
if (isCodexStyle) {
|
||||
return (
|
||||
<Box marginTop={0}>
|
||||
<Text>
|
||||
<Text dimColor>{'> '}</Text>
|
||||
<Text>{item.text}</Text>
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Box marginTop={1} marginBottom={0}>
|
||||
<Text>
|
||||
<Text color={theme.colors.secondary} bold>{theme.icons.user}</Text>
|
||||
<Text color="white" bold>{'> '}</Text>
|
||||
<Text>{item.text}</Text>
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
|
||||
case 'assistant':
|
||||
if (isCodexStyle) {
|
||||
return (
|
||||
<Box marginTop={0} marginBottom={0}>
|
||||
<Text>{item.text}</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Box marginTop={1} marginBottom={0} flexDirection="column">
|
||||
<Text>
|
||||
<Text color={theme.colors.success} bold>{theme.icons.assistant}</Text>
|
||||
<Text color="green" bold>{'\u23FA '}</Text>
|
||||
<Text>{item.text}</Text>
|
||||
</Text>
|
||||
<Box marginLeft={2} flexDirection="column">
|
||||
<MarkdownText content={item.text} />
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
|
||||
case 'tool':
|
||||
case 'tool_result':
|
||||
return <ToolCallDisplay item={item} outputStyle={outputStyle} />;
|
||||
return <ToolCallDisplay item={item} />;
|
||||
|
||||
case 'system':
|
||||
if (isCodexStyle) {
|
||||
return (
|
||||
<Box marginTop={0}>
|
||||
<Text>
|
||||
<Text color={theme.colors.warning}>[system]</Text>
|
||||
<Text> {item.text}</Text>
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Box marginTop={0}>
|
||||
<Text>
|
||||
<Text color={theme.colors.warning}>{theme.icons.system}</Text>
|
||||
<Text color={theme.colors.warning}>{item.text}</Text>
|
||||
<Text color="yellow">{'\u2139 '}</Text>
|
||||
<Text color="yellow">{item.text}</Text>
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
|
||||
case 'status':
|
||||
return (
|
||||
<Box marginTop={0}>
|
||||
<Text color={theme.colors.info}>{item.text}</Text>
|
||||
</Box>
|
||||
);
|
||||
|
||||
case 'log':
|
||||
return (
|
||||
<Box>
|
||||
|
||||
@@ -1,119 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {PassThrough} from 'node:stream';
|
||||
import React from 'react';
|
||||
import {render} from 'ink';
|
||||
|
||||
import {ThemeProvider} from '../theme/ThemeContext.js';
|
||||
import {MarkdownText} from './MarkdownText.js';
|
||||
|
||||
const stripAnsi = (value: string): string => value.replace(/\u001B\[[0-9;?]*[ -/]*[@-~]/g, '');
|
||||
const nextLoopTurn = (): Promise<void> => new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
type InkTestStdout = PassThrough & {
|
||||
isTTY: boolean;
|
||||
columns: number;
|
||||
rows: number;
|
||||
cursorTo: () => boolean;
|
||||
clearLine: () => boolean;
|
||||
moveCursor: () => boolean;
|
||||
};
|
||||
|
||||
function createTestStdout(): InkTestStdout {
|
||||
return Object.assign(new PassThrough(), {
|
||||
isTTY: true,
|
||||
columns: 120,
|
||||
rows: 40,
|
||||
cursorTo: () => true,
|
||||
clearLine: () => true,
|
||||
moveCursor: () => true,
|
||||
});
|
||||
}
|
||||
|
||||
async function waitForOutputToStabilize(getOutput: () => string): Promise<string> {
|
||||
let previous = '';
|
||||
let sawOutput = false;
|
||||
|
||||
for (let i = 0; i < 50; i++) {
|
||||
await nextLoopTurn();
|
||||
const current = getOutput();
|
||||
sawOutput ||= current.length > 0;
|
||||
if (sawOutput && current === previous) {
|
||||
return current;
|
||||
}
|
||||
|
||||
previous = current;
|
||||
}
|
||||
|
||||
throw new Error(`Ink output did not stabilize: ${JSON.stringify(previous)}`);
|
||||
}
|
||||
|
||||
async function renderMarkdownLines(content: string): Promise<string[]> {
|
||||
const stdout = createTestStdout();
|
||||
|
||||
let output = '';
|
||||
stdout.on('data', (chunk) => {
|
||||
output += chunk.toString();
|
||||
});
|
||||
|
||||
const instance = render(
|
||||
<ThemeProvider initialTheme="default">
|
||||
<MarkdownText content={content} />
|
||||
</ThemeProvider>,
|
||||
{stdout: stdout as unknown as NodeJS.WriteStream, debug: true, patchConsole: false},
|
||||
);
|
||||
|
||||
const exitPromise = instance.waitUntilExit();
|
||||
const stableOutput = await waitForOutputToStabilize(() => output);
|
||||
instance.unmount();
|
||||
await exitPromise;
|
||||
await waitForOutputToStabilize(() => output);
|
||||
instance.cleanup();
|
||||
|
||||
return stripAnsi(stableOutput)
|
||||
.split('\n')
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
async function renderTableLines(content: string): Promise<string[]> {
|
||||
return (await renderMarkdownLines(content))
|
||||
.filter((line) => /[┌├│└]/.test(line))
|
||||
.slice(0, 5);
|
||||
}
|
||||
|
||||
test('keeps table borders aligned when cells contain inline markdown', async () => {
|
||||
const lines = await renderTableLines('| `aa` | bb |\n|------|----|\n| c | **ddd** |');
|
||||
|
||||
assert.equal(lines.length, 5);
|
||||
|
||||
const widths = lines.map((line) => [...line].length);
|
||||
assert.ok(
|
||||
widths.every((width) => width === widths[0]),
|
||||
`Expected table lines to share a width, got ${JSON.stringify(
|
||||
lines.map((line, index) => ({line, width: widths[index]})),
|
||||
)}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('renders unknown inline table tokens using the visible token text fallback', async () => {
|
||||
const lines = await renderTableLines('|  | ok |\n|---|---|\n| x | y |');
|
||||
|
||||
assert.equal(lines.length, 5);
|
||||
assert.match(lines[1] ?? '', /\balt\b/);
|
||||
assert.doesNotMatch(lines[1] ?? '', /!\[alt\]/);
|
||||
|
||||
const widths = lines.map((line) => [...line].length);
|
||||
assert.ok(
|
||||
widths.every((width) => width === widths[0]),
|
||||
`Expected fallback-token table lines to share a width, got ${JSON.stringify(
|
||||
lines.map((line, index) => ({line, width: widths[index]})),
|
||||
)}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('preserves nested markdown structure inside blockquotes', async () => {
|
||||
const lines = await renderMarkdownLines('> - first\n> - second');
|
||||
|
||||
assert.ok(lines.some((line) => line.includes('• first')), `Expected blockquote output to include a rendered bullet: ${JSON.stringify(lines)}`);
|
||||
assert.ok(lines.some((line) => line.includes('• second')), `Expected blockquote output to include the second rendered bullet: ${JSON.stringify(lines)}`);
|
||||
});
|
||||
@@ -1,315 +0,0 @@
|
||||
import React from 'react';
|
||||
import {Box, Text} from 'ink';
|
||||
import {lexer, type Token, type Tokens} from 'marked';
|
||||
import stringWidth from 'string-width';
|
||||
|
||||
import {useTheme} from '../theme/ThemeContext.js';
|
||||
import type {ThemeConfig} from '../theme/builtinThemes.js';
|
||||
|
||||
function getInlineFallbackText(token: Token): string {
|
||||
if ('text' in token && typeof token.text === 'string') {
|
||||
return token.text;
|
||||
}
|
||||
|
||||
return token.raw;
|
||||
}
|
||||
|
||||
function getInlineDisplayText(tokens: Token[] | undefined): string {
|
||||
if (!tokens || tokens.length === 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return tokens.map((token) => {
|
||||
switch (token.type) {
|
||||
case 'text': {
|
||||
const t = token as Tokens.Text;
|
||||
return t.tokens && t.tokens.length > 0 ? getInlineDisplayText(t.tokens) : t.text;
|
||||
}
|
||||
case 'strong':
|
||||
case 'em':
|
||||
case 'del':
|
||||
return getInlineDisplayText((token as Tokens.Strong | Tokens.Em | Tokens.Del).tokens);
|
||||
case 'codespan':
|
||||
return (token as Tokens.Codespan).text;
|
||||
case 'link': {
|
||||
const l = token as Tokens.Link;
|
||||
return l.text || l.href;
|
||||
}
|
||||
case 'image': {
|
||||
const image = token as Tokens.Image;
|
||||
return image.text || image.href;
|
||||
}
|
||||
case 'br':
|
||||
return '\n';
|
||||
case 'escape':
|
||||
return (token as Tokens.Escape).text;
|
||||
default:
|
||||
return getInlineFallbackText(token);
|
||||
}
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function getTableCellDisplayText(cell: Tokens.TableCell): string {
|
||||
const displayText = getInlineDisplayText(cell.tokens);
|
||||
return displayText.length > 0 ? displayText : cell.text;
|
||||
}
|
||||
|
||||
// Inline token renderer — returns an array of <Text> elements.
|
||||
function renderInline(tokens: Token[] | undefined, theme: ThemeConfig): React.ReactNode {
|
||||
if (!tokens || tokens.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return tokens.map((token, i) => {
|
||||
switch (token.type) {
|
||||
case 'text': {
|
||||
const t = token as Tokens.Text;
|
||||
// Text tokens can themselves contain inline children, such as list items.
|
||||
if (t.tokens && t.tokens.length > 0) {
|
||||
return <React.Fragment key={i}>{renderInline(t.tokens, theme)}</React.Fragment>;
|
||||
}
|
||||
return <Text key={i}>{t.text}</Text>;
|
||||
}
|
||||
case 'strong': {
|
||||
const s = token as Tokens.Strong;
|
||||
return (
|
||||
<Text key={i} bold>
|
||||
{renderInline(s.tokens, theme)}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
case 'em': {
|
||||
const e = token as Tokens.Em;
|
||||
return (
|
||||
<Text key={i} italic>
|
||||
{renderInline(e.tokens, theme)}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
case 'del': {
|
||||
const d = token as Tokens.Del;
|
||||
return (
|
||||
<Text key={i} strikethrough>
|
||||
{renderInline(d.tokens, theme)}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
case 'codespan': {
|
||||
const c = token as Tokens.Codespan;
|
||||
return (
|
||||
<Text key={i} color={theme.colors.accent}>
|
||||
{c.text}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
case 'link': {
|
||||
const l = token as Tokens.Link;
|
||||
const label = l.text || l.href;
|
||||
return (
|
||||
<Text key={i} color={theme.colors.info}>
|
||||
{label}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
case 'image': {
|
||||
const image = token as Tokens.Image;
|
||||
return <Text key={i}>{image.text || image.href}</Text>;
|
||||
}
|
||||
case 'br':
|
||||
return <Text key={i}>{'\n'}</Text>;
|
||||
case 'escape': {
|
||||
const es = token as Tokens.Escape;
|
||||
return <Text key={i}>{es.text}</Text>;
|
||||
}
|
||||
default:
|
||||
return <Text key={i}>{getInlineFallbackText(token)}</Text>;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function renderBlocks(tokens: Token[] | undefined, theme: ThemeConfig): React.ReactNode {
|
||||
if (!tokens || tokens.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return tokens.map((token, i) => (
|
||||
<MarkdownBlock key={i} token={token} theme={theme} />
|
||||
));
|
||||
}
|
||||
|
||||
function MarkdownBlock({
|
||||
token,
|
||||
theme,
|
||||
}: {
|
||||
token: Token;
|
||||
theme: ThemeConfig;
|
||||
}): React.JSX.Element | null {
|
||||
switch (token.type) {
|
||||
case 'heading': {
|
||||
const h = token as Tokens.Heading;
|
||||
const headingColors: string[] = [
|
||||
theme.colors.primary,
|
||||
theme.colors.secondary,
|
||||
theme.colors.accent,
|
||||
theme.colors.info,
|
||||
theme.colors.muted,
|
||||
theme.colors.muted,
|
||||
];
|
||||
const color = headingColors[h.depth - 1] ?? theme.colors.primary;
|
||||
const isMajor = h.depth <= 2;
|
||||
return (
|
||||
<Box marginTop={1} flexDirection="column">
|
||||
<Text color={color} bold={isMajor} underline={h.depth === 1}>
|
||||
{renderInline(h.tokens, theme)}
|
||||
</Text>
|
||||
{h.depth === 1 ? <Text color={color} dimColor>{'━'.repeat(32)}</Text> : null}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
case 'paragraph': {
|
||||
const p = token as Tokens.Paragraph;
|
||||
return (
|
||||
<Box marginTop={0} flexWrap="wrap">
|
||||
<Text>{renderInline(p.tokens, theme)}</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
case 'code': {
|
||||
const c = token as Tokens.Code;
|
||||
const lines = c.text.split('\n');
|
||||
return (
|
||||
<Box flexDirection="column" marginTop={1} marginLeft={2} borderStyle="round" paddingX={1} borderColor={theme.colors.muted}>
|
||||
{c.lang ? (
|
||||
<Text dimColor>{c.lang}</Text>
|
||||
) : null}
|
||||
{lines.map((line, i) => (
|
||||
<Text key={i} color={theme.colors.accent}>
|
||||
{line}
|
||||
</Text>
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
case 'blockquote': {
|
||||
const bq = token as Tokens.Blockquote;
|
||||
return (
|
||||
<Box flexDirection="column" marginTop={0} marginLeft={0}>
|
||||
{bq.tokens.map((t, i) => (
|
||||
<Box key={i} flexDirection="row">
|
||||
<Text color={theme.colors.muted}>{'│ '}</Text>
|
||||
<Box flexDirection="column" flexGrow={1}>
|
||||
{renderBlocks([t], theme)}
|
||||
</Box>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
case 'list': {
|
||||
const l = token as Tokens.List;
|
||||
return (
|
||||
<Box flexDirection="column" marginTop={0} marginLeft={2}>
|
||||
{l.items.map((item, i) => {
|
||||
// For tight lists, item.tokens = [{type:'text', tokens:[...inline]}]
|
||||
// For loose lists, item.tokens = [{type:'paragraph', tokens:[...inline]}]
|
||||
const inlineTokens: Token[] = item.tokens.flatMap((t) =>
|
||||
'tokens' in t && t.tokens ? (t.tokens as Token[]) : [],
|
||||
);
|
||||
const bullet = l.ordered ? `${(Number(l.start) || 1) + i}. ` : '• ';
|
||||
return (
|
||||
<Box key={i} flexDirection="row">
|
||||
<Text color={theme.colors.primary}>{bullet}</Text>
|
||||
<Box flexGrow={1}>
|
||||
<Text>
|
||||
{inlineTokens.length > 0
|
||||
? renderInline(inlineTokens, theme)
|
||||
: item.text}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
case 'hr':
|
||||
return (
|
||||
<Box marginTop={1}>
|
||||
<Text dimColor>{'─'.repeat(48)}</Text>
|
||||
</Box>
|
||||
);
|
||||
|
||||
case 'space':
|
||||
return null;
|
||||
|
||||
case 'table': {
|
||||
const t = token as Tokens.Table;
|
||||
const headerTexts = t.header.map(getTableCellDisplayText);
|
||||
const rowTexts = t.rows.map((row) => row.map(getTableCellDisplayText));
|
||||
// Use stringWidth for correct CJK and wide-char column widths.
|
||||
const colCount = t.header.length;
|
||||
const colWidths: number[] = headerTexts.map((cellText) => stringWidth(cellText));
|
||||
for (const row of rowTexts) {
|
||||
for (let c = 0; c < colCount; c++) {
|
||||
colWidths[c] = Math.max(colWidths[c] ?? 0, stringWidth(row[c] ?? ''));
|
||||
}
|
||||
}
|
||||
const trailing = (cellText: string, c: number): string =>
|
||||
' '.repeat(Math.max(0, (colWidths[c] ?? 0) - stringWidth(cellText)));
|
||||
const top = '┌' + colWidths.map((w) => '─'.repeat(w + 2)).join('┬') + '┐';
|
||||
const mid = '├' + colWidths.map((w) => '─'.repeat(w + 2)).join('┼') + '┤';
|
||||
const bot = '└' + colWidths.map((w) => '─'.repeat(w + 2)).join('┴') + '┘';
|
||||
return (
|
||||
<Box flexDirection="column" marginTop={1} marginLeft={1}>
|
||||
<Text color={theme.colors.muted}>{top}</Text>
|
||||
<Text>
|
||||
<Text color={theme.colors.muted}>{'│'}</Text>
|
||||
{t.header.map((cell, c) => (
|
||||
<React.Fragment key={c}>
|
||||
<Text color={theme.colors.primary} bold>
|
||||
{' '}{renderInline(cell.tokens, theme)}{trailing(headerTexts[c] ?? '', c)}{' '}
|
||||
</Text>
|
||||
<Text color={theme.colors.muted}>{'│'}</Text>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</Text>
|
||||
<Text color={theme.colors.muted}>{mid}</Text>
|
||||
{t.rows.map((row, i) => (
|
||||
<Text key={i}>
|
||||
<Text color={theme.colors.muted}>{'│'}</Text>
|
||||
{row.map((cell, c) => (
|
||||
<React.Fragment key={c}>
|
||||
<Text>
|
||||
{' '}{renderInline(cell.tokens, theme)}{trailing(rowTexts[i]?.[c] ?? '', c)}{' '}
|
||||
</Text>
|
||||
<Text color={theme.colors.muted}>{'│'}</Text>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</Text>
|
||||
))}
|
||||
<Text color={theme.colors.muted}>{bot}</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
default:
|
||||
if ((token as Token).raw) {
|
||||
return <Text>{(token as Token).raw}</Text>;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export const MarkdownText = React.memo(function MarkdownText({content}: {content: string}): React.JSX.Element {
|
||||
const {theme} = useTheme();
|
||||
const tokens = React.useMemo(() => lexer(content), [content]);
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
{renderBlocks(tokens, theme)}
|
||||
</Box>
|
||||
);
|
||||
});
|
||||
@@ -1,87 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {PassThrough} from 'node:stream';
|
||||
import React from 'react';
|
||||
import {render} from 'ink';
|
||||
|
||||
import {ModalHost} from './ModalHost.js';
|
||||
|
||||
const stripAnsi = (value: string): string => value.replace(/\u001B\[[0-9;?]*[ -/]*[@-~]/g, '');
|
||||
const nextLoopTurn = (): Promise<void> => new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
type InkTestStdout = PassThrough & {
|
||||
isTTY: boolean;
|
||||
columns: number;
|
||||
rows: number;
|
||||
cursorTo: () => boolean;
|
||||
clearLine: () => boolean;
|
||||
moveCursor: () => boolean;
|
||||
};
|
||||
|
||||
function createTestStdout(): InkTestStdout {
|
||||
return Object.assign(new PassThrough(), {
|
||||
isTTY: true,
|
||||
columns: 120,
|
||||
rows: 40,
|
||||
cursorTo: () => true,
|
||||
clearLine: () => true,
|
||||
moveCursor: () => true,
|
||||
});
|
||||
}
|
||||
|
||||
async function waitForOutputToStabilize(getOutput: () => string): Promise<string> {
|
||||
let previous = '';
|
||||
let sawOutput = false;
|
||||
|
||||
for (let i = 0; i < 50; i += 1) {
|
||||
await nextLoopTurn();
|
||||
const current = getOutput();
|
||||
sawOutput ||= current.length > 0;
|
||||
if (sawOutput && current === previous) {
|
||||
return current;
|
||||
}
|
||||
previous = current;
|
||||
}
|
||||
|
||||
throw new Error(`Ink output did not stabilize: ${JSON.stringify(previous)}`);
|
||||
}
|
||||
|
||||
test('renders edit diff preview with stats and always shortcut', async () => {
|
||||
const stdout = createTestStdout();
|
||||
let output = '';
|
||||
|
||||
stdout.on('data', (chunk) => {
|
||||
output += chunk.toString();
|
||||
});
|
||||
|
||||
const instance = render(
|
||||
<ModalHost
|
||||
modal={{
|
||||
kind: 'edit_diff',
|
||||
path: 'src/demo.txt',
|
||||
diff: '@@ -1 +1 @@\n-old line\n+new line',
|
||||
added: 1,
|
||||
removed: 1,
|
||||
}}
|
||||
modalInput=""
|
||||
setModalInput={() => undefined}
|
||||
onSubmit={() => undefined}
|
||||
/>,
|
||||
{stdout: stdout as unknown as NodeJS.WriteStream, debug: true, patchConsole: false},
|
||||
);
|
||||
|
||||
const exitPromise = instance.waitUntilExit();
|
||||
const stableOutput = await waitForOutputToStabilize(() => output);
|
||||
instance.unmount();
|
||||
await exitPromise;
|
||||
instance.cleanup();
|
||||
stdout.destroy();
|
||||
|
||||
const rendered = stripAnsi(stableOutput);
|
||||
assert.match(rendered, /Edit src\/demo\.txt/);
|
||||
assert.match(rendered, /\+1/);
|
||||
assert.match(rendered, /-1/);
|
||||
assert.match(rendered, /\+new line/);
|
||||
assert.match(rendered, /-old line/);
|
||||
assert.match(rendered, /\[a\] Always/);
|
||||
});
|
||||
@@ -1,190 +1,8 @@
|
||||
import React, {useEffect, useState} from 'react';
|
||||
import {Box, Text, useInput} from 'ink';
|
||||
import React from 'react';
|
||||
import {Box, Text} from 'ink';
|
||||
import TextInput from 'ink-text-input';
|
||||
|
||||
const WAIT_FRAMES = [
|
||||
'Agent is waiting for your input ',
|
||||
'Agent is waiting for your input. ',
|
||||
'Agent is waiting for your input.. ',
|
||||
'Agent is waiting for your input...',
|
||||
];
|
||||
const MAX_DIFF_LINES = 40;
|
||||
|
||||
function WaitingAnimation(): React.JSX.Element {
|
||||
const [frame, setFrame] = useState(0);
|
||||
useEffect(() => {
|
||||
const timer = setInterval(() => setFrame((f) => (f + 1) % WAIT_FRAMES.length), 500);
|
||||
return () => clearInterval(timer);
|
||||
}, []);
|
||||
return (
|
||||
<Text color="magenta" dimColor>
|
||||
{WAIT_FRAMES[frame]}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
function QuestionModal({
|
||||
modal,
|
||||
modalInput,
|
||||
setModalInput,
|
||||
onSubmit,
|
||||
}: {
|
||||
modal: Record<string, unknown>;
|
||||
modalInput: string;
|
||||
setModalInput: (value: string) => void;
|
||||
onSubmit: (value: string) => void;
|
||||
}): React.JSX.Element {
|
||||
const [extraLines, setExtraLines] = useState<string[]>([]);
|
||||
|
||||
useInput((_chunk, key) => {
|
||||
if (key.shift && key.return) {
|
||||
setExtraLines((lines) => [...lines, modalInput]);
|
||||
setModalInput('');
|
||||
}
|
||||
});
|
||||
|
||||
const handleSubmit = (value: string): void => {
|
||||
const allLines = [...extraLines, value];
|
||||
setExtraLines([]);
|
||||
onSubmit(allLines.join('\n'));
|
||||
};
|
||||
|
||||
const toolName = modal.tool_name ? String(modal.tool_name) : null;
|
||||
const reason = modal.reason ? String(modal.reason) : null;
|
||||
const question = String(modal.question ?? 'Question');
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" marginTop={1} borderStyle="double" borderColor="magenta" paddingX={1}>
|
||||
<WaitingAnimation />
|
||||
<Box marginTop={1}>
|
||||
<Text color="magenta" bold>{'\u2753 '}</Text>
|
||||
<Text bold>{question}</Text>
|
||||
</Box>
|
||||
{toolName ? (
|
||||
<Text dimColor>
|
||||
{' '}Tool: <Text color="cyan">{toolName}</Text>
|
||||
</Text>
|
||||
) : null}
|
||||
{reason ? (
|
||||
<Text dimColor>{' '}Reason: {reason}</Text>
|
||||
) : null}
|
||||
{extraLines.length > 0 && (
|
||||
<Box flexDirection="column" marginTop={1} marginLeft={2}>
|
||||
{extraLines.map((line, i) => (
|
||||
<Text key={i} dimColor>
|
||||
{line}
|
||||
</Text>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
<Box marginTop={1}>
|
||||
<Text color="cyan">{'> '}</Text>
|
||||
<TextInput value={modalInput} onChange={setModalInput} onSubmit={handleSubmit} />
|
||||
</Box>
|
||||
<Text dimColor>{' '}shift+enter: newline | enter: submit</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
type DiffLineKind = 'add' | 'del' | 'hunk' | 'context';
|
||||
|
||||
type ParsedDiffLine = {
|
||||
kind: DiffLineKind;
|
||||
content: string;
|
||||
};
|
||||
|
||||
function parseDiffLines(diffText: string): ParsedDiffLine[] {
|
||||
return diffText
|
||||
.split('\n')
|
||||
.flatMap((raw): ParsedDiffLine[] => {
|
||||
if (!raw || raw.startsWith('+++') || raw.startsWith('---')) {
|
||||
return [];
|
||||
}
|
||||
if (raw.startsWith('@@')) {
|
||||
return [{kind: 'hunk', content: raw}];
|
||||
}
|
||||
if (raw.startsWith('+')) {
|
||||
return [{kind: 'add', content: raw.slice(1)}];
|
||||
}
|
||||
if (raw.startsWith('-')) {
|
||||
return [{kind: 'del', content: raw.slice(1)}];
|
||||
}
|
||||
return [{kind: 'context', content: raw.startsWith(' ') ? raw.slice(1) : raw}];
|
||||
});
|
||||
}
|
||||
|
||||
function EditDiffModal({modal}: {modal: Record<string, unknown>}): React.JSX.Element {
|
||||
const path = String(modal.path ?? '');
|
||||
const added = Number(modal.added ?? 0);
|
||||
const removed = Number(modal.removed ?? 0);
|
||||
const lines = parseDiffLines(String(modal.diff ?? ''));
|
||||
const visibleLines = lines.slice(0, MAX_DIFF_LINES);
|
||||
const hiddenCount = lines.length - visibleLines.length;
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
<Text>
|
||||
<Text color="yellow" bold>{'\u250C '}</Text>
|
||||
<Text bold>Edit </Text>
|
||||
<Text color="cyan" bold>{path}</Text>
|
||||
<Text>{' '}</Text>
|
||||
<Text color="green">{`+${added}`}</Text>
|
||||
<Text>{' '}</Text>
|
||||
<Text color="red">{`-${removed}`}</Text>
|
||||
</Text>
|
||||
{visibleLines.map((line, index) => {
|
||||
if (line.kind === 'hunk') {
|
||||
return (
|
||||
<Text key={index}>
|
||||
<Text color="yellow">{'\u2502 '}</Text>
|
||||
<Text color="cyan" dimColor>{line.content}</Text>
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
if (line.kind === 'add') {
|
||||
return (
|
||||
<Text key={index}>
|
||||
<Text color="yellow">{'\u2502 '}</Text>
|
||||
<Text color="green">+</Text>
|
||||
<Text color="green">{line.content}</Text>
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
if (line.kind === 'del') {
|
||||
return (
|
||||
<Text key={index}>
|
||||
<Text color="yellow">{'\u2502 '}</Text>
|
||||
<Text color="red">-</Text>
|
||||
<Text color="red">{line.content}</Text>
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Text key={index}>
|
||||
<Text color="yellow">{'\u2502 '}</Text>
|
||||
<Text dimColor>{' '}{line.content}</Text>
|
||||
</Text>
|
||||
);
|
||||
})}
|
||||
{hiddenCount > 0 ? (
|
||||
<Text>
|
||||
<Text color="yellow">{'\u2502 '}</Text>
|
||||
<Text dimColor>... {hiddenCount} more lines hidden</Text>
|
||||
</Text>
|
||||
) : null}
|
||||
<Text>
|
||||
<Text color="yellow">{'\u2514 '}</Text>
|
||||
<Text color="green">[y] Once</Text>
|
||||
<Text>{' '}</Text>
|
||||
<Text color="green">[a] Always</Text>
|
||||
<Text>{' '}</Text>
|
||||
<Text color="red">[n] Deny</Text>
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function ModalHostInner({
|
||||
export function ModalHost({
|
||||
modal,
|
||||
modalInput,
|
||||
setModalInput,
|
||||
@@ -219,17 +37,18 @@ function ModalHostInner({
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
if (modal?.kind === 'edit_diff') {
|
||||
return <EditDiffModal modal={modal} />;
|
||||
}
|
||||
if (modal?.kind === 'question') {
|
||||
return (
|
||||
<QuestionModal
|
||||
modal={modal}
|
||||
modalInput={modalInput}
|
||||
setModalInput={setModalInput}
|
||||
onSubmit={onSubmit}
|
||||
/>
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
<Text>
|
||||
<Text color="magenta" bold>{'\u2753 '}</Text>
|
||||
<Text bold>{String(modal.question ?? 'Question')}</Text>
|
||||
</Text>
|
||||
<Box>
|
||||
<Text color="cyan">{'> '}</Text>
|
||||
<TextInput value={modalInput} onChange={setModalInput} onSubmit={onSubmit} />
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
if (modal?.kind === 'mcp_auth') {
|
||||
@@ -249,5 +68,3 @@ function ModalHostInner({
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export const ModalHost = React.memo(ModalHostInner);
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import {getBackspaceDeleteCount} from './PromptInput.js';
|
||||
|
||||
test('counts repeated backspace control characters from a single input chunk', () => {
|
||||
assert.equal(getBackspaceDeleteCount('\b\b\b'), 3);
|
||||
assert.equal(getBackspaceDeleteCount('\u007f\u007f'), 2);
|
||||
assert.equal(getBackspaceDeleteCount('\x7f\x7f\x7f'), 3);
|
||||
});
|
||||
|
||||
test('falls back to a single delete for empty or unexpected input', () => {
|
||||
assert.equal(getBackspaceDeleteCount(''), 1);
|
||||
assert.equal(getBackspaceDeleteCount('abc'), 1);
|
||||
assert.equal(getBackspaceDeleteCount('\bA'), 1);
|
||||
});
|
||||
@@ -1,244 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {PassThrough} from 'node:stream';
|
||||
import React, {useState} from 'react';
|
||||
import {render} from 'ink';
|
||||
|
||||
import {ThemeProvider} from '../theme/ThemeContext.js';
|
||||
import {PromptInput} from './PromptInput.js';
|
||||
|
||||
const nextLoopTurn = (): Promise<void> => new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
type InkTestStdout = PassThrough & {
|
||||
isTTY: boolean;
|
||||
columns: number;
|
||||
rows: number;
|
||||
cursorTo: () => boolean;
|
||||
clearLine: () => boolean;
|
||||
moveCursor: () => boolean;
|
||||
};
|
||||
|
||||
type InkTestStdin = PassThrough & {
|
||||
isTTY: boolean;
|
||||
setRawMode: (_mode: boolean) => void;
|
||||
resume: () => InkTestStdin;
|
||||
pause: () => InkTestStdin;
|
||||
ref: () => InkTestStdin;
|
||||
unref: () => InkTestStdin;
|
||||
};
|
||||
|
||||
function createTestStdout(): InkTestStdout {
|
||||
return Object.assign(new PassThrough(), {
|
||||
isTTY: true,
|
||||
columns: 120,
|
||||
rows: 40,
|
||||
cursorTo: () => true,
|
||||
clearLine: () => true,
|
||||
moveCursor: () => true,
|
||||
});
|
||||
}
|
||||
|
||||
function createTestStdin(): InkTestStdin {
|
||||
return Object.assign(new PassThrough(), {
|
||||
isTTY: true,
|
||||
setRawMode: () => undefined,
|
||||
resume() {
|
||||
return this;
|
||||
},
|
||||
pause() {
|
||||
return this;
|
||||
},
|
||||
ref() {
|
||||
return this;
|
||||
},
|
||||
unref() {
|
||||
return this;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function sendKey(stdin: InkTestStdin, chunk: string | Buffer): Promise<void> {
|
||||
stdin.write(chunk);
|
||||
await nextLoopTurn();
|
||||
await nextLoopTurn();
|
||||
}
|
||||
|
||||
async function waitForValue(getValue: () => string, expected: string): Promise<void> {
|
||||
for (let i = 0; i < 50; i += 1) {
|
||||
await nextLoopTurn();
|
||||
if (getValue() === expected) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
assert.equal(getValue(), expected);
|
||||
}
|
||||
|
||||
function PromptHarness({
|
||||
busy = false,
|
||||
onInputChange,
|
||||
onSubmit = () => undefined,
|
||||
}: {
|
||||
busy?: boolean;
|
||||
onInputChange: (value: string) => void;
|
||||
onSubmit?: (value: string) => void;
|
||||
}): React.JSX.Element {
|
||||
const [input, setInput] = useState('');
|
||||
|
||||
return (
|
||||
<ThemeProvider initialTheme="default">
|
||||
<PromptInput
|
||||
busy={busy}
|
||||
input={input}
|
||||
setInput={(value) => {
|
||||
onInputChange(value);
|
||||
setInput(value);
|
||||
}}
|
||||
onSubmit={onSubmit}
|
||||
/>
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
|
||||
test('treats terminal DEL at end-of-line as backward delete', async () => {
|
||||
const stdin = createTestStdin();
|
||||
const stdout = createTestStdout();
|
||||
let currentValue = '';
|
||||
|
||||
const instance = render(<PromptHarness onInputChange={(value) => {
|
||||
currentValue = value;
|
||||
}} />, {
|
||||
stdin: stdin as unknown as NodeJS.ReadStream & {fd: 0},
|
||||
stdout: stdout as unknown as NodeJS.WriteStream,
|
||||
debug: true,
|
||||
patchConsole: false,
|
||||
});
|
||||
const exitPromise = instance.waitUntilExit();
|
||||
|
||||
try {
|
||||
await nextLoopTurn();
|
||||
|
||||
await sendKey(stdin, 'a');
|
||||
await waitForValue(() => currentValue, 'a');
|
||||
|
||||
await sendKey(stdin, 'b');
|
||||
await waitForValue(() => currentValue, 'ab');
|
||||
|
||||
await sendKey(stdin, Buffer.from([0x7f]));
|
||||
await waitForValue(() => currentValue, 'a');
|
||||
} finally {
|
||||
instance.unmount();
|
||||
await exitPromise;
|
||||
instance.cleanup();
|
||||
stdin.destroy();
|
||||
stdout.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
test('keeps forward delete behavior when cursor is inside the line', async () => {
|
||||
const stdin = createTestStdin();
|
||||
const stdout = createTestStdout();
|
||||
let currentValue = '';
|
||||
|
||||
const instance = render(<PromptHarness onInputChange={(value) => {
|
||||
currentValue = value;
|
||||
}} />, {
|
||||
stdin: stdin as unknown as NodeJS.ReadStream & {fd: 0},
|
||||
stdout: stdout as unknown as NodeJS.WriteStream,
|
||||
debug: true,
|
||||
patchConsole: false,
|
||||
});
|
||||
const exitPromise = instance.waitUntilExit();
|
||||
|
||||
try {
|
||||
await nextLoopTurn();
|
||||
|
||||
await sendKey(stdin, 'a');
|
||||
await waitForValue(() => currentValue, 'a');
|
||||
|
||||
await sendKey(stdin, 'b');
|
||||
await waitForValue(() => currentValue, 'ab');
|
||||
|
||||
await sendKey(stdin, '\u001B[D');
|
||||
await nextLoopTurn();
|
||||
|
||||
await sendKey(stdin, '\u001B[3~');
|
||||
await waitForValue(() => currentValue, 'a');
|
||||
} finally {
|
||||
instance.unmount();
|
||||
await exitPromise;
|
||||
instance.cleanup();
|
||||
stdin.destroy();
|
||||
stdout.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
test('ignores ctrl+v so the app can attach clipboard images', async () => {
|
||||
const stdin = createTestStdin();
|
||||
const stdout = createTestStdout();
|
||||
let currentValue = '';
|
||||
|
||||
const instance = render(<PromptHarness onInputChange={(value) => {
|
||||
currentValue = value;
|
||||
}} />, {
|
||||
stdin: stdin as unknown as NodeJS.ReadStream & {fd: 0},
|
||||
stdout: stdout as unknown as NodeJS.WriteStream,
|
||||
debug: true,
|
||||
patchConsole: false,
|
||||
});
|
||||
const exitPromise = instance.waitUntilExit();
|
||||
|
||||
try {
|
||||
await nextLoopTurn();
|
||||
|
||||
await sendKey(stdin, Buffer.from([0x16]));
|
||||
await nextLoopTurn();
|
||||
assert.equal(currentValue, '');
|
||||
|
||||
await sendKey(stdin, 'v');
|
||||
await waitForValue(() => currentValue, 'v');
|
||||
} finally {
|
||||
instance.unmount();
|
||||
await exitPromise;
|
||||
instance.cleanup();
|
||||
stdin.destroy();
|
||||
stdout.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
test('accepts explicit /stop submission while busy', async () => {
|
||||
const stdin = createTestStdin();
|
||||
const stdout = createTestStdout();
|
||||
let currentValue = '';
|
||||
let submitted = '';
|
||||
|
||||
const instance = render(<PromptHarness busy onInputChange={(value) => {
|
||||
currentValue = value;
|
||||
}} onSubmit={(value) => {
|
||||
submitted = value;
|
||||
}} />, {
|
||||
stdin: stdin as unknown as NodeJS.ReadStream & {fd: 0},
|
||||
stdout: stdout as unknown as NodeJS.WriteStream,
|
||||
debug: true,
|
||||
patchConsole: false,
|
||||
});
|
||||
const exitPromise = instance.waitUntilExit();
|
||||
|
||||
try {
|
||||
await nextLoopTurn();
|
||||
|
||||
for (const char of '/stop') {
|
||||
await sendKey(stdin, char);
|
||||
}
|
||||
await waitForValue(() => currentValue, '/stop');
|
||||
|
||||
await sendKey(stdin, '\r');
|
||||
await waitForValue(() => submitted, '/stop');
|
||||
} finally {
|
||||
instance.unmount();
|
||||
await exitPromise;
|
||||
instance.cleanup();
|
||||
stdin.destroy();
|
||||
stdout.destroy();
|
||||
}
|
||||
});
|
||||
@@ -1,200 +1,10 @@
|
||||
import React, {useEffect, useRef, useState} from 'react';
|
||||
import {Box, Text, useInput, useStdin} from 'ink';
|
||||
import chalk from 'chalk';
|
||||
import React from 'react';
|
||||
import {Box, Text} from 'ink';
|
||||
import TextInput from 'ink-text-input';
|
||||
|
||||
import {useTheme} from '../theme/ThemeContext.js';
|
||||
import {Spinner} from './Spinner.js';
|
||||
|
||||
const noop = (): void => {};
|
||||
const BACKSPACE_CONTROL_PATTERN = /^[\b\u007f]+$/;
|
||||
|
||||
export function getBackspaceDeleteCount(sequence: string): number {
|
||||
if (!sequence || !BACKSPACE_CONTROL_PATTERN.test(sequence)) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
return [...sequence].length;
|
||||
}
|
||||
|
||||
function MultilineTextInput({
|
||||
value,
|
||||
onChange,
|
||||
onSubmit,
|
||||
focus = true,
|
||||
promptPrefix,
|
||||
promptColor,
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
onSubmit?: (value: string) => void;
|
||||
focus?: boolean;
|
||||
promptPrefix: string;
|
||||
promptColor: string;
|
||||
}): React.JSX.Element {
|
||||
const [cursorOffset, setCursorOffset] = useState(value.length);
|
||||
const {internal_eventEmitter} = useStdin();
|
||||
const lastSequenceRef = useRef('');
|
||||
// Tracks the last value this component produced via onChange. If the
|
||||
// incoming `value` prop diverges from this, the change came from outside
|
||||
// (tab completion, history recall, programmatic clear) and we should
|
||||
// move the cursor to the end — otherwise the cursor stays wherever the
|
||||
// user had it, which puts subsequent keystrokes in the middle of the
|
||||
// newly-completed text. See HKUDS/OpenHarness#183.
|
||||
const lastInternalValueRef = useRef<string>(value);
|
||||
|
||||
useEffect(() => {
|
||||
if (value === lastInternalValueRef.current) {
|
||||
// Self-authored update; cursor was already positioned by the
|
||||
// handler that called onChange.
|
||||
return;
|
||||
}
|
||||
lastInternalValueRef.current = value;
|
||||
setCursorOffset(value.length);
|
||||
}, [value]);
|
||||
|
||||
const commitValue = (nextValue: string): void => {
|
||||
lastInternalValueRef.current = nextValue;
|
||||
onChange(nextValue);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!focus) {
|
||||
return;
|
||||
}
|
||||
|
||||
const handleRawInput = (chunk: string | Buffer): void => {
|
||||
lastSequenceRef.current = Buffer.isBuffer(chunk) ? chunk.toString('utf8') : String(chunk);
|
||||
};
|
||||
|
||||
internal_eventEmitter.on('input', handleRawInput);
|
||||
return () => {
|
||||
internal_eventEmitter.removeListener('input', handleRawInput);
|
||||
};
|
||||
}, [focus, internal_eventEmitter]);
|
||||
|
||||
useInput(
|
||||
(input, key) => {
|
||||
if (!focus) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
key.upArrow ||
|
||||
key.downArrow ||
|
||||
key.tab ||
|
||||
(key.shift && key.tab) ||
|
||||
key.escape ||
|
||||
(key.ctrl && (input === 'c' || input === 'v'))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (key.return) {
|
||||
if (key.shift) {
|
||||
const nextValue = value.slice(0, cursorOffset) + '\n' + value.slice(cursorOffset);
|
||||
setCursorOffset(cursorOffset + 1);
|
||||
commitValue(nextValue);
|
||||
return;
|
||||
}
|
||||
onSubmit?.(value);
|
||||
return;
|
||||
}
|
||||
|
||||
if (key.leftArrow) {
|
||||
setCursorOffset((previous) => Math.max(0, previous - 1));
|
||||
return;
|
||||
}
|
||||
|
||||
if (key.rightArrow) {
|
||||
setCursorOffset((previous) => Math.min(value.length, previous + 1));
|
||||
return;
|
||||
}
|
||||
|
||||
if (key.backspace) {
|
||||
if (cursorOffset === 0) {
|
||||
return;
|
||||
}
|
||||
const deleteCount = Math.min(cursorOffset, getBackspaceDeleteCount(lastSequenceRef.current || input));
|
||||
const nextValue = value.slice(0, cursorOffset - deleteCount) + value.slice(cursorOffset);
|
||||
setCursorOffset(cursorOffset - deleteCount);
|
||||
commitValue(nextValue);
|
||||
return;
|
||||
}
|
||||
|
||||
if (key.delete) {
|
||||
// Ink reports the common DEL byte (`0x7f`) as `delete`, even though
|
||||
// many terminals emit it for the Backspace key. Use the raw sequence
|
||||
// to distinguish that case from a true forward-delete escape sequence.
|
||||
if (
|
||||
lastSequenceRef.current === '\x7f' ||
|
||||
lastSequenceRef.current === '\x1b\x7f' ||
|
||||
BACKSPACE_CONTROL_PATTERN.test(lastSequenceRef.current)
|
||||
) {
|
||||
if (cursorOffset === 0) {
|
||||
return;
|
||||
}
|
||||
const deleteCount = Math.min(cursorOffset, getBackspaceDeleteCount(lastSequenceRef.current));
|
||||
const nextValue = value.slice(0, cursorOffset - deleteCount) + value.slice(cursorOffset);
|
||||
setCursorOffset(cursorOffset - deleteCount);
|
||||
commitValue(nextValue);
|
||||
return;
|
||||
}
|
||||
|
||||
if (cursorOffset >= value.length) {
|
||||
return;
|
||||
}
|
||||
const nextValue = value.slice(0, cursorOffset) + value.slice(cursorOffset + 1);
|
||||
commitValue(nextValue);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!input) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextValue = value.slice(0, cursorOffset) + input + value.slice(cursorOffset);
|
||||
setCursorOffset(cursorOffset + input.length);
|
||||
commitValue(nextValue);
|
||||
},
|
||||
{isActive: focus},
|
||||
);
|
||||
|
||||
let renderedValue = value;
|
||||
if (focus) {
|
||||
if (value.length === 0) {
|
||||
renderedValue = chalk.inverse(' ');
|
||||
} else {
|
||||
renderedValue = '';
|
||||
let index = 0;
|
||||
for (const char of value) {
|
||||
if (index === cursorOffset) {
|
||||
renderedValue += chalk.inverse(char === '\n' ? ' ' : char);
|
||||
} else {
|
||||
renderedValue += char;
|
||||
}
|
||||
index += 1;
|
||||
}
|
||||
if (cursorOffset === value.length) {
|
||||
renderedValue += chalk.inverse(' ');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const lines = renderedValue.split('\n');
|
||||
const indent = ' '.repeat(promptPrefix.length);
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
{lines.map((line, index) => (
|
||||
<Box key={`${index}:${line}`}>
|
||||
<Text color={promptColor} bold>
|
||||
{index === 0 ? promptPrefix : indent}
|
||||
</Text>
|
||||
<Text>{line.length > 0 ? line : ' '}</Text>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export function PromptInput({
|
||||
busy,
|
||||
@@ -203,9 +13,6 @@ export function PromptInput({
|
||||
onSubmit,
|
||||
toolName,
|
||||
suppressSubmit,
|
||||
statusLabel,
|
||||
imageAttachmentLabels = [],
|
||||
clipboardStatus,
|
||||
}: {
|
||||
busy: boolean;
|
||||
input: string;
|
||||
@@ -213,42 +20,19 @@ export function PromptInput({
|
||||
onSubmit: (value: string) => void;
|
||||
toolName?: string;
|
||||
suppressSubmit?: boolean;
|
||||
statusLabel?: string;
|
||||
imageAttachmentLabels?: string[];
|
||||
clipboardStatus?: string | null;
|
||||
}): React.JSX.Element {
|
||||
const {theme} = useTheme();
|
||||
const promptPrefix = busy ? '… ' : '> ';
|
||||
if (busy) {
|
||||
return (
|
||||
<Box>
|
||||
<Spinner label={toolName ? `Running ${toolName}...` : undefined} />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
{busy ? (
|
||||
<Box flexDirection="column" marginBottom={0}>
|
||||
<Box>
|
||||
<Spinner label={statusLabel ?? (toolName ? `Running ${toolName}...` : 'Running...')} />
|
||||
</Box>
|
||||
</Box>
|
||||
) : null}
|
||||
{imageAttachmentLabels.length > 0 ? (
|
||||
<Box>
|
||||
<Text color={theme.colors.accent}>
|
||||
{imageAttachmentLabels.map((label, index) => `[image ${index + 1}: ${label}]`).join(' ')}
|
||||
</Text>
|
||||
</Box>
|
||||
) : null}
|
||||
{clipboardStatus ? (
|
||||
<Box>
|
||||
<Text color={theme.colors.muted}>{clipboardStatus}</Text>
|
||||
</Box>
|
||||
) : null}
|
||||
<MultilineTextInput
|
||||
value={input}
|
||||
onChange={setInput}
|
||||
onSubmit={suppressSubmit ? noop : onSubmit}
|
||||
focus
|
||||
promptPrefix={promptPrefix}
|
||||
promptColor={theme.colors.primary}
|
||||
/>
|
||||
<Box>
|
||||
<Text color="cyan" bold>{'> '}</Text>
|
||||
<TextInput value={input} onChange={setInput} onSubmit={suppressSubmit ? noop : onSubmit} />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import React, {useEffect, useState} from 'react';
|
||||
import {Text} from 'ink';
|
||||
|
||||
import {useTheme} from '../theme/ThemeContext.js';
|
||||
|
||||
const FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
|
||||
const VERBS = [
|
||||
'Thinking',
|
||||
'Processing',
|
||||
@@ -14,20 +13,16 @@ const VERBS = [
|
||||
'Considering',
|
||||
];
|
||||
|
||||
const WINDOWS_SAFE_FRAMES = ['-', '\\', '|', '/'];
|
||||
|
||||
export function Spinner({label}: {label?: string}): React.JSX.Element {
|
||||
const {theme} = useTheme();
|
||||
const frames = process.platform === 'win32' ? WINDOWS_SAFE_FRAMES : theme.icons.spinner;
|
||||
const [frame, setFrame] = useState(0);
|
||||
const [verbIndex, setVerbIndex] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setInterval(() => {
|
||||
setFrame((f) => (f + 1) % frames.length);
|
||||
}, 100);
|
||||
setFrame((f) => (f + 1) % FRAMES.length);
|
||||
}, 80);
|
||||
return () => clearInterval(timer);
|
||||
}, [frames.length]);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setInterval(() => {
|
||||
@@ -40,7 +35,7 @@ export function Spinner({label}: {label?: string}): React.JSX.Element {
|
||||
|
||||
return (
|
||||
<Text>
|
||||
<Text color={theme.colors.primary}>{frames[frame]}</Text>
|
||||
<Text color="cyan">{FRAMES[frame]}</Text>
|
||||
<Text dimColor> {verb}</Text>
|
||||
</Text>
|
||||
);
|
||||
|
||||
@@ -1,83 +1,24 @@
|
||||
import React, {useEffect, useState} from 'react';
|
||||
import React from 'react';
|
||||
import {Box, Text} from 'ink';
|
||||
|
||||
import {useTheme} from '../theme/ThemeContext.js';
|
||||
import type {TaskSnapshot} from '../types.js';
|
||||
|
||||
const SEP = ' \u2502 ';
|
||||
|
||||
const WRITE_TOOLS = new Set([
|
||||
'Write', 'Edit', 'MultiEdit', 'NotebookEdit',
|
||||
'Bash', 'computer', 'str_replace_editor',
|
||||
]);
|
||||
|
||||
function PlanModeIndicator({
|
||||
mode,
|
||||
activeToolName,
|
||||
}: {
|
||||
mode: string;
|
||||
activeToolName?: string;
|
||||
}): React.JSX.Element | null {
|
||||
const [flash, setFlash] = useState(false);
|
||||
const [prevMode, setPrevMode] = useState(mode);
|
||||
|
||||
useEffect(() => {
|
||||
if (prevMode === 'plan' && mode !== 'plan' && prevMode !== mode) {
|
||||
setFlash(true);
|
||||
const timer = setTimeout(() => setFlash(false), 800);
|
||||
setPrevMode(mode);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
setPrevMode(mode);
|
||||
}, [mode]);
|
||||
|
||||
if (mode !== 'plan' && mode !== 'Plan Mode') {
|
||||
if (flash) {
|
||||
return (
|
||||
<Text color="green" bold>
|
||||
{' PLAN MODE OFF '}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const isBlockedTool = activeToolName != null && WRITE_TOOLS.has(activeToolName);
|
||||
|
||||
return (
|
||||
<Text>
|
||||
<Text color="yellow" bold>{' [PLAN MODE] '}</Text>
|
||||
{isBlockedTool ? (
|
||||
<Text color="red">{'\uD83D\uDEAB '}{activeToolName} blocked</Text>
|
||||
) : null}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusBarInner({
|
||||
status,
|
||||
tasks,
|
||||
activeToolName,
|
||||
}: {
|
||||
status: Record<string, unknown>;
|
||||
tasks: TaskSnapshot[];
|
||||
activeToolName?: string;
|
||||
}): React.JSX.Element {
|
||||
const {theme} = useTheme();
|
||||
export function StatusBar({status, tasks}: {status: Record<string, unknown>; tasks: TaskSnapshot[]}): React.JSX.Element {
|
||||
const model = String(status.model ?? 'unknown');
|
||||
const mode = String(status.permission_mode ?? 'default');
|
||||
const taskCount = tasks.length;
|
||||
const mcpCount = Number(status.mcp_connected ?? 0);
|
||||
const inputTokens = Number(status.input_tokens ?? 0);
|
||||
const outputTokens = Number(status.output_tokens ?? 0);
|
||||
const isPlanMode = mode === 'plan' || mode === 'Plan Mode';
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Text dimColor>{'─'.repeat(60)}</Text>
|
||||
<Box flexDirection="row" alignItems="center">
|
||||
<Box flexDirection="row">
|
||||
<Text>
|
||||
<Text color={theme.colors.primary} dimColor>model: {model}</Text>
|
||||
<Text color="cyan" dimColor>model: {model}</Text>
|
||||
<Text dimColor>{SEP}</Text>
|
||||
{inputTokens > 0 || outputTokens > 0 ? (
|
||||
<>
|
||||
@@ -85,9 +26,7 @@ function StatusBarInner({
|
||||
<Text dimColor>{SEP}</Text>
|
||||
</>
|
||||
) : null}
|
||||
{!isPlanMode ? (
|
||||
<Text dimColor>mode: {mode}</Text>
|
||||
) : null}
|
||||
<Text dimColor>mode: {mode}</Text>
|
||||
{taskCount > 0 ? (
|
||||
<>
|
||||
<Text dimColor>{SEP}</Text>
|
||||
@@ -101,16 +40,11 @@ function StatusBarInner({
|
||||
</>
|
||||
) : null}
|
||||
</Text>
|
||||
{isPlanMode ? (
|
||||
<PlanModeIndicator mode={mode} activeToolName={activeToolName} />
|
||||
) : null}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export const StatusBar = React.memo(StatusBarInner);
|
||||
|
||||
function formatNum(n: number): string {
|
||||
if (n >= 1000) {
|
||||
return `${(n / 1000).toFixed(1)}k`;
|
||||
|
||||
@@ -1,127 +0,0 @@
|
||||
import React, {useState} from 'react';
|
||||
import {Box, Text, useInput} from 'ink';
|
||||
|
||||
export type SwarmTeammate = {
|
||||
name: string;
|
||||
status: 'running' | 'idle' | 'done' | 'error';
|
||||
duration?: number; // seconds
|
||||
task?: string;
|
||||
};
|
||||
|
||||
export type SwarmNotification = {
|
||||
from: string;
|
||||
message: string;
|
||||
timestamp: number;
|
||||
};
|
||||
|
||||
function statusIcon(status: SwarmTeammate['status']): string {
|
||||
switch (status) {
|
||||
case 'running':
|
||||
return '🟢';
|
||||
case 'idle':
|
||||
return '🟡';
|
||||
case 'done':
|
||||
return '✅';
|
||||
case 'error':
|
||||
return '🔴';
|
||||
}
|
||||
}
|
||||
|
||||
function formatDuration(seconds: number): string {
|
||||
if (seconds < 60) {
|
||||
return `${seconds}s`;
|
||||
}
|
||||
const m = Math.floor(seconds / 60);
|
||||
const s = seconds % 60;
|
||||
return `${m}m${s}s`;
|
||||
}
|
||||
|
||||
function SwarmPanelInner({
|
||||
teammates,
|
||||
notifications,
|
||||
collapsed: initialCollapsed = false,
|
||||
}: {
|
||||
teammates: SwarmTeammate[];
|
||||
notifications: SwarmNotification[];
|
||||
collapsed?: boolean;
|
||||
}): React.JSX.Element | null {
|
||||
const [collapsed, setCollapsed] = useState(initialCollapsed);
|
||||
|
||||
useInput((chunk, key) => {
|
||||
if (key.ctrl && chunk === 'w') {
|
||||
setCollapsed((c) => !c);
|
||||
}
|
||||
});
|
||||
|
||||
if (teammates.length === 0 && notifications.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const activeCount = teammates.filter((t) => t.status === 'running').length;
|
||||
|
||||
if (collapsed) {
|
||||
return (
|
||||
<Box>
|
||||
<Text color="cyan" bold>
|
||||
{'⚡ '}
|
||||
</Text>
|
||||
<Text dimColor>
|
||||
Swarm: {teammates.length} agents ({activeCount} active)
|
||||
</Text>
|
||||
<Text dimColor> [ctrl+w expand]</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" borderStyle="round" borderColor="cyan" paddingX={1} marginTop={1}>
|
||||
<Box>
|
||||
<Text color="cyan" bold>
|
||||
{'⚡ '}
|
||||
</Text>
|
||||
<Text bold>Swarm</Text>
|
||||
<Text dimColor>
|
||||
{' '}
|
||||
({activeCount}/{teammates.length} active) [ctrl+w collapse]
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
{teammates.length > 0 && (
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
{teammates.map((teammate) => (
|
||||
<Box key={teammate.name} flexDirection="row" marginBottom={0}>
|
||||
<Text>{statusIcon(teammate.status)} </Text>
|
||||
<Box flexDirection="column">
|
||||
<Box>
|
||||
<Text bold color={teammate.status === 'running' ? 'green' : teammate.status === 'error' ? 'red' : undefined}>
|
||||
{teammate.name}
|
||||
</Text>
|
||||
{teammate.duration !== undefined && (
|
||||
<Text dimColor> ({formatDuration(teammate.duration)})</Text>
|
||||
)}
|
||||
</Box>
|
||||
{teammate.task && (
|
||||
<Text dimColor> {teammate.task.slice(0, 60)}{teammate.task.length > 60 ? '…' : ''}</Text>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{notifications.length > 0 && (
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
<Text dimColor bold>Recent notifications:</Text>
|
||||
{notifications.slice(-3).map((n, i) => (
|
||||
<Box key={i}>
|
||||
<Text dimColor>[{n.from}] </Text>
|
||||
<Text>{n.message.slice(0, 70)}{n.message.length > 70 ? '…' : ''}</Text>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export const SwarmPanel = React.memo(SwarmPanelInner);
|
||||
@@ -1,91 +0,0 @@
|
||||
import React, {useState} from 'react';
|
||||
import {Box, Text, useInput} from 'ink';
|
||||
|
||||
export type TodoItem = {
|
||||
text: string;
|
||||
checked: boolean;
|
||||
};
|
||||
|
||||
function parseTodoItems(markdown: string): TodoItem[] {
|
||||
const lines = markdown.split('\n');
|
||||
const items: TodoItem[] = [];
|
||||
for (const line of lines) {
|
||||
const m = line.match(/^\s*-\s+\[([ xX])\]\s+(.+)/);
|
||||
if (m) {
|
||||
items.push({checked: m[1].toLowerCase() === 'x', text: m[2].trim()});
|
||||
}
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
function TodoPanelInner({
|
||||
markdown,
|
||||
compact: initialCompact = false,
|
||||
}: {
|
||||
markdown: string;
|
||||
compact?: boolean;
|
||||
}): React.JSX.Element | null {
|
||||
const [compact, setCompact] = useState(initialCompact);
|
||||
const items = parseTodoItems(markdown);
|
||||
|
||||
useInput((chunk, key) => {
|
||||
if (key.ctrl && chunk === 't') {
|
||||
setCompact((c) => !c);
|
||||
}
|
||||
});
|
||||
|
||||
if (items.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const done = items.filter((i) => i.checked).length;
|
||||
const total = items.length;
|
||||
|
||||
if (compact) {
|
||||
return (
|
||||
<Box>
|
||||
<Text color="yellow" bold>
|
||||
{'☑ '}
|
||||
</Text>
|
||||
<Text dimColor>
|
||||
Todos: {done}/{total} done
|
||||
</Text>
|
||||
<Text dimColor> [ctrl+t expand]</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" borderStyle="round" borderColor="yellow" paddingX={1} marginTop={1}>
|
||||
<Box>
|
||||
<Text color="yellow" bold>
|
||||
{'☑ '}
|
||||
</Text>
|
||||
<Text bold>
|
||||
Todo List{' '}
|
||||
</Text>
|
||||
<Text dimColor>
|
||||
({done}/{total})
|
||||
</Text>
|
||||
<Text dimColor> [ctrl+t compact]</Text>
|
||||
</Box>
|
||||
{items.map((item, i) => (
|
||||
<Box key={i}>
|
||||
<Text color={item.checked ? 'green' : 'white'}>
|
||||
{item.checked ? ' ☑ ' : ' ☐ '}
|
||||
</Text>
|
||||
<Text
|
||||
color={item.checked ? 'green' : undefined}
|
||||
dimColor={item.checked}
|
||||
>
|
||||
{item.text}
|
||||
</Text>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export const TodoPanel = React.memo(TodoPanelInner);
|
||||
|
||||
export {parseTodoItems};
|
||||
@@ -1,110 +1,34 @@
|
||||
import React from 'react';
|
||||
import {Box, Text} from 'ink';
|
||||
|
||||
import {useTheme} from '../theme/ThemeContext.js';
|
||||
import type {TranscriptItem} from '../types.js';
|
||||
|
||||
export function ToolCallDisplay({
|
||||
item,
|
||||
resultItem,
|
||||
outputStyle,
|
||||
}: {
|
||||
item: TranscriptItem;
|
||||
resultItem?: TranscriptItem;
|
||||
outputStyle?: string;
|
||||
}): React.JSX.Element {
|
||||
const {theme} = useTheme();
|
||||
const isCodexStyle = outputStyle === 'codex';
|
||||
|
||||
export function ToolCallDisplay({item}: {item: TranscriptItem}): React.JSX.Element {
|
||||
if (item.role === 'tool') {
|
||||
const toolName = item.tool_name ?? 'tool';
|
||||
const summary = summarizeInput(toolName, item.tool_input, item.text).replace(/\s+/g, ' ').trim();
|
||||
|
||||
let statusNode: React.ReactNode = null;
|
||||
let errorLines: string[] | null = null;
|
||||
|
||||
if (resultItem) {
|
||||
if (resultItem.is_error) {
|
||||
statusNode = isCodexStyle
|
||||
? <Text color={theme.colors.error}> error</Text>
|
||||
: <Text color={theme.colors.error}> {theme.icons.error.trim()}</Text>;
|
||||
const lines = resultItem.text.split('\n').filter((l) => l.trim());
|
||||
const maxErrLines = isCodexStyle ? 8 : 5;
|
||||
errorLines = lines.length > maxErrLines
|
||||
? [...lines.slice(0, maxErrLines), `... (${lines.length - maxErrLines} more lines)`]
|
||||
: lines;
|
||||
} else if (!isCodexStyle) {
|
||||
const lineCount = resultItem.text.split('\n').filter((l) => l.trim()).length;
|
||||
const resultLabel = lineCount > 0 ? `${lineCount}L` : theme.icons.success.trim();
|
||||
statusNode = <Text dimColor> → {resultLabel}</Text>;
|
||||
} else {
|
||||
const lineCount = resultItem.text.split('\n').filter((l) => l.trim()).length;
|
||||
statusNode = <Text dimColor>{lineCount > 0 ? ` ${lineCount}L` : ''}</Text>;
|
||||
}
|
||||
}
|
||||
|
||||
if (isCodexStyle) {
|
||||
return (
|
||||
<Box marginLeft={0} flexDirection="column">
|
||||
<Text dimColor>{`• Ran ${toolName}${summary ? ` ${summary}` : ''}`}{statusNode}</Text>
|
||||
{errorLines?.map((line, i) => {
|
||||
const prefix = i === errorLines.length - 1 ? '└ ' : '│ ';
|
||||
return (
|
||||
<Text key={i} color={theme.colors.error}>
|
||||
{prefix}
|
||||
{line}
|
||||
</Text>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
const summary = summarizeInput(toolName, item.tool_input, item.text);
|
||||
return (
|
||||
<Box marginLeft={2} flexDirection="column">
|
||||
<Text>
|
||||
<Text color={theme.colors.accent} bold>{theme.icons.tool}</Text>
|
||||
<Text color={theme.colors.accent} bold>{toolName}</Text>
|
||||
<Text color="cyan" bold>{' \u23F5 '}</Text>
|
||||
<Text color="cyan" bold>{toolName}</Text>
|
||||
<Text dimColor> {summary}</Text>
|
||||
{statusNode}
|
||||
</Text>
|
||||
{errorLines?.map((line, i) => (
|
||||
<Box key={i} marginLeft={4}>
|
||||
<Text color={theme.colors.error}>{line}</Text>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (item.role === 'tool_result') {
|
||||
if (!item.is_error) {
|
||||
return <></>;
|
||||
}
|
||||
const lines = item.text.length > 0
|
||||
? item.text.split('\n').filter((l) => l.trim())
|
||||
: [''];
|
||||
const maxLines = isCodexStyle ? 8 : 5;
|
||||
const lines = item.text.split('\n');
|
||||
const maxLines = 12;
|
||||
const display = lines.length > maxLines ? [...lines.slice(0, maxLines), `... (${lines.length - maxLines} more lines)`] : lines;
|
||||
if (isCodexStyle) {
|
||||
return (
|
||||
<Box marginLeft={0} flexDirection="column">
|
||||
{display.map((line, i) => {
|
||||
const prefix = i === display.length - 1 ? '└ ' : '│ ';
|
||||
return (
|
||||
<Text key={i} color={theme.colors.error}>
|
||||
{prefix}
|
||||
{line}
|
||||
</Text>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
const color = item.is_error ? 'red' : undefined;
|
||||
return (
|
||||
<Box marginLeft={4} flexDirection="column">
|
||||
{display.map((line, i) => (
|
||||
<Text key={i} color={theme.colors.error}>{line}</Text>
|
||||
<Text key={i} color={color} dimColor={!item.is_error}>
|
||||
{line}
|
||||
</Text>
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
@@ -139,6 +63,7 @@ function summarizeInput(toolName: string, toolInput?: Record<string, unknown>, f
|
||||
if (lower === 'agent' && toolInput.description) {
|
||||
return String(toolInput.description);
|
||||
}
|
||||
// Fallback: show first key=value
|
||||
const entries = Object.entries(toolInput);
|
||||
if (entries.length > 0) {
|
||||
const [key, val] = entries[0];
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import React from 'react';
|
||||
import {Box, Text} from 'ink';
|
||||
|
||||
import {useTheme} from '../theme/ThemeContext.js';
|
||||
|
||||
const VERSION = '0.1.0';
|
||||
|
||||
// prettier-ignore
|
||||
@@ -16,13 +14,11 @@ const LOGO = [
|
||||
];
|
||||
|
||||
export function WelcomeBanner(): React.JSX.Element {
|
||||
const {theme} = useTheme();
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" marginBottom={1}>
|
||||
<Box flexDirection="column" paddingX={0}>
|
||||
{LOGO.map((line, i) => (
|
||||
<Text key={i} color={theme.colors.primary} bold>{line}</Text>
|
||||
<Text key={i} color="cyan" bold>{line}</Text>
|
||||
))}
|
||||
<Text> </Text>
|
||||
<Text>
|
||||
@@ -32,13 +28,13 @@ export function WelcomeBanner(): React.JSX.Element {
|
||||
<Text> </Text>
|
||||
<Text>
|
||||
<Text dimColor> </Text>
|
||||
<Text color={theme.colors.primary}>/help</Text>
|
||||
<Text color="cyan">/help</Text>
|
||||
<Text dimColor> commands</Text>
|
||||
<Text dimColor>{' '}|{' '}</Text>
|
||||
<Text color={theme.colors.primary}>/model</Text>
|
||||
<Text color="cyan">/model</Text>
|
||||
<Text dimColor> switch</Text>
|
||||
<Text dimColor>{' '}|{' '}</Text>
|
||||
<Text color={theme.colors.primary}>Ctrl+C</Text>
|
||||
<Text color="cyan">Ctrl+C</Text>
|
||||
<Text dimColor> exit</Text>
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import {startTransition, useEffect, useMemo, useRef, useState} from 'react';
|
||||
import {useEffect, useMemo, useRef, useState} from 'react';
|
||||
import {spawn, type ChildProcessWithoutNullStreams} from 'node:child_process';
|
||||
import readline from 'node:readline';
|
||||
|
||||
@@ -8,18 +8,11 @@ import type {
|
||||
FrontendConfig,
|
||||
McpServerSnapshot,
|
||||
SelectOptionPayload,
|
||||
SwarmNotificationSnapshot,
|
||||
SwarmTeammateSnapshot,
|
||||
TaskSnapshot,
|
||||
TranscriptItem,
|
||||
} from '../types.js';
|
||||
|
||||
const PROTOCOL_PREFIX = 'OHJSON:';
|
||||
const ASSISTANT_DELTA_FLUSH_MS = 50;
|
||||
const ASSISTANT_DELTA_FLUSH_CHARS = 384;
|
||||
const TRANSCRIPT_EVENT_FLUSH_MS = 50;
|
||||
|
||||
const stableStringify = (value: unknown): string => JSON.stringify(value);
|
||||
|
||||
export function useBackendSession(config: FrontendConfig, onExit: (code?: number | null) => void) {
|
||||
const [transcript, setTranscript] = useState<TranscriptItem[]>([]);
|
||||
@@ -30,79 +23,10 @@ export function useBackendSession(config: FrontendConfig, onExit: (code?: number
|
||||
const [mcpServers, setMcpServers] = useState<McpServerSnapshot[]>([]);
|
||||
const [bridgeSessions, setBridgeSessions] = useState<BridgeSessionSnapshot[]>([]);
|
||||
const [modal, setModal] = useState<Record<string, unknown> | null>(null);
|
||||
const [selectRequest, setSelectRequest] = useState<{title: string; command: string; options: SelectOptionPayload[]} | null>(null);
|
||||
const [selectRequest, setSelectRequest] = useState<{title: string; submitPrefix: string; options: SelectOptionPayload[]} | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [busyLabel, setBusyLabel] = useState<string | undefined>(undefined);
|
||||
const [ready, setReady] = useState(false);
|
||||
const [todoMarkdown, setTodoMarkdown] = useState('');
|
||||
const [swarmTeammates, setSwarmTeammates] = useState<SwarmTeammateSnapshot[]>([]);
|
||||
const [swarmNotifications, setSwarmNotifications] = useState<SwarmNotificationSnapshot[]>([]);
|
||||
const statusRef = useRef<Record<string, unknown>>({});
|
||||
const childRef = useRef<ChildProcessWithoutNullStreams | null>(null);
|
||||
const sentInitialPrompt = useRef(false);
|
||||
const lastStatusSnapshotRef = useRef('');
|
||||
const lastTasksSnapshotRef = useRef('');
|
||||
const lastMcpSnapshotRef = useRef('');
|
||||
const lastBridgeSnapshotRef = useRef('');
|
||||
|
||||
// Streaming deltas can arrive one token at a time; updating Ink state for each
|
||||
// delta causes heavy re-rendering/flicker. Buffer and flush at ~30fps.
|
||||
const assistantBufferRef = useRef('');
|
||||
const pendingAssistantDeltaRef = useRef('');
|
||||
const assistantFlushTimerRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const pendingTranscriptItemsRef = useRef<TranscriptItem[]>([]);
|
||||
const transcriptFlushTimerRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
const flushAssistantDelta = (): void => {
|
||||
const pending = pendingAssistantDeltaRef.current;
|
||||
if (!pending) {
|
||||
return;
|
||||
}
|
||||
pendingAssistantDeltaRef.current = '';
|
||||
assistantBufferRef.current += pending;
|
||||
startTransition(() => {
|
||||
setAssistantBuffer(assistantBufferRef.current);
|
||||
});
|
||||
};
|
||||
|
||||
const flushTranscriptItems = (): void => {
|
||||
const pending = pendingTranscriptItemsRef.current;
|
||||
if (pending.length === 0) {
|
||||
return;
|
||||
}
|
||||
pendingTranscriptItemsRef.current = [];
|
||||
startTransition(() => {
|
||||
setTranscript((items) => [...items, ...pending]);
|
||||
});
|
||||
};
|
||||
|
||||
const queueTranscriptItem = (item: TranscriptItem): void => {
|
||||
pendingTranscriptItemsRef.current.push(item);
|
||||
if (!transcriptFlushTimerRef.current) {
|
||||
transcriptFlushTimerRef.current = setTimeout(() => {
|
||||
transcriptFlushTimerRef.current = null;
|
||||
flushTranscriptItems();
|
||||
}, TRANSCRIPT_EVENT_FLUSH_MS);
|
||||
}
|
||||
};
|
||||
|
||||
const clearAssistantDelta = (): void => {
|
||||
pendingAssistantDeltaRef.current = '';
|
||||
assistantBufferRef.current = '';
|
||||
if (assistantFlushTimerRef.current) {
|
||||
clearTimeout(assistantFlushTimerRef.current);
|
||||
assistantFlushTimerRef.current = null;
|
||||
}
|
||||
setAssistantBuffer('');
|
||||
};
|
||||
|
||||
const clearPendingTranscriptItems = (): void => {
|
||||
pendingTranscriptItemsRef.current = [];
|
||||
if (transcriptFlushTimerRef.current) {
|
||||
clearTimeout(transcriptFlushTimerRef.current);
|
||||
transcriptFlushTimerRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
const sendRequest = (payload: Record<string, unknown>): void => {
|
||||
const child = childRef.current;
|
||||
@@ -114,21 +38,16 @@ export function useBackendSession(config: FrontendConfig, onExit: (code?: number
|
||||
|
||||
useEffect(() => {
|
||||
const [command, ...args] = config.backend_command;
|
||||
const useDetachedGroup = process.platform !== 'win32';
|
||||
const child = spawn(command, args, {
|
||||
stdio: ['pipe', 'pipe', 'inherit'],
|
||||
env: process.env,
|
||||
// On Windows, a detached child gets its own console window and can
|
||||
// flash open/closed. Keep detached groups for POSIX only.
|
||||
detached: useDetachedGroup,
|
||||
windowsHide: true,
|
||||
});
|
||||
childRef.current = child;
|
||||
|
||||
const reader = readline.createInterface({input: child.stdout});
|
||||
reader.on('line', (line) => {
|
||||
if (!line.startsWith(PROTOCOL_PREFIX)) {
|
||||
queueTranscriptItem({role: 'log', text: line});
|
||||
setTranscript((items) => [...items, {role: 'log', text: line}]);
|
||||
return;
|
||||
}
|
||||
const event = JSON.parse(line.slice(PROTOCOL_PREFIX.length)) as BackendEvent;
|
||||
@@ -136,72 +55,26 @@ export function useBackendSession(config: FrontendConfig, onExit: (code?: number
|
||||
});
|
||||
|
||||
child.on('exit', (code) => {
|
||||
flushTranscriptItems();
|
||||
queueTranscriptItem({role: 'system', text: `backend exited with code ${code ?? 0}`});
|
||||
setTranscript((items) => [...items, {role: 'system', text: `backend exited with code ${code ?? 0}`}]);
|
||||
process.exitCode = code ?? 0;
|
||||
onExit(code);
|
||||
});
|
||||
|
||||
// Ensure child processes are killed on parent exit (prevents stale processes)
|
||||
const killChild = (): void => {
|
||||
if (!child.killed) {
|
||||
// Kill the whole process group on POSIX. On Windows, terminate the
|
||||
// direct child to avoid relying on negative PIDs.
|
||||
try {
|
||||
if (useDetachedGroup && child.pid) {
|
||||
process.kill(-child.pid, 'SIGTERM');
|
||||
} else {
|
||||
child.kill('SIGTERM');
|
||||
}
|
||||
} catch {
|
||||
child.kill('SIGTERM');
|
||||
}
|
||||
}
|
||||
if (assistantFlushTimerRef.current) {
|
||||
clearTimeout(assistantFlushTimerRef.current);
|
||||
assistantFlushTimerRef.current = null;
|
||||
}
|
||||
clearPendingTranscriptItems();
|
||||
};
|
||||
process.on('exit', killChild);
|
||||
process.on('SIGINT', killChild);
|
||||
process.on('SIGTERM', killChild);
|
||||
|
||||
return () => {
|
||||
reader.close();
|
||||
killChild();
|
||||
process.removeListener('exit', killChild);
|
||||
process.removeListener('SIGINT', killChild);
|
||||
process.removeListener('SIGTERM', killChild);
|
||||
if (!child.killed) {
|
||||
child.kill();
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleEvent = (event: BackendEvent): void => {
|
||||
if (event.type === 'ready') {
|
||||
setReady(true);
|
||||
const statusSnapshot = stableStringify(event.state ?? {});
|
||||
lastStatusSnapshotRef.current = statusSnapshot;
|
||||
const nextStatus = event.state ?? {};
|
||||
statusRef.current = nextStatus;
|
||||
startTransition(() => {
|
||||
setStatus(nextStatus);
|
||||
});
|
||||
const tasksSnapshot = stableStringify(event.tasks ?? []);
|
||||
lastTasksSnapshotRef.current = tasksSnapshot;
|
||||
startTransition(() => {
|
||||
setTasks(event.tasks ?? []);
|
||||
});
|
||||
setStatus(event.state ?? {});
|
||||
setTasks(event.tasks ?? []);
|
||||
setCommands(event.commands ?? []);
|
||||
const mcpSnapshot = stableStringify(event.mcp_servers ?? []);
|
||||
lastMcpSnapshotRef.current = mcpSnapshot;
|
||||
startTransition(() => {
|
||||
setMcpServers(event.mcp_servers ?? []);
|
||||
});
|
||||
const bridgeSnapshot = stableStringify(event.bridge_sessions ?? []);
|
||||
lastBridgeSnapshotRef.current = bridgeSnapshot;
|
||||
startTransition(() => {
|
||||
setBridgeSessions(event.bridge_sessions ?? []);
|
||||
});
|
||||
setMcpServers(event.mcp_servers ?? []);
|
||||
setBridgeSessions(event.bridge_sessions ?? []);
|
||||
if (config.initial_prompt && !sentInitialPrompt.current) {
|
||||
sentInitialPrompt.current = true;
|
||||
sendRequest({type: 'submit_line', line: config.initial_prompt});
|
||||
@@ -210,176 +83,54 @@ export function useBackendSession(config: FrontendConfig, onExit: (code?: number
|
||||
return;
|
||||
}
|
||||
if (event.type === 'state_snapshot') {
|
||||
const statusSnapshot = stableStringify(event.state ?? {});
|
||||
if (statusSnapshot !== lastStatusSnapshotRef.current) {
|
||||
lastStatusSnapshotRef.current = statusSnapshot;
|
||||
const nextStatus = event.state ?? {};
|
||||
statusRef.current = nextStatus;
|
||||
startTransition(() => {
|
||||
setStatus(nextStatus);
|
||||
});
|
||||
}
|
||||
const mcpSnapshot = stableStringify(event.mcp_servers ?? []);
|
||||
if (mcpSnapshot !== lastMcpSnapshotRef.current) {
|
||||
lastMcpSnapshotRef.current = mcpSnapshot;
|
||||
startTransition(() => {
|
||||
setMcpServers(event.mcp_servers ?? []);
|
||||
});
|
||||
}
|
||||
const bridgeSnapshot = stableStringify(event.bridge_sessions ?? []);
|
||||
if (bridgeSnapshot !== lastBridgeSnapshotRef.current) {
|
||||
lastBridgeSnapshotRef.current = bridgeSnapshot;
|
||||
startTransition(() => {
|
||||
setBridgeSessions(event.bridge_sessions ?? []);
|
||||
});
|
||||
}
|
||||
setStatus(event.state ?? {});
|
||||
setMcpServers(event.mcp_servers ?? []);
|
||||
setBridgeSessions(event.bridge_sessions ?? []);
|
||||
return;
|
||||
}
|
||||
if (event.type === 'tasks_snapshot') {
|
||||
const tasksSnapshot = stableStringify(event.tasks ?? []);
|
||||
if (tasksSnapshot !== lastTasksSnapshotRef.current) {
|
||||
lastTasksSnapshotRef.current = tasksSnapshot;
|
||||
startTransition(() => {
|
||||
setTasks(event.tasks ?? []);
|
||||
});
|
||||
}
|
||||
setTasks(event.tasks ?? []);
|
||||
return;
|
||||
}
|
||||
if (event.type === 'transcript_item' && event.item) {
|
||||
queueTranscriptItem(event.item as TranscriptItem);
|
||||
return;
|
||||
}
|
||||
if (event.type === 'status') {
|
||||
const message = event.message?.trim();
|
||||
if (!message) {
|
||||
return;
|
||||
}
|
||||
queueTranscriptItem({role: 'status', text: message});
|
||||
if (busy) {
|
||||
setBusyLabel(message);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (event.type === 'compact_progress') {
|
||||
const phase = String(event.compact_phase ?? '');
|
||||
const trigger = String(event.compact_trigger ?? '');
|
||||
const attempt = event.attempt != null ? Number(event.attempt) : undefined;
|
||||
if (phase === 'hooks_start') {
|
||||
setBusyLabel(
|
||||
trigger === 'reactive'
|
||||
? 'Preparing retry compaction…'
|
||||
: 'Preparing conversation compaction…',
|
||||
);
|
||||
} else if (phase === 'context_collapse_start') {
|
||||
setBusyLabel('Collapsing oversized context…');
|
||||
} else if (phase === 'context_collapse_end') {
|
||||
setBusyLabel('Context collapse complete…');
|
||||
} else if (phase === 'session_memory_start') {
|
||||
setBusyLabel('Condensing earlier conversation…');
|
||||
} else if (phase === 'compact_start') {
|
||||
setBusyLabel(
|
||||
trigger === 'reactive'
|
||||
? 'Context is too large. Compacting and retrying…'
|
||||
: 'Compacting conversation memory…',
|
||||
);
|
||||
} else if (phase === 'compact_retry') {
|
||||
setBusyLabel(attempt ? `Retrying compaction (${attempt})…` : 'Retrying compaction…');
|
||||
} else if (phase === 'compact_end') {
|
||||
setBusyLabel('Compaction complete. Continuing…');
|
||||
} else if (phase === 'compact_failed') {
|
||||
setBusyLabel('Compaction failed. Continuing without it…');
|
||||
}
|
||||
if (event.message) {
|
||||
queueTranscriptItem({role: 'status', text: event.message!});
|
||||
}
|
||||
setTranscript((items) => [...items, event.item as TranscriptItem]);
|
||||
return;
|
||||
}
|
||||
if (event.type === 'assistant_delta') {
|
||||
const delta = event.message ?? '';
|
||||
if (!delta) {
|
||||
return;
|
||||
}
|
||||
const isCodexStyle = String(statusRef.current.output_style ?? 'default') === 'codex';
|
||||
if (isCodexStyle) {
|
||||
// Keep collecting text for assistant_complete fallback, but avoid
|
||||
// token-level rerenders in compact codex mode.
|
||||
assistantBufferRef.current += delta;
|
||||
return;
|
||||
}
|
||||
pendingAssistantDeltaRef.current += delta;
|
||||
if (pendingAssistantDeltaRef.current.length >= ASSISTANT_DELTA_FLUSH_CHARS) {
|
||||
flushAssistantDelta();
|
||||
return;
|
||||
}
|
||||
if (!assistantFlushTimerRef.current) {
|
||||
assistantFlushTimerRef.current = setTimeout(() => {
|
||||
assistantFlushTimerRef.current = null;
|
||||
flushAssistantDelta();
|
||||
}, ASSISTANT_DELTA_FLUSH_MS);
|
||||
}
|
||||
setAssistantBuffer((value) => value + (event.message ?? ''));
|
||||
return;
|
||||
}
|
||||
if (event.type === 'assistant_complete') {
|
||||
if (assistantFlushTimerRef.current) {
|
||||
clearTimeout(assistantFlushTimerRef.current);
|
||||
assistantFlushTimerRef.current = null;
|
||||
}
|
||||
flushTranscriptItems();
|
||||
const isCodexStyle = String(statusRef.current.output_style ?? 'default') === 'codex';
|
||||
if (isCodexStyle) {
|
||||
if (pendingAssistantDeltaRef.current) {
|
||||
assistantBufferRef.current += pendingAssistantDeltaRef.current;
|
||||
pendingAssistantDeltaRef.current = '';
|
||||
}
|
||||
} else {
|
||||
flushAssistantDelta();
|
||||
}
|
||||
const text = event.message ?? assistantBufferRef.current;
|
||||
startTransition(() => {
|
||||
setTranscript((items) => [...items, {role: 'assistant', text}]);
|
||||
});
|
||||
clearAssistantDelta();
|
||||
// Do NOT reset busy here: tool calls may follow this event.
|
||||
// busy is reset by line_complete (the true end-of-turn signal).
|
||||
setBusyLabel(undefined);
|
||||
const text = event.message ?? assistantBuffer;
|
||||
setTranscript((items) => [...items, {role: 'assistant', text}]);
|
||||
setAssistantBuffer('');
|
||||
setBusy(false);
|
||||
return;
|
||||
}
|
||||
if (event.type === 'line_complete') {
|
||||
// Final end-of-turn: clear everything, stop spinner.
|
||||
clearAssistantDelta();
|
||||
setBusy(false);
|
||||
setBusyLabel(undefined);
|
||||
return;
|
||||
}
|
||||
if ((event.type === 'tool_started' || event.type === 'tool_completed') && event.item) {
|
||||
if (event.type === 'tool_started') {
|
||||
setBusy(true);
|
||||
setBusyLabel(`Running ${event.tool_name ?? 'tool'}...`);
|
||||
} else {
|
||||
setBusyLabel('Processing...');
|
||||
}
|
||||
const enrichedItem: TranscriptItem = {
|
||||
...event.item,
|
||||
tool_name: event.item.tool_name ?? event.tool_name ?? undefined,
|
||||
tool_input: event.item.tool_input ?? undefined,
|
||||
is_error: event.item.is_error ?? event.is_error ?? undefined,
|
||||
};
|
||||
queueTranscriptItem(enrichedItem);
|
||||
setTranscript((items) => [...items, enrichedItem]);
|
||||
return;
|
||||
}
|
||||
if (event.type === 'clear_transcript') {
|
||||
flushTranscriptItems();
|
||||
clearPendingTranscriptItems();
|
||||
setTranscript([]);
|
||||
clearAssistantDelta();
|
||||
setBusyLabel(undefined);
|
||||
setAssistantBuffer('');
|
||||
return;
|
||||
}
|
||||
if (event.type === 'select_request') {
|
||||
const m = event.modal ?? {};
|
||||
setSelectRequest({
|
||||
title: String(m.title ?? 'Select'),
|
||||
command: String(m.command ?? ''),
|
||||
submitPrefix: String(m.submit_prefix ?? ''),
|
||||
options: event.select_options ?? [],
|
||||
});
|
||||
return;
|
||||
@@ -389,44 +140,8 @@ export function useBackendSession(config: FrontendConfig, onExit: (code?: number
|
||||
return;
|
||||
}
|
||||
if (event.type === 'error') {
|
||||
flushTranscriptItems();
|
||||
queueTranscriptItem({role: 'system', text: `error: ${event.message ?? 'unknown error'}`});
|
||||
clearAssistantDelta();
|
||||
setTranscript((items) => [...items, {role: 'system', text: `error: ${event.message ?? 'unknown error'}`}]);
|
||||
setBusy(false);
|
||||
setBusyLabel(undefined);
|
||||
return;
|
||||
}
|
||||
if (event.type === 'todo_update') {
|
||||
if (event.todo_markdown != null) {
|
||||
startTransition(() => {
|
||||
setTodoMarkdown(event.todo_markdown);
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (event.type === 'swarm_status') {
|
||||
if (event.swarm_teammates != null) {
|
||||
startTransition(() => {
|
||||
setSwarmTeammates(event.swarm_teammates);
|
||||
});
|
||||
}
|
||||
if (event.swarm_notifications != null) {
|
||||
startTransition(() => {
|
||||
setSwarmNotifications((prev) => [...prev, ...event.swarm_notifications!].slice(-20));
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (event.type === 'plan_mode_change') {
|
||||
if (event.plan_mode != null) {
|
||||
startTransition(() => {
|
||||
setStatus((s) => {
|
||||
const next = {...s, permission_mode: event.plan_mode};
|
||||
statusRef.current = next;
|
||||
return next;
|
||||
});
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (event.type === 'shutdown') {
|
||||
@@ -446,17 +161,11 @@ export function useBackendSession(config: FrontendConfig, onExit: (code?: number
|
||||
modal,
|
||||
selectRequest,
|
||||
busy,
|
||||
busyLabel,
|
||||
ready,
|
||||
todoMarkdown,
|
||||
swarmTeammates,
|
||||
swarmNotifications,
|
||||
setModal,
|
||||
setSelectRequest,
|
||||
setBusy,
|
||||
setBusyLabel,
|
||||
sendRequest,
|
||||
}),
|
||||
[assistantBuffer, bridgeSessions, busy, busyLabel, commands, mcpServers, modal, ready, selectRequest, status, swarmNotifications, swarmTeammates, tasks, todoMarkdown, transcript]
|
||||
[assistantBuffer, bridgeSessions, busy, commands, mcpServers, modal, selectRequest, status, tasks, transcript]
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,81 +1,9 @@
|
||||
import React from 'react';
|
||||
import {render} from 'ink';
|
||||
import fs from 'node:fs';
|
||||
import tty from 'node:tty';
|
||||
|
||||
import {App} from './App.js';
|
||||
import type {FrontendConfig} from './types.js';
|
||||
|
||||
// Guard against EIO crashes in both stdin reads and setRawMode calls.
|
||||
// Ink's React reconciler calls setRawMode during mount/unmount which can
|
||||
// throw EIO in certain terminal environments (SSH, tmux, Docker).
|
||||
process.stdin.on('error', (err: NodeJS.ErrnoException) => {
|
||||
if (err.code === 'EIO' || err.code === 'EAGAIN') {
|
||||
process.exit(1);
|
||||
}
|
||||
throw err;
|
||||
});
|
||||
|
||||
if (process.stdin.isTTY && typeof process.stdin.setRawMode === 'function') {
|
||||
const origSetRawMode = process.stdin.setRawMode.bind(process.stdin);
|
||||
process.stdin.setRawMode = (mode: boolean) => {
|
||||
try {
|
||||
return origSetRawMode(mode);
|
||||
} catch (err: any) {
|
||||
if (err?.code === 'EIO' || err?.code === 'EAGAIN') {
|
||||
process.exit(1);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
process.on('uncaughtException', (err: NodeJS.ErrnoException) => {
|
||||
if (err.code === 'EIO' || err.code === 'EAGAIN') {
|
||||
process.exit(1);
|
||||
}
|
||||
throw err;
|
||||
});
|
||||
|
||||
const config = JSON.parse(process.env.OPENHARNESS_FRONTEND_CONFIG ?? '{}') as FrontendConfig;
|
||||
|
||||
// Restore terminal cursor visibility on exit (Ink hides it by default).
|
||||
// Also write a newline so the shell prompt starts on a fresh line and does
|
||||
// not run into the last line of the TUI output.
|
||||
const restoreTerminal = (): void => {
|
||||
process.stdout.write('\x1B[?25h\n');
|
||||
};
|
||||
process.on('exit', restoreTerminal);
|
||||
process.on('SIGINT', () => {
|
||||
restoreTerminal();
|
||||
process.exit(130);
|
||||
});
|
||||
process.on('SIGTERM', () => {
|
||||
restoreTerminal();
|
||||
process.exit(143);
|
||||
});
|
||||
|
||||
// On WSL / Windows the process-spawning chain (npm exec → tsx → node) can
|
||||
// lose the TTY on stdin, which prevents Ink's useInput from enabling raw mode.
|
||||
// When that happens, open /dev/tty directly to get a real TTY stream.
|
||||
let stdinStream: NodeJS.ReadStream & {fd: 0} = process.stdin;
|
||||
let ttyFd: number | undefined;
|
||||
|
||||
if (!process.stdin.isTTY) {
|
||||
try {
|
||||
ttyFd = fs.openSync('/dev/tty', 'r');
|
||||
const ttyStream = new tty.ReadStream(ttyFd);
|
||||
// Cast is safe — tty.ReadStream is a full readable TTY stream
|
||||
stdinStream = ttyStream as unknown as NodeJS.ReadStream & {fd: 0};
|
||||
} catch {
|
||||
// /dev/tty unavailable (e.g. non-interactive CI) — fall back to process.stdin
|
||||
}
|
||||
}
|
||||
|
||||
process.on('exit', () => {
|
||||
if (ttyFd !== undefined) {
|
||||
try { fs.closeSync(ttyFd); } catch { /* ignore */ }
|
||||
}
|
||||
});
|
||||
|
||||
render(<App config={config} />, {stdin: stdinStream});
|
||||
render(<App config={config} />);
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
import React, {createContext, useContext, useState} from 'react';
|
||||
|
||||
import {type ThemeConfig, BUILTIN_THEMES, defaultTheme, getTheme} from './builtinThemes.js';
|
||||
|
||||
export type {ThemeConfig};
|
||||
|
||||
type ThemeContextValue = {
|
||||
theme: ThemeConfig;
|
||||
setThemeName: (name: string) => void;
|
||||
};
|
||||
|
||||
const ThemeContext = createContext<ThemeContextValue>({
|
||||
theme: defaultTheme,
|
||||
setThemeName: () => undefined,
|
||||
});
|
||||
|
||||
export function ThemeProvider({
|
||||
children,
|
||||
initialTheme = 'default',
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
initialTheme?: string;
|
||||
}): React.JSX.Element {
|
||||
const [theme, setTheme] = useState<ThemeConfig>(() => getTheme(initialTheme));
|
||||
|
||||
const setThemeName = (name: string): void => {
|
||||
const resolved = BUILTIN_THEMES[name] ?? defaultTheme;
|
||||
setTheme(resolved);
|
||||
};
|
||||
|
||||
return (
|
||||
<ThemeContext.Provider value={{theme, setThemeName}}>
|
||||
{children}
|
||||
</ThemeContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useTheme(): ThemeContextValue {
|
||||
return useContext(ThemeContext);
|
||||
}
|
||||
@@ -1,161 +0,0 @@
|
||||
export type ThemeConfig = {
|
||||
name: string;
|
||||
colors: {
|
||||
primary: string;
|
||||
secondary: string;
|
||||
accent: string;
|
||||
foreground: string;
|
||||
background: string;
|
||||
muted: string;
|
||||
success: string;
|
||||
warning: string;
|
||||
error: string;
|
||||
info: string;
|
||||
};
|
||||
icons: {
|
||||
spinner: string[];
|
||||
tool: string;
|
||||
assistant: string;
|
||||
user: string;
|
||||
system: string;
|
||||
success: string;
|
||||
error: string;
|
||||
};
|
||||
};
|
||||
|
||||
export const defaultTheme: ThemeConfig = {
|
||||
name: 'default',
|
||||
colors: {
|
||||
primary: 'cyan',
|
||||
secondary: 'white',
|
||||
accent: 'cyan',
|
||||
foreground: 'white',
|
||||
background: 'black',
|
||||
muted: 'gray',
|
||||
success: 'green',
|
||||
warning: 'yellow',
|
||||
error: 'red',
|
||||
info: 'blue',
|
||||
},
|
||||
icons: {
|
||||
spinner: ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'],
|
||||
tool: ' ⏵ ',
|
||||
assistant: '⏺ ',
|
||||
user: '> ',
|
||||
system: 'ℹ ',
|
||||
success: '✓ ',
|
||||
error: '✗ ',
|
||||
},
|
||||
};
|
||||
|
||||
export const darkTheme: ThemeConfig = {
|
||||
name: 'dark',
|
||||
colors: {
|
||||
primary: '#7aa2f7',
|
||||
secondary: '#c0caf5',
|
||||
accent: '#bb9af7',
|
||||
foreground: '#c0caf5',
|
||||
background: '#1a1b26',
|
||||
muted: '#565f89',
|
||||
success: '#9ece6a',
|
||||
warning: '#e0af68',
|
||||
error: '#f7768e',
|
||||
info: '#7dcfff',
|
||||
},
|
||||
icons: {
|
||||
spinner: ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'],
|
||||
tool: ' ⏵ ',
|
||||
assistant: '⏺ ',
|
||||
user: '> ',
|
||||
system: 'ℹ ',
|
||||
success: '✓ ',
|
||||
error: '✗ ',
|
||||
},
|
||||
};
|
||||
|
||||
export const minimalTheme: ThemeConfig = {
|
||||
name: 'minimal',
|
||||
colors: {
|
||||
primary: 'white',
|
||||
secondary: 'white',
|
||||
accent: 'white',
|
||||
foreground: 'white',
|
||||
background: 'black',
|
||||
muted: 'gray',
|
||||
success: 'white',
|
||||
warning: 'white',
|
||||
error: 'white',
|
||||
info: 'white',
|
||||
},
|
||||
icons: {
|
||||
spinner: ['-', '\\', '|', '/'],
|
||||
tool: ' > ',
|
||||
assistant: ': ',
|
||||
user: '> ',
|
||||
system: '# ',
|
||||
success: '+ ',
|
||||
error: '! ',
|
||||
},
|
||||
};
|
||||
|
||||
export const cyberpunkTheme: ThemeConfig = {
|
||||
name: 'cyberpunk',
|
||||
colors: {
|
||||
primary: '#ff007c',
|
||||
secondary: '#00fff9',
|
||||
accent: '#ffe600',
|
||||
foreground: '#00fff9',
|
||||
background: '#0d0d0d',
|
||||
muted: '#444444',
|
||||
success: '#00ff41',
|
||||
warning: '#ffe600',
|
||||
error: '#ff003c',
|
||||
info: '#00fff9',
|
||||
},
|
||||
icons: {
|
||||
spinner: ['◐', '◓', '◑', '◒'],
|
||||
tool: ' ▶ ',
|
||||
assistant: '◆ ',
|
||||
user: '▸ ',
|
||||
system: '⚡ ',
|
||||
success: '✦ ',
|
||||
error: '✖ ',
|
||||
},
|
||||
};
|
||||
|
||||
export const solarizedTheme: ThemeConfig = {
|
||||
name: 'solarized',
|
||||
colors: {
|
||||
primary: '#268bd2',
|
||||
secondary: '#839496',
|
||||
accent: '#2aa198',
|
||||
foreground: '#839496',
|
||||
background: '#002b36',
|
||||
muted: '#586e75',
|
||||
success: '#859900',
|
||||
warning: '#b58900',
|
||||
error: '#dc322f',
|
||||
info: '#268bd2',
|
||||
},
|
||||
icons: {
|
||||
spinner: ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'],
|
||||
tool: ' ⏵ ',
|
||||
assistant: '⏺ ',
|
||||
user: '> ',
|
||||
system: 'ℹ ',
|
||||
success: '✓ ',
|
||||
error: '✗ ',
|
||||
},
|
||||
};
|
||||
|
||||
export const BUILTIN_THEMES: Record<string, ThemeConfig> = {
|
||||
default: defaultTheme,
|
||||
dark: darkTheme,
|
||||
minimal: minimalTheme,
|
||||
cyberpunk: cyberpunkTheme,
|
||||
solarized: solarizedTheme,
|
||||
};
|
||||
|
||||
export function getTheme(name: string): ThemeConfig {
|
||||
return BUILTIN_THEMES[name] ?? defaultTheme;
|
||||
}
|
||||
@@ -4,19 +4,13 @@ export type FrontendConfig = {
|
||||
};
|
||||
|
||||
export type TranscriptItem = {
|
||||
role: 'system' | 'user' | 'assistant' | 'tool' | 'tool_result' | 'log' | 'status';
|
||||
role: 'system' | 'user' | 'assistant' | 'tool' | 'tool_result' | 'log';
|
||||
text: string;
|
||||
tool_name?: string;
|
||||
tool_input?: Record<string, unknown>;
|
||||
is_error?: boolean;
|
||||
};
|
||||
|
||||
export type ImageAttachmentPayload = {
|
||||
media_type: string;
|
||||
data: string;
|
||||
source_path?: string;
|
||||
};
|
||||
|
||||
export type TaskSnapshot = {
|
||||
id: string;
|
||||
type: string;
|
||||
@@ -49,25 +43,6 @@ export type SelectOptionPayload = {
|
||||
value: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
active?: boolean;
|
||||
};
|
||||
|
||||
export type TodoItemSnapshot = {
|
||||
text: string;
|
||||
checked: boolean;
|
||||
};
|
||||
|
||||
export type SwarmTeammateSnapshot = {
|
||||
name: string;
|
||||
status: 'running' | 'idle' | 'done' | 'error';
|
||||
duration?: number;
|
||||
task?: string;
|
||||
};
|
||||
|
||||
export type SwarmNotificationSnapshot = {
|
||||
from: string;
|
||||
message: string;
|
||||
timestamp: number;
|
||||
};
|
||||
|
||||
export type BackendEvent = {
|
||||
@@ -84,15 +59,4 @@ export type BackendEvent = {
|
||||
tool_name?: string | null;
|
||||
output?: string | null;
|
||||
is_error?: boolean | null;
|
||||
compact_phase?: string | null;
|
||||
compact_trigger?: string | null;
|
||||
attempt?: number | null;
|
||||
compact_checkpoint?: string | null;
|
||||
compact_metadata?: Record<string, unknown> | null;
|
||||
// New event payloads
|
||||
todo_items?: TodoItemSnapshot[] | null;
|
||||
todo_markdown?: string | null;
|
||||
plan_mode?: string | null;
|
||||
swarm_teammates?: SwarmTeammateSnapshot[] | null;
|
||||
swarm_notifications?: SwarmNotificationSnapshot[] | null;
|
||||
};
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
"""ohmo personal agent app built on top of OpenHarness."""
|
||||
|
||||
__all__ = ["__version__"]
|
||||
|
||||
__version__ = "0.1.9"
|
||||
@@ -1,8 +0,0 @@
|
||||
"""Module entry point for ``python -m ohmo``."""
|
||||
|
||||
from ohmo.cli import app
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
app()
|
||||
|
||||
-710
@@ -1,710 +0,0 @@
|
||||
"""CLI entry point for the ohmo personal-agent app."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import typer
|
||||
|
||||
from openharness.auth.manager import AuthManager
|
||||
from openharness.config import load_settings
|
||||
|
||||
from ohmo.gateway.config import load_gateway_config, save_gateway_config
|
||||
from ohmo.gateway.models import GatewayConfig
|
||||
from ohmo.gateway.service import (
|
||||
OhmoGatewayService,
|
||||
gateway_status,
|
||||
start_gateway_process,
|
||||
stop_gateway_process,
|
||||
)
|
||||
from ohmo.memory import add_memory_entry, list_memory_files, remove_memory_entry
|
||||
from ohmo.runtime import launch_ohmo_react_tui, run_ohmo_backend, run_ohmo_print_mode
|
||||
from ohmo.session_storage import OhmoSessionBackend
|
||||
from ohmo.workspace import (
|
||||
get_gateway_config_path,
|
||||
get_logs_dir,
|
||||
get_workspace_root,
|
||||
get_soul_path,
|
||||
get_state_path,
|
||||
get_user_path,
|
||||
initialize_workspace,
|
||||
workspace_health,
|
||||
)
|
||||
|
||||
|
||||
app = typer.Typer(
|
||||
name="ohmo",
|
||||
help="ohmo: a personal-agent app built on top of OpenHarness.",
|
||||
invoke_without_command=True,
|
||||
add_completion=False,
|
||||
)
|
||||
memory_app = typer.Typer(name="memory", help="Manage .ohmo memory")
|
||||
soul_app = typer.Typer(name="soul", help="Inspect or edit soul.md")
|
||||
user_app = typer.Typer(name="user", help="Inspect or edit user.md")
|
||||
gateway_app = typer.Typer(name="gateway", help="Run the ohmo gateway")
|
||||
|
||||
app.add_typer(memory_app)
|
||||
app.add_typer(soul_app)
|
||||
app.add_typer(user_app)
|
||||
app.add_typer(gateway_app)
|
||||
|
||||
_INTERACTIVE_CHANNELS = ("telegram", "slack", "discord", "feishu")
|
||||
_WORKSPACE_HELP = "Path to the ohmo workspace (defaults to ~/.ohmo)"
|
||||
|
||||
|
||||
def _can_use_questionary() -> bool:
|
||||
"""Return True when a real interactive terminal is available."""
|
||||
if not (sys.stdin.isatty() and sys.stdout.isatty()):
|
||||
return False
|
||||
if sys.stdin is not sys.__stdin__ or sys.stdout is not sys.__stdout__:
|
||||
return False
|
||||
try:
|
||||
import questionary # noqa: F401
|
||||
except ImportError:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _select_with_questionary(
|
||||
title: str,
|
||||
options: list[tuple[str, str]],
|
||||
*,
|
||||
default_value: str | None = None,
|
||||
) -> str:
|
||||
import questionary
|
||||
|
||||
choices = [
|
||||
questionary.Choice(
|
||||
title=label,
|
||||
value=value,
|
||||
checked=(value == default_value),
|
||||
)
|
||||
for value, label in options
|
||||
]
|
||||
result = questionary.select(title, choices=choices, default=default_value).ask()
|
||||
if result is None:
|
||||
raise typer.Abort()
|
||||
return str(result)
|
||||
|
||||
|
||||
def _confirm_prompt(message: str, *, default: bool = False) -> bool:
|
||||
"""Ask for confirmation, preferring questionary in a real TTY."""
|
||||
if _can_use_questionary():
|
||||
import questionary
|
||||
|
||||
result = questionary.confirm(message, default=default).ask()
|
||||
if result is None:
|
||||
raise typer.Abort()
|
||||
return bool(result)
|
||||
return typer.confirm(message, default=default)
|
||||
|
||||
|
||||
def _text_prompt(message: str, *, default: str = "") -> str:
|
||||
"""Prompt for text input, preferring questionary in a real TTY."""
|
||||
if _can_use_questionary():
|
||||
import questionary
|
||||
|
||||
result = questionary.text(message, default=default).ask()
|
||||
if result is None:
|
||||
raise typer.Abort()
|
||||
return str(result)
|
||||
return typer.prompt(message, default=default)
|
||||
|
||||
|
||||
def _select_from_menu(
|
||||
title: str,
|
||||
options: list[tuple[str, str]],
|
||||
*,
|
||||
default_value: str | None = None,
|
||||
) -> str:
|
||||
"""Render a simple numbered picker and return the selected value."""
|
||||
if _can_use_questionary():
|
||||
return _select_with_questionary(title, options, default_value=default_value)
|
||||
print(title)
|
||||
default_index = 1
|
||||
for index, (value, label) in enumerate(options, 1):
|
||||
marker = " (default)" if value == default_value else ""
|
||||
if value == default_value:
|
||||
default_index = index
|
||||
print(f" {index}. {label}{marker}")
|
||||
raw = typer.prompt("Choose", default=str(default_index))
|
||||
try:
|
||||
selected = options[int(raw) - 1]
|
||||
except (ValueError, IndexError):
|
||||
raise typer.BadParameter(f"Invalid selection: {raw}") from None
|
||||
return selected[0]
|
||||
|
||||
|
||||
def _format_provider_profile_label(info: dict[str, object]) -> str:
|
||||
label = str(info["label"])
|
||||
if bool(info["configured"]):
|
||||
return label
|
||||
return f"{label} (missing)"
|
||||
|
||||
|
||||
def _prompt_provider_profile(workspace: str | Path) -> str:
|
||||
settings = load_settings()
|
||||
statuses = AuthManager(settings).get_profile_statuses()
|
||||
default_value = load_gateway_config(workspace).provider_profile
|
||||
hints = {
|
||||
"claude-api": ("Claude / Kimi / GLM / MiniMax", "fg:#7aa2f7"),
|
||||
"openai-compatible": ("OpenAI / OpenRouter", "fg:#9ece6a"),
|
||||
}
|
||||
|
||||
if _can_use_questionary():
|
||||
import questionary
|
||||
|
||||
choices = []
|
||||
for name, info in statuses.items():
|
||||
label = str(info["label"])
|
||||
missing = "" if bool(info["configured"]) else " (missing)"
|
||||
hint = hints.get(name)
|
||||
if hint is None:
|
||||
title = label if not missing else [("", label), ("fg:#d3869b", missing)]
|
||||
else:
|
||||
hint_text, hint_style = hint
|
||||
title = [
|
||||
("", f"{label} "),
|
||||
(hint_style, hint_text),
|
||||
]
|
||||
if missing:
|
||||
title.extend([("", " "), ("fg:#d3869b", missing.strip())])
|
||||
choices.append(questionary.Choice(title=title, value=name, checked=(name == default_value)))
|
||||
result = questionary.select("Choose provider profile for ohmo:", choices=choices, default=default_value).ask()
|
||||
if result is None:
|
||||
raise typer.Abort()
|
||||
return str(result)
|
||||
|
||||
options = []
|
||||
for name, info in statuses.items():
|
||||
label = _format_provider_profile_label(info)
|
||||
hint = hints.get(name)
|
||||
if hint is not None:
|
||||
label = f"{label} ({hint[0]})"
|
||||
options.append((name, label))
|
||||
return _select_from_menu(
|
||||
"Choose provider profile for ohmo:",
|
||||
options,
|
||||
default_value=default_value,
|
||||
)
|
||||
|
||||
|
||||
def _prompt_channels(existing: GatewayConfig) -> tuple[list[str], dict[str, dict]]:
|
||||
enabled: list[str] = []
|
||||
configs: dict[str, dict] = {}
|
||||
print("Configure channels for ohmo gateway:")
|
||||
for channel in _INTERACTIVE_CHANNELS:
|
||||
current = channel in existing.enabled_channels
|
||||
prior = dict(existing.channel_configs.get(channel, {}))
|
||||
if current:
|
||||
enabled.append(channel)
|
||||
if not _confirm_prompt(f"Reconfigure {channel}?", default=False):
|
||||
configs[channel] = prior
|
||||
continue
|
||||
elif not _confirm_prompt(f"Enable {channel}?", default=False):
|
||||
continue
|
||||
else:
|
||||
enabled.append(channel)
|
||||
allow_from_raw = _text_prompt(
|
||||
f"{channel} allow_from (comma separated user/chat IDs; leave blank to deny all; '*' for everyone)",
|
||||
default=",".join(prior.get("allow_from", [])),
|
||||
)
|
||||
allow_from = [item.strip() for item in allow_from_raw.split(",") if item.strip()]
|
||||
config: dict[str, object] = {"allow_from": allow_from}
|
||||
if channel == "telegram":
|
||||
config["token"] = _text_prompt(
|
||||
"Telegram bot token",
|
||||
default=str(prior.get("token", "")),
|
||||
)
|
||||
config["reply_to_message"] = _confirm_prompt(
|
||||
"Reply to the original Telegram message?",
|
||||
default=bool(prior.get("reply_to_message", True)),
|
||||
)
|
||||
elif channel == "slack":
|
||||
config["bot_token"] = _text_prompt(
|
||||
"Slack bot token",
|
||||
default=str(prior.get("bot_token", "")),
|
||||
)
|
||||
config["app_token"] = _text_prompt(
|
||||
"Slack app token",
|
||||
default=str(prior.get("app_token", "")),
|
||||
)
|
||||
config["mode"] = "socket"
|
||||
config["reply_in_thread"] = _confirm_prompt(
|
||||
"Reply in thread?",
|
||||
default=bool(prior.get("reply_in_thread", True)),
|
||||
)
|
||||
config["group_policy"] = _select_from_menu(
|
||||
"Slack group policy:",
|
||||
[
|
||||
("mention", "Mention only"),
|
||||
("open", "Always reply in channels"),
|
||||
("allowlist", "Only allow configured channels"),
|
||||
],
|
||||
default_value=str(prior.get("group_policy", "mention")),
|
||||
)
|
||||
elif channel == "discord":
|
||||
config["token"] = _text_prompt(
|
||||
"Discord bot token",
|
||||
default=str(prior.get("token", "")),
|
||||
)
|
||||
config["gateway_url"] = _text_prompt(
|
||||
"Discord gateway URL",
|
||||
default=str(prior.get("gateway_url", "wss://gateway.discord.gg/?v=10&encoding=json")),
|
||||
)
|
||||
config["intents"] = int(
|
||||
_text_prompt(
|
||||
"Discord intents bitmask",
|
||||
default=str(prior.get("intents", 513)),
|
||||
)
|
||||
)
|
||||
config["group_policy"] = _select_from_menu(
|
||||
"Discord group policy:",
|
||||
[
|
||||
("mention", "Mention only"),
|
||||
("open", "Always reply in channels"),
|
||||
],
|
||||
default_value=str(prior.get("group_policy", "mention")),
|
||||
)
|
||||
elif channel == "feishu":
|
||||
config["domain"] = _select_from_menu(
|
||||
"Feishu domain:",
|
||||
[
|
||||
("https://open.feishu.cn", "Feishu (China)"),
|
||||
("https://open.larksuite.com", "Lark (International)"),
|
||||
],
|
||||
default_value=str(prior.get("domain", "https://open.feishu.cn")),
|
||||
)
|
||||
config["app_id"] = _text_prompt(
|
||||
"Feishu app id",
|
||||
default=str(prior.get("app_id", "")),
|
||||
)
|
||||
config["app_secret"] = _text_prompt(
|
||||
"Feishu app secret",
|
||||
default=str(prior.get("app_secret", "")),
|
||||
)
|
||||
config["encrypt_key"] = _text_prompt(
|
||||
"Feishu encrypt key",
|
||||
default=str(prior.get("encrypt_key", "")),
|
||||
)
|
||||
config["verification_token"] = _text_prompt(
|
||||
"Feishu verification token",
|
||||
default=str(prior.get("verification_token", "")),
|
||||
)
|
||||
config["react_emoji"] = _text_prompt(
|
||||
"Feishu reaction emoji",
|
||||
default=str(prior.get("react_emoji", "OK")),
|
||||
)
|
||||
config["group_policy"] = _select_from_menu(
|
||||
"Feishu group policy:",
|
||||
[
|
||||
("managed_or_mention", "Managed groups open; other groups require @mention"),
|
||||
("mention", "Always require @mention in groups"),
|
||||
("open", "Always reply to group messages"),
|
||||
],
|
||||
default_value=str(prior.get("group_policy", "managed_or_mention")),
|
||||
)
|
||||
prior_bot_names = prior.get("bot_names", ["ohmo", "openclaw", "openharness"])
|
||||
if isinstance(prior_bot_names, str):
|
||||
prior_bot_names_default = prior_bot_names
|
||||
else:
|
||||
prior_bot_names_default = ",".join(str(item) for item in prior_bot_names)
|
||||
bot_names_raw = _text_prompt(
|
||||
"Feishu bot mention names (comma separated)",
|
||||
default=prior_bot_names_default,
|
||||
)
|
||||
config["bot_names"] = [item.strip() for item in bot_names_raw.split(",") if item.strip()]
|
||||
config["bot_open_id"] = _text_prompt(
|
||||
"Feishu bot open_id for exact mention detection (optional)",
|
||||
default=str(prior.get("bot_open_id", "")),
|
||||
)
|
||||
configs[channel] = config
|
||||
return enabled, configs
|
||||
|
||||
|
||||
def _run_gateway_config_wizard(workspace: str | Path) -> GatewayConfig:
|
||||
"""Interactive flow for provider/channel setup."""
|
||||
existing = load_gateway_config(workspace)
|
||||
provider_profile = _prompt_provider_profile(workspace)
|
||||
enabled_channels, channel_configs = _prompt_channels(existing)
|
||||
send_progress = _confirm_prompt(
|
||||
"Send progress updates to channels?",
|
||||
default=existing.send_progress,
|
||||
)
|
||||
send_tool_hints = _confirm_prompt(
|
||||
"Send tool hints to channels?",
|
||||
default=existing.send_tool_hints,
|
||||
)
|
||||
allow_remote_admin_commands = _confirm_prompt(
|
||||
"Allow explicitly listed administrative slash commands from remote channels?",
|
||||
default=existing.allow_remote_admin_commands,
|
||||
)
|
||||
default_allowlist = ", ".join(existing.allowed_remote_admin_commands)
|
||||
allowed_remote_admin_commands: list[str] = []
|
||||
if allow_remote_admin_commands:
|
||||
allowlist_raw = _text_prompt(
|
||||
"Allowed remote admin commands (comma-separated, e.g. permissions, plan)",
|
||||
default=default_allowlist,
|
||||
)
|
||||
allowed_remote_admin_commands = [
|
||||
item.strip().lstrip("/")
|
||||
for item in allowlist_raw.split(",")
|
||||
if item.strip()
|
||||
]
|
||||
config = existing.model_copy(
|
||||
update={
|
||||
"provider_profile": provider_profile,
|
||||
"enabled_channels": enabled_channels,
|
||||
"channel_configs": channel_configs,
|
||||
"send_progress": send_progress,
|
||||
"send_tool_hints": send_tool_hints,
|
||||
"allow_remote_admin_commands": allow_remote_admin_commands,
|
||||
"allowed_remote_admin_commands": allowed_remote_admin_commands,
|
||||
}
|
||||
)
|
||||
save_gateway_config(config, workspace)
|
||||
return config
|
||||
|
||||
|
||||
def _print_gateway_config_summary(config: GatewayConfig) -> None:
|
||||
if config.enabled_channels:
|
||||
print(
|
||||
"Configured channels: "
|
||||
+ ", ".join(config.enabled_channels)
|
||||
+ f" | provider_profile={config.provider_profile}"
|
||||
)
|
||||
deny_all_channels = [
|
||||
name for name in config.enabled_channels
|
||||
if not list(config.channel_configs.get(name, {}).get("allow_from", []))
|
||||
]
|
||||
if deny_all_channels:
|
||||
print(
|
||||
"Remote access denied until allow_from is configured for: "
|
||||
+ ", ".join(deny_all_channels)
|
||||
)
|
||||
else:
|
||||
print(f"Configured provider_profile={config.provider_profile}; no channels enabled yet.")
|
||||
if config.allow_remote_admin_commands and config.allowed_remote_admin_commands:
|
||||
print(
|
||||
"Remote admin opt-in enabled for: "
|
||||
+ ", ".join(f"/{name}" for name in config.allowed_remote_admin_commands)
|
||||
)
|
||||
else:
|
||||
print("Remote admin commands remain local-only.")
|
||||
|
||||
|
||||
def _maybe_restart_gateway(*, cwd: str | Path, workspace: str | Path) -> None:
|
||||
state = gateway_status(cwd, workspace)
|
||||
if not state.running:
|
||||
return
|
||||
if not _confirm_prompt("Gateway is running. Restart now to apply changes?", default=True):
|
||||
print("Configuration saved. Restart later with `ohmo gateway restart`.")
|
||||
return
|
||||
stop_gateway_process(cwd, workspace)
|
||||
pid = start_gateway_process(cwd, workspace)
|
||||
print(f"ohmo gateway restarted (pid={pid})")
|
||||
|
||||
|
||||
def _configure_gateway_logging(
|
||||
workspace: str | Path | None = None,
|
||||
*,
|
||||
console: bool = True,
|
||||
log_file: bool = True,
|
||||
) -> None:
|
||||
"""Configure foreground gateway logging."""
|
||||
config = load_gateway_config(workspace)
|
||||
level_name = str(config.log_level or "INFO").upper()
|
||||
level = getattr(logging, level_name, logging.INFO)
|
||||
handlers = _build_gateway_logging_handlers(workspace, console=console, log_file=log_file)
|
||||
logging.basicConfig(
|
||||
level=level,
|
||||
format="%(asctime)s [%(name)s] %(levelname)s %(message)s",
|
||||
handlers=handlers,
|
||||
force=True,
|
||||
)
|
||||
|
||||
|
||||
def _build_gateway_logging_handlers(
|
||||
workspace: str | Path | None = None,
|
||||
*,
|
||||
console: bool,
|
||||
log_file: bool,
|
||||
) -> list[logging.Handler]:
|
||||
"""Build gateway log handlers for foreground and daemon modes."""
|
||||
handlers: list[logging.Handler] = []
|
||||
if console:
|
||||
handlers.append(logging.StreamHandler())
|
||||
if log_file:
|
||||
log_path = get_logs_dir(workspace) / "gateway.log"
|
||||
log_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
handlers.append(logging.FileHandler(log_path, encoding="utf-8", delay=True))
|
||||
return handlers
|
||||
|
||||
|
||||
@app.callback(invoke_without_command=True)
|
||||
def main(
|
||||
ctx: typer.Context,
|
||||
print_mode: str | None = typer.Option(None, "--print", "-p", help="Run a single prompt and exit"),
|
||||
model: str | None = typer.Option(None, "--model", help="Model override for this session"),
|
||||
profile: str | None = typer.Option(None, "--profile", help="Provider profile to use"),
|
||||
workspace: str | None = typer.Option(None, "--workspace", help=_WORKSPACE_HELP),
|
||||
max_turns: int | None = typer.Option(None, "--max-turns", help="Override max turns"),
|
||||
cwd: str = typer.Option(str(Path.cwd()), "--cwd", help="Working directory"),
|
||||
backend_only: bool = typer.Option(False, "--backend-only", hidden=True),
|
||||
resume: str | None = typer.Option(None, "--resume", help="Resume an ohmo session by id"),
|
||||
continue_session: bool = typer.Option(False, "--continue", help="Continue the latest ohmo session"),
|
||||
) -> None:
|
||||
"""Launch the ohmo app or invoke a subcommand."""
|
||||
if ctx.invoked_subcommand is not None:
|
||||
return
|
||||
|
||||
cwd_path = str(Path(cwd).resolve())
|
||||
workspace_root = initialize_workspace(workspace)
|
||||
backend = OhmoSessionBackend(workspace_root)
|
||||
restore_messages = None
|
||||
restore_tool_metadata = None
|
||||
if continue_session:
|
||||
latest = backend.load_latest(cwd_path)
|
||||
if latest is None:
|
||||
print("No previous ohmo session found in this directory.", file=sys.stderr)
|
||||
raise typer.Exit(1)
|
||||
restore_messages = latest.get("messages")
|
||||
restore_tool_metadata = latest.get("tool_metadata")
|
||||
elif resume:
|
||||
snapshot = backend.load_by_id(cwd_path, resume)
|
||||
if snapshot is None:
|
||||
print(f"ohmo session not found: {resume}", file=sys.stderr)
|
||||
raise typer.Exit(1)
|
||||
restore_messages = snapshot.get("messages")
|
||||
restore_tool_metadata = snapshot.get("tool_metadata")
|
||||
|
||||
if backend_only:
|
||||
raise SystemExit(
|
||||
asyncio.run(
|
||||
run_ohmo_backend(
|
||||
cwd=cwd_path,
|
||||
workspace=workspace_root,
|
||||
model=model,
|
||||
max_turns=max_turns,
|
||||
provider_profile=profile,
|
||||
restore_messages=restore_messages,
|
||||
restore_tool_metadata=restore_tool_metadata,
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
if print_mode is not None:
|
||||
raise SystemExit(
|
||||
asyncio.run(
|
||||
run_ohmo_print_mode(
|
||||
prompt=print_mode,
|
||||
cwd=cwd_path,
|
||||
workspace=workspace_root,
|
||||
model=model,
|
||||
max_turns=max_turns,
|
||||
provider_profile=profile,
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
raise SystemExit(
|
||||
asyncio.run(
|
||||
launch_ohmo_react_tui(
|
||||
cwd=cwd_path,
|
||||
workspace=workspace_root,
|
||||
model=model,
|
||||
max_turns=max_turns,
|
||||
provider_profile=profile,
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@app.command("init")
|
||||
def init_cmd(
|
||||
cwd: str = typer.Option(str(Path.cwd()), "--cwd", help="Project working directory (reserved for future project overrides)"),
|
||||
workspace: str | None = typer.Option(None, "--workspace", help=_WORKSPACE_HELP),
|
||||
interactive: bool = typer.Option(
|
||||
True,
|
||||
"--interactive/--no-interactive",
|
||||
help="Run the provider/channel setup wizard when attached to a terminal",
|
||||
),
|
||||
) -> None:
|
||||
"""Initialize the .ohmo workspace."""
|
||||
root_path = get_workspace_root(workspace)
|
||||
already_exists = root_path.exists()
|
||||
root = initialize_workspace(root_path)
|
||||
print(f"Initialized ohmo workspace at {root}")
|
||||
if already_exists:
|
||||
print("ohmo workspace already exists.")
|
||||
if not interactive:
|
||||
print("Use `ohmo config` to update provider and channel settings.")
|
||||
return
|
||||
if not _confirm_prompt("Open configuration now?", default=True):
|
||||
print("Use `ohmo config` when you want to change provider or channel settings.")
|
||||
return
|
||||
if interactive:
|
||||
config = _run_gateway_config_wizard(root)
|
||||
_print_gateway_config_summary(config)
|
||||
print(f"Saved gateway config to {get_gateway_config_path(root)}")
|
||||
|
||||
|
||||
@app.command("config")
|
||||
def config_cmd(
|
||||
cwd: str = typer.Option(str(Path.cwd()), "--cwd", help="Project working directory"),
|
||||
workspace: str | None = typer.Option(None, "--workspace", help=_WORKSPACE_HELP),
|
||||
) -> None:
|
||||
"""Configure provider profile and gateway channels."""
|
||||
cwd_path = str(Path(cwd).resolve())
|
||||
workspace_root = initialize_workspace(workspace)
|
||||
config = _run_gateway_config_wizard(workspace_root)
|
||||
_print_gateway_config_summary(config)
|
||||
print(f"Saved gateway config to {get_gateway_config_path(workspace_root)}")
|
||||
_maybe_restart_gateway(cwd=cwd_path, workspace=workspace_root)
|
||||
|
||||
|
||||
@app.command("doctor")
|
||||
def doctor_cmd(
|
||||
cwd: str = typer.Option(str(Path.cwd()), "--cwd", help="Project working directory"),
|
||||
workspace: str | None = typer.Option(None, "--workspace", help=_WORKSPACE_HELP),
|
||||
) -> None:
|
||||
"""Check .ohmo workspace and provider readiness."""
|
||||
cwd_path = str(Path(cwd).resolve())
|
||||
workspace_root = initialize_workspace(workspace)
|
||||
health = workspace_health(workspace_root)
|
||||
settings = load_settings()
|
||||
statuses = AuthManager(settings).get_profile_statuses()
|
||||
lines = ["ohmo doctor:"]
|
||||
for name, ok in health.items():
|
||||
lines.append(f"- {name}: {'ok' if ok else 'missing'}")
|
||||
lines.append(f"- project_cwd: {cwd_path}")
|
||||
lines.append(f"- workspace_root: {workspace_root}")
|
||||
lines.append(f"- workspace_state: {get_state_path(workspace_root)}")
|
||||
lines.append(f"- gateway_config: {get_gateway_config_path(workspace_root)}")
|
||||
lines.append("- available_profiles:")
|
||||
for name, info in statuses.items():
|
||||
lines.append(
|
||||
f" - {name}: {info['label']} ({'configured' if info['configured'] else 'missing auth'})"
|
||||
)
|
||||
print("\n".join(lines))
|
||||
|
||||
|
||||
@memory_app.command("list")
|
||||
def memory_list_cmd(workspace: str | None = typer.Option(None, "--workspace", help=_WORKSPACE_HELP)) -> None:
|
||||
for path in list_memory_files(workspace):
|
||||
print(path.name)
|
||||
|
||||
|
||||
@memory_app.command("add")
|
||||
def memory_add_cmd(
|
||||
title: str = typer.Argument(...),
|
||||
content: str = typer.Argument(...),
|
||||
workspace: str | None = typer.Option(None, "--workspace", help=_WORKSPACE_HELP),
|
||||
) -> None:
|
||||
path = add_memory_entry(workspace, title, content)
|
||||
print(f"Added memory entry {path.name}")
|
||||
|
||||
|
||||
@memory_app.command("remove")
|
||||
def memory_remove_cmd(
|
||||
name: str = typer.Argument(...),
|
||||
workspace: str | None = typer.Option(None, "--workspace", help=_WORKSPACE_HELP),
|
||||
) -> None:
|
||||
if remove_memory_entry(workspace, name):
|
||||
print(f"Removed memory entry {name}")
|
||||
return
|
||||
print(f"Memory entry not found: {name}", file=sys.stderr)
|
||||
raise typer.Exit(1)
|
||||
|
||||
|
||||
def _show_or_edit(path: Path, set_text: str | None) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
if set_text is not None:
|
||||
path.write_text(set_text.strip() + "\n", encoding="utf-8")
|
||||
print(f"Updated {path}")
|
||||
return
|
||||
if not path.exists():
|
||||
print(f"{path} does not exist yet.", file=sys.stderr)
|
||||
raise typer.Exit(1)
|
||||
print(path.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
@soul_app.command("show")
|
||||
def soul_show_cmd(workspace: str | None = typer.Option(None, "--workspace", help=_WORKSPACE_HELP)) -> None:
|
||||
_show_or_edit(get_soul_path(workspace), None)
|
||||
|
||||
|
||||
@soul_app.command("edit")
|
||||
def soul_edit_cmd(
|
||||
workspace: str | None = typer.Option(None, "--workspace", help=_WORKSPACE_HELP),
|
||||
set_text: str | None = typer.Option(None, "--set", help="Replace soul.md with this text"),
|
||||
) -> None:
|
||||
_show_or_edit(get_soul_path(workspace), set_text)
|
||||
|
||||
|
||||
@user_app.command("show")
|
||||
def user_show_cmd(workspace: str | None = typer.Option(None, "--workspace", help=_WORKSPACE_HELP)) -> None:
|
||||
_show_or_edit(get_user_path(workspace), None)
|
||||
|
||||
|
||||
@user_app.command("edit")
|
||||
def user_edit_cmd(
|
||||
workspace: str | None = typer.Option(None, "--workspace", help=_WORKSPACE_HELP),
|
||||
set_text: str | None = typer.Option(None, "--set", help="Replace user.md with this text"),
|
||||
) -> None:
|
||||
_show_or_edit(get_user_path(workspace), set_text)
|
||||
|
||||
|
||||
@gateway_app.command("run")
|
||||
def gateway_run_cmd(
|
||||
cwd: str = typer.Option(str(Path.cwd()), "--cwd", help="Project working directory"),
|
||||
workspace: str | None = typer.Option(None, "--workspace", help=_WORKSPACE_HELP),
|
||||
console_log: bool = typer.Option(True, "--console-log/--no-console-log", hidden=True),
|
||||
log_file: bool = typer.Option(True, "--log-file/--no-log-file", hidden=True),
|
||||
) -> None:
|
||||
"""Run the ohmo gateway in the foreground."""
|
||||
_configure_gateway_logging(workspace, console=console_log, log_file=log_file)
|
||||
service = OhmoGatewayService(cwd, workspace)
|
||||
raise SystemExit(asyncio.run(service.run_foreground()))
|
||||
|
||||
|
||||
@gateway_app.command("start")
|
||||
def gateway_start_cmd(
|
||||
cwd: str = typer.Option(str(Path.cwd()), "--cwd", help="Project working directory"),
|
||||
workspace: str | None = typer.Option(None, "--workspace", help=_WORKSPACE_HELP),
|
||||
) -> None:
|
||||
pid = start_gateway_process(cwd, workspace)
|
||||
print(f"ohmo gateway started (pid={pid})")
|
||||
|
||||
|
||||
@gateway_app.command("stop")
|
||||
def gateway_stop_cmd(
|
||||
cwd: str = typer.Option(str(Path.cwd()), "--cwd", help="Project working directory"),
|
||||
workspace: str | None = typer.Option(None, "--workspace", help=_WORKSPACE_HELP),
|
||||
) -> None:
|
||||
if stop_gateway_process(cwd, workspace):
|
||||
print("ohmo gateway stopped.")
|
||||
return
|
||||
print("ohmo gateway is not running.")
|
||||
|
||||
|
||||
@gateway_app.command("restart")
|
||||
def gateway_restart_cmd(
|
||||
cwd: str = typer.Option(str(Path.cwd()), "--cwd", help="Project working directory"),
|
||||
workspace: str | None = typer.Option(None, "--workspace", help=_WORKSPACE_HELP),
|
||||
) -> None:
|
||||
stop_gateway_process(cwd, workspace)
|
||||
pid = start_gateway_process(cwd, workspace)
|
||||
print(f"ohmo gateway restarted (pid={pid})")
|
||||
|
||||
|
||||
@gateway_app.command("status")
|
||||
def gateway_status_cmd(
|
||||
cwd: str = typer.Option(str(Path.cwd()), "--cwd", help="Project working directory"),
|
||||
workspace: str | None = typer.Option(None, "--workspace", help=_WORKSPACE_HELP),
|
||||
) -> None:
|
||||
state = gateway_status(cwd, workspace)
|
||||
print(state.model_dump_json(indent=2))
|
||||
@@ -1,2 +0,0 @@
|
||||
"""ohmo gateway package."""
|
||||
|
||||
@@ -1,414 +0,0 @@
|
||||
"""Gateway bridge connecting channel bus traffic to ohmo runtimes."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from collections.abc import Awaitable, Callable
|
||||
from pathlib import Path
|
||||
|
||||
from openharness.channels.bus.events import InboundMessage
|
||||
from openharness.channels.bus.events import OutboundMessage
|
||||
from openharness.channels.bus.queue import MessageBus
|
||||
|
||||
from ohmo.group_registry import load_managed_group_record
|
||||
from ohmo.gateway.router import session_key_for_message
|
||||
from ohmo.gateway.runtime import OhmoSessionRuntimePool
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _content_snippet(text: str, *, limit: int = 160) -> str:
|
||||
"""Return a single-line preview suitable for logs."""
|
||||
normalized = " ".join(text.split())
|
||||
if len(normalized) <= limit:
|
||||
return normalized
|
||||
return normalized[: limit - 3] + "..."
|
||||
|
||||
|
||||
def _format_gateway_error(exc: Exception) -> str:
|
||||
"""Return a short, user-facing gateway error message."""
|
||||
message = str(exc).strip() or exc.__class__.__name__
|
||||
lowered = message.lower()
|
||||
if "claude oauth refresh failed" in lowered:
|
||||
return (
|
||||
"[ohmo gateway error] Claude subscription auth refresh failed. "
|
||||
"Run `oh auth claude-login` again or switch the gateway profile."
|
||||
)
|
||||
if "claude oauth refresh token is invalid or expired" in lowered:
|
||||
return (
|
||||
"[ohmo gateway error] Claude subscription token is expired. "
|
||||
"Run `claude auth login`, then `oh auth claude-login`, or switch the gateway profile."
|
||||
)
|
||||
if "auth source not found" in lowered or "access token" in lowered:
|
||||
return (
|
||||
"[ohmo gateway error] Authentication is not configured for the current "
|
||||
"gateway profile. Run `oh setup` or `ohmo config`."
|
||||
)
|
||||
if "api key" in lowered or "auth" in lowered or "credential" in lowered:
|
||||
return (
|
||||
"[ohmo gateway error] Authentication failed for the current gateway "
|
||||
"profile. Check `oh auth status` and `ohmo config`."
|
||||
)
|
||||
return f"[ohmo gateway error] {message}"
|
||||
|
||||
|
||||
class OhmoGatewayBridge:
|
||||
"""Consume inbound messages and publish assistant replies."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
bus: MessageBus,
|
||||
runtime_pool: OhmoSessionRuntimePool,
|
||||
restart_gateway: Callable[[object, str], Awaitable[None] | None] | None = None,
|
||||
workspace: str | Path | None = None,
|
||||
feishu_group_policy: str = "open",
|
||||
) -> None:
|
||||
self._bus = bus
|
||||
self._runtime_pool = runtime_pool
|
||||
self._restart_gateway = restart_gateway
|
||||
self._workspace = workspace
|
||||
self._feishu_group_policy = _normalize_feishu_group_policy(feishu_group_policy)
|
||||
self._running = False
|
||||
self._session_tasks: dict[str, asyncio.Task[None]] = {}
|
||||
self._session_cancel_reasons: dict[str, str] = {}
|
||||
|
||||
async def run(self) -> None:
|
||||
self._running = True
|
||||
while self._running:
|
||||
try:
|
||||
message = await asyncio.wait_for(self._bus.consume_inbound(), timeout=1.0)
|
||||
except asyncio.TimeoutError:
|
||||
continue
|
||||
except asyncio.CancelledError:
|
||||
break
|
||||
|
||||
if not self._should_process_message(message):
|
||||
logger.info(
|
||||
"ohmo inbound ignored channel=%s chat_id=%s sender_id=%s reason=feishu_group_policy policy=%s content=%r",
|
||||
message.channel,
|
||||
message.chat_id,
|
||||
message.sender_id,
|
||||
self._feishu_group_policy,
|
||||
_content_snippet(message.content),
|
||||
)
|
||||
continue
|
||||
|
||||
session_key = session_key_for_message(message)
|
||||
logger.info(
|
||||
"ohmo inbound received channel=%s chat_id=%s sender_id=%s session_key=%s content=%r",
|
||||
message.channel,
|
||||
message.chat_id,
|
||||
message.sender_id,
|
||||
session_key,
|
||||
_content_snippet(message.content),
|
||||
)
|
||||
if message.content.strip() == "/stop":
|
||||
await self._handle_stop(message, session_key)
|
||||
continue
|
||||
if message.content.strip() == "/restart":
|
||||
await self._handle_restart(message, session_key)
|
||||
continue
|
||||
group_args = _parse_group_command(message.content)
|
||||
if group_args is not None:
|
||||
prepared = await self._prepare_group_prompt_message(message, session_key, group_args)
|
||||
if prepared is None:
|
||||
continue
|
||||
message = prepared
|
||||
session_key = session_key_for_message(message)
|
||||
await self._interrupt_session(
|
||||
session_key,
|
||||
reason="replaced by a newer user message",
|
||||
notify=OutboundMessage(
|
||||
channel=message.channel,
|
||||
chat_id=message.chat_id,
|
||||
content="⏹️ 已停止上一条正在处理的任务,继续看你的最新消息。",
|
||||
metadata={"_progress": True, "_session_key": session_key},
|
||||
),
|
||||
)
|
||||
task = asyncio.create_task(
|
||||
self._process_message(message, session_key),
|
||||
name=f"ohmo-session:{session_key}",
|
||||
)
|
||||
self._session_tasks[session_key] = task
|
||||
task.add_done_callback(lambda finished, key=session_key: self._cleanup_task(key, finished))
|
||||
|
||||
def stop(self) -> None:
|
||||
self._running = False
|
||||
for session_key, task in list(self._session_tasks.items()):
|
||||
self._session_cancel_reasons[session_key] = "gateway stopping"
|
||||
task.cancel()
|
||||
|
||||
async def _handle_stop(self, message, session_key: str) -> None:
|
||||
stopped = await self._interrupt_session(
|
||||
session_key,
|
||||
reason="stopped by user command",
|
||||
)
|
||||
content = "⏹️ 已停止当前正在运行的任务。" if stopped else "当前没有正在运行的任务。"
|
||||
await self._bus.publish_outbound(
|
||||
OutboundMessage(
|
||||
channel=message.channel,
|
||||
chat_id=message.chat_id,
|
||||
content=content,
|
||||
metadata={"_session_key": session_key},
|
||||
)
|
||||
)
|
||||
|
||||
async def _handle_restart(self, message, session_key: str) -> None:
|
||||
await self._interrupt_session(
|
||||
session_key,
|
||||
reason="restarting gateway by user command",
|
||||
)
|
||||
await self._bus.publish_outbound(
|
||||
OutboundMessage(
|
||||
channel=message.channel,
|
||||
chat_id=message.chat_id,
|
||||
content="🔄 正在重启 gateway,马上回来。\nRestarting the gateway now. I'll be back in a moment.",
|
||||
metadata={"_session_key": session_key},
|
||||
)
|
||||
)
|
||||
if self._restart_gateway is not None:
|
||||
result = self._restart_gateway(message, session_key)
|
||||
if asyncio.iscoroutine(result):
|
||||
await result
|
||||
|
||||
async def _prepare_group_prompt_message(
|
||||
self,
|
||||
message,
|
||||
session_key: str,
|
||||
args: str,
|
||||
) -> InboundMessage | None:
|
||||
"""Convert a private /group command into an agent task."""
|
||||
if message.channel != "feishu":
|
||||
await self._publish_command_reply(
|
||||
message,
|
||||
session_key,
|
||||
"/group 当前只支持飞书。\n/group is currently only available for Feishu.",
|
||||
)
|
||||
return None
|
||||
|
||||
chat_type = str(message.metadata.get("chat_type") or "").strip().lower()
|
||||
is_private = chat_type in {"p2p", "private", "im", "direct"} or (
|
||||
not chat_type and str(message.chat_id) == str(message.sender_id)
|
||||
)
|
||||
if not is_private:
|
||||
await self._publish_command_reply(
|
||||
message,
|
||||
session_key,
|
||||
"请在和 ohmo 的私聊里使用 /group 创建新群。\nUse /group in a private chat with ohmo to create a new group.",
|
||||
)
|
||||
return None
|
||||
|
||||
metadata = dict(message.metadata)
|
||||
metadata["_ohmo_group_command"] = True
|
||||
metadata["_ohmo_group_raw_request"] = args
|
||||
prompt = _build_group_agent_prompt(args)
|
||||
return InboundMessage(
|
||||
channel=message.channel,
|
||||
sender_id=message.sender_id,
|
||||
chat_id=message.chat_id,
|
||||
content=prompt,
|
||||
timestamp=message.timestamp,
|
||||
media=list(message.media),
|
||||
metadata=metadata,
|
||||
session_key_override=message.session_key_override,
|
||||
)
|
||||
|
||||
async def _publish_command_reply(self, message, session_key: str, content: str) -> None:
|
||||
await self._bus.publish_outbound(
|
||||
OutboundMessage(
|
||||
channel=message.channel,
|
||||
chat_id=message.chat_id,
|
||||
content=content,
|
||||
metadata={"_session_key": session_key},
|
||||
)
|
||||
)
|
||||
|
||||
async def _interrupt_session(
|
||||
self,
|
||||
session_key: str,
|
||||
*,
|
||||
reason: str,
|
||||
notify: OutboundMessage | None = None,
|
||||
) -> bool:
|
||||
task = self._session_tasks.get(session_key)
|
||||
if task is None or task.done():
|
||||
return False
|
||||
self._session_cancel_reasons[session_key] = reason
|
||||
task.cancel()
|
||||
if notify is not None:
|
||||
await self._bus.publish_outbound(notify)
|
||||
try:
|
||||
await asyncio.wait_for(asyncio.shield(task), timeout=3.0)
|
||||
except (asyncio.CancelledError, asyncio.TimeoutError):
|
||||
pass
|
||||
return True
|
||||
|
||||
async def _process_message(self, message, session_key: str) -> None:
|
||||
# Preserve thread metadata only for shared chats. Feishu p2p replies
|
||||
# should stay as normal private messages, not topic replies.
|
||||
inbound_meta = {
|
||||
k: message.metadata[k] for k in ("thread_id",) if k in message.metadata
|
||||
}
|
||||
chat_type = str(message.metadata.get("chat_type") or "").lower()
|
||||
if chat_type == "group" or inbound_meta.get("thread_id"):
|
||||
if "message_id" in message.metadata:
|
||||
inbound_meta["message_id"] = message.metadata["message_id"]
|
||||
try:
|
||||
reply = ""
|
||||
final_media: list[str] = []
|
||||
final_metadata: dict[str, object] = {}
|
||||
async for update in self._runtime_pool.stream_message(message, session_key):
|
||||
if update.kind == "final":
|
||||
reply = update.text
|
||||
final_media = list(getattr(update, "media", None) or (update.metadata or {}).get("_media") or [])
|
||||
final_metadata = dict(update.metadata or {})
|
||||
continue
|
||||
if not update.text:
|
||||
continue
|
||||
logger.info(
|
||||
"ohmo outbound update channel=%s chat_id=%s session_key=%s kind=%s content=%r",
|
||||
message.channel,
|
||||
message.chat_id,
|
||||
session_key,
|
||||
update.kind,
|
||||
_content_snippet(update.text),
|
||||
)
|
||||
await self._bus.publish_outbound(
|
||||
OutboundMessage(
|
||||
channel=message.channel,
|
||||
chat_id=message.chat_id,
|
||||
content=update.text,
|
||||
media=list(getattr(update, "media", None) or (update.metadata or {}).get("_media") or []),
|
||||
metadata={**inbound_meta, **(update.metadata or {})},
|
||||
)
|
||||
)
|
||||
except asyncio.CancelledError:
|
||||
logger.info(
|
||||
"ohmo session interrupted channel=%s chat_id=%s session_key=%s reason=%s",
|
||||
message.channel,
|
||||
message.chat_id,
|
||||
session_key,
|
||||
self._session_cancel_reasons.get(session_key, "cancelled"),
|
||||
)
|
||||
raise
|
||||
except Exception as exc: # pragma: no cover - gateway failure path
|
||||
logger.exception(
|
||||
"ohmo gateway failed to process inbound message channel=%s chat_id=%s sender_id=%s session_key=%s content=%r",
|
||||
message.channel,
|
||||
message.chat_id,
|
||||
message.sender_id,
|
||||
session_key,
|
||||
_content_snippet(message.content),
|
||||
)
|
||||
reply = _format_gateway_error(exc)
|
||||
if not reply:
|
||||
logger.info(
|
||||
"ohmo inbound finished without final reply channel=%s chat_id=%s session_key=%s",
|
||||
message.channel,
|
||||
message.chat_id,
|
||||
session_key,
|
||||
)
|
||||
return
|
||||
logger.info(
|
||||
"ohmo outbound final channel=%s chat_id=%s session_key=%s content=%r",
|
||||
message.channel,
|
||||
message.chat_id,
|
||||
session_key,
|
||||
_content_snippet(reply),
|
||||
)
|
||||
await self._bus.publish_outbound(
|
||||
OutboundMessage(
|
||||
channel=message.channel,
|
||||
chat_id=message.chat_id,
|
||||
content=reply,
|
||||
media=final_media,
|
||||
metadata={**inbound_meta, **final_metadata, "_session_key": session_key},
|
||||
)
|
||||
)
|
||||
|
||||
def _cleanup_task(self, session_key: str, task: asyncio.Task[None]) -> None:
|
||||
current = self._session_tasks.get(session_key)
|
||||
if current is task:
|
||||
self._session_tasks.pop(session_key, None)
|
||||
self._session_cancel_reasons.pop(session_key, None)
|
||||
|
||||
def _should_process_message(self, message: InboundMessage) -> bool:
|
||||
if message.channel != "feishu":
|
||||
return True
|
||||
chat_type = str(message.metadata.get("chat_type") or "").strip().lower()
|
||||
if chat_type != "group":
|
||||
return True
|
||||
policy = self._feishu_group_policy
|
||||
if policy == "open":
|
||||
return True
|
||||
mentioned = _message_mentions_bot(message)
|
||||
if policy == "mention":
|
||||
return mentioned
|
||||
if policy == "managed":
|
||||
return self._is_managed_feishu_group(message.chat_id)
|
||||
if policy == "managed_or_mention":
|
||||
return mentioned or self._is_managed_feishu_group(message.chat_id)
|
||||
return mentioned
|
||||
|
||||
def _is_managed_feishu_group(self, chat_id: str) -> bool:
|
||||
try:
|
||||
return load_managed_group_record(
|
||||
workspace=self._workspace,
|
||||
channel="feishu",
|
||||
chat_id=chat_id,
|
||||
) is not None
|
||||
except Exception:
|
||||
logger.exception("failed to load ohmo managed group metadata chat_id=%s", chat_id)
|
||||
return False
|
||||
|
||||
|
||||
def _parse_group_command(content: str) -> str | None:
|
||||
stripped = content.strip()
|
||||
parts = stripped.split(maxsplit=1)
|
||||
if not parts or parts[0] != "/group":
|
||||
return None
|
||||
if len(parts) == 1:
|
||||
return ""
|
||||
return parts[1].strip()
|
||||
|
||||
|
||||
def _build_group_agent_prompt(raw_request: str) -> str:
|
||||
request = raw_request.strip() or "(user did not provide details)"
|
||||
return (
|
||||
"The user invoked `/group` from a Feishu private chat.\n"
|
||||
"Your task is to create a dedicated Feishu group for this request.\n\n"
|
||||
"Use the `ohmo_create_feishu_group` tool exactly once if you can infer a safe group name. "
|
||||
"You, the model, must decide the final `name`, optional `repo`, and optional `cwd` from the user's "
|
||||
"natural-language request and available local context. If the cwd is not obvious, inspect the filesystem "
|
||||
"before calling the tool. If there is not enough information to choose safely, ask one concise clarification "
|
||||
"instead of calling the tool. Do not create the group via bash or direct API calls.\n\n"
|
||||
f"User /group request:\n{request}"
|
||||
)
|
||||
|
||||
|
||||
def _normalize_feishu_group_policy(value: str | None) -> str:
|
||||
normalized = str(value or "").strip().lower().replace("-", "_")
|
||||
aliases = {
|
||||
"all": "open",
|
||||
"always": "open",
|
||||
"always_reply": "open",
|
||||
"managed_mention": "managed_or_mention",
|
||||
"managed_or_at": "managed_or_mention",
|
||||
"at": "mention",
|
||||
"mentions": "mention",
|
||||
}
|
||||
normalized = aliases.get(normalized, normalized)
|
||||
if normalized in {"open", "mention", "managed", "managed_or_mention"}:
|
||||
return normalized
|
||||
return "managed_or_mention"
|
||||
|
||||
|
||||
def _message_mentions_bot(message: InboundMessage) -> bool:
|
||||
value = message.metadata.get("mentions_bot")
|
||||
if isinstance(value, bool):
|
||||
return value
|
||||
if isinstance(value, str):
|
||||
return value.strip().lower() in {"1", "true", "yes", "y"}
|
||||
return False
|
||||
@@ -1,41 +0,0 @@
|
||||
"""Config IO for ohmo gateway."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from openharness.config.schema import Config
|
||||
|
||||
from ohmo.gateway.models import GatewayConfig
|
||||
from ohmo.workspace import get_gateway_config_path
|
||||
|
||||
|
||||
def load_gateway_config(workspace: str | Path | None = None) -> GatewayConfig:
|
||||
"""Load ``.ohmo/gateway.json``."""
|
||||
path = get_gateway_config_path(workspace)
|
||||
if path.exists():
|
||||
return GatewayConfig.model_validate_json(path.read_text(encoding="utf-8"))
|
||||
return GatewayConfig()
|
||||
|
||||
|
||||
def save_gateway_config(config: GatewayConfig, workspace: str | Path | None = None) -> Path:
|
||||
"""Persist ``.ohmo/gateway.json``."""
|
||||
path = get_gateway_config_path(workspace)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(config.model_dump_json(indent=2) + "\n", encoding="utf-8")
|
||||
return path
|
||||
|
||||
|
||||
def build_channel_manager_config(config: GatewayConfig) -> Config:
|
||||
"""Project gateway settings into the channel compatibility models."""
|
||||
root = Config()
|
||||
root.channels.send_progress = config.send_progress
|
||||
root.channels.send_tool_hints = config.send_tool_hints
|
||||
for name in config.enabled_channels:
|
||||
if not hasattr(root.channels, name):
|
||||
continue
|
||||
channel_config = getattr(root.channels, name).model_copy(
|
||||
update={"enabled": True, **config.channel_configs.get(name, {})}
|
||||
)
|
||||
setattr(root.channels, name, channel_config)
|
||||
return root
|
||||
@@ -1,158 +0,0 @@
|
||||
"""ohmo-only Feishu group management tool."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections.abc import Awaitable, Callable
|
||||
from pathlib import Path
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from openharness.tools.base import BaseTool, ToolExecutionContext, ToolResult
|
||||
|
||||
from ohmo.group_registry import normalize_group_name, save_managed_group_record
|
||||
|
||||
|
||||
CreateFeishuGroup = Callable[[str, str], Awaitable[str] | str]
|
||||
PublishGroupWelcome = Callable[[str, str, str], Awaitable[None] | None]
|
||||
|
||||
|
||||
class OhmoCreateFeishuGroupInput(BaseModel):
|
||||
"""Arguments selected by the model for creating a Feishu group."""
|
||||
|
||||
name: str = Field(description="Final Feishu group name to create.")
|
||||
cwd: str | None = Field(
|
||||
default=None,
|
||||
description="Workspace directory to bind to the group. Use an absolute path, ~ path, or path relative to cwd.",
|
||||
)
|
||||
repo: str | None = Field(
|
||||
default=None,
|
||||
description="Repository identifier associated with the group, for example HKUDS/OpenHarness.",
|
||||
)
|
||||
reason: str | None = Field(
|
||||
default=None,
|
||||
description="Short reason explaining why these name/cwd/repo values were chosen.",
|
||||
)
|
||||
|
||||
|
||||
class OhmoCreateFeishuGroupTool(BaseTool):
|
||||
"""Create a Feishu group for the current ohmo private-chat requester."""
|
||||
|
||||
name = "ohmo_create_feishu_group"
|
||||
description = (
|
||||
"Create a Feishu group for the current private-chat /group request and bind optional cwd/repo metadata. "
|
||||
"Only use this after the user explicitly invokes /group. Infer name, cwd, and repo from the user's "
|
||||
"natural-language request and the local workspace context; inspect files first if needed."
|
||||
)
|
||||
input_model = OhmoCreateFeishuGroupInput
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
workspace: str | Path | None,
|
||||
create_group: CreateFeishuGroup,
|
||||
publish_group_welcome: PublishGroupWelcome | None = None,
|
||||
) -> None:
|
||||
self._workspace = workspace
|
||||
self._create_group = create_group
|
||||
self._publish_group_welcome = publish_group_welcome
|
||||
|
||||
def is_read_only(self, arguments: OhmoCreateFeishuGroupInput) -> bool:
|
||||
# Permission is enforced by the slash-command context guard below. This
|
||||
# tool is only registered inside ohmo gateway sessions and cannot run
|
||||
# unless the current inbound message was a private Feishu /group request.
|
||||
del arguments
|
||||
return True
|
||||
|
||||
async def execute(self, arguments: OhmoCreateFeishuGroupInput, context: ToolExecutionContext) -> ToolResult:
|
||||
request = context.metadata.get("ohmo_group_request")
|
||||
if not isinstance(request, dict):
|
||||
return ToolResult(
|
||||
output="ohmo_create_feishu_group can only run immediately after a Feishu private /group request.",
|
||||
is_error=True,
|
||||
)
|
||||
if request.get("used"):
|
||||
return ToolResult(output="This /group request has already created a group.", is_error=True)
|
||||
if request.get("channel") != "feishu" or request.get("chat_type") not in {"p2p", "private", "im", "direct", ""}:
|
||||
return ToolResult(output="/group group creation is only allowed from a Feishu private chat.", is_error=True)
|
||||
|
||||
owner_open_id = str(request.get("sender_id") or "").strip()
|
||||
if not owner_open_id:
|
||||
return ToolResult(output="Cannot create group: missing Feishu requester open_id.", is_error=True)
|
||||
|
||||
try:
|
||||
name = normalize_group_name(arguments.name)
|
||||
except ValueError as exc:
|
||||
return ToolResult(output=f"Cannot create group: {exc}", is_error=True)
|
||||
|
||||
cwd = _resolve_cwd(arguments.cwd, context.cwd)
|
||||
if cwd is not None and not Path(cwd).is_dir():
|
||||
return ToolResult(output=f"Cannot bind cwd because the directory does not exist: {cwd}", is_error=True)
|
||||
|
||||
try:
|
||||
result = self._create_group(owner_open_id, name)
|
||||
chat_id = await result if asyncio.iscoroutine(result) else result
|
||||
except Exception as exc:
|
||||
return ToolResult(output=f"Cannot create Feishu group: {exc}", is_error=True)
|
||||
chat_id = str(chat_id).strip()
|
||||
if not chat_id:
|
||||
return ToolResult(output="Cannot create group: Feishu returned an empty chat_id.", is_error=True)
|
||||
|
||||
request["used"] = True
|
||||
try:
|
||||
record_path = save_managed_group_record(
|
||||
workspace=self._workspace,
|
||||
channel="feishu",
|
||||
chat_id=chat_id,
|
||||
owner_open_id=owner_open_id,
|
||||
name=name,
|
||||
cwd=cwd,
|
||||
repo=arguments.repo,
|
||||
binding_status="bound" if cwd else "pending_agent",
|
||||
metadata={
|
||||
"source": "slash_group_tool",
|
||||
"raw_group_request": request.get("raw_request"),
|
||||
"source_chat_id": request.get("source_chat_id"),
|
||||
"source_session_key": request.get("source_session_key"),
|
||||
"sender_display_name": request.get("sender_display_name"),
|
||||
"tool_reason": arguments.reason,
|
||||
},
|
||||
)
|
||||
except Exception as exc:
|
||||
return ToolResult(
|
||||
output=f"Created Feishu group {chat_id}, but failed to save metadata: {exc}",
|
||||
is_error=True,
|
||||
metadata={"chat_id": chat_id},
|
||||
)
|
||||
|
||||
welcome = (
|
||||
"这个群已经创建好。"
|
||||
+ (f"\n已绑定工作目录:{cwd}" if cwd else "")
|
||||
+ (f"\n关联仓库:{arguments.repo}" if arguments.repo else "")
|
||||
+ "\n这个 ohmo 管理的群默认可以不用 @ 直接和我说话;普通群仍建议 @ohmo 触发。"
|
||||
+ "\n如果我没有响应,请确认飞书应用已开通接收群聊所有消息的权限。"
|
||||
)
|
||||
if self._publish_group_welcome is not None:
|
||||
published = self._publish_group_welcome(chat_id, welcome, owner_open_id)
|
||||
if asyncio.iscoroutine(published):
|
||||
await published
|
||||
|
||||
lines = [
|
||||
f"Created Feishu group: {name}",
|
||||
f"chat_id: {chat_id}",
|
||||
f"metadata: {record_path}",
|
||||
]
|
||||
if cwd:
|
||||
lines.append(f"cwd: {cwd}")
|
||||
if arguments.repo:
|
||||
lines.append(f"repo: {arguments.repo}")
|
||||
return ToolResult(output="\n".join(lines), metadata={"chat_id": chat_id, "cwd": cwd, "repo": arguments.repo})
|
||||
|
||||
|
||||
def _resolve_cwd(raw: str | None, base_cwd: Path) -> str | None:
|
||||
if raw is None or not str(raw).strip():
|
||||
return None
|
||||
path = Path(str(raw).strip()).expanduser()
|
||||
if not path.is_absolute():
|
||||
path = base_cwd / path
|
||||
return str(path.resolve())
|
||||
@@ -1,33 +0,0 @@
|
||||
"""Gateway models for ohmo."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class GatewayConfig(BaseModel):
|
||||
"""Persistent gateway configuration."""
|
||||
|
||||
provider_profile: str = "codex"
|
||||
enabled_channels: list[str] = Field(default_factory=list)
|
||||
session_routing: str = "chat-thread"
|
||||
send_progress: bool = True
|
||||
send_tool_hints: bool = True
|
||||
permission_mode: str = "default"
|
||||
sandbox_enabled: bool = False
|
||||
allow_remote_admin_commands: bool = False
|
||||
allowed_remote_admin_commands: list[str] = Field(default_factory=list)
|
||||
log_level: str = "INFO"
|
||||
channel_configs: dict[str, dict] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class GatewayState(BaseModel):
|
||||
"""Runtime gateway status snapshot."""
|
||||
|
||||
running: bool = False
|
||||
pid: int | None = None
|
||||
active_sessions: int = 0
|
||||
provider_profile: str = "codex"
|
||||
enabled_channels: list[str] = Field(default_factory=list)
|
||||
last_error: str | None = None
|
||||
|
||||
@@ -1,78 +0,0 @@
|
||||
"""Proactive notification helpers for ohmo gateway channels."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from ohmo.gateway.config import load_gateway_config
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class OhmoNotificationError(RuntimeError):
|
||||
"""Raised when a proactive notification cannot be delivered."""
|
||||
|
||||
|
||||
def _chunk_text(text: str, *, max_chars: int = 1800) -> list[str]:
|
||||
"""Split text into message-sized chunks without losing content."""
|
||||
stripped = text.strip()
|
||||
if not stripped:
|
||||
return []
|
||||
chunks: list[str] = []
|
||||
remaining = stripped
|
||||
while len(remaining) > max_chars:
|
||||
split_at = remaining.rfind("\n", 0, max_chars)
|
||||
if split_at < max_chars // 2:
|
||||
split_at = max_chars
|
||||
chunks.append(remaining[:split_at].strip())
|
||||
remaining = remaining[split_at:].strip()
|
||||
if remaining:
|
||||
chunks.append(remaining)
|
||||
return chunks
|
||||
|
||||
|
||||
def _send_feishu_text_sync(*, user_open_id: str, content: str, workspace: str | Path | None = None) -> None:
|
||||
"""Send a Feishu direct message using ohmo gateway Feishu credentials."""
|
||||
try:
|
||||
import lark_oapi as lark
|
||||
from lark_oapi.api.im.v1 import CreateMessageRequest, CreateMessageRequestBody
|
||||
except ImportError as exc: # pragma: no cover - depends on optional extra
|
||||
raise OhmoNotificationError("Feishu SDK is not installed. Run: pip install lark-oapi") from exc
|
||||
|
||||
config = load_gateway_config(workspace)
|
||||
feishu_config: dict[str, Any] = config.channel_configs.get("feishu", {})
|
||||
app_id = str(feishu_config.get("app_id") or "").strip()
|
||||
app_secret = str(feishu_config.get("app_secret") or "").strip()
|
||||
if not app_id or not app_secret:
|
||||
raise OhmoNotificationError("Feishu app_id/app_secret are not configured in ohmo gateway config.")
|
||||
|
||||
client = lark.Client.builder().app_id(app_id).app_secret(app_secret).log_level(lark.LogLevel.INFO).build()
|
||||
for chunk in _chunk_text(content):
|
||||
request = (
|
||||
CreateMessageRequest.builder()
|
||||
.receive_id_type("open_id")
|
||||
.request_body(
|
||||
CreateMessageRequestBody.builder()
|
||||
.receive_id(user_open_id)
|
||||
.msg_type("text")
|
||||
.content(json.dumps({"text": chunk}, ensure_ascii=False))
|
||||
.build()
|
||||
)
|
||||
.build()
|
||||
)
|
||||
response = client.im.v1.message.create(request)
|
||||
if not response.success():
|
||||
log_id = response.get_log_id() if hasattr(response, "get_log_id") else ""
|
||||
raise OhmoNotificationError(
|
||||
f"send Feishu DM failed: code={response.code}, msg={response.msg}, log_id={log_id}"
|
||||
)
|
||||
|
||||
|
||||
async def send_feishu_dm(*, user_open_id: str, content: str, workspace: str | Path | None = None) -> None:
|
||||
"""Send a proactive Feishu direct message to a user open_id."""
|
||||
await asyncio.to_thread(_send_feishu_text_sync, user_open_id=user_open_id, content=content, workspace=workspace)
|
||||
logger.info("Sent proactive Feishu DM to open_id=%s", user_open_id)
|
||||
@@ -1,139 +0,0 @@
|
||||
"""ohmo gateway-scoped provider and model commands."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from openharness.auth.manager import AuthManager
|
||||
from openharness.config import load_settings
|
||||
|
||||
from ohmo.gateway.config import load_gateway_config, save_gateway_config
|
||||
|
||||
|
||||
def handle_gateway_provider_command(args: str, *, workspace: str | Path | None) -> tuple[str, bool]:
|
||||
"""Handle ``/provider`` against the ohmo gateway config."""
|
||||
tokens = args.split()
|
||||
statuses = AuthManager(load_settings()).get_profile_statuses()
|
||||
config = load_gateway_config(workspace)
|
||||
active = config.provider_profile
|
||||
if not tokens or tokens[0] == "show":
|
||||
info = statuses.get(active)
|
||||
if info is None:
|
||||
return f"ohmo gateway provider_profile: {active}\nStatus: unknown profile", False
|
||||
return (
|
||||
f"ohmo gateway provider_profile: {active}\n"
|
||||
f"Label: {info['label']}\n"
|
||||
f"Configured: {'yes' if info['configured'] else 'no'}\n"
|
||||
f"Base URL: {info['base_url'] or '(default)'}\n"
|
||||
f"Model: {info['model']}",
|
||||
False,
|
||||
)
|
||||
if tokens[0] == "list":
|
||||
lines = ["ohmo gateway provider profiles:"]
|
||||
for name, info in statuses.items():
|
||||
marker = "*" if name == active else " "
|
||||
configured = "ready" if info["configured"] else "missing auth"
|
||||
lines.append(f"{marker} {name} [{configured}] {info['label']} -> {info['model']}")
|
||||
return "\n".join(lines), False
|
||||
target = tokens[1] if tokens[0] == "use" and len(tokens) == 2 else (tokens[0] if len(tokens) == 1 else None)
|
||||
if target is None:
|
||||
return "Usage: /provider [show|list|PROFILE]", False
|
||||
if target not in statuses:
|
||||
return f"Unknown provider profile: {target}", False
|
||||
if target == active:
|
||||
return f"ohmo gateway already uses provider_profile={target}.", False
|
||||
save_gateway_config(config.model_copy(update={"provider_profile": target}), workspace)
|
||||
info = statuses[target]
|
||||
configured = "ready" if info["configured"] else "missing auth"
|
||||
return (
|
||||
f"ohmo gateway provider_profile set to {target} ({info['label']}, {configured}).\n"
|
||||
"Refreshing the current ohmo runtime to apply it.",
|
||||
True,
|
||||
)
|
||||
|
||||
|
||||
def handle_gateway_model_command(args: str, *, workspace: str | Path | None) -> tuple[str, bool]:
|
||||
"""Handle ``/model`` against the profile selected by ohmo gateway."""
|
||||
settings = load_settings()
|
||||
manager = AuthManager(settings)
|
||||
config = load_gateway_config(workspace)
|
||||
profile_name = config.provider_profile
|
||||
profiles = manager.list_profiles()
|
||||
profile = profiles.get(profile_name)
|
||||
if profile is None:
|
||||
return f"ohmo gateway provider_profile is unknown: {profile_name}", False
|
||||
|
||||
tokens = args.split(maxsplit=1)
|
||||
if not tokens or tokens[0] == "show":
|
||||
return _format_model_status(profile_name, profile), False
|
||||
if tokens[0] == "list":
|
||||
if profile.allowed_models:
|
||||
return (
|
||||
f"Switchable models for ohmo gateway profile '{profile_name}':\n"
|
||||
+ "\n".join(f"- {model}" for model in profile.allowed_models)
|
||||
), False
|
||||
return (
|
||||
f"Profile '{profile_name}' has no pinned model list. "
|
||||
"Any model value is accepted. Use /model add MODEL to pin one."
|
||||
), False
|
||||
if tokens[0] == "add" and len(tokens) == 2:
|
||||
model_name = tokens[1].strip()
|
||||
if not model_name:
|
||||
return "Usage: /model add MODEL", False
|
||||
manager.update_profile(profile_name, allowed_models=_dedupe([*_seed_models(profile), model_name]))
|
||||
return f"Added model '{model_name}' to ohmo gateway profile '{profile_name}'.", False
|
||||
if tokens[0] == "add":
|
||||
return "Usage: /model add MODEL", False
|
||||
if tokens[0] in {"remove", "rm"} and len(tokens) == 2:
|
||||
model_name = tokens[1].strip()
|
||||
models = [model for model in _dedupe(profile.allowed_models) if model != model_name]
|
||||
if len(models) == len(_dedupe(profile.allowed_models)):
|
||||
return f"Model '{model_name}' is not pinned for ohmo gateway profile '{profile_name}'.", False
|
||||
reset_current = (profile.last_model or "").strip() == model_name
|
||||
manager.update_profile(profile_name, allowed_models=models, last_model="" if reset_current else None)
|
||||
return f"Removed model '{model_name}' from ohmo gateway profile '{profile_name}'.", True
|
||||
if tokens[0] in {"remove", "rm"}:
|
||||
return "Usage: /model remove MODEL", False
|
||||
if tokens[0] == "clear":
|
||||
manager.update_profile(profile_name, allowed_models=[])
|
||||
return f"Cleared pinned models for ohmo gateway profile '{profile_name}'.", False
|
||||
model_name = tokens[1].strip() if tokens[0] == "set" and len(tokens) == 2 else args.strip()
|
||||
if not model_name:
|
||||
return "Usage: /model [show|list|add MODEL|remove MODEL|clear|MODEL]", False
|
||||
if profile.allowed_models and model_name.lower() != "default" and model_name not in profile.allowed_models:
|
||||
allowed = ", ".join(profile.allowed_models)
|
||||
return f"Model '{model_name}' is not allowed for ohmo gateway profile '{profile_name}'. Allowed models: {allowed}", False
|
||||
if model_name.lower() == "default":
|
||||
manager.update_profile(profile_name, last_model="")
|
||||
return f"ohmo gateway model reset to default for profile '{profile_name}'. Refreshing runtime to apply it.", True
|
||||
manager.update_profile(profile_name, last_model=model_name)
|
||||
return f"ohmo gateway model set to {model_name} for profile '{profile_name}'. Refreshing runtime to apply it.", True
|
||||
|
||||
|
||||
def _format_model_status(profile_name, profile) -> str:
|
||||
lines = [
|
||||
f"ohmo gateway model: {profile.resolved_model}",
|
||||
f"Profile: {profile_name}",
|
||||
]
|
||||
if profile.allowed_models:
|
||||
lines.append("Available models:")
|
||||
lines.extend(f"- {model}" for model in profile.allowed_models)
|
||||
else:
|
||||
lines.append("Available models: unrestricted for this profile")
|
||||
lines.append("Use /model add MODEL to pin switchable models.")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _dedupe(values) -> list[str]:
|
||||
seen: set[str] = set()
|
||||
result: list[str] = []
|
||||
for value in values:
|
||||
model = str(value).strip()
|
||||
if model and model not in seen:
|
||||
result.append(model)
|
||||
seen.add(model)
|
||||
return result
|
||||
|
||||
|
||||
def _seed_models(profile) -> list[str]:
|
||||
return _dedupe([*profile.allowed_models, profile.resolved_model])
|
||||
@@ -1,31 +0,0 @@
|
||||
"""Session routing for ohmo gateway."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from openharness.channels.bus.events import InboundMessage
|
||||
|
||||
|
||||
def session_key_for_message(message: InboundMessage) -> str:
|
||||
"""Route sessions by chat, isolating shared chats by thread/sender.
|
||||
|
||||
Private chats keep the original ``channel:chat_id`` key so existing long
|
||||
ohmo sessions remain resumable. Group/shared chats include sender identity
|
||||
to avoid multiple people sharing one agent memory.
|
||||
"""
|
||||
if message.session_key_override:
|
||||
return message.session_key_override
|
||||
sender_id = str(message.sender_id).strip() or "anonymous"
|
||||
chat_type = str(message.metadata.get("chat_type") or "").strip().lower()
|
||||
is_shared_chat = chat_type in {"group", "chat", "supergroup", "channel", "room"}
|
||||
thread_id = (
|
||||
message.metadata.get("thread_id")
|
||||
or message.metadata.get("thread_ts")
|
||||
or message.metadata.get("message_thread_id")
|
||||
)
|
||||
if thread_id:
|
||||
if is_shared_chat:
|
||||
return f"{message.channel}:{message.chat_id}:{thread_id}:{sender_id}"
|
||||
return f"{message.channel}:{message.chat_id}:{thread_id}"
|
||||
if is_shared_chat:
|
||||
return f"{message.channel}:{message.chat_id}:{sender_id}"
|
||||
return f"{message.channel}:{message.chat_id}"
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,450 +0,0 @@
|
||||
"""Gateway service lifecycle for ohmo."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import os.path
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
if sys.platform == "win32":
|
||||
import ctypes
|
||||
|
||||
from openharness.channels.bus.events import OutboundMessage
|
||||
from openharness.channels.bus.queue import MessageBus
|
||||
from openharness.channels.impl.manager import ChannelManager
|
||||
|
||||
from ohmo.gateway.bridge import OhmoGatewayBridge
|
||||
from ohmo.gateway.config import build_channel_manager_config, load_gateway_config
|
||||
from ohmo.gateway.models import GatewayState
|
||||
from ohmo.gateway.runtime import OhmoSessionRuntimePool
|
||||
from ohmo.workspace import (
|
||||
get_gateway_restart_notice_path,
|
||||
get_logs_dir,
|
||||
get_state_path,
|
||||
get_workspace_root,
|
||||
initialize_workspace,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
_REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
|
||||
|
||||
class OhmoGatewayService:
|
||||
"""Foreground/background service wrapper for the personal gateway."""
|
||||
|
||||
def __init__(self, cwd: str | Path | None = None, workspace: str | Path | None = None) -> None:
|
||||
self._cwd = str(Path(cwd or Path.cwd()).resolve())
|
||||
self._workspace = workspace
|
||||
os.chdir(self._cwd)
|
||||
root = initialize_workspace(self._workspace)
|
||||
os.environ["OHMO_WORKSPACE"] = str(root)
|
||||
self._config = load_gateway_config(self._workspace)
|
||||
if self._config.allow_remote_admin_commands and self._config.allowed_remote_admin_commands:
|
||||
logger.warning(
|
||||
"ohmo gateway remote administrative commands enabled commands=%s",
|
||||
",".join(self._config.allowed_remote_admin_commands),
|
||||
)
|
||||
self._bus = MessageBus()
|
||||
self._manager = ChannelManager(build_channel_manager_config(self._config), self._bus)
|
||||
self._runtime_pool = OhmoSessionRuntimePool(
|
||||
cwd=self._cwd,
|
||||
workspace=self._workspace,
|
||||
provider_profile=self._config.provider_profile,
|
||||
create_feishu_group=self.create_group_for_user,
|
||||
publish_group_welcome=self.publish_group_welcome,
|
||||
)
|
||||
self._stop_event: asyncio.Event | None = None
|
||||
self._restart_requested = False
|
||||
self._bridge = OhmoGatewayBridge(
|
||||
bus=self._bus,
|
||||
runtime_pool=self._runtime_pool,
|
||||
restart_gateway=self.request_restart,
|
||||
workspace=root,
|
||||
feishu_group_policy=str(
|
||||
self._config.channel_configs.get("feishu", {}).get("group_policy", "managed_or_mention")
|
||||
),
|
||||
)
|
||||
|
||||
@property
|
||||
def pid_file(self) -> Path:
|
||||
return get_workspace_root(self._workspace) / "gateway.pid"
|
||||
|
||||
@property
|
||||
def log_file(self) -> Path:
|
||||
return get_logs_dir(self._workspace) / "gateway.log"
|
||||
|
||||
@property
|
||||
def state_file(self) -> Path:
|
||||
return get_state_path(self._workspace)
|
||||
|
||||
def _channel_last_error(self) -> str | None:
|
||||
for name, channel in self._manager.channels.items():
|
||||
error = getattr(channel, "last_error", None)
|
||||
if error:
|
||||
return f"{name}: {error}"
|
||||
return None
|
||||
|
||||
def write_state(self, *, running: bool, last_error: str | None = None) -> None:
|
||||
state = GatewayState(
|
||||
running=running,
|
||||
pid=os.getpid() if running else None,
|
||||
active_sessions=self._runtime_pool.active_sessions,
|
||||
provider_profile=self._config.provider_profile,
|
||||
enabled_channels=self._config.enabled_channels,
|
||||
last_error=last_error or self._channel_last_error(),
|
||||
)
|
||||
self.state_file.write_text(state.model_dump_json(indent=2) + "\n", encoding="utf-8")
|
||||
|
||||
async def request_restart(self, message, session_key: str) -> None:
|
||||
"""Ask the foreground gateway loop to restart itself."""
|
||||
restart_notice = {
|
||||
"channel": message.channel,
|
||||
"chat_id": message.chat_id,
|
||||
"session_key": session_key,
|
||||
"content": "✅ gateway 已经重新连上,可以继续了。\nGateway is back online. We can continue.",
|
||||
}
|
||||
get_gateway_restart_notice_path(self._workspace).write_text(
|
||||
json.dumps(restart_notice, ensure_ascii=False, indent=2) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
self._restart_requested = True
|
||||
# Let the outbound dispatcher flush the restart notice to the IM channel
|
||||
# before we tear down the bridge and channel connections.
|
||||
await asyncio.sleep(0.75)
|
||||
if self._stop_event is not None:
|
||||
self._stop_event.set()
|
||||
|
||||
async def create_group(self, message, name: str) -> str:
|
||||
"""Create a managed group through the active channel implementation."""
|
||||
if message.channel != "feishu":
|
||||
raise RuntimeError(f"{message.channel} does not support managed group creation.")
|
||||
return await self.create_group_for_user(str(message.sender_id), name)
|
||||
|
||||
async def create_group_for_user(self, user_open_id: str, name: str) -> str:
|
||||
"""Create a managed Feishu group for a user open_id."""
|
||||
channel = self._manager.get_channel("feishu")
|
||||
if channel is None:
|
||||
raise RuntimeError("Feishu channel is not enabled.")
|
||||
creator = getattr(channel, "create_managed_group", None)
|
||||
if creator is None:
|
||||
raise RuntimeError("Feishu channel does not support managed group creation.")
|
||||
result = creator(user_open_id=str(user_open_id), name=name)
|
||||
return str(await result if asyncio.iscoroutine(result) else result)
|
||||
|
||||
async def publish_group_welcome(self, chat_id: str, content: str, owner_open_id: str) -> None:
|
||||
"""Send a welcome message to a newly created managed group."""
|
||||
await self._bus.publish_outbound(
|
||||
OutboundMessage(
|
||||
channel="feishu",
|
||||
chat_id=chat_id,
|
||||
content=content,
|
||||
metadata={"chat_type": "group", "_session_key": f"feishu:{chat_id}:{owner_open_id}"},
|
||||
)
|
||||
)
|
||||
|
||||
def _exec_restart(self) -> None:
|
||||
root = str(get_workspace_root(self._workspace))
|
||||
argv = [
|
||||
sys.executable,
|
||||
"-m",
|
||||
"ohmo",
|
||||
"gateway",
|
||||
"run",
|
||||
"--cwd",
|
||||
self._cwd,
|
||||
"--workspace",
|
||||
root,
|
||||
]
|
||||
logger.info("ohmo gateway restarting in-place argv=%s", argv)
|
||||
os.execv(sys.executable, argv)
|
||||
|
||||
async def _publish_pending_restart_notice(self) -> None:
|
||||
path = get_gateway_restart_notice_path(self._workspace)
|
||||
if not path.exists():
|
||||
return
|
||||
try:
|
||||
payload = json.loads(path.read_text(encoding="utf-8"))
|
||||
channel = payload.get("channel")
|
||||
chat_id = payload.get("chat_id")
|
||||
content = payload.get("content")
|
||||
session_key = payload.get("session_key")
|
||||
if not isinstance(channel, str) or not isinstance(chat_id, str) or not isinstance(content, str):
|
||||
return
|
||||
await asyncio.sleep(2.0)
|
||||
await self._bus.publish_outbound(
|
||||
OutboundMessage(
|
||||
channel=channel,
|
||||
chat_id=chat_id,
|
||||
content=content,
|
||||
metadata={"_session_key": session_key} if isinstance(session_key, str) else {},
|
||||
)
|
||||
)
|
||||
logger.info(
|
||||
"ohmo gateway published restart confirmation channel=%s chat_id=%s session_key=%s",
|
||||
channel,
|
||||
chat_id,
|
||||
session_key,
|
||||
)
|
||||
finally:
|
||||
path.unlink(missing_ok=True)
|
||||
|
||||
async def run_foreground(self) -> int:
|
||||
self.pid_file.write_text(str(os.getpid()), encoding="utf-8")
|
||||
self.write_state(running=True)
|
||||
bridge_task = asyncio.create_task(self._bridge.run(), name="ohmo-gateway-bridge")
|
||||
manager_task = asyncio.create_task(self._manager.start_all(), name="ohmo-gateway-channels")
|
||||
restart_notice_task = asyncio.create_task(
|
||||
self._publish_pending_restart_notice(),
|
||||
name="ohmo-gateway-restart-notice",
|
||||
)
|
||||
stop_event = asyncio.Event()
|
||||
self._stop_event = stop_event
|
||||
self._restart_requested = False
|
||||
|
||||
def _stop(*_: object) -> None:
|
||||
stop_event.set()
|
||||
|
||||
loop = asyncio.get_running_loop()
|
||||
for sig in (signal.SIGTERM, signal.SIGINT):
|
||||
with contextlib.suppress(NotImplementedError):
|
||||
loop.add_signal_handler(sig, _stop)
|
||||
|
||||
async def _state_heartbeat() -> None:
|
||||
while not stop_event.is_set():
|
||||
self.write_state(running=True)
|
||||
await asyncio.sleep(5.0)
|
||||
|
||||
state_task = asyncio.create_task(_state_heartbeat(), name="ohmo-gateway-state")
|
||||
|
||||
try:
|
||||
await stop_event.wait()
|
||||
except Exception as exc:
|
||||
self.write_state(running=False, last_error=str(exc))
|
||||
raise
|
||||
finally:
|
||||
self._bridge.stop()
|
||||
bridge_task.cancel()
|
||||
manager_task.cancel()
|
||||
with contextlib.suppress(asyncio.CancelledError):
|
||||
await bridge_task
|
||||
with contextlib.suppress(asyncio.CancelledError):
|
||||
await manager_task
|
||||
if not state_task.done():
|
||||
state_task.cancel()
|
||||
with contextlib.suppress(asyncio.CancelledError):
|
||||
await state_task
|
||||
if not restart_notice_task.done():
|
||||
restart_notice_task.cancel()
|
||||
with contextlib.suppress(asyncio.CancelledError):
|
||||
await restart_notice_task
|
||||
await self._manager.stop_all()
|
||||
self.write_state(running=False)
|
||||
self.pid_file.unlink(missing_ok=True)
|
||||
self._stop_event = None
|
||||
if self._restart_requested:
|
||||
self._exec_restart()
|
||||
return 0
|
||||
|
||||
|
||||
def start_gateway_process(cwd: str | Path | None = None, workspace: str | Path | None = None) -> int:
|
||||
"""Start the gateway as a detached subprocess."""
|
||||
service = OhmoGatewayService(cwd, workspace)
|
||||
service.log_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
env = os.environ.copy()
|
||||
pythonpath_entries = [str(_REPO_ROOT)]
|
||||
existing_pythonpath = env.get("PYTHONPATH")
|
||||
if existing_pythonpath:
|
||||
pythonpath_entries.append(existing_pythonpath)
|
||||
env["PYTHONPATH"] = os.pathsep.join(pythonpath_entries)
|
||||
|
||||
popen_kwargs: dict = {
|
||||
"cwd": service._cwd,
|
||||
"stdout": None,
|
||||
"stderr": None,
|
||||
"env": env,
|
||||
}
|
||||
if sys.platform == "win32":
|
||||
popen_kwargs["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP | subprocess.DETACHED_PROCESS # type: ignore[attr-defined]
|
||||
popen_kwargs["stdin"] = subprocess.DEVNULL
|
||||
else:
|
||||
popen_kwargs["start_new_session"] = True
|
||||
|
||||
with service.log_file.open("a", encoding="utf-8") as log_file:
|
||||
popen_kwargs["stdout"] = log_file
|
||||
popen_kwargs["stderr"] = log_file
|
||||
process = subprocess.Popen(
|
||||
[
|
||||
sys.executable,
|
||||
"-m",
|
||||
"ohmo",
|
||||
"gateway",
|
||||
"run",
|
||||
"--cwd",
|
||||
service._cwd,
|
||||
"--workspace",
|
||||
str(get_workspace_root(workspace)),
|
||||
"--no-console-log",
|
||||
],
|
||||
**popen_kwargs,
|
||||
)
|
||||
return process.pid
|
||||
|
||||
|
||||
def _pid_is_running(pid: int) -> bool:
|
||||
if sys.platform == "win32":
|
||||
kernel32 = ctypes.windll.kernel32 # type: ignore[attr-defined]
|
||||
PROCESS_QUERY_LIMITED_INFORMATION = 0x1000
|
||||
handle = kernel32.OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, False, pid)
|
||||
if not handle:
|
||||
return False
|
||||
try:
|
||||
exit_code = ctypes.c_ulong()
|
||||
if kernel32.GetExitCodeProcess(handle, ctypes.byref(exit_code)):
|
||||
return exit_code.value == 259 # STILL_ACTIVE
|
||||
return False
|
||||
finally:
|
||||
kernel32.CloseHandle(handle)
|
||||
else:
|
||||
try:
|
||||
os.kill(pid, 0)
|
||||
except OSError:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _iter_workspace_gateway_pids(workspace: str | Path | None = None) -> list[int]:
|
||||
root = str(get_workspace_root(workspace))
|
||||
if sys.platform == "win32":
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["wmic", "process", "where",
|
||||
f"commandline like '%-m ohmo gateway run%' and commandline like '%--workspace {root}%'",
|
||||
"get", "processid"],
|
||||
capture_output=True, text=True, check=True,
|
||||
)
|
||||
except Exception:
|
||||
return []
|
||||
current_pid = os.getpid()
|
||||
pids: list[int] = []
|
||||
for line in result.stdout.splitlines():
|
||||
line = line.strip()
|
||||
if not line or line.lower() == "processid":
|
||||
continue
|
||||
try:
|
||||
pid = int(line)
|
||||
except ValueError:
|
||||
continue
|
||||
if pid == current_pid:
|
||||
continue
|
||||
if _pid_is_running(pid):
|
||||
pids.append(pid)
|
||||
return pids
|
||||
else:
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["ps", "-eo", "pid=,args="],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
)
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
current_pid = os.getpid()
|
||||
pids = []
|
||||
for line in result.stdout.splitlines():
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
pid_text, args = line.split(None, 1)
|
||||
pid = int(pid_text)
|
||||
except ValueError:
|
||||
continue
|
||||
if pid == current_pid:
|
||||
continue
|
||||
if "-m ohmo gateway run" not in args:
|
||||
continue
|
||||
if f"--workspace {root}" not in args:
|
||||
continue
|
||||
if _pid_is_running(pid):
|
||||
pids.append(pid)
|
||||
return pids
|
||||
|
||||
|
||||
def stop_gateway_process(cwd: str | Path | None = None, workspace: str | Path | None = None) -> bool:
|
||||
"""Stop the background gateway process if present."""
|
||||
service = OhmoGatewayService(cwd, workspace)
|
||||
pids: list[int] = []
|
||||
if service.pid_file.exists():
|
||||
try:
|
||||
pids.append(int(service.pid_file.read_text(encoding="utf-8").strip()))
|
||||
except ValueError:
|
||||
pass
|
||||
pids.extend(_iter_workspace_gateway_pids(workspace))
|
||||
unique_pids = []
|
||||
for pid in pids:
|
||||
if pid not in unique_pids and _pid_is_running(pid):
|
||||
unique_pids.append(pid)
|
||||
if not unique_pids:
|
||||
service.pid_file.unlink(missing_ok=True)
|
||||
return False
|
||||
if sys.platform == "win32":
|
||||
for pid in unique_pids:
|
||||
with contextlib.suppress(Exception):
|
||||
subprocess.run(
|
||||
["taskkill", "/F", "/T", "/PID", str(pid)],
|
||||
capture_output=True,
|
||||
check=False,
|
||||
)
|
||||
else:
|
||||
for pid in unique_pids:
|
||||
with contextlib.suppress(ProcessLookupError):
|
||||
os.kill(pid, signal.SIGTERM)
|
||||
service.pid_file.unlink(missing_ok=True)
|
||||
service.write_state(running=False)
|
||||
return True
|
||||
|
||||
|
||||
def gateway_status(cwd: str | Path | None = None, workspace: str | Path | None = None) -> GatewayState:
|
||||
"""Load the last known gateway state."""
|
||||
service = OhmoGatewayService(cwd, workspace)
|
||||
live_pid: int | None = None
|
||||
if service.pid_file.exists():
|
||||
try:
|
||||
pid = int(service.pid_file.read_text(encoding="utf-8").strip())
|
||||
except ValueError:
|
||||
pid = None
|
||||
if pid is not None and _pid_is_running(pid):
|
||||
live_pid = pid
|
||||
if live_pid is None:
|
||||
live_pids = _iter_workspace_gateway_pids(workspace)
|
||||
if live_pids:
|
||||
live_pid = live_pids[0]
|
||||
service.pid_file.write_text(str(live_pid), encoding="utf-8")
|
||||
else:
|
||||
service.pid_file.unlink(missing_ok=True)
|
||||
|
||||
active_sessions = 0
|
||||
last_error: str | None = None
|
||||
if service.state_file.exists():
|
||||
with contextlib.suppress(Exception):
|
||||
state = GatewayState.model_validate_json(service.state_file.read_text(encoding="utf-8"))
|
||||
active_sessions = state.active_sessions
|
||||
last_error = state.last_error
|
||||
|
||||
return GatewayState(
|
||||
running=live_pid is not None,
|
||||
pid=live_pid,
|
||||
active_sessions=active_sessions,
|
||||
provider_profile=service._config.provider_profile,
|
||||
enabled_channels=service._config.enabled_channels,
|
||||
last_error=last_error,
|
||||
)
|
||||
@@ -1,95 +0,0 @@
|
||||
"""Persistent metadata for ohmo-managed chat groups."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import asdict, dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
import json
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from ohmo.workspace import get_groups_dir
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ManagedGroupRecord:
|
||||
"""Metadata for a chat group created and managed by ohmo."""
|
||||
|
||||
channel: str
|
||||
chat_id: str
|
||||
owner_open_id: str
|
||||
name: str
|
||||
created_at: str
|
||||
cwd: str | None = None
|
||||
repo: str | None = None
|
||||
binding_status: str = "pending_agent"
|
||||
metadata: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
def normalize_group_name(raw: str) -> str:
|
||||
"""Return a safe Feishu group name from command text."""
|
||||
name = " ".join(str(raw).strip().split())
|
||||
if not name:
|
||||
raise ValueError("Group name is required.")
|
||||
if len(name) > 100:
|
||||
raise ValueError("Group name is too long; keep it within 100 characters.")
|
||||
return name
|
||||
|
||||
|
||||
def group_record_path(
|
||||
*,
|
||||
workspace: str | Path | None,
|
||||
channel: str,
|
||||
chat_id: str,
|
||||
) -> Path:
|
||||
safe_chat_id = re.sub(r"[^A-Za-z0-9_.-]+", "_", str(chat_id)).strip("._") or "unknown"
|
||||
return get_groups_dir(workspace) / channel / f"{safe_chat_id}.json"
|
||||
|
||||
|
||||
def save_managed_group_record(
|
||||
*,
|
||||
workspace: str | Path | None,
|
||||
channel: str,
|
||||
chat_id: str,
|
||||
owner_open_id: str,
|
||||
name: str,
|
||||
cwd: str | None = None,
|
||||
repo: str | None = None,
|
||||
binding_status: str = "pending_agent",
|
||||
metadata: dict[str, Any] | None = None,
|
||||
) -> Path:
|
||||
"""Persist metadata for an ohmo-managed group and return the path."""
|
||||
record = ManagedGroupRecord(
|
||||
channel=channel,
|
||||
chat_id=chat_id,
|
||||
owner_open_id=owner_open_id,
|
||||
name=name,
|
||||
created_at=datetime.now(timezone.utc).isoformat(),
|
||||
cwd=normalize_cwd(cwd) if cwd else None,
|
||||
repo=repo,
|
||||
binding_status=binding_status,
|
||||
metadata=metadata or {},
|
||||
)
|
||||
path = group_record_path(workspace=workspace, channel=channel, chat_id=chat_id)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(json.dumps(asdict(record), ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
||||
return path
|
||||
|
||||
|
||||
def load_managed_group_record(
|
||||
*,
|
||||
workspace: str | Path | None,
|
||||
channel: str,
|
||||
chat_id: str,
|
||||
) -> dict[str, Any] | None:
|
||||
"""Load metadata for an ohmo-managed group if present."""
|
||||
path = group_record_path(workspace=workspace, channel=channel, chat_id=chat_id)
|
||||
if not path.exists():
|
||||
return None
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def normalize_cwd(cwd: str | Path) -> str:
|
||||
"""Normalize a cwd binding."""
|
||||
return str(Path(cwd).expanduser().resolve())
|
||||
-219
@@ -1,219 +0,0 @@
|
||||
"""Personal memory helpers for ``.ohmo``."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from re import sub
|
||||
|
||||
from openharness.commands import MemoryCommandBackend
|
||||
from openharness.memory.scan import scan_memory_files
|
||||
from openharness.memory.schema import (
|
||||
SCHEMA_VERSION,
|
||||
coerce_int,
|
||||
compute_memory_signature,
|
||||
first_content_line,
|
||||
format_datetime,
|
||||
generate_memory_id,
|
||||
memory_metadata_from_path,
|
||||
render_memory_file,
|
||||
split_memory_file,
|
||||
utc_now,
|
||||
)
|
||||
from openharness.utils.file_lock import exclusive_file_lock
|
||||
from openharness.utils.fs import atomic_write_text
|
||||
|
||||
from ohmo.workspace import get_memory_dir, get_memory_index_path
|
||||
|
||||
|
||||
def list_memory_files(workspace: str | Path | None = None) -> list[Path]:
|
||||
"""List ``.ohmo`` memory markdown files."""
|
||||
memory_dir = get_memory_dir(workspace)
|
||||
return sorted(
|
||||
header.path
|
||||
for header in scan_memory_files(
|
||||
_scan_cwd(workspace, memory_dir),
|
||||
max_files=None,
|
||||
memory_dir=memory_dir,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def add_memory_entry(workspace: str | Path | None, title: str, content: str) -> Path:
|
||||
"""Create a personal memory file and append it to ``MEMORY.md``."""
|
||||
memory_dir = get_memory_dir(workspace)
|
||||
memory_dir.mkdir(parents=True, exist_ok=True)
|
||||
slug = sub(r"[^a-zA-Z0-9]+", "_", title.strip().lower()).strip("_") or "memory"
|
||||
with exclusive_file_lock(memory_dir / ".memory.lock"):
|
||||
memory_type = "personal"
|
||||
category = "preference"
|
||||
body = content.strip() + "\n"
|
||||
signature = compute_memory_signature(body, memory_type, category)
|
||||
existing = scan_memory_files(
|
||||
_scan_cwd(workspace, memory_dir),
|
||||
max_files=None,
|
||||
include_disabled=True,
|
||||
include_expired=True,
|
||||
memory_dir=memory_dir,
|
||||
)
|
||||
duplicate = next(
|
||||
(header for header in existing if _effective_signature(header.path, header.signature) == signature),
|
||||
None,
|
||||
)
|
||||
path = duplicate.path if duplicate is not None else _next_memory_path(memory_dir, slug)
|
||||
now = utc_now()
|
||||
now_text = format_datetime(now)
|
||||
if path.exists():
|
||||
metadata, old_body, _, _ = split_memory_file(path.read_text(encoding="utf-8"))
|
||||
metadata = memory_metadata_from_path(
|
||||
path,
|
||||
metadata,
|
||||
old_body,
|
||||
now=now,
|
||||
source=str(metadata.get("source") or "manual"),
|
||||
default_type=memory_type,
|
||||
default_category=category,
|
||||
)
|
||||
created_at = str(metadata.get("created_at") or now_text)
|
||||
memory_id = str(metadata.get("id") or generate_memory_id(now))
|
||||
else:
|
||||
metadata = {}
|
||||
created_at = now_text
|
||||
memory_id = generate_memory_id(now)
|
||||
metadata.update(
|
||||
{
|
||||
"schema_version": SCHEMA_VERSION,
|
||||
"id": memory_id,
|
||||
"name": title.strip(),
|
||||
"description": first_content_line(body) or title.strip(),
|
||||
"type": str(metadata.get("type") or memory_type),
|
||||
"category": str(metadata.get("category") or category),
|
||||
"importance": max(coerce_int(metadata.get("importance"), default=0), 1),
|
||||
"source": "manual",
|
||||
"signature": signature,
|
||||
"created_at": created_at,
|
||||
"updated_at": now_text,
|
||||
"ttl_days": metadata.get("ttl_days"),
|
||||
"disabled": False,
|
||||
"supersedes": metadata.get("supersedes") or [],
|
||||
}
|
||||
)
|
||||
atomic_write_text(path, render_memory_file(metadata, body))
|
||||
|
||||
index_path = get_memory_index_path(workspace)
|
||||
existing_index = index_path.read_text(encoding="utf-8") if index_path.exists() else "# Memory Index\n"
|
||||
if path.name not in existing_index:
|
||||
existing_index = existing_index.rstrip() + f"\n- [{title}]({path.name})\n"
|
||||
atomic_write_text(index_path, existing_index)
|
||||
return path
|
||||
|
||||
|
||||
def remove_memory_entry(workspace: str | Path | None, name: str) -> bool:
|
||||
"""Soft-delete a memory file and remove its index entry."""
|
||||
memory_dir = get_memory_dir(workspace)
|
||||
matches = [
|
||||
header
|
||||
for header in scan_memory_files(
|
||||
_scan_cwd(workspace, memory_dir),
|
||||
max_files=None,
|
||||
include_disabled=True,
|
||||
include_expired=True,
|
||||
memory_dir=memory_dir,
|
||||
)
|
||||
if name in {header.path.stem, header.path.name, header.title, header.id}
|
||||
]
|
||||
if not matches:
|
||||
return False
|
||||
header = matches[0]
|
||||
if header.disabled:
|
||||
return False
|
||||
path = header.path
|
||||
with exclusive_file_lock(memory_dir / ".memory.lock"):
|
||||
content = path.read_text(encoding="utf-8")
|
||||
metadata, body, _, _ = split_memory_file(content)
|
||||
metadata = memory_metadata_from_path(
|
||||
path,
|
||||
metadata,
|
||||
body,
|
||||
source="manual",
|
||||
default_type="personal",
|
||||
default_category="preference",
|
||||
)
|
||||
metadata["disabled"] = True
|
||||
metadata["updated_at"] = format_datetime(utc_now())
|
||||
atomic_write_text(path, render_memory_file(metadata, body))
|
||||
|
||||
index_path = get_memory_index_path(workspace)
|
||||
if index_path.exists():
|
||||
lines = [
|
||||
line
|
||||
for line in index_path.read_text(encoding="utf-8").splitlines()
|
||||
if path.name not in line
|
||||
]
|
||||
atomic_write_text(index_path, "\n".join(lines).rstrip() + "\n")
|
||||
return True
|
||||
|
||||
|
||||
def load_memory_prompt(workspace: str | Path | None = None, *, max_files: int = 5) -> str | None:
|
||||
"""Return a prompt section describing personal memory."""
|
||||
memory_dir = get_memory_dir(workspace)
|
||||
index_path = get_memory_index_path(workspace)
|
||||
lines = [
|
||||
"# ohmo Memory",
|
||||
f"- Personal memory directory: {memory_dir}",
|
||||
"- Use this memory for stable user preferences and durable personal context.",
|
||||
]
|
||||
|
||||
if index_path.exists():
|
||||
index_lines = index_path.read_text(encoding="utf-8").splitlines()[:200]
|
||||
lines.extend(["", "## MEMORY.md", "```md", *index_lines, "```"])
|
||||
|
||||
for path in list_memory_files(workspace)[:max_files]:
|
||||
content = path.read_text(encoding="utf-8", errors="replace").strip()
|
||||
if not content:
|
||||
continue
|
||||
lines.extend(["", f"## {path.name}", "```md", content[:4000], "```"])
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def create_memory_command_backend(workspace: str | Path | None = None) -> MemoryCommandBackend:
|
||||
"""Return a ``/memory`` backend bound to ohmo's personal memory store."""
|
||||
|
||||
return MemoryCommandBackend(
|
||||
label="ohmo personal memory",
|
||||
default_type="personal",
|
||||
default_category="preference",
|
||||
get_memory_dir=lambda: get_memory_dir(workspace),
|
||||
get_entrypoint=lambda: get_memory_index_path(workspace),
|
||||
list_files=lambda: list_memory_files(workspace),
|
||||
add_entry=lambda title, content: add_memory_entry(workspace, title, content),
|
||||
remove_entry=lambda name: remove_memory_entry(workspace, name),
|
||||
)
|
||||
|
||||
|
||||
def _scan_cwd(workspace: str | Path | None, memory_dir: Path) -> Path:
|
||||
return Path(workspace) if workspace is not None else memory_dir.parent
|
||||
|
||||
|
||||
def _next_memory_path(memory_dir: Path, slug: str) -> Path:
|
||||
path = memory_dir / f"{slug}.md"
|
||||
if not path.exists():
|
||||
return path
|
||||
index = 2
|
||||
while True:
|
||||
candidate = memory_dir / f"{slug}_{index}.md"
|
||||
if not candidate.exists():
|
||||
return candidate
|
||||
index += 1
|
||||
|
||||
|
||||
def _effective_signature(path: Path, existing_signature: str) -> str:
|
||||
if existing_signature:
|
||||
return existing_signature
|
||||
try:
|
||||
metadata, body, _, _ = split_memory_file(path.read_text(encoding="utf-8"))
|
||||
except OSError:
|
||||
return ""
|
||||
memory_type = str(metadata.get("type") or "personal")
|
||||
category = str(metadata.get("category") or "preference")
|
||||
return compute_memory_signature(body, memory_type, category)
|
||||
@@ -1,74 +0,0 @@
|
||||
"""Prompt assembly for ohmo persona and workspace context."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from openharness.memory import load_memory_prompt as load_project_memory_prompt
|
||||
from openharness.prompts.system_prompt import get_base_system_prompt
|
||||
|
||||
from ohmo.memory import load_memory_prompt as load_ohmo_memory_prompt
|
||||
from ohmo.workspace import (
|
||||
get_bootstrap_path,
|
||||
get_identity_path,
|
||||
get_soul_path,
|
||||
get_user_path,
|
||||
get_workspace_root,
|
||||
)
|
||||
|
||||
|
||||
def _read_text(path: Path) -> str | None:
|
||||
if not path.exists():
|
||||
return None
|
||||
content = path.read_text(encoding="utf-8", errors="replace").strip()
|
||||
return content or None
|
||||
|
||||
|
||||
def build_ohmo_system_prompt(
|
||||
cwd: str | Path,
|
||||
*,
|
||||
workspace: str | Path | None = None,
|
||||
extra_prompt: str | None = None,
|
||||
include_project_memory: bool = False,
|
||||
) -> str:
|
||||
"""Build the custom base prompt for ohmo sessions."""
|
||||
root = get_workspace_root(workspace)
|
||||
sections = [get_base_system_prompt()]
|
||||
|
||||
if extra_prompt:
|
||||
sections.extend(["# Additional Instructions", extra_prompt.strip()])
|
||||
|
||||
soul = _read_text(get_soul_path(root))
|
||||
if soul:
|
||||
sections.extend(["# ohmo Soul", soul])
|
||||
|
||||
identity = _read_text(get_identity_path(root))
|
||||
if identity:
|
||||
sections.extend(["# ohmo Identity", identity])
|
||||
|
||||
user = _read_text(get_user_path(root))
|
||||
if user:
|
||||
sections.extend(["# User Profile", user])
|
||||
|
||||
bootstrap = _read_text(get_bootstrap_path(root))
|
||||
if bootstrap:
|
||||
sections.extend(["# First-Run Bootstrap", bootstrap])
|
||||
|
||||
sections.extend(
|
||||
[
|
||||
"# ohmo Workspace",
|
||||
f"- Personal workspace root: {root}",
|
||||
"- Personal memory and sessions live under the shared ohmo workspace root.",
|
||||
"- Resume only within ohmo sessions; do not assume interoperability with plain OpenHarness sessions.",
|
||||
]
|
||||
)
|
||||
|
||||
if ohmo_memory := load_ohmo_memory_prompt(root):
|
||||
sections.append(ohmo_memory)
|
||||
|
||||
if include_project_memory:
|
||||
project_memory = load_project_memory_prompt(cwd)
|
||||
if project_memory:
|
||||
sections.append(project_memory)
|
||||
|
||||
return "\n\n".join(section for section in sections if section and section.strip())
|
||||
-218
@@ -1,218 +0,0 @@
|
||||
"""Runtime helpers for ohmo."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from openharness.api.client import SupportsStreamingMessages
|
||||
from openharness.engine.stream_events import AssistantTextDelta, AssistantTurnComplete, CompactProgressEvent, ErrorEvent, StatusEvent
|
||||
from openharness.ui.backend_host import run_backend_host
|
||||
from openharness.ui.runtime import build_runtime, close_runtime, handle_line, start_runtime
|
||||
from openharness.ui.react_launcher import _resolve_npm, _resolve_tsx, get_frontend_dir
|
||||
|
||||
from ohmo.memory import create_memory_command_backend
|
||||
from ohmo.prompts import build_ohmo_system_prompt
|
||||
from ohmo.session_storage import OhmoSessionBackend
|
||||
from ohmo.workspace import get_memory_dir, get_plugins_dir, get_sessions_dir, get_skills_dir, initialize_workspace
|
||||
|
||||
|
||||
def _ohmo_extra_roots(workspace: str | Path | None) -> tuple[tuple[str, ...], tuple[str, ...]]:
|
||||
root = initialize_workspace(workspace)
|
||||
return ((str(get_skills_dir(root)),), (str(get_plugins_dir(root)),))
|
||||
|
||||
|
||||
async def run_ohmo_backend(
|
||||
*,
|
||||
cwd: str | None = None,
|
||||
workspace: str | Path | None = None,
|
||||
model: str | None = None,
|
||||
max_turns: int | None = None,
|
||||
provider_profile: str | None = None,
|
||||
api_client: SupportsStreamingMessages | None = None,
|
||||
restore_messages: list[dict] | None = None,
|
||||
restore_tool_metadata: dict[str, object] | None = None,
|
||||
backend_only: bool = True,
|
||||
) -> int:
|
||||
"""Run the shared React backend host with ohmo workspace semantics."""
|
||||
del backend_only
|
||||
cwd_path = str(Path(cwd or Path.cwd()).resolve())
|
||||
workspace_root = initialize_workspace(workspace)
|
||||
extra_skill_dirs, extra_plugin_roots = _ohmo_extra_roots(workspace_root)
|
||||
return await run_backend_host(
|
||||
cwd=cwd_path,
|
||||
model=model,
|
||||
max_turns=max_turns,
|
||||
system_prompt=build_ohmo_system_prompt(cwd_path, workspace=workspace_root),
|
||||
active_profile=provider_profile,
|
||||
api_client=api_client,
|
||||
restore_messages=restore_messages,
|
||||
restore_tool_metadata=restore_tool_metadata,
|
||||
enforce_max_turns=max_turns is not None,
|
||||
session_backend=OhmoSessionBackend(workspace_root),
|
||||
extra_skill_dirs=extra_skill_dirs,
|
||||
extra_plugin_roots=extra_plugin_roots,
|
||||
memory_backend=create_memory_command_backend(workspace_root),
|
||||
include_project_memory=False,
|
||||
autodream_context={
|
||||
"memory_dir": str(get_memory_dir(workspace_root)),
|
||||
"session_dir": str(get_sessions_dir(workspace_root)),
|
||||
"app_label": "ohmo personal memory",
|
||||
"runner_module": "ohmo",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def build_ohmo_backend_command(
|
||||
*,
|
||||
cwd: str | None = None,
|
||||
workspace: str | Path | None = None,
|
||||
model: str | None = None,
|
||||
max_turns: int | None = None,
|
||||
provider_profile: str | None = None,
|
||||
) -> list[str]:
|
||||
"""Return the backend command for the React terminal UI."""
|
||||
command = [sys.executable, "-m", "ohmo", "--backend-only"]
|
||||
if cwd:
|
||||
command.extend(["--cwd", cwd])
|
||||
if workspace:
|
||||
command.extend(["--workspace", str(workspace)])
|
||||
if model:
|
||||
command.extend(["--model", model])
|
||||
if max_turns is not None:
|
||||
command.extend(["--max-turns", str(max_turns)])
|
||||
if provider_profile:
|
||||
command.extend(["--profile", provider_profile])
|
||||
return command
|
||||
|
||||
|
||||
async def launch_ohmo_react_tui(
|
||||
*,
|
||||
cwd: str | None = None,
|
||||
workspace: str | Path | None = None,
|
||||
model: str | None = None,
|
||||
max_turns: int | None = None,
|
||||
provider_profile: str | None = None,
|
||||
) -> int:
|
||||
"""Launch the shared React terminal UI with an ohmo backend."""
|
||||
frontend_dir = get_frontend_dir()
|
||||
package_json = frontend_dir / "package.json"
|
||||
if not package_json.exists():
|
||||
raise RuntimeError(f"React terminal frontend is missing: {package_json}")
|
||||
|
||||
npm = _resolve_npm()
|
||||
if not (frontend_dir / "node_modules").exists():
|
||||
install = await asyncio.create_subprocess_exec(
|
||||
npm,
|
||||
"install",
|
||||
"--no-fund",
|
||||
"--no-audit",
|
||||
cwd=str(frontend_dir),
|
||||
)
|
||||
if await install.wait() != 0:
|
||||
raise RuntimeError("Failed to install React terminal frontend dependencies")
|
||||
|
||||
cwd_path = str(Path(cwd or Path.cwd()).resolve())
|
||||
workspace_root = initialize_workspace(workspace)
|
||||
env = os.environ.copy()
|
||||
env["OPENHARNESS_FRONTEND_CONFIG"] = json.dumps(
|
||||
{
|
||||
"backend_command": build_ohmo_backend_command(
|
||||
cwd=cwd_path,
|
||||
workspace=workspace_root,
|
||||
model=model,
|
||||
max_turns=max_turns,
|
||||
provider_profile=provider_profile,
|
||||
),
|
||||
"initial_prompt": None,
|
||||
"theme": "default",
|
||||
}
|
||||
)
|
||||
tsx_cmd = _resolve_tsx(frontend_dir)
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
*tsx_cmd,
|
||||
"src/index.tsx",
|
||||
cwd=str(frontend_dir),
|
||||
env=env,
|
||||
stdin=None,
|
||||
stdout=None,
|
||||
stderr=None,
|
||||
)
|
||||
return await process.wait()
|
||||
|
||||
|
||||
async def run_ohmo_print_mode(
|
||||
*,
|
||||
prompt: str,
|
||||
cwd: str | None = None,
|
||||
workspace: str | Path | None = None,
|
||||
model: str | None = None,
|
||||
max_turns: int | None = None,
|
||||
provider_profile: str | None = None,
|
||||
) -> int:
|
||||
"""Run a single ohmo prompt and print the assistant output."""
|
||||
cwd_path = str(Path(cwd or Path.cwd()).resolve())
|
||||
workspace_root = initialize_workspace(workspace)
|
||||
extra_skill_dirs, extra_plugin_roots = _ohmo_extra_roots(workspace_root)
|
||||
previous_cwd = Path.cwd()
|
||||
os.chdir(cwd_path)
|
||||
try:
|
||||
bundle = await build_runtime(
|
||||
model=model,
|
||||
max_turns=max_turns,
|
||||
system_prompt=build_ohmo_system_prompt(cwd_path, workspace=workspace_root),
|
||||
active_profile=provider_profile,
|
||||
session_backend=OhmoSessionBackend(workspace_root),
|
||||
enforce_max_turns=max_turns is not None,
|
||||
extra_skill_dirs=extra_skill_dirs,
|
||||
extra_plugin_roots=extra_plugin_roots,
|
||||
memory_backend=create_memory_command_backend(workspace_root),
|
||||
include_project_memory=False,
|
||||
autodream_context={
|
||||
"memory_dir": str(get_memory_dir(workspace_root)),
|
||||
"session_dir": str(get_sessions_dir(workspace_root)),
|
||||
"app_label": "ohmo personal memory",
|
||||
"runner_module": "ohmo",
|
||||
},
|
||||
)
|
||||
await start_runtime(bundle)
|
||||
|
||||
async def _print_system(message: str) -> None:
|
||||
print(message, file=sys.stderr)
|
||||
|
||||
saw_error = False
|
||||
|
||||
async def _render_event(event) -> None:
|
||||
nonlocal saw_error
|
||||
if isinstance(event, AssistantTextDelta):
|
||||
sys.stdout.write(event.text)
|
||||
sys.stdout.flush()
|
||||
elif isinstance(event, AssistantTurnComplete):
|
||||
sys.stdout.write("\n")
|
||||
sys.stdout.flush()
|
||||
elif isinstance(event, ErrorEvent):
|
||||
saw_error = True
|
||||
print(event.message, file=sys.stderr)
|
||||
elif isinstance(event, CompactProgressEvent):
|
||||
if event.message:
|
||||
print(event.message, file=sys.stderr)
|
||||
elif isinstance(event, StatusEvent):
|
||||
print(event.message, file=sys.stderr)
|
||||
|
||||
async def _clear_output() -> None:
|
||||
return None
|
||||
|
||||
await handle_line(
|
||||
bundle,
|
||||
prompt,
|
||||
print_system=_print_system,
|
||||
render_event=_render_event,
|
||||
clear_output=_clear_output,
|
||||
)
|
||||
await close_runtime(bundle)
|
||||
return 1 if saw_error else 0
|
||||
finally:
|
||||
os.chdir(previous_cwd)
|
||||
@@ -1,202 +0,0 @@
|
||||
"""Session persistence for ``ohmo``."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import hashlib
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
from openharness.api.usage import UsageSnapshot
|
||||
from openharness.engine.messages import ConversationMessage, sanitize_conversation_messages
|
||||
from openharness.services.session_backend import SessionBackend
|
||||
from openharness.services.session_storage import (
|
||||
_persistable_tool_metadata,
|
||||
_sanitize_snapshot_payload,
|
||||
)
|
||||
from openharness.utils.fs import atomic_write_text
|
||||
|
||||
from ohmo.workspace import get_sessions_dir
|
||||
|
||||
|
||||
def get_session_dir(workspace: str | Path | None = None) -> Path:
|
||||
"""Return the ohmo sessions directory."""
|
||||
session_dir = get_sessions_dir(workspace)
|
||||
session_dir.mkdir(parents=True, exist_ok=True)
|
||||
return session_dir
|
||||
|
||||
|
||||
def _session_key_token(session_key: str) -> str:
|
||||
return hashlib.sha1(session_key.encode("utf-8")).hexdigest()[:12]
|
||||
|
||||
|
||||
def _session_key_latest_path(workspace: str | Path | None, session_key: str) -> Path:
|
||||
session_dir = get_session_dir(workspace)
|
||||
token = _session_key_token(session_key)
|
||||
return session_dir / f"latest-{token}.json"
|
||||
|
||||
|
||||
def save_session_snapshot(
|
||||
*,
|
||||
cwd: str | Path,
|
||||
workspace: str | Path | None = None,
|
||||
model: str,
|
||||
system_prompt: str,
|
||||
messages: list[ConversationMessage],
|
||||
usage: UsageSnapshot,
|
||||
session_id: str | None = None,
|
||||
session_key: str | None = None,
|
||||
tool_metadata: dict[str, object] | None = None,
|
||||
) -> Path:
|
||||
"""Persist the latest ohmo session snapshot."""
|
||||
session_dir = get_session_dir(workspace)
|
||||
sid = session_id or uuid4().hex[:12]
|
||||
now = time.time()
|
||||
messages = sanitize_conversation_messages(messages)
|
||||
summary = ""
|
||||
for msg in messages:
|
||||
if msg.role == "user" and msg.text.strip():
|
||||
summary = msg.text.strip()[:80]
|
||||
break
|
||||
|
||||
payload = {
|
||||
"app": "ohmo",
|
||||
"session_id": sid,
|
||||
"session_key": session_key,
|
||||
"cwd": str(Path(cwd).resolve()),
|
||||
"model": model,
|
||||
"system_prompt": system_prompt,
|
||||
"messages": [message.model_dump(mode="json") for message in messages],
|
||||
"usage": usage.model_dump(),
|
||||
"tool_metadata": _persistable_tool_metadata(tool_metadata),
|
||||
"created_at": now,
|
||||
"summary": summary,
|
||||
"message_count": len(messages),
|
||||
}
|
||||
data = json.dumps(payload, indent=2) + "\n"
|
||||
latest_path = session_dir / "latest.json"
|
||||
atomic_write_text(latest_path, data)
|
||||
if session_key:
|
||||
atomic_write_text(_session_key_latest_path(workspace, session_key), data)
|
||||
session_path = session_dir / f"session-{sid}.json"
|
||||
atomic_write_text(session_path, data)
|
||||
return latest_path
|
||||
|
||||
|
||||
def load_latest(workspace: str | Path | None = None) -> dict[str, Any] | None:
|
||||
path = get_session_dir(workspace) / "latest.json"
|
||||
if not path.exists():
|
||||
return None
|
||||
return _sanitize_snapshot_payload(json.loads(path.read_text(encoding="utf-8")))
|
||||
|
||||
|
||||
def load_latest_for_session_key(workspace: str | Path | None, session_key: str) -> dict[str, Any] | None:
|
||||
path = _session_key_latest_path(workspace, session_key)
|
||||
if path.exists():
|
||||
return _sanitize_snapshot_payload(json.loads(path.read_text(encoding="utf-8")))
|
||||
return None
|
||||
|
||||
|
||||
def list_snapshots(workspace: str | Path | None = None, limit: int = 20) -> list[dict[str, Any]]:
|
||||
session_dir = get_session_dir(workspace)
|
||||
sessions: list[dict[str, Any]] = []
|
||||
for path in sorted(session_dir.glob("session-*.json"), key=lambda p: p.stat().st_mtime, reverse=True):
|
||||
try:
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (json.JSONDecodeError, OSError):
|
||||
continue
|
||||
sessions.append(
|
||||
{
|
||||
"session_id": data.get("session_id", path.stem.replace("session-", "")),
|
||||
"summary": data.get("summary", ""),
|
||||
"message_count": data.get("message_count", len(data.get("messages", []))),
|
||||
"model": data.get("model", ""),
|
||||
"created_at": data.get("created_at", path.stat().st_mtime),
|
||||
}
|
||||
)
|
||||
if len(sessions) >= limit:
|
||||
break
|
||||
return sessions
|
||||
|
||||
|
||||
def load_by_id(workspace: str | Path | None, session_id: str) -> dict[str, Any] | None:
|
||||
path = get_session_dir(workspace) / f"session-{session_id}.json"
|
||||
if path.exists():
|
||||
return _sanitize_snapshot_payload(json.loads(path.read_text(encoding="utf-8")))
|
||||
latest = load_latest(workspace)
|
||||
if latest and (latest.get("session_id") == session_id or session_id == "latest"):
|
||||
return latest
|
||||
return None
|
||||
|
||||
|
||||
def export_session_markdown(
|
||||
*,
|
||||
cwd: str | Path,
|
||||
workspace: str | Path | None = None,
|
||||
messages: list[ConversationMessage],
|
||||
) -> Path:
|
||||
path = get_session_dir(workspace) / "transcript.md"
|
||||
parts = ["# ohmo Session Transcript"]
|
||||
for message in messages:
|
||||
parts.append(f"\n## {message.role.capitalize()}\n")
|
||||
text = message.text.strip()
|
||||
if text:
|
||||
parts.append(text)
|
||||
atomic_write_text(path, "\n".join(parts).strip() + "\n")
|
||||
return path
|
||||
|
||||
|
||||
class OhmoSessionBackend(SessionBackend):
|
||||
"""Session backend rooted in ``.ohmo/sessions``."""
|
||||
|
||||
def __init__(self, workspace: str | Path | None = None) -> None:
|
||||
self._workspace = workspace
|
||||
|
||||
def get_session_dir(self, cwd: str | Path) -> Path:
|
||||
return get_session_dir(self._workspace)
|
||||
|
||||
def save_snapshot(
|
||||
self,
|
||||
*,
|
||||
cwd: str | Path,
|
||||
model: str,
|
||||
system_prompt: str,
|
||||
messages: list[ConversationMessage],
|
||||
usage: UsageSnapshot,
|
||||
session_id: str | None = None,
|
||||
session_key: str | None = None,
|
||||
tool_metadata: dict[str, object] | None = None,
|
||||
) -> Path:
|
||||
return save_session_snapshot(
|
||||
cwd=cwd,
|
||||
workspace=self._workspace,
|
||||
model=model,
|
||||
system_prompt=system_prompt,
|
||||
messages=messages,
|
||||
usage=usage,
|
||||
session_id=session_id,
|
||||
session_key=session_key,
|
||||
tool_metadata=tool_metadata,
|
||||
)
|
||||
|
||||
def load_latest(self, cwd: str | Path) -> dict[str, Any] | None:
|
||||
return load_latest(self._workspace)
|
||||
|
||||
def list_snapshots(self, cwd: str | Path, limit: int = 20) -> list[dict[str, Any]]:
|
||||
return list_snapshots(self._workspace, limit=limit)
|
||||
|
||||
def load_by_id(self, cwd: str | Path, session_id: str) -> dict[str, Any] | None:
|
||||
return load_by_id(self._workspace, session_id)
|
||||
|
||||
def load_latest_for_session_key(self, session_key: str) -> dict[str, Any] | None:
|
||||
return load_latest_for_session_key(self._workspace, session_key)
|
||||
|
||||
def export_markdown(
|
||||
self,
|
||||
*,
|
||||
cwd: str | Path,
|
||||
messages: list[ConversationMessage],
|
||||
) -> Path:
|
||||
return export_session_markdown(cwd=cwd, workspace=self._workspace, messages=messages)
|
||||
@@ -1,319 +0,0 @@
|
||||
"""Workspace helpers for the ohmo personal-agent app."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
WORKSPACE_DIRNAME = ".ohmo"
|
||||
|
||||
SOUL_TEMPLATE = """# SOUL.md - Who You Are
|
||||
|
||||
You are ohmo, a personal agent built on top of OpenHarness.
|
||||
|
||||
You are not trying to sound like a generic assistant. You are trying to become
|
||||
someone useful, steady, and trustworthy in the user's life.
|
||||
|
||||
## Core truths
|
||||
|
||||
- Be genuinely helpful, not performatively helpful.
|
||||
Skip filler like “great question” or “happy to help” unless it is actually
|
||||
natural in context.
|
||||
- Have judgment.
|
||||
You can prefer one option over another, notice tradeoffs, and explain your
|
||||
reasons plainly.
|
||||
- Be resourceful before asking.
|
||||
Read the file, check the context, inspect the state, and try to figure things
|
||||
out before bouncing work back to the user.
|
||||
- Earn trust through competence.
|
||||
Be careful with anything public, destructive, costly, or user-facing.
|
||||
Be bolder with internal investigation, drafting, organizing, and synthesis.
|
||||
- Remember that access is intimacy.
|
||||
Messages, files, notes, and history are personal. Treat them with respect.
|
||||
|
||||
## Boundaries
|
||||
|
||||
- Private things stay private.
|
||||
- When in doubt, ask before acting externally.
|
||||
- Do not send half-baked replies on messaging channels.
|
||||
- In groups, do not casually speak as if you are the user.
|
||||
- Do not optimize for flattery; optimize for usefulness, honesty, and good taste.
|
||||
|
||||
## Vibe
|
||||
|
||||
Be concise when the answer is simple. Be thorough when the stakes are high.
|
||||
Sound like a capable companion with taste, not a corporate support bot.
|
||||
|
||||
## Continuity
|
||||
|
||||
Your continuity lives in this workspace:
|
||||
- `user.md` tells you who the user is.
|
||||
- `memory/` holds durable notes and recurring context.
|
||||
- `state.json` and session history tell you what has been happening recently.
|
||||
|
||||
Read these files. Update them when something should persist.
|
||||
|
||||
If you materially change this file, tell the user. It is your soul.
|
||||
"""
|
||||
|
||||
USER_TEMPLATE = """# user.md - About Your Human
|
||||
|
||||
Learn the person you are helping. Keep this useful, respectful, and current.
|
||||
|
||||
## Profile
|
||||
|
||||
- Name:
|
||||
- What to call them:
|
||||
- Pronouns: *(optional)*
|
||||
- Timezone:
|
||||
- Languages:
|
||||
|
||||
## Defaults
|
||||
|
||||
- Preferred tone:
|
||||
- Preferred answer length:
|
||||
- Decision style:
|
||||
- Typical working hours:
|
||||
|
||||
## Ongoing context
|
||||
|
||||
- Main projects:
|
||||
- Recurring responsibilities:
|
||||
- Current pressures or priorities:
|
||||
- Tools and platforms they use often:
|
||||
|
||||
## Preferences
|
||||
|
||||
- What they usually want more of:
|
||||
- What tends to annoy them:
|
||||
- What they want handled carefully:
|
||||
- What kinds of reminders or follow-through help them:
|
||||
|
||||
## Relationship notes
|
||||
|
||||
How should ohmo show up for this user over time?
|
||||
What kind of assistant relationship feels right: terse operator, thoughtful
|
||||
partner, organized chief of staff, calm technical companion, or something else?
|
||||
|
||||
## Notes
|
||||
|
||||
Use this section for facts that are too important to forget but too small for a
|
||||
dedicated memory file.
|
||||
|
||||
Remember: learn enough to help well, not to build a dossier.
|
||||
"""
|
||||
|
||||
IDENTITY_TEMPLATE = """# IDENTITY.md - Your Shape
|
||||
|
||||
- Name: ohmo
|
||||
- Kind: personal agent
|
||||
- Vibe: calm, capable, warm when useful
|
||||
- Signature:
|
||||
|
||||
Keep this short and concrete. Update it when the user and the agent have a
|
||||
clearer shared sense of who ohmo is.
|
||||
"""
|
||||
|
||||
BOOTSTRAP_TEMPLATE = """# BOOTSTRAP.md - First Contact
|
||||
|
||||
You just came online in a fresh personal workspace.
|
||||
|
||||
Your job is not to interrogate the user. Start naturally, then learn just
|
||||
enough to become useful.
|
||||
|
||||
## Goals for this first conversation
|
||||
|
||||
1. Figure out who you are to this user.
|
||||
- What should they call you?
|
||||
- What kind of assistant relationship feels right?
|
||||
- What tone should you have?
|
||||
|
||||
2. Learn the essentials about the user.
|
||||
- How should you address them?
|
||||
- What timezone are they in?
|
||||
- What are they working on lately?
|
||||
- What kind of help do they want most often?
|
||||
|
||||
3. Make the workspace real.
|
||||
- Update `IDENTITY.md`
|
||||
- Update `user.md`
|
||||
- If something durable matters, write it into `memory/`
|
||||
|
||||
## Style
|
||||
|
||||
- Don't dump a questionnaire.
|
||||
- Start with a simple, human opening.
|
||||
- Ask a few high-value questions, not twenty low-value ones.
|
||||
- Offer suggestions when the user is unsure.
|
||||
|
||||
## When done
|
||||
|
||||
Once the initial landing is complete, this file can be deleted.
|
||||
If it is gone later, do not assume it should come back.
|
||||
"""
|
||||
|
||||
MEMORY_INDEX_TEMPLATE = """# Memory Index
|
||||
|
||||
- Add durable personal facts and preferences as focused markdown files in this directory.
|
||||
- Keep entries concise and update this index as the memory corpus grows.
|
||||
"""
|
||||
|
||||
|
||||
def get_workspace_root(workspace: str | Path | None = None) -> Path:
|
||||
"""Return the ohmo workspace root.
|
||||
|
||||
Resolution order:
|
||||
1. Explicit ``workspace`` argument
|
||||
2. ``OHMO_WORKSPACE`` environment variable
|
||||
3. ``~/.ohmo``
|
||||
"""
|
||||
explicit = workspace or os.environ.get("OHMO_WORKSPACE")
|
||||
if explicit:
|
||||
path = Path(explicit).expanduser().resolve()
|
||||
return path if path.name == WORKSPACE_DIRNAME else path
|
||||
return (Path.home() / WORKSPACE_DIRNAME).resolve()
|
||||
|
||||
|
||||
def get_soul_path(workspace: str | Path | None = None) -> Path:
|
||||
return get_workspace_root(workspace) / "soul.md"
|
||||
|
||||
|
||||
def get_user_path(workspace: str | Path | None = None) -> Path:
|
||||
return get_workspace_root(workspace) / "user.md"
|
||||
|
||||
|
||||
def get_identity_path(workspace: str | Path | None = None) -> Path:
|
||||
return get_workspace_root(workspace) / "identity.md"
|
||||
|
||||
|
||||
def get_bootstrap_path(workspace: str | Path | None = None) -> Path:
|
||||
return get_workspace_root(workspace) / "BOOTSTRAP.md"
|
||||
|
||||
|
||||
def get_memory_dir(workspace: str | Path | None = None) -> Path:
|
||||
return get_workspace_root(workspace) / "memory"
|
||||
|
||||
|
||||
def get_skills_dir(workspace: str | Path | None = None) -> Path:
|
||||
return get_workspace_root(workspace) / "skills"
|
||||
|
||||
|
||||
def get_plugins_dir(workspace: str | Path | None = None) -> Path:
|
||||
return get_workspace_root(workspace) / "plugins"
|
||||
|
||||
|
||||
def get_groups_dir(workspace: str | Path | None = None) -> Path:
|
||||
return get_workspace_root(workspace) / "groups"
|
||||
|
||||
|
||||
def get_memory_index_path(workspace: str | Path | None = None) -> Path:
|
||||
return get_memory_dir(workspace) / "MEMORY.md"
|
||||
|
||||
|
||||
def get_sessions_dir(workspace: str | Path | None = None) -> Path:
|
||||
return get_workspace_root(workspace) / "sessions"
|
||||
|
||||
|
||||
def get_logs_dir(workspace: str | Path | None = None) -> Path:
|
||||
return get_workspace_root(workspace) / "logs"
|
||||
|
||||
|
||||
def get_attachments_dir(workspace: str | Path | None = None) -> Path:
|
||||
return get_workspace_root(workspace) / "attachments"
|
||||
|
||||
|
||||
def get_state_path(workspace: str | Path | None = None) -> Path:
|
||||
return get_workspace_root(workspace) / "state.json"
|
||||
|
||||
|
||||
def get_gateway_config_path(workspace: str | Path | None = None) -> Path:
|
||||
return get_workspace_root(workspace) / "gateway.json"
|
||||
|
||||
|
||||
def get_gateway_restart_notice_path(workspace: str | Path | None = None) -> Path:
|
||||
return get_workspace_root(workspace) / "gateway-restart-notice.json"
|
||||
|
||||
|
||||
def ensure_workspace(workspace: str | Path | None = None) -> Path:
|
||||
"""Create the workspace if needed and return its root."""
|
||||
root = get_workspace_root(workspace)
|
||||
root.mkdir(parents=True, exist_ok=True)
|
||||
get_memory_dir(root).mkdir(parents=True, exist_ok=True)
|
||||
get_skills_dir(root).mkdir(parents=True, exist_ok=True)
|
||||
get_plugins_dir(root).mkdir(parents=True, exist_ok=True)
|
||||
get_groups_dir(root).mkdir(parents=True, exist_ok=True)
|
||||
get_sessions_dir(root).mkdir(parents=True, exist_ok=True)
|
||||
get_logs_dir(root).mkdir(parents=True, exist_ok=True)
|
||||
get_attachments_dir(root).mkdir(parents=True, exist_ok=True)
|
||||
return root
|
||||
|
||||
|
||||
def initialize_workspace(workspace: str | Path | None = None) -> Path:
|
||||
"""Create the workspace and seed template files when missing."""
|
||||
root = ensure_workspace(workspace)
|
||||
templates = {
|
||||
get_soul_path(root): SOUL_TEMPLATE,
|
||||
get_user_path(root): USER_TEMPLATE,
|
||||
get_memory_index_path(root): MEMORY_INDEX_TEMPLATE,
|
||||
get_identity_path(root): IDENTITY_TEMPLATE,
|
||||
}
|
||||
for path, content in templates.items():
|
||||
if not path.exists():
|
||||
path.write_text(content.strip() + "\n", encoding="utf-8")
|
||||
state_path = get_state_path(root)
|
||||
state_data = {"app": "ohmo", "workspace": str(root.resolve())}
|
||||
if not state_path.exists():
|
||||
state_path.write_text(json.dumps(state_data, indent=2) + "\n", encoding="utf-8")
|
||||
else:
|
||||
try:
|
||||
state_data = json.loads(state_path.read_text(encoding="utf-8"))
|
||||
except json.JSONDecodeError:
|
||||
state_data = {"app": "ohmo", "workspace": str(root.resolve())}
|
||||
bootstrap_path = get_bootstrap_path(root)
|
||||
if not state_data.get("bootstrap_seeded"):
|
||||
state_data["bootstrap_seeded"] = True
|
||||
if not bootstrap_path.exists():
|
||||
bootstrap_path.write_text(BOOTSTRAP_TEMPLATE.strip() + "\n", encoding="utf-8")
|
||||
state_path.write_text(json.dumps(state_data, indent=2) + "\n", encoding="utf-8")
|
||||
gateway_path = get_gateway_config_path(root)
|
||||
if not gateway_path.exists():
|
||||
gateway_path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"provider_profile": "codex",
|
||||
"enabled_channels": [],
|
||||
"session_routing": "chat-thread",
|
||||
"send_progress": True,
|
||||
"send_tool_hints": True,
|
||||
"permission_mode": "default",
|
||||
"sandbox_enabled": False,
|
||||
"allow_remote_admin_commands": False,
|
||||
"allowed_remote_admin_commands": [],
|
||||
"log_level": "INFO",
|
||||
"channel_configs": {},
|
||||
},
|
||||
indent=2,
|
||||
)
|
||||
+ "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
return root
|
||||
|
||||
|
||||
def workspace_health(workspace: str | Path | None = None) -> dict[str, bool]:
|
||||
"""Return presence checks for the key workspace assets."""
|
||||
root = get_workspace_root(workspace)
|
||||
return {
|
||||
"workspace": root.exists(),
|
||||
"soul": get_soul_path(root).exists(),
|
||||
"user": get_user_path(root).exists(),
|
||||
"identity": get_identity_path(root).exists(),
|
||||
"memory_dir": get_memory_dir(root).exists(),
|
||||
"skills_dir": get_skills_dir(root).exists(),
|
||||
"plugins_dir": get_plugins_dir(root).exists(),
|
||||
"groups_dir": get_groups_dir(root).exists(),
|
||||
"memory_index": get_memory_index_path(root).exists(),
|
||||
"sessions_dir": get_sessions_dir(root).exists(),
|
||||
"gateway_config": get_gateway_config_path(root).exists(),
|
||||
}
|
||||
+3
-31
@@ -3,10 +3,9 @@ requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "openharness-ai"
|
||||
version = "0.1.9"
|
||||
name = "openharness"
|
||||
version = "0.1.0"
|
||||
description = "Open-source Python port of Claude Code - an AI-powered CLI coding assistant"
|
||||
readme = "README.md"
|
||||
license = "MIT"
|
||||
requires-python = ">=3.10"
|
||||
authors = [
|
||||
@@ -15,7 +14,6 @@ authors = [
|
||||
|
||||
dependencies = [
|
||||
"anthropic>=0.40.0",
|
||||
"openai>=1.0.0",
|
||||
"rich>=13.0.0",
|
||||
"prompt-toolkit>=3.0.0",
|
||||
"textual>=0.80.0",
|
||||
@@ -26,13 +24,7 @@ dependencies = [
|
||||
"mcp>=1.0.0",
|
||||
"pyperclip>=1.9.0",
|
||||
"pyyaml>=6.0",
|
||||
"questionary>=2.0.1",
|
||||
"watchfiles>=0.20.0",
|
||||
"croniter>=2.0.0",
|
||||
"slack-sdk>=3.0.0",
|
||||
"python-telegram-bot>=21.0.0",
|
||||
"discord.py>=2.0.0",
|
||||
"lark-oapi>=1.5.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
@@ -48,29 +40,9 @@ dev = [
|
||||
[project.scripts]
|
||||
openharness = "openharness.cli:app"
|
||||
oh = "openharness.cli:app"
|
||||
openh = "openharness.cli:app"
|
||||
ohmo = "ohmo.cli:app"
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["src/openharness", "ohmo"]
|
||||
|
||||
[tool.hatch.build]
|
||||
exclude = [
|
||||
"/.git",
|
||||
"/.venv",
|
||||
"/.openharness-venv",
|
||||
"/.openharness",
|
||||
"/.pytest_cache",
|
||||
"/.mypy_cache",
|
||||
"/.ruff_cache",
|
||||
"/build",
|
||||
"/dist",
|
||||
]
|
||||
|
||||
[tool.hatch.build.targets.wheel.force-include]
|
||||
"frontend/terminal/package.json" = "openharness/_frontend/package.json"
|
||||
"frontend/terminal/tsconfig.json" = "openharness/_frontend/tsconfig.json"
|
||||
"frontend/terminal/src" = "openharness/_frontend/src"
|
||||
packages = ["src/openharness"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
|
||||
@@ -1,329 +0,0 @@
|
||||
# OpenHarness Windows Installer (PowerShell)
|
||||
# Usage: iex (Invoke-WebRequest -Uri 'https://raw.githubusercontent.com/HKUDS/OpenHarness/main/scripts/install.ps1')
|
||||
# or: powershell -ExecutionPolicy Bypass -File scripts/install.ps1
|
||||
|
||||
param(
|
||||
[switch]$FromSource,
|
||||
[switch]$WithChannels,
|
||||
[switch]$Help
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helper functions
|
||||
# ---------------------------------------------------------------------------
|
||||
function Write-Info { Write-Host "[INFO] $args" -ForegroundColor Cyan }
|
||||
function Write-Success { Write-Host "[OK] $args" -ForegroundColor Green }
|
||||
function Write-Warn { Write-Host "[WARN] $args" -ForegroundColor Yellow }
|
||||
function Write-Error { Write-Host "[ERROR] $args" -ForegroundColor Red }
|
||||
function Write-Step { Write-Host ""; Write-Host "==>$args" -ForegroundColor Blue -BackgroundColor White }
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Banner
|
||||
# ---------------------------------------------------------------------------
|
||||
Write-Host ""
|
||||
Write-Host " ==============================" -ForegroundColor Cyan
|
||||
Write-Host " OpenHarness Installer" -ForegroundColor Cyan
|
||||
Write-Host " Windows Native Setup" -ForegroundColor Cyan
|
||||
Write-Host " ==============================" -ForegroundColor Cyan
|
||||
Write-Host ""
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Parse arguments
|
||||
# ---------------------------------------------------------------------------
|
||||
if ($Help) {
|
||||
Write-Host "Usage: .\install.ps1 [-FromSource] [-WithChannels]"
|
||||
Write-Host ""
|
||||
Write-Host " -FromSource Clone from GitHub and install in editable mode"
|
||||
Write-Host " -WithChannels Deprecated compatibility flag (dependencies installed by default)"
|
||||
exit 0
|
||||
}
|
||||
|
||||
if ($WithChannels) {
|
||||
Write-Warn "-WithChannels is no longer required; common IM channel dependencies are installed by default."
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Step 1: Check PowerShell version
|
||||
# ---------------------------------------------------------------------------
|
||||
Write-Step "Checking PowerShell version"
|
||||
|
||||
if ($PSVersionTable.PSVersion.Major -lt 5) {
|
||||
Write-Error "PowerShell 5.1 or newer is required."
|
||||
Write-Host " Please upgrade PowerShell or use PowerShell Core (pwsh):"
|
||||
Write-Host " https://github.com/PowerShell/PowerShell"
|
||||
exit 1
|
||||
}
|
||||
|
||||
Write-Success "PowerShell $($PSVersionTable.PSVersion) detected"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Step 2: Check Python 3.10+
|
||||
# ---------------------------------------------------------------------------
|
||||
Write-Step "Checking Python version (3.10+ required)"
|
||||
|
||||
$PythonCmd = $null
|
||||
$PythonCommands = @("python", "python3", "py")
|
||||
|
||||
foreach ($cmd in $PythonCommands) {
|
||||
$pyPath = Get-Command $cmd -ErrorAction SilentlyContinue
|
||||
if ($pyPath) {
|
||||
$versionOutput = & $cmd --version 2>&1
|
||||
$versionMatch = $versionOutput -match "Python (\d+)\.(\d+)"
|
||||
if ($versionMatch) {
|
||||
$major = [int]$matches[1]
|
||||
$minor = [int]$matches[2]
|
||||
if ($major -ge 3 -and $minor -ge 10) {
|
||||
$PythonCmd = $cmd
|
||||
break
|
||||
} elseif ($major -eq 3 -and $minor -lt 10) {
|
||||
Write-Warn "Python $major.$minor found but version 3.10+ is required"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (-not $PythonCmd) {
|
||||
Write-Error "Python 3.10+ not found."
|
||||
Write-Host ""
|
||||
Write-Host " Please install Python 3.10 or newer:"
|
||||
Write-Host " Download from: https://www.python.org/downloads/"
|
||||
Write-Host " Or use winget: winget install Python.Python.3.12"
|
||||
Write-Host ""
|
||||
exit 1
|
||||
}
|
||||
|
||||
$PyVersion = & $PythonCmd --version 2>&1
|
||||
Write-Success "Found $PyVersion ($PythonCmd)"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Step 3: Check Node.js >= 18 (optional)
|
||||
# ---------------------------------------------------------------------------
|
||||
Write-Step "Checking Node.js version (>= 18 required for React TUI)"
|
||||
|
||||
$NodeOk = $false
|
||||
$NodePath = Get-Command node -ErrorAction SilentlyContinue
|
||||
if ($NodePath) {
|
||||
$NodeVersionOutput = & node --version 2>&1
|
||||
$NodeVersionMatch = $NodeVersionOutput -match "v(\d+)"
|
||||
if ($NodeVersionMatch) {
|
||||
$NodeMajor = [int]$matches[1]
|
||||
if ($NodeMajor -ge 18) {
|
||||
$NodeOk = $true
|
||||
Write-Success "Found Node.js $NodeVersionOutput"
|
||||
} else {
|
||||
Write-Warn "Node.js $NodeVersionOutput is too old (need >= 18). React TUI will be skipped."
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Write-Warn "Node.js not found. React TUI will be skipped."
|
||||
Write-Host " To enable the React terminal UI, install Node.js 18+:"
|
||||
Write-Host " Download from: https://nodejs.org/"
|
||||
Write-Host " Or use winget: winget install OpenJS.NodeJS.LTS"
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Step 4: Install OpenHarness
|
||||
# ---------------------------------------------------------------------------
|
||||
Write-Step "Installing OpenHarness"
|
||||
|
||||
$RepoUrl = "https://github.com/HKUDS/OpenHarness.git"
|
||||
$InstallDir = "$env:USERPROFILE\.openharness-src"
|
||||
$VenvDir = "$env:USERPROFILE\.openharness-venv"
|
||||
|
||||
# Create virtual environment
|
||||
if (Test-Path $VenvDir) {
|
||||
Write-Info "Virtual environment already exists at $VenvDir"
|
||||
} else {
|
||||
Write-Info "Creating virtual environment at $VenvDir..."
|
||||
& $PythonCmd -m venv $VenvDir
|
||||
if (-not (Test-Path $VenvDir)) {
|
||||
Write-Error "Failed to create virtual environment"
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
|
||||
# Activate the venv
|
||||
$ActivateScript = "$VenvDir\Scripts\Activate.ps1"
|
||||
if (-not (Test-Path $ActivateScript)) {
|
||||
Write-Error "Virtual environment activation script not found: $ActivateScript"
|
||||
exit 1
|
||||
}
|
||||
|
||||
Write-Info "Activating virtual environment..."
|
||||
& $ActivateScript
|
||||
|
||||
Write-Success "Virtual environment ready: $VenvDir"
|
||||
|
||||
# Install OpenHarness
|
||||
if ($FromSource) {
|
||||
Write-Info "Mode: -FromSource (git clone + pip install -e .)"
|
||||
|
||||
$GitPath = Get-Command git -ErrorAction SilentlyContinue
|
||||
if (-not $GitPath) {
|
||||
Write-Error "git is required for -FromSource installation."
|
||||
Write-Host " Install git and retry:"
|
||||
Write-Host " winget install Git.Git"
|
||||
Write-Host " Or download from: https://git-scm.com/download/win"
|
||||
exit 1
|
||||
}
|
||||
|
||||
if (Test-Path "$InstallDir\.git") {
|
||||
Write-Info "Source directory exists, pulling latest changes..."
|
||||
Push-Location $InstallDir
|
||||
git pull --ff-only
|
||||
Pop-Location
|
||||
} else {
|
||||
Write-Info "Cloning OpenHarness into $InstallDir..."
|
||||
git clone $RepoUrl $InstallDir
|
||||
if (-not (Test-Path $InstallDir)) {
|
||||
Write-Error "Failed to clone repository"
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
|
||||
Write-Info "Installing in editable mode (pip install -e .)..."
|
||||
pip install -e $InstallDir --quiet
|
||||
} else {
|
||||
Write-Info "Mode: pip install openharness-ai"
|
||||
pip install openharness-ai --quiet --upgrade
|
||||
}
|
||||
|
||||
Write-Success "OpenHarness package installed"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Step 5: Install frontend/terminal npm dependencies
|
||||
# ---------------------------------------------------------------------------
|
||||
if ($NodeOk) {
|
||||
if ($FromSource) {
|
||||
$FrontendDir = "$InstallDir\frontend\terminal"
|
||||
} else {
|
||||
# Find installed package location
|
||||
$PackageInfo = pip show openharness-ai 2>&1
|
||||
$LocationMatch = $PackageInfo -match "Location: (.+)"
|
||||
if ($LocationMatch) {
|
||||
$PackageLocation = $matches[1].Trim()
|
||||
$FrontendDir = "$PackageLocation\openharness\_frontend"
|
||||
} else {
|
||||
$FrontendDir = $null
|
||||
}
|
||||
}
|
||||
|
||||
if ($FrontendDir -and (Test-Path "$FrontendDir\package.json")) {
|
||||
Write-Step "Installing React TUI dependencies"
|
||||
Write-Info "Running npm install in $FrontendDir..."
|
||||
Push-Location $FrontendDir
|
||||
npm install --no-fund --no-audit --silent 2>&1 | Out-Null
|
||||
Pop-Location
|
||||
Write-Success "React TUI dependencies installed"
|
||||
} else {
|
||||
Write-Info "No frontend/terminal directory found - skipping npm install"
|
||||
}
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Step 6: Create OpenHarness config directory
|
||||
# ---------------------------------------------------------------------------
|
||||
Write-Step "Setting up OpenHarness config directory"
|
||||
|
||||
$ConfigDir = "$env:USERPROFILE\.openharness"
|
||||
$SkillsDir = "$ConfigDir\skills"
|
||||
$PluginsDir = "$ConfigDir\plugins"
|
||||
|
||||
New-Item -ItemType Directory -Force -Path $ConfigDir | Out-Null
|
||||
New-Item -ItemType Directory -Force -Path $SkillsDir | Out-Null
|
||||
New-Item -ItemType Directory -Force -Path $PluginsDir | Out-Null
|
||||
|
||||
Write-Success "Config directory ready: ~/.openharness/"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Step 7: Add to PATH (Windows environment variable)
|
||||
# ---------------------------------------------------------------------------
|
||||
Write-Step "Setting up PATH integration"
|
||||
|
||||
$VenvBinDir = "$VenvDir\Scripts"
|
||||
$CurrentPath = [Environment]::GetEnvironmentVariable("PATH", "User")
|
||||
|
||||
if ($CurrentPath -like "*$VenvBinDir*") {
|
||||
Write-Info "PATH already contains $VenvBinDir"
|
||||
} else {
|
||||
Write-Info "Adding $VenvBinDir to user PATH..."
|
||||
$NewPath = "$VenvBinDir;$CurrentPath"
|
||||
[Environment]::SetEnvironmentVariable("PATH", $NewPath, "User")
|
||||
Write-Success "Added $VenvBinDir to PATH"
|
||||
Write-Warn "You may need to restart your terminal or log out/log in for PATH changes to take effect."
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Step 8: Verify installation
|
||||
# ---------------------------------------------------------------------------
|
||||
Write-Step "Verifying installation"
|
||||
|
||||
$OhPath = "$VenvBinDir\oh.exe"
|
||||
$OpenhPath = "$VenvBinDir\openh.exe"
|
||||
$OpenharnessPath = "$VenvBinDir\openharness.exe"
|
||||
$OhmoPath = "$VenvBinDir\ohmo.exe"
|
||||
|
||||
# Pick the best available launcher. The 'openh' alias was added after v0.1.6,
|
||||
# so PyPI installs of older releases won't have openh.exe. Prefer it when
|
||||
# present, otherwise fall back to 'openharness', then 'oh' (which collides
|
||||
# with PowerShell's Out-Host alias unless invoked as oh.exe).
|
||||
$Launcher = $null
|
||||
$LauncherExe = $null
|
||||
if (Test-Path $OpenhPath) {
|
||||
$Launcher = "openh"
|
||||
$LauncherExe = $OpenhPath
|
||||
} elseif (Test-Path $OpenharnessPath) {
|
||||
$Launcher = "openharness"
|
||||
$LauncherExe = $OpenharnessPath
|
||||
} elseif (Test-Path $OhPath) {
|
||||
$Launcher = "oh"
|
||||
$LauncherExe = $OhPath
|
||||
}
|
||||
|
||||
if ($LauncherExe -and (Test-Path $OhmoPath)) {
|
||||
$OhVersion = & $LauncherExe --version 2>&1
|
||||
Write-Success "Installation successful!"
|
||||
Write-Host ""
|
||||
Write-Host " $Launcher is ready: $OhVersion" -ForegroundColor Green
|
||||
if ($Launcher -eq "oh") {
|
||||
Write-Host " Note: 'oh' collides with PowerShell's built-in Out-Host alias." -ForegroundColor Yellow
|
||||
Write-Host " Invoke it as 'oh.exe', or use 'openharness' instead." -ForegroundColor Yellow
|
||||
} elseif (Test-Path $OhPath) {
|
||||
Write-Host " 'oh' is also installed, but PowerShell may resolve it to Out-Host first." -ForegroundColor Yellow
|
||||
}
|
||||
Write-Host " ohmo is ready" -ForegroundColor Green
|
||||
} else {
|
||||
# Try module execution
|
||||
$ModuleVersion = python -m openharness --version 2>&1
|
||||
if ($ModuleVersion) {
|
||||
Write-Warn "Launcher commands not yet available on PATH. Run via: python -m openharness"
|
||||
Write-Host " Version: $ModuleVersion"
|
||||
} else {
|
||||
Write-Warn "Could not verify launcher commands. The package may need a PATH update."
|
||||
Write-Host " Try: python -m openharness --version"
|
||||
}
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Done
|
||||
# ---------------------------------------------------------------------------
|
||||
Write-Host ""
|
||||
Write-Host "OpenHarness is installed!" -ForegroundColor Green -BackgroundColor White
|
||||
Write-Host ""
|
||||
Write-Host " Next steps:"
|
||||
Write-Host " 1. Restart terminal, or run: refreshenv (if using Chocolatey)"
|
||||
Write-Host " Or manually refresh: `$env:PATH = [System.Environment]::GetEnvironmentVariable('PATH','User')"
|
||||
Write-Host " 2. Set your API key: `$env:ANTHROPIC_API_KEY = 'your_key'"
|
||||
if ($Launcher -eq "openharness") {
|
||||
Write-Host " 3. Launch (PowerShell): openharness"
|
||||
Write-Host " ('openh' is not available on this release; 'oh' collides with PowerShell's Out-Host alias.)"
|
||||
} elseif ($Launcher -eq "oh") {
|
||||
Write-Host " 3. Launch (PowerShell): oh.exe"
|
||||
Write-Host " ('oh' alone collides with PowerShell's Out-Host alias — use 'oh.exe' or 'openharness'.)"
|
||||
} else {
|
||||
Write-Host " 3. Launch (PowerShell): openh"
|
||||
Write-Host " Note: 'oh' may collide with the built-in Out-Host alias in PowerShell."
|
||||
}
|
||||
Write-Host " 4. Launch ohmo: ohmo"
|
||||
Write-Host " 5. Docs: https://github.com/HKUDS/OpenHarness"
|
||||
Write-Host ""
|
||||
@@ -1,387 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# OpenHarness one-click installer
|
||||
# Usage: curl -fsSL https://raw.githubusercontent.com/HKUDS/OpenHarness/main/scripts/install.sh | bash
|
||||
# bash scripts/install.sh [--from-source] [--with-channels]
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Colors
|
||||
# ---------------------------------------------------------------------------
|
||||
if [ -t 1 ]; then
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
CYAN='\033[0;36m'
|
||||
BOLD='\033[1m'
|
||||
RESET='\033[0m'
|
||||
else
|
||||
RED='' GREEN='' YELLOW='' BLUE='' CYAN='' BOLD='' RESET=''
|
||||
fi
|
||||
|
||||
info() { echo -e "${CYAN}[INFO]${RESET} $*"; }
|
||||
success() { echo -e "${GREEN}[OK]${RESET} $*"; }
|
||||
warn() { echo -e "${YELLOW}[WARN]${RESET} $*"; }
|
||||
error() { echo -e "${RED}[ERROR]${RESET} $*" >&2; }
|
||||
step() { echo -e "\n${BOLD}${BLUE}==>${RESET}${BOLD} $*${RESET}"; }
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Parse arguments
|
||||
# ---------------------------------------------------------------------------
|
||||
FROM_SOURCE=false
|
||||
WITH_CHANNELS=false
|
||||
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--from-source) FROM_SOURCE=true ;;
|
||||
--with-channels) WITH_CHANNELS=true ;;
|
||||
--help|-h)
|
||||
echo "Usage: $0 [--from-source] [--with-channels]"
|
||||
echo ""
|
||||
echo " --from-source Clone from GitHub and install in editable mode"
|
||||
echo " --with-channels Deprecated compatibility flag."
|
||||
echo " Common IM channel dependencies are installed by default."
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
error "Unknown argument: $arg"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Banner
|
||||
# ---------------------------------------------------------------------------
|
||||
echo ""
|
||||
echo -e "${BOLD}${CYAN} ██████╗ ██╗ ██╗${RESET}"
|
||||
echo -e "${BOLD}${CYAN} ██╔═══██╗██║ ██║${RESET}"
|
||||
echo -e "${BOLD}${CYAN} ██║ ██║███████║${RESET} OpenHarness Installer"
|
||||
echo -e "${BOLD}${CYAN} ██║ ██║██╔══██║${RESET} Open Agent Harness"
|
||||
echo -e "${BOLD}${CYAN} ╚██████╔╝██║ ██║${RESET}"
|
||||
echo -e "${BOLD}${CYAN} ╚═════╝ ╚═╝ ╚═╝${RESET}"
|
||||
echo ""
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Step 1: Detect OS
|
||||
# ---------------------------------------------------------------------------
|
||||
step "Detecting operating system"
|
||||
|
||||
OS_TYPE="unknown"
|
||||
if [[ "$OSTYPE" == "linux-gnu"* ]]; then
|
||||
# Check for WSL
|
||||
if grep -qi microsoft /proc/version 2>/dev/null; then
|
||||
OS_TYPE="WSL"
|
||||
else
|
||||
OS_TYPE="Linux"
|
||||
fi
|
||||
elif [[ "$OSTYPE" == "darwin"* ]]; then
|
||||
OS_TYPE="macOS"
|
||||
elif [[ "$OSTYPE" == "msys" || "$OSTYPE" == "cygwin" ]]; then
|
||||
OS_TYPE="Windows (Git Bash)"
|
||||
fi
|
||||
|
||||
info "OS detected: ${BOLD}${OS_TYPE}${RESET}"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Step 2: Check Python >= 3.10
|
||||
# ---------------------------------------------------------------------------
|
||||
step "Checking Python version (>= 3.10 required)"
|
||||
|
||||
PYTHON_CMD=""
|
||||
for cmd in python3 python; do
|
||||
if command -v "$cmd" &>/dev/null; then
|
||||
PY_VER=$("$cmd" --version 2>&1 | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -1)
|
||||
PY_MAJOR=$(echo "$PY_VER" | cut -d. -f1)
|
||||
PY_MINOR=$(echo "$PY_VER" | cut -d. -f2)
|
||||
if [ "${PY_MAJOR}" -ge 3 ] && [ "${PY_MINOR}" -ge 10 ]; then
|
||||
PYTHON_CMD="$cmd"
|
||||
break
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
if [ -z "$PYTHON_CMD" ]; then
|
||||
error "Python 3.10+ not found."
|
||||
echo ""
|
||||
echo " Please install Python 3.10 or newer:"
|
||||
case "$OS_TYPE" in
|
||||
macOS)
|
||||
echo " brew install python@3.12"
|
||||
echo " or download from: https://www.python.org/downloads/"
|
||||
;;
|
||||
Linux|WSL)
|
||||
echo " sudo apt update && sudo apt install -y python3 python3-pip # Debian/Ubuntu"
|
||||
echo " sudo dnf install -y python3 # Fedora/RHEL"
|
||||
echo " or download from: https://www.python.org/downloads/"
|
||||
;;
|
||||
*)
|
||||
echo " Download from: https://www.python.org/downloads/"
|
||||
;;
|
||||
esac
|
||||
echo ""
|
||||
exit 1
|
||||
fi
|
||||
|
||||
PY_VERSION=$("$PYTHON_CMD" --version 2>&1)
|
||||
success "Found ${PY_VERSION} (${PYTHON_CMD})"
|
||||
|
||||
# Determine pip command
|
||||
PIP_CMD=""
|
||||
for cmd in pip3 pip; do
|
||||
if command -v "$cmd" &>/dev/null; then
|
||||
PIP_CMD="$cmd"
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
if [ -z "$PIP_CMD" ]; then
|
||||
# Try python -m pip
|
||||
if "$PYTHON_CMD" -m pip --version &>/dev/null 2>&1; then
|
||||
PIP_CMD="$PYTHON_CMD -m pip"
|
||||
else
|
||||
error "pip not found. Please install pip:"
|
||||
echo " $PYTHON_CMD -m ensurepip --upgrade"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Step 3: Check Node.js >= 18
|
||||
# ---------------------------------------------------------------------------
|
||||
step "Checking Node.js version (>= 18 required for React TUI)"
|
||||
|
||||
NODE_OK=false
|
||||
if command -v node &>/dev/null; then
|
||||
NODE_VER=$(node --version 2>&1 | grep -oE '[0-9]+' | head -1)
|
||||
if [ "${NODE_VER}" -ge 18 ] 2>/dev/null; then
|
||||
NODE_OK=true
|
||||
success "Found Node.js $(node --version)"
|
||||
else
|
||||
warn "Node.js $(node --version) is too old (need >= 18). React TUI will be skipped."
|
||||
fi
|
||||
else
|
||||
warn "Node.js not found. React TUI will be skipped."
|
||||
echo " To enable the React terminal UI, install Node.js 18+:"
|
||||
case "$OS_TYPE" in
|
||||
macOS)
|
||||
echo " brew install node"
|
||||
;;
|
||||
Linux|WSL)
|
||||
echo " curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash -"
|
||||
echo " sudo apt install -y nodejs"
|
||||
;;
|
||||
*)
|
||||
echo " Download from: https://nodejs.org/"
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Step 4: Install OpenHarness
|
||||
# ---------------------------------------------------------------------------
|
||||
step "Installing OpenHarness"
|
||||
|
||||
REPO_URL="https://github.com/HKUDS/OpenHarness.git"
|
||||
INSTALL_DIR="$HOME/.openharness-src"
|
||||
VENV_DIR="$HOME/.openharness-venv"
|
||||
BIN_DIR="$HOME/.local/bin"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Create a virtual environment to avoid PEP 668 externally-managed errors
|
||||
# ---------------------------------------------------------------------------
|
||||
if [ -d "$VENV_DIR" ] && [ ! -f "$VENV_DIR/bin/activate" ]; then
|
||||
warn "Found incomplete virtual environment at ${VENV_DIR}; recreating it..."
|
||||
rm -rf "$VENV_DIR"
|
||||
fi
|
||||
|
||||
if [ ! -f "$VENV_DIR/bin/activate" ]; then
|
||||
info "Creating virtual environment at ${VENV_DIR}..."
|
||||
"$PYTHON_CMD" -m venv "$VENV_DIR"
|
||||
fi
|
||||
|
||||
# Activate the venv — all pip installs go here
|
||||
source "$VENV_DIR/bin/activate"
|
||||
PYTHON_CMD="python"
|
||||
PIP_CMD="pip"
|
||||
success "Virtual environment ready: ${VENV_DIR}"
|
||||
|
||||
if [ "$FROM_SOURCE" = true ]; then
|
||||
info "Mode: --from-source (git clone + pip install -e .)"
|
||||
|
||||
if command -v git &>/dev/null; then
|
||||
if [ -d "$INSTALL_DIR/.git" ]; then
|
||||
info "Source directory exists, pulling latest changes..."
|
||||
git -C "$INSTALL_DIR" pull --ff-only
|
||||
else
|
||||
info "Cloning OpenHarness into ${INSTALL_DIR}..."
|
||||
git clone "$REPO_URL" "$INSTALL_DIR"
|
||||
fi
|
||||
else
|
||||
error "git is required for --from-source installation."
|
||||
echo " Install git and retry:"
|
||||
case "$OS_TYPE" in
|
||||
macOS) echo " brew install git" ;;
|
||||
Linux|WSL) echo " sudo apt install -y git" ;;
|
||||
esac
|
||||
exit 1
|
||||
fi
|
||||
|
||||
info "Installing in editable mode (pip install -e .)..."
|
||||
$PIP_CMD install -e "$INSTALL_DIR" --quiet
|
||||
else
|
||||
info "Mode: pip install openharness-ai"
|
||||
$PIP_CMD install openharness-ai --quiet --upgrade
|
||||
fi
|
||||
|
||||
success "OpenHarness package installed"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Step 5: Channel dependencies
|
||||
# ---------------------------------------------------------------------------
|
||||
if [ "$WITH_CHANNELS" = true ]; then
|
||||
step "Channel dependencies"
|
||||
info "--with-channels is no longer required; common IM channel dependencies are installed by default."
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Step 6: Install frontend/terminal npm dependencies
|
||||
# ---------------------------------------------------------------------------
|
||||
if [ "$NODE_OK" = true ]; then
|
||||
# Determine the frontend/terminal path
|
||||
if [ "$FROM_SOURCE" = true ]; then
|
||||
FRONTEND_DIR="$INSTALL_DIR/frontend/terminal"
|
||||
else
|
||||
FRONTEND_DIR="$(pwd)/frontend/terminal"
|
||||
fi
|
||||
|
||||
if [ -d "$FRONTEND_DIR" ] && [ -f "$FRONTEND_DIR/package.json" ]; then
|
||||
step "Installing React TUI dependencies"
|
||||
info "Running npm install in ${FRONTEND_DIR}..."
|
||||
(cd "$FRONTEND_DIR" && npm install --no-fund --no-audit --silent)
|
||||
success "React TUI dependencies installed"
|
||||
else
|
||||
info "No frontend/terminal directory found — skipping npm install"
|
||||
fi
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Step 7: Create OpenHarness config directory
|
||||
# ---------------------------------------------------------------------------
|
||||
step "Setting up OpenHarness config directory"
|
||||
|
||||
mkdir -p "$HOME/.openharness"
|
||||
mkdir -p "$HOME/.openharness/skills"
|
||||
mkdir -p "$HOME/.openharness/plugins"
|
||||
|
||||
success "Config directory ready: ~/.openharness/"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Step 8: Register global commands
|
||||
# ---------------------------------------------------------------------------
|
||||
step "Registering global commands"
|
||||
|
||||
mkdir -p "$BIN_DIR"
|
||||
ln -snf "$VENV_DIR/bin/oh" "$BIN_DIR/oh"
|
||||
ln -snf "$VENV_DIR/bin/ohmo" "$BIN_DIR/ohmo"
|
||||
ln -snf "$VENV_DIR/bin/openharness" "$BIN_DIR/openharness"
|
||||
success "Linked oh/ohmo into ${BIN_DIR}"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Step 9: Verify installation
|
||||
# ---------------------------------------------------------------------------
|
||||
step "Verifying installation"
|
||||
|
||||
if [ -x "$BIN_DIR/oh" ] && [ -x "$BIN_DIR/ohmo" ]; then
|
||||
OH_VERSION=$("$BIN_DIR/oh" --version 2>&1 || echo "(version check failed)")
|
||||
OHMO_VERSION=$("$BIN_DIR/ohmo" --help >/dev/null 2>&1 && echo "available" || echo "not available")
|
||||
success "Installation successful!"
|
||||
echo ""
|
||||
echo -e " ${BOLD}oh${RESET} is ready: ${GREEN}${OH_VERSION}${RESET}"
|
||||
echo -e " ${BOLD}ohmo${RESET} is ready: ${GREEN}${OHMO_VERSION}${RESET}"
|
||||
elif "$PYTHON_CMD" -m openharness --version &>/dev/null 2>&1; then
|
||||
OH_VERSION=$("$PYTHON_CMD" -m openharness --version 2>&1)
|
||||
warn "'oh'/'ohmo' command links are not executable yet. Run via: python -m openharness or python -m ohmo"
|
||||
echo " Version: ${OH_VERSION}"
|
||||
echo " To add them to PATH, ensure ${BIN_DIR} is in PATH:"
|
||||
echo " export PATH=\"${BIN_DIR}:\$PATH\""
|
||||
else
|
||||
warn "Could not verify 'oh'/'ohmo' commands. The package may need a PATH update."
|
||||
echo " Try: $PYTHON_CMD -m openharness --version"
|
||||
echo " Or add ${BIN_DIR} to PATH and restart your shell."
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Step 10: Add command directory to shell profile
|
||||
# ---------------------------------------------------------------------------
|
||||
step "Setting up shell integration"
|
||||
|
||||
ACTIVATION_LINE="export PATH=\"$BIN_DIR:\$PATH\""
|
||||
FISH_CONFIG="$HOME/.config/fish/config.fish"
|
||||
FISH_BLOCK=$(cat <<EOF
|
||||
# OpenHarness
|
||||
if not contains -- "$BIN_DIR" \$PATH
|
||||
set -gx PATH "$BIN_DIR" \$PATH
|
||||
end
|
||||
EOF
|
||||
)
|
||||
|
||||
configured_any=false
|
||||
|
||||
append_shell_path() {
|
||||
local rc_file="$1"
|
||||
if [ ! -f "$rc_file" ]; then
|
||||
return
|
||||
fi
|
||||
if grep -q "$BIN_DIR" "$rc_file" 2>/dev/null; then
|
||||
info "PATH already configured in $(basename "$rc_file")"
|
||||
configured_any=true
|
||||
return
|
||||
fi
|
||||
echo "" >> "$rc_file"
|
||||
echo "# OpenHarness" >> "$rc_file"
|
||||
echo "$ACTIVATION_LINE" >> "$rc_file"
|
||||
success "Added $BIN_DIR to PATH in $(basename "$rc_file")"
|
||||
configured_any=true
|
||||
}
|
||||
|
||||
append_shell_path "$HOME/.zshrc"
|
||||
append_shell_path "$HOME/.bashrc"
|
||||
append_shell_path "$HOME/.bash_profile"
|
||||
|
||||
mkdir -p "$(dirname "$FISH_CONFIG")"
|
||||
if [ -f "$FISH_CONFIG" ] && grep -q "$BIN_DIR" "$FISH_CONFIG" 2>/dev/null; then
|
||||
info "PATH already configured in $(basename "$FISH_CONFIG")"
|
||||
configured_any=true
|
||||
else
|
||||
echo "" >> "$FISH_CONFIG"
|
||||
printf "%s\n" "$FISH_BLOCK" >> "$FISH_CONFIG"
|
||||
success "Added $BIN_DIR to PATH in $(basename "$FISH_CONFIG")"
|
||||
configured_any=true
|
||||
fi
|
||||
|
||||
if [ "$configured_any" = false ]; then
|
||||
warn "Could not find shell config file. Add this to your shell profile:"
|
||||
echo " $ACTIVATION_LINE"
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Done
|
||||
# ---------------------------------------------------------------------------
|
||||
echo ""
|
||||
echo -e "${BOLD}${GREEN}OpenHarness is installed!${RESET}"
|
||||
echo ""
|
||||
echo " Next steps:"
|
||||
echo " 1. Restart shell, or reload your shell config:"
|
||||
echo " bash/zsh: source ~/.bashrc (or ~/.zshrc)"
|
||||
echo " fish: source ~/.config/fish/config.fish"
|
||||
echo " 2. Set your API key: export ANTHROPIC_API_KEY=your_key"
|
||||
echo " 3. Launch: oh"
|
||||
echo " 4. Launch ohmo: ohmo"
|
||||
echo " 5. Docs: https://github.com/HKUDS/OpenHarness"
|
||||
echo ""
|
||||
echo " Notes:"
|
||||
echo " - Commands are linked into: ${BIN_DIR}"
|
||||
echo " - The virtual environment remains at: ${VENV_DIR}"
|
||||
echo ""
|
||||
@@ -1,181 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Developer install for the current checkout.
|
||||
# Usage:
|
||||
# bash scripts/install_dev.sh
|
||||
# bash scripts/install_dev.sh --global-venv
|
||||
# bash scripts/install_dev.sh --with-channels
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
if [ -t 1 ]; then
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
CYAN='\033[0;36m'
|
||||
BOLD='\033[1m'
|
||||
RESET='\033[0m'
|
||||
else
|
||||
RED='' GREEN='' YELLOW='' BLUE='' CYAN='' BOLD='' RESET=''
|
||||
fi
|
||||
|
||||
info() { echo -e "${CYAN}[INFO]${RESET} $*"; }
|
||||
success() { echo -e "${GREEN}[OK]${RESET} $*"; }
|
||||
warn() { echo -e "${YELLOW}[WARN]${RESET} $*"; }
|
||||
error() { echo -e "${RED}[ERROR]${RESET} $*" >&2; }
|
||||
step() { echo -e "\n${BOLD}${BLUE}==>${RESET}${BOLD} $*${RESET}"; }
|
||||
|
||||
WITH_CHANNELS=false
|
||||
GLOBAL_VENV=false
|
||||
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--with-channels) WITH_CHANNELS=true ;;
|
||||
--global-venv) GLOBAL_VENV=true ;;
|
||||
--help|-h)
|
||||
echo "Usage: $0 [--with-channels] [--global-venv]"
|
||||
echo ""
|
||||
echo "Installs the current checkout in editable mode and"
|
||||
echo "registers oh/ohmo in ~/.local/bin."
|
||||
echo ""
|
||||
echo " default use ./ .openharness-venv inside the current repo"
|
||||
echo " --global-venv use ~/.openharness-venv but still install the current repo"
|
||||
echo " --with-channels deprecated compatibility flag; common IM deps install by default"
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
error "Unknown argument: $arg"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
if [ "$GLOBAL_VENV" = true ]; then
|
||||
VENV_DIR="$HOME/.openharness-venv"
|
||||
else
|
||||
VENV_DIR="$REPO_ROOT/.openharness-venv"
|
||||
fi
|
||||
BIN_DIR="$HOME/.local/bin"
|
||||
|
||||
step "Checking Python version (>= 3.10 required)"
|
||||
|
||||
PYTHON_CMD=""
|
||||
for cmd in python3 python; do
|
||||
if command -v "$cmd" >/dev/null 2>&1; then
|
||||
PY_VER=$("$cmd" --version 2>&1 | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -1)
|
||||
PY_MAJOR=$(echo "$PY_VER" | cut -d. -f1)
|
||||
PY_MINOR=$(echo "$PY_VER" | cut -d. -f2)
|
||||
if [ "${PY_MAJOR}" -ge 3 ] && [ "${PY_MINOR}" -ge 10 ]; then
|
||||
PYTHON_CMD="$cmd"
|
||||
break
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
if [ -z "$PYTHON_CMD" ]; then
|
||||
error "Python 3.10+ not found."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
success "Found $("$PYTHON_CMD" --version 2>&1) (${PYTHON_CMD})"
|
||||
|
||||
step "Preparing developer virtual environment"
|
||||
|
||||
if [ ! -d "$VENV_DIR" ]; then
|
||||
info "Creating virtual environment at ${VENV_DIR}"
|
||||
"$PYTHON_CMD" -m venv "$VENV_DIR"
|
||||
fi
|
||||
|
||||
source "$VENV_DIR/bin/activate"
|
||||
python -m pip install --upgrade pip setuptools wheel --quiet
|
||||
success "Virtual environment ready: ${VENV_DIR}"
|
||||
|
||||
step "Installing current checkout in editable mode"
|
||||
python -m pip install -e "$REPO_ROOT" --quiet
|
||||
success "Installed OpenHarness from ${REPO_ROOT}"
|
||||
|
||||
if [ "$WITH_CHANNELS" = true ]; then
|
||||
step "Channel dependencies"
|
||||
info "--with-channels is no longer required; common IM channel dependencies are installed by default."
|
||||
fi
|
||||
|
||||
step "Installing React terminal dependencies (optional)"
|
||||
if command -v node >/dev/null 2>&1; then
|
||||
NODE_MAJOR=$(node --version 2>&1 | grep -oE '[0-9]+' | head -1)
|
||||
FRONTEND_DIR="$REPO_ROOT/frontend/terminal"
|
||||
if [ "${NODE_MAJOR}" -ge 18 ] 2>/dev/null && [ -f "$FRONTEND_DIR/package.json" ]; then
|
||||
info "Running npm install in ${FRONTEND_DIR}"
|
||||
(cd "$FRONTEND_DIR" && npm install --no-fund --no-audit --silent)
|
||||
success "React terminal dependencies installed"
|
||||
else
|
||||
warn "Node.js too old or frontend directory missing; skipping npm install"
|
||||
fi
|
||||
else
|
||||
warn "Node.js not found; skipping npm install"
|
||||
fi
|
||||
|
||||
step "Registering global commands"
|
||||
|
||||
mkdir -p "$BIN_DIR"
|
||||
ln -snf "$VENV_DIR/bin/oh" "$BIN_DIR/oh"
|
||||
ln -snf "$VENV_DIR/bin/ohmo" "$BIN_DIR/ohmo"
|
||||
ln -snf "$VENV_DIR/bin/openharness" "$BIN_DIR/openharness"
|
||||
success "Linked oh/ohmo into ${BIN_DIR}"
|
||||
|
||||
ensure_path_in_file() {
|
||||
local rc_file="$1"
|
||||
local line="$2"
|
||||
[ -f "$rc_file" ] || return 0
|
||||
if ! grep -qF "$line" "$rc_file" 2>/dev/null; then
|
||||
echo "" >> "$rc_file"
|
||||
echo "# OpenHarness dev" >> "$rc_file"
|
||||
echo "$line" >> "$rc_file"
|
||||
success "Added ${BIN_DIR} to PATH in $(basename "$rc_file")"
|
||||
fi
|
||||
}
|
||||
|
||||
step "Ensuring ~/.local/bin is on PATH"
|
||||
mkdir -p "$HOME/.config/fish"
|
||||
ensure_path_in_file "$HOME/.bashrc" "export PATH=\"$BIN_DIR:\$PATH\""
|
||||
ensure_path_in_file "$HOME/.bash_profile" "export PATH=\"$BIN_DIR:\$PATH\""
|
||||
ensure_path_in_file "$HOME/.zshrc" "export PATH=\"$BIN_DIR:\$PATH\""
|
||||
|
||||
if [ -f "$HOME/.config/fish/config.fish" ]; then
|
||||
if ! grep -qF "$BIN_DIR" "$HOME/.config/fish/config.fish" 2>/dev/null; then
|
||||
{
|
||||
echo ""
|
||||
echo "# OpenHarness dev"
|
||||
echo "if not contains -- \"$BIN_DIR\" \$PATH"
|
||||
echo " set -gx PATH \"$BIN_DIR\" \$PATH"
|
||||
echo "end"
|
||||
} >> "$HOME/.config/fish/config.fish"
|
||||
success "Added ${BIN_DIR} to PATH in config.fish"
|
||||
fi
|
||||
else
|
||||
cat > "$HOME/.config/fish/config.fish" <<EOF
|
||||
# OpenHarness dev
|
||||
if not contains -- "$BIN_DIR" \$PATH
|
||||
set -gx PATH "$BIN_DIR" \$PATH
|
||||
end
|
||||
EOF
|
||||
success "Created config.fish with ${BIN_DIR} on PATH"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo -e "${BOLD}${GREEN}Developer install complete.${RESET}"
|
||||
echo ""
|
||||
echo " Repo root: $REPO_ROOT"
|
||||
echo " Virtual environment: $VENV_DIR"
|
||||
echo " Command links: $BIN_DIR/oh , $BIN_DIR/ohmo"
|
||||
echo ""
|
||||
echo " If this shell does not see the commands yet, run one of:"
|
||||
echo " bash: source ~/.bashrc"
|
||||
echo " zsh: source ~/.zshrc"
|
||||
echo " fish: source ~/.config/fish/config.fish"
|
||||
echo ""
|
||||
echo " Or use immediately in this shell:"
|
||||
echo " export PATH=\"$BIN_DIR:\$PATH\""
|
||||
echo " hash -r"
|
||||
echo ""
|
||||
@@ -1,14 +0,0 @@
|
||||
"""Run the OpenHarness memory schema migration from a source checkout."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
|
||||
|
||||
from openharness.memory.migrate import main
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
+34
-25
@@ -18,8 +18,10 @@ from openharness.config.settings import load_settings
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def _spawn_oh(*, env: dict[str, str] | None = None) -> pexpect.spawn:
|
||||
def _spawn_oh(prompt: str | None = None, *, env: dict[str, str] | None = None) -> pexpect.spawn:
|
||||
args = ["run", "oh"]
|
||||
if prompt is not None:
|
||||
args.append(prompt)
|
||||
child = pexpect.spawn(
|
||||
"uv",
|
||||
args,
|
||||
@@ -65,17 +67,16 @@ def _run_permission_file_io() -> None:
|
||||
if path.exists():
|
||||
path.unlink()
|
||||
temp_dir, env = _isolated_env()
|
||||
child = _spawn_oh(env=env)
|
||||
child = _spawn_oh(
|
||||
"You are running a React TUI end-to-end test. "
|
||||
"Use write_file to create react_tui_smoke.txt with exact content REACT_TUI_OK, "
|
||||
"then use read_file to verify it, then reply with exactly FINAL_OK_REACT_TUI.",
|
||||
env=env,
|
||||
)
|
||||
try:
|
||||
print("[react_tui_permission_file_io] waiting for app shell")
|
||||
child.expect("OpenHarness")
|
||||
child.expect("model: kimi-k2.5")
|
||||
_submit(
|
||||
child,
|
||||
"You are running a React TUI end-to-end test. "
|
||||
"Use write_file to create react_tui_smoke.txt with exact content REACT_TUI_OK, "
|
||||
"then use read_file to verify it, then reply with exactly FINAL_OK_REACT_TUI.",
|
||||
)
|
||||
child.expect("OpenHarness React TUI")
|
||||
child.expect("model=kimi-k2.5")
|
||||
print("[react_tui_permission_file_io] waiting for final marker")
|
||||
child.expect(r"(?s)assistant>.*FINAL_OK_REACT_TUI")
|
||||
finally:
|
||||
@@ -91,17 +92,16 @@ def _run_question_flow() -> None:
|
||||
if path.exists():
|
||||
path.unlink()
|
||||
temp_dir, env = _isolated_env()
|
||||
child = _spawn_oh(env=env)
|
||||
child = _spawn_oh(
|
||||
"You are running a React TUI question flow test. "
|
||||
"Use ask_user_question to ask for a color. "
|
||||
"After the answer arrives, use write_file to create react_tui_question.txt with that exact answer, "
|
||||
"then use read_file to verify it, then reply with exactly FINAL_OK_REACT_TUI_QUESTION.",
|
||||
env=env,
|
||||
)
|
||||
try:
|
||||
child.expect("OpenHarness")
|
||||
child.expect("model: kimi-k2.5")
|
||||
_submit(
|
||||
child,
|
||||
"You are running a React TUI question flow test. "
|
||||
"Use ask_user_question to ask for a color. "
|
||||
"After the answer arrives, use write_file to create react_tui_question.txt with that exact answer, "
|
||||
"then use read_file to verify it, then reply with exactly FINAL_OK_REACT_TUI_QUESTION.",
|
||||
)
|
||||
child.expect("OpenHarness React TUI")
|
||||
child.expect("model=kimi-k2.5")
|
||||
print("[react_tui_question_flow] waiting for question modal")
|
||||
child.expect("Question")
|
||||
child.expect("color")
|
||||
@@ -120,17 +120,26 @@ def _run_command_flow() -> None:
|
||||
temp_dir, env = _isolated_env()
|
||||
env["OPENHARNESS_FRONTEND_SCRIPT"] = json.dumps(
|
||||
[
|
||||
"/plan",
|
||||
"/permissions set full_auto",
|
||||
"/effort high",
|
||||
"/passes 3",
|
||||
"/status",
|
||||
"Reply with exactly FINAL_OK_REACT_TUI_COMMANDS.",
|
||||
]
|
||||
)
|
||||
child = _spawn_oh(env=env)
|
||||
try:
|
||||
print("[react_tui_command_flow] waiting for app shell")
|
||||
child.expect("OpenHarness")
|
||||
child.expect("model: kimi-k2.5")
|
||||
print("[react_tui_command_flow] waiting for plan mode indicator")
|
||||
child.expect("PLAN MODE")
|
||||
child.expect("OpenHarness React TUI")
|
||||
child.expect("model=kimi-k2.5")
|
||||
child.expect("Permission mode set to full_auto")
|
||||
print("[react_tui_command_flow] waiting for effort confirmation")
|
||||
child.expect("Reasoning effort set to high.")
|
||||
print("[react_tui_command_flow] waiting for passes confirmation")
|
||||
child.expect("Pass count set to 3.")
|
||||
print("[react_tui_command_flow] waiting for status output")
|
||||
child.expect("Effort: high")
|
||||
child.expect("Passes: 3")
|
||||
print("[react_tui_command_flow] waiting for final marker")
|
||||
child.expect(r"(?s)assistant>.*FINAL_OK_REACT_TUI_COMMANDS")
|
||||
finally:
|
||||
|
||||
@@ -1,90 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# sync_nanobot_channels.sh — diff and optionally apply upstream nanobot changes
|
||||
#
|
||||
# Usage:
|
||||
# ./scripts/sync_nanobot_channels.sh [--apply] [--nanobot-dir DIR]
|
||||
#
|
||||
# Without --apply: shows a diff between the recorded upstream commit and HEAD.
|
||||
# With --apply: copies updated files, rewrites imports, and updates UPSTREAM.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
UPSTREAM_FILE="$REPO_ROOT/src/openharness/channels/UPSTREAM"
|
||||
CHANNELS_DEST="$REPO_ROOT/src/openharness/channels"
|
||||
|
||||
# ---------- parse args ----------
|
||||
APPLY=false
|
||||
NANOBOT_DIR="${NANOBOT_DIR:-$HOME/nanobot}"
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--apply) APPLY=true; shift ;;
|
||||
--nanobot-dir) NANOBOT_DIR="$2"; shift 2 ;;
|
||||
*) echo "Unknown arg: $1" >&2; exit 1 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# ---------- read recorded commit ----------
|
||||
OLD_COMMIT=$(grep '^commit:' "$UPSTREAM_FILE" | awk '{print $2}')
|
||||
echo "Upstream UPSTREAM commit : $OLD_COMMIT"
|
||||
|
||||
cd "$NANOBOT_DIR"
|
||||
NEW_COMMIT=$(git rev-parse HEAD)
|
||||
echo "Nanobot HEAD : $NEW_COMMIT"
|
||||
|
||||
if [[ "$OLD_COMMIT" == "$NEW_COMMIT" ]]; then
|
||||
echo "Already up-to-date."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# ---------- show diff ----------
|
||||
echo ""
|
||||
echo "=== diff nanobot/bus/ ==="
|
||||
git diff "$OLD_COMMIT".."$NEW_COMMIT" -- nanobot/bus/ || true
|
||||
|
||||
echo ""
|
||||
echo "=== diff nanobot/channels/ ==="
|
||||
git diff "$OLD_COMMIT".."$NEW_COMMIT" -- nanobot/channels/ || true
|
||||
|
||||
if [[ "$APPLY" == false ]]; then
|
||||
echo ""
|
||||
echo "Run with --apply to apply these changes."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# ---------- apply ----------
|
||||
echo ""
|
||||
echo "Applying changes..."
|
||||
|
||||
# Copy files
|
||||
cp nanobot/bus/events.py "$CHANNELS_DEST/bus/events.py"
|
||||
cp nanobot/bus/queue.py "$CHANNELS_DEST/bus/queue.py"
|
||||
for f in nanobot/channels/*.py; do
|
||||
fname="$(basename "$f")"
|
||||
[[ "$fname" == "__init__.py" ]] && continue
|
||||
cp "$f" "$CHANNELS_DEST/impl/$fname"
|
||||
done
|
||||
|
||||
# Rewrite imports
|
||||
for f in "$CHANNELS_DEST/bus/"*.py "$CHANNELS_DEST/impl/"*.py; do
|
||||
sed -i \
|
||||
-e 's/from nanobot\.bus\./from openharness.channels.bus./g' \
|
||||
-e 's/from nanobot\.channels\./from openharness.channels.impl./g' \
|
||||
-e 's/from nanobot\.config\.schema import/from openharness.config.schema import/g' \
|
||||
-e 's/from nanobot\.utils\.helpers import/from openharness.utils.helpers import/g' \
|
||||
-e 's/from nanobot\.config\.loader import/from openharness.config.loader import/g' \
|
||||
"$f"
|
||||
# Replace loguru
|
||||
sed -i \
|
||||
's/^from loguru import logger$/import logging\nlogger = logging.getLogger(__name__)/' \
|
||||
"$f"
|
||||
done
|
||||
|
||||
# Update UPSTREAM
|
||||
SYNC_TIME=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
|
||||
sed -i "s/^commit: .*/commit: $NEW_COMMIT/" "$UPSTREAM_FILE"
|
||||
sed -i "s/^synced: .*/synced: $SYNC_TIME/" "$UPSTREAM_FILE"
|
||||
|
||||
echo "Done. Updated UPSTREAM to $NEW_COMMIT (synced $SYNC_TIME)"
|
||||
@@ -1,654 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""End-to-end tests for the Docker sandbox backend.
|
||||
|
||||
These tests require a running Docker daemon. They exercise the full container
|
||||
lifecycle: image build, container start, command execution, file isolation,
|
||||
network isolation, resource limits, and cleanup.
|
||||
|
||||
Run directly:
|
||||
python3 scripts/test_docker_sandbox_e2e.py
|
||||
|
||||
Or via pytest (skipped automatically when Docker is unavailable):
|
||||
uv run pytest scripts/test_docker_sandbox_e2e.py -v
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Bootstrap: ensure the src package is importable when run as a script
|
||||
# ---------------------------------------------------------------------------
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
|
||||
|
||||
from openharness.config.settings import (
|
||||
DockerSandboxSettings,
|
||||
SandboxNetworkSettings,
|
||||
SandboxSettings,
|
||||
Settings,
|
||||
)
|
||||
from openharness.sandbox.docker_backend import DockerSandboxSession, get_docker_availability
|
||||
from openharness.sandbox.docker_image import ensure_image_available
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Skip condition: Docker daemon must be reachable
|
||||
# ---------------------------------------------------------------------------
|
||||
_DOCKER = shutil.which("docker")
|
||||
_DOCKER_OK = False
|
||||
if _DOCKER:
|
||||
try:
|
||||
subprocess.run([_DOCKER, "info"], capture_output=True, timeout=10, check=True)
|
||||
_DOCKER_OK = True
|
||||
except (subprocess.CalledProcessError, subprocess.TimeoutExpired, OSError):
|
||||
pass
|
||||
|
||||
_SKIP_REASON = "Docker daemon is not available"
|
||||
_E2E_IMAGE = "openharness-sandbox-e2e:latest"
|
||||
|
||||
pytestmark = pytest.mark.skipif(not _DOCKER_OK, reason=_SKIP_REASON)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _settings(*, network_domains: list[str] | None = None, **docker_kw) -> Settings:
|
||||
"""Build a Settings object with Docker sandbox enabled."""
|
||||
return Settings(
|
||||
sandbox=SandboxSettings(
|
||||
enabled=True,
|
||||
backend="docker",
|
||||
fail_if_unavailable=True,
|
||||
network=SandboxNetworkSettings(
|
||||
allowed_domains=network_domains or [],
|
||||
),
|
||||
docker=DockerSandboxSettings(
|
||||
image=_E2E_IMAGE,
|
||||
auto_build_image=True,
|
||||
**docker_kw,
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
async def _run_and_capture(session: DockerSandboxSession, argv: list[str], cwd: Path) -> tuple[int, str, str]:
|
||||
"""Run a command inside the sandbox and return (returncode, stdout, stderr)."""
|
||||
proc = await session.exec_command(
|
||||
argv,
|
||||
cwd=cwd,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
stdout, stderr = await proc.communicate()
|
||||
return (
|
||||
proc.returncode or 0,
|
||||
stdout.decode("utf-8", errors="replace").strip(),
|
||||
stderr.decode("utf-8", errors="replace").strip(),
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def e2e_image():
|
||||
"""Ensure the E2E sandbox image exists (built once per module)."""
|
||||
ok = asyncio.run(ensure_image_available(_E2E_IMAGE, auto_build=True))
|
||||
if not ok:
|
||||
pytest.skip(f"Could not build Docker image {_E2E_IMAGE}")
|
||||
return _E2E_IMAGE
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def project_dir():
|
||||
"""Create a temporary project directory for one test."""
|
||||
with tempfile.TemporaryDirectory(prefix="oh-docker-e2e-") as tmpdir:
|
||||
yield Path(tmpdir)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 1. Image management
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestImageManagement:
|
||||
def test_image_build(self, e2e_image):
|
||||
"""Image should be available after the module fixture runs."""
|
||||
result = subprocess.run(
|
||||
[_DOCKER, "image", "inspect", e2e_image],
|
||||
capture_output=True,
|
||||
)
|
||||
assert result.returncode == 0, "E2E image should exist after build"
|
||||
|
||||
def test_image_has_expected_tools(self, e2e_image):
|
||||
"""Image should contain bash, rg (ripgrep), and git."""
|
||||
for tool in ("bash", "rg", "git"):
|
||||
result = subprocess.run(
|
||||
[_DOCKER, "run", "--rm", e2e_image, "which", tool],
|
||||
capture_output=True,
|
||||
)
|
||||
assert result.returncode == 0, f"{tool} should be available in the image"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2. Container lifecycle
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestContainerLifecycle:
|
||||
def test_start_and_stop(self, e2e_image, project_dir):
|
||||
"""Container should start, appear in docker ps, then stop cleanly."""
|
||||
settings = _settings()
|
||||
session = DockerSandboxSession(
|
||||
settings=settings, session_id="e2e-lifecycle", cwd=project_dir,
|
||||
)
|
||||
|
||||
async def _run():
|
||||
await session.start()
|
||||
assert session.is_running
|
||||
|
||||
# Verify container is visible
|
||||
result = subprocess.run(
|
||||
[_DOCKER, "ps", "--filter", f"name={session.container_name}", "-q"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
assert result.stdout.strip(), "Container should appear in docker ps"
|
||||
|
||||
await session.stop()
|
||||
assert not session.is_running
|
||||
|
||||
# Verify container is gone (--rm flag auto-removes)
|
||||
result = subprocess.run(
|
||||
[_DOCKER, "ps", "-a", "--filter", f"name={session.container_name}", "-q"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
assert not result.stdout.strip(), "Container should be removed after stop"
|
||||
|
||||
asyncio.run(_run())
|
||||
|
||||
def test_stop_sync(self, e2e_image, project_dir):
|
||||
"""Synchronous stop (atexit handler) should also clean up."""
|
||||
settings = _settings()
|
||||
session = DockerSandboxSession(
|
||||
settings=settings, session_id="e2e-stopsync", cwd=project_dir,
|
||||
)
|
||||
|
||||
async def _start():
|
||||
await session.start()
|
||||
|
||||
asyncio.run(_start())
|
||||
assert session.is_running
|
||||
|
||||
session.stop_sync()
|
||||
assert not session.is_running
|
||||
|
||||
def test_availability_check(self):
|
||||
"""get_docker_availability should return available=True when Docker is running."""
|
||||
settings = _settings()
|
||||
avail = get_docker_availability(settings)
|
||||
assert avail.enabled is True
|
||||
assert avail.available is True
|
||||
assert avail.command is not None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 3. Command execution
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestCommandExecution:
|
||||
def test_echo(self, e2e_image, project_dir):
|
||||
"""Basic command should execute and return output."""
|
||||
settings = _settings()
|
||||
session = DockerSandboxSession(
|
||||
settings=settings, session_id="e2e-echo", cwd=project_dir,
|
||||
)
|
||||
|
||||
async def _run():
|
||||
await session.start()
|
||||
try:
|
||||
rc, stdout, stderr = await _run_and_capture(
|
||||
session, ["echo", "hello-from-sandbox"], project_dir,
|
||||
)
|
||||
assert rc == 0
|
||||
assert "hello-from-sandbox" in stdout
|
||||
finally:
|
||||
await session.stop()
|
||||
|
||||
asyncio.run(_run())
|
||||
|
||||
def test_exit_code_preserved(self, e2e_image, project_dir):
|
||||
"""Non-zero exit codes should propagate correctly."""
|
||||
settings = _settings()
|
||||
session = DockerSandboxSession(
|
||||
settings=settings, session_id="e2e-exitcode", cwd=project_dir,
|
||||
)
|
||||
|
||||
async def _run():
|
||||
await session.start()
|
||||
try:
|
||||
rc, _, _ = await _run_and_capture(
|
||||
session, ["bash", "-c", "exit 42"], project_dir,
|
||||
)
|
||||
assert rc == 42
|
||||
finally:
|
||||
await session.stop()
|
||||
|
||||
asyncio.run(_run())
|
||||
|
||||
def test_env_vars_passed(self, e2e_image, project_dir):
|
||||
"""Environment variables should be forwarded into the container."""
|
||||
settings = _settings()
|
||||
session = DockerSandboxSession(
|
||||
settings=settings, session_id="e2e-env", cwd=project_dir,
|
||||
)
|
||||
|
||||
async def _run():
|
||||
await session.start()
|
||||
try:
|
||||
rc, stdout, _ = await _run_and_capture(
|
||||
session,
|
||||
["bash", "-c", "echo $MY_TEST_VAR"],
|
||||
project_dir,
|
||||
)
|
||||
# env passed through exec_command
|
||||
proc = await session.exec_command(
|
||||
["bash", "-c", "echo $MY_TEST_VAR"],
|
||||
cwd=project_dir,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
env={"MY_TEST_VAR": "sandbox-value"},
|
||||
)
|
||||
out, _ = await proc.communicate()
|
||||
assert "sandbox-value" in out.decode()
|
||||
finally:
|
||||
await session.stop()
|
||||
|
||||
asyncio.run(_run())
|
||||
|
||||
def test_working_directory(self, e2e_image, project_dir):
|
||||
"""Commands should run in the specified working directory."""
|
||||
settings = _settings()
|
||||
session = DockerSandboxSession(
|
||||
settings=settings, session_id="e2e-cwd", cwd=project_dir,
|
||||
)
|
||||
|
||||
async def _run():
|
||||
await session.start()
|
||||
try:
|
||||
rc, stdout, _ = await _run_and_capture(
|
||||
session, ["pwd"], project_dir,
|
||||
)
|
||||
assert rc == 0
|
||||
assert str(project_dir.resolve()) in stdout
|
||||
finally:
|
||||
await session.stop()
|
||||
|
||||
asyncio.run(_run())
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 4. Filesystem isolation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestFilesystemIsolation:
|
||||
def test_bind_mount_readable(self, e2e_image, project_dir):
|
||||
"""Files in the project directory should be readable from inside the container."""
|
||||
marker = project_dir / "test_marker.txt"
|
||||
marker.write_text("E2E_MARKER_OK", encoding="utf-8")
|
||||
|
||||
settings = _settings()
|
||||
session = DockerSandboxSession(
|
||||
settings=settings, session_id="e2e-fsread", cwd=project_dir,
|
||||
)
|
||||
|
||||
async def _run():
|
||||
await session.start()
|
||||
try:
|
||||
rc, stdout, _ = await _run_and_capture(
|
||||
session, ["cat", str(marker)], project_dir,
|
||||
)
|
||||
assert rc == 0
|
||||
assert "E2E_MARKER_OK" in stdout
|
||||
finally:
|
||||
await session.stop()
|
||||
|
||||
asyncio.run(_run())
|
||||
|
||||
def test_bind_mount_writable(self, e2e_image, project_dir):
|
||||
"""Files written inside the container should appear on the host."""
|
||||
output_file = project_dir / "from_container.txt"
|
||||
settings = _settings()
|
||||
session = DockerSandboxSession(
|
||||
settings=settings, session_id="e2e-fswrite", cwd=project_dir,
|
||||
)
|
||||
|
||||
async def _run():
|
||||
await session.start()
|
||||
try:
|
||||
rc, _, _ = await _run_and_capture(
|
||||
session,
|
||||
["bash", "-c", f"echo CONTAINER_WROTE_THIS > {output_file}"],
|
||||
project_dir,
|
||||
)
|
||||
assert rc == 0
|
||||
assert output_file.exists(), "File written in container should exist on host"
|
||||
assert "CONTAINER_WROTE_THIS" in output_file.read_text()
|
||||
finally:
|
||||
await session.stop()
|
||||
|
||||
asyncio.run(_run())
|
||||
|
||||
def test_host_root_not_accessible(self, e2e_image, project_dir):
|
||||
"""The container should NOT be able to read host files outside the bind mount."""
|
||||
settings = _settings()
|
||||
session = DockerSandboxSession(
|
||||
settings=settings, session_id="e2e-fsfence", cwd=project_dir,
|
||||
)
|
||||
|
||||
async def _run():
|
||||
await session.start()
|
||||
try:
|
||||
# /etc/hostname exists on the host but should not be the same inside
|
||||
# the container (container has its own /etc/hostname).
|
||||
# More importantly, a path like /root/.bashrc should not be the host's.
|
||||
rc, stdout, _ = await _run_and_capture(
|
||||
session, ["ls", "/home"], project_dir,
|
||||
)
|
||||
# The container should have its own /home (with ohuser),
|
||||
# not the host's /home
|
||||
assert rc == 0
|
||||
assert "ohuser" in stdout, (
|
||||
"Container /home should contain the sandbox user, not host users"
|
||||
)
|
||||
finally:
|
||||
await session.stop()
|
||||
|
||||
asyncio.run(_run())
|
||||
|
||||
def test_ripgrep_inside_container(self, e2e_image, project_dir):
|
||||
"""rg should work inside the container for glob/grep tool integration."""
|
||||
(project_dir / "hello.py").write_text("print('hello world')\n", encoding="utf-8")
|
||||
|
||||
settings = _settings()
|
||||
session = DockerSandboxSession(
|
||||
settings=settings, session_id="e2e-rg", cwd=project_dir,
|
||||
)
|
||||
|
||||
async def _run():
|
||||
await session.start()
|
||||
try:
|
||||
rc, stdout, _ = await _run_and_capture(
|
||||
session, ["rg", "--no-heading", "hello", "."], project_dir,
|
||||
)
|
||||
assert rc == 0
|
||||
assert "hello world" in stdout
|
||||
finally:
|
||||
await session.stop()
|
||||
|
||||
asyncio.run(_run())
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 5. Network isolation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestNetworkIsolation:
|
||||
def test_network_none_blocks_connectivity(self, e2e_image, project_dir):
|
||||
"""With --network=none, outbound connections should fail."""
|
||||
settings = _settings() # no allowed_domains -> --network=none
|
||||
session = DockerSandboxSession(
|
||||
settings=settings, session_id="e2e-netblk", cwd=project_dir,
|
||||
)
|
||||
|
||||
async def _run():
|
||||
await session.start()
|
||||
try:
|
||||
# Try to reach an external host; should fail
|
||||
rc, _, _ = await _run_and_capture(
|
||||
session,
|
||||
["bash", "-c", "timeout 3 bash -c 'echo > /dev/tcp/8.8.8.8/53' 2>&1 || exit 1"],
|
||||
project_dir,
|
||||
)
|
||||
assert rc != 0, "Network should be blocked with --network=none"
|
||||
finally:
|
||||
await session.stop()
|
||||
|
||||
asyncio.run(_run())
|
||||
|
||||
def test_network_bridge_allows_connectivity(self, e2e_image, project_dir):
|
||||
"""With allowed_domains set, --network=bridge is used and DNS resolves."""
|
||||
settings = _settings(network_domains=["github.com"])
|
||||
session = DockerSandboxSession(
|
||||
settings=settings, session_id="e2e-netok", cwd=project_dir,
|
||||
)
|
||||
|
||||
async def _run():
|
||||
await session.start()
|
||||
try:
|
||||
# With bridge network, DNS resolution should work
|
||||
rc, stdout, _ = await _run_and_capture(
|
||||
session,
|
||||
["bash", "-c", "getent hosts github.com 2>/dev/null && echo DNS_OK || echo DNS_FAIL"],
|
||||
project_dir,
|
||||
)
|
||||
# getent may not be installed in slim image; check if we at least
|
||||
# have network interfaces beyond loopback
|
||||
rc2, stdout2, _ = await _run_and_capture(
|
||||
session,
|
||||
["bash", "-c", "cat /proc/net/route | wc -l"],
|
||||
project_dir,
|
||||
)
|
||||
route_lines = int(stdout2.strip()) if stdout2.strip().isdigit() else 0
|
||||
assert route_lines > 1, (
|
||||
"Bridge network should have routing entries beyond just the header"
|
||||
)
|
||||
finally:
|
||||
await session.stop()
|
||||
|
||||
asyncio.run(_run())
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 6. Resource limits
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestResourceLimits:
|
||||
def test_cpu_limit_applied(self, e2e_image, project_dir):
|
||||
"""Container should reflect the configured CPU limit."""
|
||||
settings = _settings(cpu_limit=1.5)
|
||||
session = DockerSandboxSession(
|
||||
settings=settings, session_id="e2e-cpu", cwd=project_dir,
|
||||
)
|
||||
|
||||
async def _run():
|
||||
await session.start()
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[_DOCKER, "inspect", "--format", "{{.HostConfig.NanoCpus}}",
|
||||
session.container_name],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
# Docker stores NanoCpus as int nanoseconds: 1.5 CPUs = 1_500_000_000
|
||||
nano = int(result.stdout.strip())
|
||||
assert nano == 1_500_000_000, f"Expected 1.5 CPUs (1500000000 nano), got {nano}"
|
||||
finally:
|
||||
await session.stop()
|
||||
|
||||
asyncio.run(_run())
|
||||
|
||||
def test_memory_limit_applied(self, e2e_image, project_dir):
|
||||
"""Container should reflect the configured memory limit."""
|
||||
settings = _settings(memory_limit="256m")
|
||||
session = DockerSandboxSession(
|
||||
settings=settings, session_id="e2e-mem", cwd=project_dir,
|
||||
)
|
||||
|
||||
async def _run():
|
||||
await session.start()
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[_DOCKER, "inspect", "--format", "{{.HostConfig.Memory}}",
|
||||
session.container_name],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
mem_bytes = int(result.stdout.strip())
|
||||
expected = 256 * 1024 * 1024 # 256 MiB
|
||||
assert mem_bytes == expected, f"Expected {expected} bytes, got {mem_bytes}"
|
||||
finally:
|
||||
await session.stop()
|
||||
|
||||
asyncio.run(_run())
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 7. Session integration (start_docker_sandbox / stop_docker_sandbox)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestSessionIntegration:
|
||||
def test_session_lifecycle(self, e2e_image, project_dir):
|
||||
"""start_docker_sandbox / stop_docker_sandbox should manage the global session."""
|
||||
from openharness.sandbox.session import (
|
||||
get_docker_sandbox,
|
||||
is_docker_sandbox_active,
|
||||
start_docker_sandbox,
|
||||
stop_docker_sandbox,
|
||||
)
|
||||
|
||||
settings = _settings()
|
||||
|
||||
async def _run():
|
||||
assert not is_docker_sandbox_active()
|
||||
|
||||
await start_docker_sandbox(settings, "e2e-session", project_dir)
|
||||
assert is_docker_sandbox_active()
|
||||
|
||||
session = get_docker_sandbox()
|
||||
assert session is not None
|
||||
assert session.is_running
|
||||
|
||||
# Run a command through the session
|
||||
rc, stdout, _ = await _run_and_capture(session, ["echo", "session-ok"], project_dir)
|
||||
assert rc == 0
|
||||
assert "session-ok" in stdout
|
||||
|
||||
await stop_docker_sandbox()
|
||||
assert not is_docker_sandbox_active()
|
||||
assert get_docker_sandbox() is None
|
||||
|
||||
asyncio.run(_run())
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 8. shell.py integration (create_shell_subprocess routes through Docker)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestShellIntegration:
|
||||
def test_create_shell_subprocess_routes_through_docker(self, e2e_image, project_dir):
|
||||
"""When Docker sandbox is active, create_shell_subprocess should exec inside container."""
|
||||
from openharness.sandbox.session import (
|
||||
start_docker_sandbox,
|
||||
stop_docker_sandbox,
|
||||
)
|
||||
from openharness.utils.shell import create_shell_subprocess
|
||||
|
||||
settings = _settings()
|
||||
|
||||
async def _run():
|
||||
await start_docker_sandbox(settings, "e2e-shell", project_dir)
|
||||
try:
|
||||
process = await create_shell_subprocess(
|
||||
"echo DOCKER_SHELL_OK",
|
||||
cwd=project_dir,
|
||||
settings=settings,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
stdout, _ = await process.communicate()
|
||||
assert "DOCKER_SHELL_OK" in stdout.decode()
|
||||
finally:
|
||||
await stop_docker_sandbox()
|
||||
|
||||
asyncio.run(_run())
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CLI entry point for running outside pytest
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _main() -> int:
|
||||
"""Run all tests and report results."""
|
||||
if not _DOCKER_OK:
|
||||
print(f"SKIP: {_SKIP_REASON}")
|
||||
return 0
|
||||
|
||||
# Collect all test classes
|
||||
test_classes = [
|
||||
TestImageManagement,
|
||||
TestContainerLifecycle,
|
||||
TestCommandExecution,
|
||||
TestFilesystemIsolation,
|
||||
TestNetworkIsolation,
|
||||
TestResourceLimits,
|
||||
TestSessionIntegration,
|
||||
TestShellIntegration,
|
||||
]
|
||||
|
||||
# Ensure image is built first
|
||||
print(f"Building sandbox image {_E2E_IMAGE}...")
|
||||
ok = asyncio.run(ensure_image_available(_E2E_IMAGE, auto_build=True))
|
||||
if not ok:
|
||||
print(f"FAIL: Could not build {_E2E_IMAGE}")
|
||||
return 1
|
||||
print("Image ready.\n")
|
||||
|
||||
passed = 0
|
||||
failed = 0
|
||||
errors: list[str] = []
|
||||
|
||||
for cls in test_classes:
|
||||
instance = cls()
|
||||
for attr in sorted(dir(instance)):
|
||||
if not attr.startswith("test_"):
|
||||
continue
|
||||
method = getattr(instance, attr)
|
||||
name = f"{cls.__name__}.{attr}"
|
||||
try:
|
||||
with tempfile.TemporaryDirectory(prefix="oh-docker-e2e-") as tmpdir:
|
||||
# Inject fixtures based on parameter names
|
||||
import inspect
|
||||
|
||||
sig = inspect.signature(method)
|
||||
kwargs = {}
|
||||
if "e2e_image" in sig.parameters:
|
||||
kwargs["e2e_image"] = _E2E_IMAGE
|
||||
if "project_dir" in sig.parameters:
|
||||
kwargs["project_dir"] = Path(tmpdir)
|
||||
method(**kwargs)
|
||||
print(f" PASS {name}")
|
||||
passed += 1
|
||||
except Exception as exc:
|
||||
print(f" FAIL {name}: {exc}")
|
||||
errors.append(f"{name}: {exc}")
|
||||
failed += 1
|
||||
|
||||
print(f"\n{'=' * 60}")
|
||||
print(f"Results: {passed} passed, {failed} failed")
|
||||
if errors:
|
||||
print("\nFailures:")
|
||||
for e in errors:
|
||||
print(f" - {e}")
|
||||
return 1 if failed else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(_main())
|
||||
@@ -1,18 +1,12 @@
|
||||
"""API exports."""
|
||||
|
||||
from openharness.api.client import AnthropicApiClient
|
||||
from openharness.api.codex_client import CodexApiClient
|
||||
from openharness.api.copilot_client import CopilotClient
|
||||
from openharness.api.errors import OpenHarnessApiError
|
||||
from openharness.api.openai_client import OpenAICompatibleClient
|
||||
from openharness.api.provider import ProviderInfo, auth_status, detect_provider
|
||||
from openharness.api.usage import UsageSnapshot
|
||||
|
||||
__all__ = [
|
||||
"AnthropicApiClient",
|
||||
"CodexApiClient",
|
||||
"CopilotClient",
|
||||
"OpenAICompatibleClient",
|
||||
"OpenHarnessApiError",
|
||||
"ProviderInfo",
|
||||
"UsageSnapshot",
|
||||
|
||||
@@ -3,11 +3,9 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, AsyncIterator, Callable, Protocol
|
||||
from typing import Any, AsyncIterator, Protocol
|
||||
|
||||
from anthropic import APIError, APIStatusError, AsyncAnthropic
|
||||
|
||||
@@ -17,12 +15,6 @@ from openharness.api.errors import (
|
||||
RateLimitFailure,
|
||||
RequestFailure,
|
||||
)
|
||||
from openharness.auth.external import (
|
||||
claude_attribution_header,
|
||||
claude_oauth_betas,
|
||||
claude_oauth_headers,
|
||||
get_claude_code_session_id,
|
||||
)
|
||||
from openharness.api.usage import UsageSnapshot
|
||||
from openharness.engine.messages import ConversationMessage, assistant_message_from_api
|
||||
|
||||
@@ -33,7 +25,6 @@ MAX_RETRIES = 3
|
||||
BASE_DELAY = 1.0 # seconds
|
||||
MAX_DELAY = 30.0
|
||||
RETRYABLE_STATUS_CODES = {429, 500, 502, 503, 529}
|
||||
OAUTH_BETA_HEADER = "oauth-2025-04-20"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -45,7 +36,6 @@ class ApiMessageRequest:
|
||||
system_prompt: str | None = None
|
||||
max_tokens: int = 4096
|
||||
tools: list[dict[str, Any]] = field(default_factory=list)
|
||||
effort: str | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -64,17 +54,7 @@ class ApiMessageCompleteEvent:
|
||||
stop_reason: str | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ApiRetryEvent:
|
||||
"""A recoverable upstream failure that will be retried automatically."""
|
||||
|
||||
message: str
|
||||
attempt: int
|
||||
max_attempts: int
|
||||
delay_seconds: float
|
||||
|
||||
|
||||
ApiStreamEvent = ApiTextDeltaEvent | ApiMessageCompleteEvent | ApiRetryEvent
|
||||
ApiStreamEvent = ApiTextDeltaEvent | ApiMessageCompleteEvent
|
||||
|
||||
|
||||
class SupportsStreamingMessages(Protocol):
|
||||
@@ -118,49 +98,11 @@ def _get_retry_delay(attempt: int, exc: Exception | None = None) -> float:
|
||||
class AnthropicApiClient:
|
||||
"""Thin wrapper around the Anthropic async SDK with retry logic."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
api_key: str | None = None,
|
||||
*,
|
||||
auth_token: str | None = None,
|
||||
base_url: str | None = None,
|
||||
claude_oauth: bool = False,
|
||||
auth_token_resolver: Callable[[], str] | None = None,
|
||||
) -> None:
|
||||
self._api_key = api_key
|
||||
self._auth_token = auth_token
|
||||
self._base_url = base_url
|
||||
self._claude_oauth = claude_oauth
|
||||
self._auth_token_resolver = auth_token_resolver
|
||||
self._session_id = get_claude_code_session_id() if claude_oauth else ""
|
||||
self._client = self._create_client()
|
||||
|
||||
def _create_client(self) -> AsyncAnthropic:
|
||||
kwargs: dict[str, Any] = {}
|
||||
if self._api_key:
|
||||
kwargs["api_key"] = self._api_key
|
||||
if self._auth_token:
|
||||
kwargs["auth_token"] = self._auth_token
|
||||
kwargs["default_headers"] = (
|
||||
claude_oauth_headers()
|
||||
if self._claude_oauth
|
||||
else {"anthropic-beta": OAUTH_BETA_HEADER}
|
||||
)
|
||||
if self._base_url:
|
||||
kwargs["base_url"] = self._base_url
|
||||
return AsyncAnthropic(**kwargs)
|
||||
|
||||
async def close(self) -> None:
|
||||
"""Close the underlying HTTP client."""
|
||||
await self._client.close()
|
||||
|
||||
def _refresh_client_auth(self) -> None:
|
||||
if not self._claude_oauth or self._auth_token_resolver is None:
|
||||
return
|
||||
next_token = self._auth_token_resolver()
|
||||
if next_token and next_token != self._auth_token:
|
||||
self._auth_token = next_token
|
||||
self._client = self._create_client()
|
||||
def __init__(self, api_key: str, *, base_url: str | None = None) -> None:
|
||||
kwargs: dict[str, Any] = {"api_key": api_key}
|
||||
if base_url:
|
||||
kwargs["base_url"] = base_url
|
||||
self._client = AsyncAnthropic(**kwargs)
|
||||
|
||||
async def stream_message(self, request: ApiMessageRequest) -> AsyncIterator[ApiStreamEvent]:
|
||||
"""Yield text deltas and the final assistant message with retry on transient errors."""
|
||||
@@ -168,7 +110,6 @@ class AnthropicApiClient:
|
||||
|
||||
for attempt in range(MAX_RETRIES + 1):
|
||||
try:
|
||||
self._refresh_client_auth()
|
||||
async for event in self._stream_once(request):
|
||||
yield event
|
||||
return # Success
|
||||
@@ -187,12 +128,6 @@ class AnthropicApiClient:
|
||||
"API request failed (attempt %d/%d, status=%s), retrying in %.1fs: %s",
|
||||
attempt + 1, MAX_RETRIES + 1, status, delay, exc,
|
||||
)
|
||||
yield ApiRetryEvent(
|
||||
message=str(exc),
|
||||
attempt=attempt + 1,
|
||||
max_attempts=MAX_RETRIES + 1,
|
||||
delay_seconds=delay,
|
||||
)
|
||||
await asyncio.sleep(delay)
|
||||
|
||||
if last_error is not None:
|
||||
@@ -209,32 +144,11 @@ class AnthropicApiClient:
|
||||
}
|
||||
if request.system_prompt:
|
||||
params["system"] = request.system_prompt
|
||||
if self._claude_oauth:
|
||||
attribution = claude_attribution_header()
|
||||
params["system"] = (
|
||||
f"{attribution}\n{params['system']}"
|
||||
if params.get("system")
|
||||
else attribution
|
||||
)
|
||||
if request.tools:
|
||||
params["tools"] = request.tools
|
||||
if self._claude_oauth:
|
||||
params["betas"] = claude_oauth_betas()
|
||||
params["metadata"] = {
|
||||
"user_id": json.dumps(
|
||||
{
|
||||
"device_id": "openharness",
|
||||
"session_id": self._session_id,
|
||||
"account_uuid": "",
|
||||
},
|
||||
separators=(",", ":"),
|
||||
)
|
||||
}
|
||||
params["extra_headers"] = {"x-client-request-id": str(uuid.uuid4())}
|
||||
|
||||
try:
|
||||
stream_api = self._client.beta.messages if self._claude_oauth else self._client.messages
|
||||
async with stream_api.stream(**params) as stream:
|
||||
async with self._client.messages.stream(**params) as stream:
|
||||
async for event in stream:
|
||||
if getattr(event, "type", None) != "content_block_delta":
|
||||
continue
|
||||
|
||||
@@ -1,407 +0,0 @@
|
||||
"""OpenAI Codex subscription client backed by chatgpt.com Codex Responses."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
import platform
|
||||
from typing import Any, AsyncIterator
|
||||
|
||||
import httpx
|
||||
|
||||
from openharness.api.client import (
|
||||
ApiMessageCompleteEvent,
|
||||
ApiMessageRequest,
|
||||
ApiRetryEvent,
|
||||
ApiStreamEvent,
|
||||
ApiTextDeltaEvent,
|
||||
)
|
||||
from openharness.api.errors import AuthenticationFailure, OpenHarnessApiError, RateLimitFailure, RequestFailure
|
||||
from openharness.api.usage import UsageSnapshot
|
||||
from openharness.engine.messages import ConversationMessage, ImageBlock, TextBlock, ToolResultBlock, ToolUseBlock
|
||||
|
||||
DEFAULT_CODEX_BASE_URL = "https://chatgpt.com/backend-api"
|
||||
JWT_CLAIM_PATH = "https://api.openai.com/auth"
|
||||
MAX_RETRIES = 3
|
||||
BASE_DELAY_SECONDS = 1.0
|
||||
MAX_DELAY_SECONDS = 30.0
|
||||
|
||||
|
||||
def _extract_account_id(token: str) -> str:
|
||||
parts = token.split(".")
|
||||
if len(parts) != 3:
|
||||
raise AuthenticationFailure("Codex access token is not a valid JWT.")
|
||||
try:
|
||||
payload = json.loads(
|
||||
base64.urlsafe_b64decode(parts[1] + "=" * (-len(parts[1]) % 4)).decode("utf-8")
|
||||
)
|
||||
except Exception as exc:
|
||||
raise AuthenticationFailure("Could not decode Codex access token payload.") from exc
|
||||
auth_claim = payload.get(JWT_CLAIM_PATH)
|
||||
if not isinstance(auth_claim, dict):
|
||||
raise AuthenticationFailure("Codex access token is missing account metadata.")
|
||||
account_id = auth_claim.get("chatgpt_account_id")
|
||||
if not isinstance(account_id, str) or not account_id:
|
||||
raise AuthenticationFailure("Codex access token is missing chatgpt_account_id.")
|
||||
return account_id
|
||||
|
||||
|
||||
def _resolve_codex_url(base_url: str | None) -> str:
|
||||
trimmed = (base_url or "").strip()
|
||||
if trimmed and "chatgpt.com/backend-api" not in trimmed:
|
||||
trimmed = ""
|
||||
raw = (trimmed or DEFAULT_CODEX_BASE_URL).rstrip("/")
|
||||
if raw.endswith("/codex/responses"):
|
||||
return raw
|
||||
if raw.endswith("/codex"):
|
||||
return f"{raw}/responses"
|
||||
return f"{raw}/codex/responses"
|
||||
|
||||
|
||||
def _build_codex_headers(token: str, *, session_id: str | None = None) -> dict[str, str]:
|
||||
account_id = _extract_account_id(token)
|
||||
headers = {
|
||||
"Authorization": f"Bearer {token}",
|
||||
"chatgpt-account-id": account_id,
|
||||
"originator": "openharness",
|
||||
"User-Agent": f"openharness ({platform.system().lower()} {platform.machine() or 'unknown'})",
|
||||
"OpenAI-Beta": "responses=experimental",
|
||||
"accept": "text/event-stream",
|
||||
"content-type": "application/json",
|
||||
}
|
||||
if session_id:
|
||||
headers["session_id"] = session_id
|
||||
return headers
|
||||
|
||||
|
||||
def _convert_messages_to_codex(messages: list[ConversationMessage]) -> list[dict[str, Any]]:
|
||||
result: list[dict[str, Any]] = []
|
||||
for msg in messages:
|
||||
if msg.role == "user":
|
||||
# Responses API requires function_call_output items to appear before
|
||||
# any following user input. A ConversationMessage can contain both
|
||||
# tool results and user text, so emit the tool outputs first to keep
|
||||
# every prior function_call immediately satisfied.
|
||||
for block in msg.content:
|
||||
if isinstance(block, ToolResultBlock):
|
||||
result.append({
|
||||
"type": "function_call_output",
|
||||
"call_id": block.tool_use_id,
|
||||
"output": block.content,
|
||||
})
|
||||
user_content: list[dict[str, Any]] = []
|
||||
for block in msg.content:
|
||||
if isinstance(block, TextBlock) and block.text.strip():
|
||||
user_content.append({"type": "input_text", "text": block.text})
|
||||
elif isinstance(block, ImageBlock):
|
||||
user_content.append({
|
||||
"type": "input_image",
|
||||
"image_url": f"data:{block.media_type};base64,{block.data}",
|
||||
})
|
||||
if user_content:
|
||||
result.append({"role": "user", "content": user_content})
|
||||
continue
|
||||
|
||||
assistant_text = "".join(block.text for block in msg.content if isinstance(block, TextBlock))
|
||||
if assistant_text:
|
||||
result.append({
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"content": [{"type": "output_text", "text": assistant_text, "annotations": []}],
|
||||
})
|
||||
for block in msg.content:
|
||||
if isinstance(block, ToolUseBlock):
|
||||
result.append({
|
||||
"type": "function_call",
|
||||
"id": f"fc_{block.id[:58]}",
|
||||
"call_id": block.id,
|
||||
"name": block.name,
|
||||
"arguments": json.dumps(block.input, separators=(",", ":")),
|
||||
})
|
||||
return result
|
||||
|
||||
|
||||
def _convert_tools_to_codex(tools: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
return [
|
||||
{
|
||||
"type": "function",
|
||||
"name": tool["name"],
|
||||
"description": tool.get("description", ""),
|
||||
"parameters": tool.get("input_schema", {}),
|
||||
}
|
||||
for tool in tools
|
||||
]
|
||||
|
||||
|
||||
def _normalize_reasoning_effort(effort: str | None) -> str | None:
|
||||
normalized = (effort or "").strip().lower()
|
||||
if normalized == "max":
|
||||
return "xhigh"
|
||||
if normalized in {"low", "medium", "high", "xhigh"}:
|
||||
return normalized
|
||||
return None
|
||||
|
||||
|
||||
def _usage_from_response(response: dict[str, Any]) -> UsageSnapshot:
|
||||
usage = response.get("usage")
|
||||
if not isinstance(usage, dict):
|
||||
return UsageSnapshot()
|
||||
return UsageSnapshot(
|
||||
input_tokens=int(usage.get("input_tokens") or 0),
|
||||
output_tokens=int(usage.get("output_tokens") or 0),
|
||||
)
|
||||
|
||||
|
||||
def _stop_reason_from_response(response: dict[str, Any], *, has_tool_calls: bool) -> str | None:
|
||||
status = response.get("status")
|
||||
if has_tool_calls and status == "completed":
|
||||
return "tool_use"
|
||||
if status == "completed":
|
||||
return "stop"
|
||||
if status == "incomplete":
|
||||
return "length"
|
||||
if status in {"failed", "cancelled"}:
|
||||
return "error"
|
||||
return None
|
||||
|
||||
|
||||
def _format_error_message(status_code: int, payload: str) -> str:
|
||||
try:
|
||||
parsed = json.loads(payload)
|
||||
except json.JSONDecodeError:
|
||||
parsed = None
|
||||
if isinstance(parsed, dict):
|
||||
error = parsed.get("error")
|
||||
if isinstance(error, dict):
|
||||
message = error.get("message")
|
||||
if isinstance(message, str) and message.strip():
|
||||
return message
|
||||
detail = parsed.get("detail")
|
||||
if isinstance(detail, str) and detail.strip():
|
||||
return detail
|
||||
text = payload.strip()
|
||||
if text:
|
||||
return text
|
||||
return f"Codex request failed with status {status_code}"
|
||||
|
||||
|
||||
def _format_codex_stream_error(event: dict[str, Any], *, fallback: str) -> str:
|
||||
error = event.get("error")
|
||||
payload = error if isinstance(error, dict) else event
|
||||
message = payload.get("message") if isinstance(payload, dict) else None
|
||||
code = payload.get("code") if isinstance(payload, dict) else None
|
||||
request_id = (
|
||||
(payload.get("request_id") if isinstance(payload, dict) else None)
|
||||
or event.get("request_id")
|
||||
)
|
||||
|
||||
parts: list[str] = []
|
||||
if isinstance(message, str) and message.strip():
|
||||
parts.append(message.strip())
|
||||
elif isinstance(code, str) and code.strip():
|
||||
parts.append(code.strip())
|
||||
else:
|
||||
parts.append(fallback)
|
||||
|
||||
if isinstance(code, str) and code.strip():
|
||||
parts.append(f"(code={code.strip()})")
|
||||
if isinstance(request_id, str) and request_id.strip():
|
||||
parts.append(f"[request_id={request_id.strip()}]")
|
||||
return " ".join(parts)
|
||||
|
||||
|
||||
def _translate_status_error(status_code: int, message: str) -> OpenHarnessApiError:
|
||||
if status_code in {401, 403}:
|
||||
return AuthenticationFailure(message)
|
||||
if status_code == 429:
|
||||
return RateLimitFailure(message)
|
||||
return RequestFailure(message)
|
||||
|
||||
|
||||
class CodexApiClient:
|
||||
"""Client for ChatGPT/Codex subscription-backed Codex Responses."""
|
||||
|
||||
def __init__(self, auth_token: str, *, base_url: str | None = None) -> None:
|
||||
self._auth_token = auth_token
|
||||
self._base_url = base_url
|
||||
self._url = _resolve_codex_url(base_url)
|
||||
|
||||
async def stream_message(self, request: ApiMessageRequest) -> AsyncIterator[ApiStreamEvent]:
|
||||
last_error: Exception | None = None
|
||||
for attempt in range(MAX_RETRIES + 1):
|
||||
try:
|
||||
async for event in self._stream_once(request):
|
||||
yield event
|
||||
return
|
||||
except Exception as exc:
|
||||
last_error = exc
|
||||
if attempt >= MAX_RETRIES or not self._is_retryable(exc):
|
||||
raise self._translate_error(exc) from exc
|
||||
delay = min(BASE_DELAY_SECONDS * (2 ** attempt), MAX_DELAY_SECONDS)
|
||||
import asyncio
|
||||
|
||||
yield ApiRetryEvent(
|
||||
message=str(exc),
|
||||
attempt=attempt + 1,
|
||||
max_attempts=MAX_RETRIES + 1,
|
||||
delay_seconds=delay,
|
||||
)
|
||||
await asyncio.sleep(delay)
|
||||
if last_error is not None:
|
||||
raise self._translate_error(last_error) from last_error
|
||||
|
||||
async def _stream_once(self, request: ApiMessageRequest) -> AsyncIterator[ApiStreamEvent]:
|
||||
body: dict[str, Any] = {
|
||||
"model": request.model,
|
||||
"store": False,
|
||||
"stream": True,
|
||||
"instructions": request.system_prompt or "You are OpenHarness.",
|
||||
"input": _convert_messages_to_codex(request.messages),
|
||||
"text": {"verbosity": "medium"},
|
||||
"include": ["reasoning.encrypted_content"],
|
||||
"tool_choice": "auto",
|
||||
"parallel_tool_calls": True,
|
||||
}
|
||||
if request.tools:
|
||||
body["tools"] = _convert_tools_to_codex(request.tools)
|
||||
effort = _normalize_reasoning_effort(request.effort)
|
||||
if effort:
|
||||
body["reasoning"] = {"effort": effort}
|
||||
|
||||
content: list[TextBlock | ToolUseBlock] = []
|
||||
current_text_parts: list[str] = []
|
||||
completed_response: dict[str, Any] | None = None
|
||||
|
||||
headers = _build_codex_headers(self._auth_token)
|
||||
async with httpx.AsyncClient(timeout=60.0, follow_redirects=True) as client:
|
||||
async with client.stream("POST", self._url, headers=headers, json=body) as response:
|
||||
if response.status_code >= 400:
|
||||
payload = await response.aread()
|
||||
message = _format_error_message(response.status_code, payload.decode("utf-8", "replace"))
|
||||
raise httpx.HTTPStatusError(message, request=response.request, response=response)
|
||||
|
||||
async for event in self._iter_sse_events(response):
|
||||
event_type = event.get("type")
|
||||
if event_type == "response.output_text.delta":
|
||||
delta = event.get("delta")
|
||||
if isinstance(delta, str) and delta:
|
||||
current_text_parts.append(delta)
|
||||
yield ApiTextDeltaEvent(text=delta)
|
||||
elif event_type == "response.output_item.done":
|
||||
item = event.get("item")
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
item_type = item.get("type")
|
||||
if item_type == "message":
|
||||
text = ""
|
||||
raw_content = item.get("content")
|
||||
if isinstance(raw_content, list):
|
||||
parts = []
|
||||
for block in raw_content:
|
||||
if isinstance(block, dict):
|
||||
if block.get("type") == "output_text":
|
||||
parts.append(str(block.get("text", "")))
|
||||
elif block.get("type") == "refusal":
|
||||
parts.append(str(block.get("refusal", "")))
|
||||
text = "".join(parts)
|
||||
if text:
|
||||
content.append(TextBlock(text=text))
|
||||
elif item_type == "function_call":
|
||||
arguments = item.get("arguments")
|
||||
parsed_arguments: dict[str, Any]
|
||||
if isinstance(arguments, str) and arguments:
|
||||
try:
|
||||
loaded = json.loads(arguments)
|
||||
except json.JSONDecodeError:
|
||||
loaded = {}
|
||||
else:
|
||||
loaded = {}
|
||||
parsed_arguments = loaded if isinstance(loaded, dict) else {}
|
||||
call_id = item.get("call_id")
|
||||
name = item.get("name")
|
||||
if isinstance(call_id, str) and call_id and isinstance(name, str) and name:
|
||||
content.append(ToolUseBlock(id=call_id, name=name, input=parsed_arguments))
|
||||
elif event_type == "response.completed":
|
||||
response_payload = event.get("response")
|
||||
if isinstance(response_payload, dict):
|
||||
completed_response = response_payload
|
||||
elif event_type == "response.failed":
|
||||
response_payload = event.get("response")
|
||||
if isinstance(response_payload, dict):
|
||||
raise RequestFailure(
|
||||
_format_codex_stream_error(
|
||||
response_payload,
|
||||
fallback="Codex response failed",
|
||||
)
|
||||
)
|
||||
raise RequestFailure("Codex response failed")
|
||||
elif event_type == "error":
|
||||
raise RequestFailure(
|
||||
_format_codex_stream_error(event, fallback="Codex error")
|
||||
)
|
||||
|
||||
if current_text_parts and not any(isinstance(block, TextBlock) for block in content):
|
||||
content.insert(0, TextBlock(text="".join(current_text_parts)))
|
||||
|
||||
final_message = ConversationMessage(role="assistant", content=content)
|
||||
usage = _usage_from_response(completed_response or {})
|
||||
stop_reason = _stop_reason_from_response(
|
||||
completed_response or {},
|
||||
has_tool_calls=bool(final_message.tool_uses),
|
||||
)
|
||||
yield ApiMessageCompleteEvent(
|
||||
message=final_message,
|
||||
usage=usage,
|
||||
stop_reason=stop_reason,
|
||||
)
|
||||
|
||||
async def _iter_sse_events(self, response: httpx.Response) -> AsyncIterator[dict[str, Any]]:
|
||||
data_lines: list[str] = []
|
||||
async for line in response.aiter_lines():
|
||||
if line == "":
|
||||
if data_lines:
|
||||
payload = "\n".join(data_lines).strip()
|
||||
data_lines = []
|
||||
if payload and payload != "[DONE]":
|
||||
try:
|
||||
event = json.loads(payload)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
if isinstance(event, dict):
|
||||
yield event
|
||||
continue
|
||||
if line.startswith("data:"):
|
||||
data_lines.append(line[5:].strip())
|
||||
if data_lines:
|
||||
payload = "\n".join(data_lines).strip()
|
||||
if payload and payload != "[DONE]":
|
||||
try:
|
||||
event = json.loads(payload)
|
||||
except json.JSONDecodeError:
|
||||
return
|
||||
if isinstance(event, dict):
|
||||
yield event
|
||||
|
||||
@staticmethod
|
||||
def _is_retryable(exc: Exception) -> bool:
|
||||
if isinstance(exc, httpx.HTTPStatusError):
|
||||
return exc.response.status_code in {429, 500, 502, 503, 504}
|
||||
if isinstance(exc, RateLimitFailure):
|
||||
return True
|
||||
if isinstance(exc, RequestFailure):
|
||||
message = str(exc).lower()
|
||||
return any(term in message for term in ["timeout", "connect", "network", "rate", "overloaded"])
|
||||
if isinstance(exc, (httpx.TimeoutException, httpx.NetworkError)):
|
||||
return True
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def _translate_error(exc: Exception) -> OpenHarnessApiError:
|
||||
if isinstance(exc, OpenHarnessApiError):
|
||||
return exc
|
||||
if isinstance(exc, httpx.HTTPStatusError):
|
||||
status = exc.response.status_code
|
||||
return _translate_status_error(status, str(exc))
|
||||
if isinstance(exc, httpx.HTTPError):
|
||||
return RequestFailure(str(exc))
|
||||
return RequestFailure(str(exc))
|
||||
@@ -1,240 +0,0 @@
|
||||
"""GitHub Copilot OAuth device-flow authentication.
|
||||
|
||||
Flow:
|
||||
1. Device code request → user visits URL and enters code
|
||||
2. Poll for OAuth token → get GitHub access token
|
||||
3. Use token directly → ``Authorization: Bearer <token>`` to Copilot API
|
||||
|
||||
Supports two deployment types:
|
||||
- **github.com** — public GitHub, API at ``https://api.githubcopilot.com``
|
||||
- **enterprise** — GitHub Enterprise (data-residency / self-hosted),
|
||||
API at ``https://copilot-api.<domain>``
|
||||
|
||||
The GitHub OAuth token (and optional enterprise URL) are persisted to
|
||||
``~/.openharness/copilot_auth.json``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
from openharness.config.paths import get_config_dir
|
||||
from openharness.utils.fs import atomic_write_text
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Constants
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# OAuth client ID registered by OpenCode for Copilot integrations.
|
||||
COPILOT_CLIENT_ID = "Ov23li8tweQw6odWQebz"
|
||||
|
||||
COPILOT_DEFAULT_API_BASE = "https://api.githubcopilot.com"
|
||||
|
||||
# Safety margin added to each poll interval to avoid server-side rate limits.
|
||||
_POLL_SAFETY_MARGIN = 3.0 # seconds
|
||||
|
||||
_AUTH_FILE_NAME = "copilot_auth.json"
|
||||
|
||||
|
||||
def copilot_api_base(enterprise_url: str | None = None) -> str:
|
||||
"""Return the Copilot API base URL.
|
||||
|
||||
For public GitHub this is ``https://api.githubcopilot.com``.
|
||||
For enterprise it is ``https://copilot-api.<domain>``.
|
||||
"""
|
||||
if enterprise_url:
|
||||
domain = enterprise_url.replace("https://", "").replace("http://", "").rstrip("/")
|
||||
return f"https://copilot-api.{domain}"
|
||||
return COPILOT_DEFAULT_API_BASE
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Data types
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DeviceCodeResponse:
|
||||
"""Parsed response from the GitHub device-code endpoint."""
|
||||
|
||||
device_code: str
|
||||
user_code: str
|
||||
verification_uri: str
|
||||
interval: int
|
||||
expires_in: int
|
||||
|
||||
|
||||
@dataclass
|
||||
class CopilotAuthInfo:
|
||||
"""Persisted + runtime auth state for Copilot."""
|
||||
|
||||
github_token: str
|
||||
enterprise_url: str | None = None
|
||||
|
||||
@property
|
||||
def api_base(self) -> str:
|
||||
return copilot_api_base(self.enterprise_url)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Persistence helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _auth_file_path() -> Path:
|
||||
return get_config_dir() / _AUTH_FILE_NAME
|
||||
|
||||
|
||||
def save_copilot_auth(token: str, *, enterprise_url: str | None = None) -> None:
|
||||
"""Persist the GitHub OAuth token (and optional enterprise URL) to disk."""
|
||||
path = _auth_file_path()
|
||||
payload: dict[str, Any] = {"github_token": token}
|
||||
if enterprise_url:
|
||||
payload["enterprise_url"] = enterprise_url
|
||||
atomic_write_text(
|
||||
path,
|
||||
json.dumps(payload, indent=2) + "\n",
|
||||
mode=0o600,
|
||||
)
|
||||
log.info("Copilot auth saved to %s", path)
|
||||
|
||||
|
||||
def load_copilot_auth() -> CopilotAuthInfo | None:
|
||||
"""Load the persisted Copilot auth, or return None."""
|
||||
path = _auth_file_path()
|
||||
if not path.exists():
|
||||
return None
|
||||
try:
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
token = data.get("github_token")
|
||||
if not token:
|
||||
return None
|
||||
return CopilotAuthInfo(
|
||||
github_token=token,
|
||||
enterprise_url=data.get("enterprise_url"),
|
||||
)
|
||||
except (json.JSONDecodeError, KeyError, OSError) as exc:
|
||||
log.warning("Failed to read Copilot auth file: %s", exc)
|
||||
return None
|
||||
|
||||
|
||||
# Keep backward-compatible aliases used by CLI and tests.
|
||||
save_github_token = save_copilot_auth
|
||||
|
||||
|
||||
def load_github_token() -> str | None:
|
||||
"""Load just the persisted GitHub OAuth token, or return None."""
|
||||
info = load_copilot_auth()
|
||||
return info.github_token if info else None
|
||||
|
||||
|
||||
def clear_github_token() -> None:
|
||||
"""Remove persisted Copilot auth."""
|
||||
path = _auth_file_path()
|
||||
if path.exists():
|
||||
path.unlink()
|
||||
log.info("Copilot auth cleared.")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# OAuth device flow (synchronous – called from CLI)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def request_device_code(
|
||||
*,
|
||||
client_id: str = COPILOT_CLIENT_ID,
|
||||
github_domain: str = "github.com",
|
||||
) -> DeviceCodeResponse:
|
||||
"""Start the OAuth device flow and return the device/user codes."""
|
||||
url = f"https://{github_domain}/login/device/code"
|
||||
resp = httpx.post(
|
||||
url,
|
||||
json={"client_id": client_id, "scope": "read:user"},
|
||||
headers={
|
||||
"Accept": "application/json",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
timeout=30,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
return DeviceCodeResponse(
|
||||
device_code=data["device_code"],
|
||||
user_code=data["user_code"],
|
||||
verification_uri=data["verification_uri"],
|
||||
interval=data.get("interval", 5),
|
||||
expires_in=data.get("expires_in", 900),
|
||||
)
|
||||
|
||||
|
||||
def poll_for_access_token(
|
||||
device_code: str,
|
||||
interval: int,
|
||||
*,
|
||||
client_id: str = COPILOT_CLIENT_ID,
|
||||
github_domain: str = "github.com",
|
||||
timeout: float = 900,
|
||||
progress_callback: Any | None = None,
|
||||
) -> str:
|
||||
"""Poll GitHub until the user authorises, returning the OAuth access token.
|
||||
|
||||
*progress_callback*, if provided, is called with ``(poll_number, elapsed_seconds)``
|
||||
before each poll so callers can show progress feedback.
|
||||
|
||||
Raises ``RuntimeError`` on expiry or unexpected error.
|
||||
"""
|
||||
url = f"https://{github_domain}/login/oauth/access_token"
|
||||
poll_interval = float(interval)
|
||||
deadline = time.monotonic() + timeout
|
||||
start = time.monotonic()
|
||||
poll_count = 0
|
||||
|
||||
while time.monotonic() < deadline:
|
||||
time.sleep(poll_interval + _POLL_SAFETY_MARGIN)
|
||||
poll_count += 1
|
||||
if progress_callback is not None:
|
||||
progress_callback(poll_count, time.monotonic() - start)
|
||||
resp = httpx.post(
|
||||
url,
|
||||
json={
|
||||
"client_id": client_id,
|
||||
"device_code": device_code,
|
||||
"grant_type": "urn:ietf:params:oauth:grant-type:device_code",
|
||||
},
|
||||
headers={
|
||||
"Accept": "application/json",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
timeout=30,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data: dict[str, Any] = resp.json()
|
||||
|
||||
if "access_token" in data:
|
||||
return data["access_token"]
|
||||
|
||||
error = data.get("error", "")
|
||||
if error == "authorization_pending":
|
||||
continue
|
||||
if error == "slow_down":
|
||||
server_interval = data.get("interval")
|
||||
if isinstance(server_interval, (int, float)) and server_interval > 0:
|
||||
poll_interval = float(server_interval)
|
||||
else:
|
||||
poll_interval += 5.0
|
||||
continue
|
||||
# Any other error is terminal.
|
||||
desc = data.get("error_description", error)
|
||||
raise RuntimeError(f"OAuth device flow failed: {desc}")
|
||||
|
||||
raise RuntimeError("OAuth device flow timed out waiting for user authorisation.")
|
||||
@@ -1,134 +0,0 @@
|
||||
"""GitHub Copilot API client for OpenHarness.
|
||||
|
||||
Wraps :class:`OpenAICompatibleClient` with Copilot-specific headers.
|
||||
The Copilot chat endpoint is OpenAI-compatible, so all message/tool
|
||||
conversion is delegated to the inner client.
|
||||
|
||||
Authentication uses the persisted GitHub OAuth token directly
|
||||
(``Authorization: Bearer <token>``) — no additional token exchange
|
||||
is required.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import AsyncIterator
|
||||
|
||||
from openai import AsyncOpenAI
|
||||
|
||||
from openharness.api.client import (
|
||||
ApiMessageRequest,
|
||||
ApiStreamEvent,
|
||||
)
|
||||
from openharness.api.copilot_auth import (
|
||||
copilot_api_base,
|
||||
load_copilot_auth,
|
||||
)
|
||||
from openharness.api.errors import AuthenticationFailure
|
||||
from openharness.api.openai_client import OpenAICompatibleClient
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Header constants
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_VERSION = "0.1.0" # OpenHarness version for User-Agent
|
||||
|
||||
# Default model for Copilot requests when the configured model is not
|
||||
# available in the Copilot model catalog.
|
||||
COPILOT_DEFAULT_MODEL = "gpt-4o"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Client
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class CopilotClient:
|
||||
"""Copilot-aware API client implementing ``SupportsStreamingMessages``.
|
||||
|
||||
Uses the GitHub OAuth token directly as a Bearer token for the
|
||||
Copilot API. No token exchange or session management is needed.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
github_token:
|
||||
GitHub OAuth token (``ghu_...`` / ``gho_...``). If *None*, the
|
||||
token is loaded from ``~/.openharness/copilot_auth.json``.
|
||||
enterprise_url:
|
||||
Optional enterprise domain. If *None*, loaded from the
|
||||
persisted auth file (falls back to public GitHub).
|
||||
model:
|
||||
Default model to request. Can be overridden per-request via
|
||||
``ApiMessageRequest.model``.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
github_token: str | None = None,
|
||||
*,
|
||||
enterprise_url: str | None = None,
|
||||
model: str | None = None,
|
||||
) -> None:
|
||||
auth_info = load_copilot_auth()
|
||||
token = github_token or (auth_info.github_token if auth_info else None)
|
||||
if not token:
|
||||
raise AuthenticationFailure(
|
||||
"No GitHub Copilot token found. Run 'oh auth copilot-login' first."
|
||||
)
|
||||
|
||||
# Resolve enterprise_url: explicit arg > persisted auth > None (public)
|
||||
ent_url = enterprise_url or (auth_info.enterprise_url if auth_info else None)
|
||||
|
||||
self._token = token
|
||||
self._enterprise_url = ent_url
|
||||
self._model = model
|
||||
|
||||
# Build the inner OpenAI-compatible client once.
|
||||
base_url = copilot_api_base(ent_url)
|
||||
default_headers: dict[str, str] = {
|
||||
"User-Agent": f"openharness/{_VERSION}",
|
||||
"Openai-Intent": "conversation-edits",
|
||||
}
|
||||
raw_openai = AsyncOpenAI(
|
||||
api_key=token,
|
||||
base_url=base_url,
|
||||
default_headers=default_headers,
|
||||
)
|
||||
self._inner = OpenAICompatibleClient(
|
||||
api_key=token,
|
||||
base_url=base_url,
|
||||
)
|
||||
# Swap the underlying SDK client so Copilot headers are used.
|
||||
self._inner._client = raw_openai # noqa: SLF001
|
||||
|
||||
log.info(
|
||||
"CopilotClient initialised (api_base=%s, enterprise=%s)",
|
||||
base_url,
|
||||
ent_url or "none",
|
||||
)
|
||||
|
||||
async def stream_message(self, request: ApiMessageRequest) -> AsyncIterator[ApiStreamEvent]:
|
||||
"""Stream a chat completion from the Copilot API.
|
||||
|
||||
Satisfies the ``SupportsStreamingMessages`` protocol expected by
|
||||
the OpenHarness query engine.
|
||||
|
||||
If a *model* was provided at construction time it overrides the
|
||||
model in *request*; otherwise the request model is passed through.
|
||||
"""
|
||||
effective_model = self._model or request.model
|
||||
patched = ApiMessageRequest(
|
||||
model=effective_model,
|
||||
messages=request.messages,
|
||||
system_prompt=request.system_prompt,
|
||||
max_tokens=request.max_tokens,
|
||||
tools=request.tools,
|
||||
)
|
||||
async for event in self._inner.stream_message(patched):
|
||||
yield event
|
||||
|
||||
async def close(self) -> None:
|
||||
"""Close the underlying OpenAI-compatible client."""
|
||||
await self._inner.close()
|
||||
@@ -1,484 +0,0 @@
|
||||
"""OpenAI-compatible API client for providers like Alibaba DashScope, GitHub Models, etc."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
from typing import Any, AsyncIterator
|
||||
from urllib.parse import urlsplit, urlunsplit
|
||||
|
||||
from openai import AsyncOpenAI
|
||||
|
||||
from openharness.api.client import (
|
||||
ApiMessageCompleteEvent,
|
||||
ApiMessageRequest,
|
||||
ApiRetryEvent,
|
||||
ApiStreamEvent,
|
||||
ApiTextDeltaEvent,
|
||||
)
|
||||
from openharness.api.errors import (
|
||||
AuthenticationFailure,
|
||||
OpenHarnessApiError,
|
||||
RateLimitFailure,
|
||||
RequestFailure,
|
||||
)
|
||||
from openharness.api.usage import UsageSnapshot
|
||||
from openharness.engine.messages import (
|
||||
ConversationMessage,
|
||||
ContentBlock,
|
||||
ImageBlock,
|
||||
TextBlock,
|
||||
ToolResultBlock,
|
||||
ToolUseBlock,
|
||||
)
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
MAX_RETRIES = 3
|
||||
BASE_DELAY = 1.0
|
||||
MAX_DELAY = 30.0
|
||||
_MAX_COMPLETION_TOKEN_MODEL_PREFIXES = ("gpt-5", "o1", "o3", "o4")
|
||||
|
||||
|
||||
def _token_limit_param_for_model(model: str, max_tokens: int) -> dict[str, int]:
|
||||
"""Return the correct token limit field for the target OpenAI model.
|
||||
|
||||
GPT-5 and the current reasoning-model families reject ``max_tokens`` and
|
||||
require ``max_completion_tokens`` instead.
|
||||
"""
|
||||
normalized = model.strip().lower()
|
||||
if "/" in normalized:
|
||||
normalized = normalized.rsplit("/", 1)[-1]
|
||||
if normalized.startswith(_MAX_COMPLETION_TOKEN_MODEL_PREFIXES):
|
||||
return {"max_completion_tokens": max_tokens}
|
||||
return {"max_tokens": max_tokens}
|
||||
|
||||
|
||||
def _convert_tools_to_openai(tools: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
"""Convert Anthropic tool schemas to OpenAI function-calling format.
|
||||
|
||||
Anthropic format:
|
||||
{"name": "...", "description": "...", "input_schema": {...}}
|
||||
OpenAI format:
|
||||
{"type": "function", "function": {"name": "...", "description": "...", "parameters": {...}}}
|
||||
"""
|
||||
result = []
|
||||
for tool in tools:
|
||||
result.append({
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": tool["name"],
|
||||
"description": tool.get("description", ""),
|
||||
"parameters": tool.get("input_schema", {}),
|
||||
},
|
||||
})
|
||||
return result
|
||||
|
||||
|
||||
def _convert_messages_to_openai(
|
||||
messages: list[ConversationMessage],
|
||||
system_prompt: str | None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Convert Anthropic-style messages to OpenAI chat format.
|
||||
|
||||
Key differences:
|
||||
- Anthropic: system prompt is a separate parameter
|
||||
- OpenAI: system prompt is a message with role="system"
|
||||
- Anthropic: tool_use / tool_result are content blocks
|
||||
- OpenAI: tool_calls on assistant message, tool results are separate messages
|
||||
"""
|
||||
openai_messages: list[dict[str, Any]] = []
|
||||
|
||||
if system_prompt:
|
||||
openai_messages.append({"role": "system", "content": system_prompt})
|
||||
|
||||
for msg in messages:
|
||||
if msg.role == "assistant":
|
||||
openai_msg = _convert_assistant_message(msg)
|
||||
openai_messages.append(openai_msg)
|
||||
elif msg.role == "user":
|
||||
# User messages may contain text or tool_result blocks
|
||||
tool_results = [b for b in msg.content if isinstance(b, ToolResultBlock)]
|
||||
user_blocks = [b for b in msg.content if isinstance(b, (TextBlock, ImageBlock))]
|
||||
|
||||
if tool_results:
|
||||
# Each tool result becomes a separate message with role="tool"
|
||||
for tr in tool_results:
|
||||
openai_messages.append({
|
||||
"role": "tool",
|
||||
"tool_call_id": tr.tool_use_id,
|
||||
"content": tr.content,
|
||||
})
|
||||
if user_blocks:
|
||||
content = _convert_user_content_to_openai(user_blocks)
|
||||
if isinstance(content, str):
|
||||
if content.strip():
|
||||
openai_messages.append({"role": "user", "content": content})
|
||||
elif content:
|
||||
openai_messages.append({"role": "user", "content": content})
|
||||
if not tool_results and not user_blocks:
|
||||
# Empty user message (shouldn't happen, but handle gracefully)
|
||||
openai_messages.append({"role": "user", "content": ""})
|
||||
|
||||
return openai_messages
|
||||
|
||||
|
||||
def _convert_user_content_to_openai(blocks: list[ContentBlock]) -> str | list[dict[str, Any]]:
|
||||
"""Convert user text/image blocks into OpenAI chat content."""
|
||||
has_image = any(isinstance(block, ImageBlock) for block in blocks)
|
||||
if not has_image:
|
||||
return "".join(block.text for block in blocks if isinstance(block, TextBlock))
|
||||
|
||||
content: list[dict[str, Any]] = []
|
||||
for block in blocks:
|
||||
if isinstance(block, TextBlock) and block.text:
|
||||
content.append({"type": "text", "text": block.text})
|
||||
elif isinstance(block, ImageBlock):
|
||||
content.append({
|
||||
"type": "image_url",
|
||||
"image_url": {
|
||||
"url": f"data:{block.media_type};base64,{block.data}",
|
||||
},
|
||||
})
|
||||
return content
|
||||
|
||||
|
||||
_EMPTY_REASONING_ENV = "OPENHARNESS_REQUIRE_EMPTY_REASONING_CONTENT"
|
||||
|
||||
|
||||
def _empty_reasoning_required() -> bool:
|
||||
"""True when the operator's provider requires an empty
|
||||
``reasoning_content`` field on tool-using assistant messages
|
||||
(Kimi-on-Anthropic style). Default off — strict-OpenAI providers
|
||||
reject the field outright.
|
||||
"""
|
||||
raw = os.environ.get(_EMPTY_REASONING_ENV, "").strip().lower()
|
||||
return raw in {"1", "true", "yes", "on"}
|
||||
|
||||
|
||||
def _convert_assistant_message(msg: ConversationMessage) -> dict[str, Any]:
|
||||
"""Convert an assistant ConversationMessage to OpenAI format.
|
||||
|
||||
``reasoning_content`` is a non-standard field used by thinking models
|
||||
(e.g. Kimi k2.5) to carry the model's internal chain-of-thought across
|
||||
turns. Some thinking-model providers require it on every assistant
|
||||
message with tool calls — even when empty — or they reject the request.
|
||||
Other OpenAI-compatible providers (Cerebras, OpenAI's own
|
||||
endpoint, etc.) reject the field outright with a 400
|
||||
``wrong_api_format`` error.
|
||||
|
||||
Behaviour:
|
||||
|
||||
- When the streaming parser captured non-empty reasoning on
|
||||
``msg._reasoning``, we always replay it. Models that emit reasoning
|
||||
tokens are by definition thinking models that round-trip them.
|
||||
- When there is no captured reasoning but the message has tool calls,
|
||||
we emit ``reasoning_content: ""`` only if the operator opts in via
|
||||
``OPENHARNESS_REQUIRE_EMPTY_REASONING_CONTENT=1``. The default is
|
||||
omit, which matches strict-OpenAI providers.
|
||||
|
||||
The opt-in default keeps strict-OpenAI providers (Cerebras, NVIDIA NIM,
|
||||
OpenAI direct, etc.) working out-of-the-box; Kimi-on-Anthropic users
|
||||
set the env var in their dotfiles or settings.
|
||||
"""
|
||||
text_parts = [b.text for b in msg.content if isinstance(b, TextBlock)]
|
||||
tool_uses = [b for b in msg.content if isinstance(b, ToolUseBlock)]
|
||||
|
||||
openai_msg: dict[str, Any] = {"role": "assistant"}
|
||||
|
||||
content = "".join(text_parts)
|
||||
openai_msg["content"] = content if content else None
|
||||
|
||||
# Replay reasoning_content for thinking models (stored by streaming parser)
|
||||
reasoning = getattr(msg, "_reasoning", None)
|
||||
if reasoning:
|
||||
openai_msg["reasoning_content"] = reasoning
|
||||
elif tool_uses and _empty_reasoning_required():
|
||||
# Kimi-style providers reject tool_use messages without this field
|
||||
# even when there's nothing to put in it. Opt-in via env var.
|
||||
openai_msg["reasoning_content"] = ""
|
||||
|
||||
if tool_uses:
|
||||
openai_msg["tool_calls"] = [
|
||||
{
|
||||
"id": tu.id,
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": tu.name,
|
||||
"arguments": json.dumps(tu.input),
|
||||
},
|
||||
}
|
||||
for tu in tool_uses
|
||||
]
|
||||
|
||||
return openai_msg
|
||||
|
||||
|
||||
def _parse_assistant_response(response: Any) -> ConversationMessage:
|
||||
"""Parse an OpenAI ChatCompletion response into a ConversationMessage."""
|
||||
choice = response.choices[0]
|
||||
message = choice.message
|
||||
content: list[ContentBlock] = []
|
||||
|
||||
if message.content:
|
||||
content.append(TextBlock(text=message.content))
|
||||
|
||||
if message.tool_calls:
|
||||
for tc in message.tool_calls:
|
||||
try:
|
||||
args = json.loads(tc.function.arguments)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
args = {}
|
||||
content.append(ToolUseBlock(
|
||||
id=tc.id,
|
||||
name=tc.function.name,
|
||||
input=args,
|
||||
))
|
||||
|
||||
return ConversationMessage(role="assistant", content=content)
|
||||
|
||||
|
||||
def _normalize_openai_base_url(base_url: str | None) -> str | None:
|
||||
"""Normalize custom OpenAI-compatible base URLs without dropping API path segments."""
|
||||
if not base_url:
|
||||
return None
|
||||
trimmed = base_url.strip()
|
||||
if not trimmed:
|
||||
return None
|
||||
parts = urlsplit(trimmed)
|
||||
if not parts.scheme or not parts.netloc:
|
||||
return trimmed.rstrip("/")
|
||||
path = parts.path.rstrip("/")
|
||||
if not path:
|
||||
path = "/v1"
|
||||
return urlunsplit((parts.scheme, parts.netloc, path, parts.query, parts.fragment))
|
||||
|
||||
|
||||
class OpenAICompatibleClient:
|
||||
"""Client for OpenAI-compatible APIs (DashScope, GitHub Models, etc.).
|
||||
|
||||
Implements the same SupportsStreamingMessages protocol as AnthropicApiClient
|
||||
so it can be used as a drop-in replacement in the agent loop.
|
||||
"""
|
||||
|
||||
def __init__(self, api_key: str, *, base_url: str | None = None, timeout: float | None = None) -> None:
|
||||
kwargs: dict[str, Any] = {
|
||||
"api_key": api_key,
|
||||
"default_headers": {"Authorization": f"Bearer {api_key}"},
|
||||
}
|
||||
normalized_base_url = _normalize_openai_base_url(base_url)
|
||||
if normalized_base_url:
|
||||
kwargs["base_url"] = normalized_base_url
|
||||
if timeout is not None:
|
||||
kwargs["timeout"] = timeout
|
||||
self._client = AsyncOpenAI(**kwargs)
|
||||
|
||||
async def close(self) -> None:
|
||||
"""Close the underlying HTTP client."""
|
||||
await self._client.close()
|
||||
|
||||
async def stream_message(self, request: ApiMessageRequest) -> AsyncIterator[ApiStreamEvent]:
|
||||
"""Yield text deltas and the final message, matching the Anthropic client interface."""
|
||||
last_error: Exception | None = None
|
||||
|
||||
for attempt in range(MAX_RETRIES + 1):
|
||||
try:
|
||||
async for event in self._stream_once(request):
|
||||
yield event
|
||||
return
|
||||
except OpenHarnessApiError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
last_error = exc
|
||||
if attempt >= MAX_RETRIES or not self._is_retryable(exc):
|
||||
raise self._translate_error(exc) from exc
|
||||
|
||||
delay = min(BASE_DELAY * (2 ** attempt), MAX_DELAY)
|
||||
log.warning(
|
||||
"OpenAI API request failed (attempt %d/%d), retrying in %.1fs: %s",
|
||||
attempt + 1, MAX_RETRIES + 1, delay, exc,
|
||||
)
|
||||
yield ApiRetryEvent(
|
||||
message=str(exc),
|
||||
attempt=attempt + 1,
|
||||
max_attempts=MAX_RETRIES + 1,
|
||||
delay_seconds=delay,
|
||||
)
|
||||
await asyncio.sleep(delay)
|
||||
|
||||
if last_error is not None:
|
||||
raise self._translate_error(last_error) from last_error
|
||||
|
||||
async def _stream_once(self, request: ApiMessageRequest) -> AsyncIterator[ApiStreamEvent]:
|
||||
"""Single attempt: stream an OpenAI chat completion."""
|
||||
openai_messages = _convert_messages_to_openai(request.messages, request.system_prompt)
|
||||
openai_tools = _convert_tools_to_openai(request.tools) if request.tools else None
|
||||
|
||||
params: dict[str, Any] = {
|
||||
"model": request.model,
|
||||
"messages": openai_messages,
|
||||
"stream": True,
|
||||
"stream_options": {"include_usage": True},
|
||||
}
|
||||
params.update(_token_limit_param_for_model(request.model, request.max_tokens))
|
||||
if openai_tools:
|
||||
params["tools"] = openai_tools
|
||||
# Some providers (Kimi) error on empty reasoning_content in
|
||||
# tool-call follow-ups. Omit the entire stream_options key if
|
||||
# tools are present – avoids triggering model-side thinking mode
|
||||
# that requires reasoning_content on every assistant message.
|
||||
params.pop("stream_options", None)
|
||||
|
||||
# Collect full response while streaming text deltas
|
||||
collected_content = ""
|
||||
collected_reasoning = ""
|
||||
collected_tool_calls: dict[int, dict[str, Any]] = {}
|
||||
finish_reason: str | None = None
|
||||
usage_data: dict[str, int] = {}
|
||||
# Buffer to strip inline <think>…</think> blocks across streaming chunks.
|
||||
_think_buf = ""
|
||||
|
||||
stream = await self._client.chat.completions.create(**params)
|
||||
async for chunk in stream:
|
||||
if not chunk.choices:
|
||||
# Usage-only chunk (some providers send this at the end)
|
||||
if chunk.usage:
|
||||
usage_data = {
|
||||
"input_tokens": chunk.usage.prompt_tokens or 0,
|
||||
"output_tokens": chunk.usage.completion_tokens or 0,
|
||||
}
|
||||
continue
|
||||
|
||||
delta = chunk.choices[0].delta
|
||||
chunk_finish = chunk.choices[0].finish_reason
|
||||
|
||||
if chunk_finish:
|
||||
finish_reason = chunk_finish
|
||||
|
||||
# Accumulate reasoning_content from thinking models (not shown to user)
|
||||
reasoning_piece = getattr(delta, "reasoning_content", None) or ""
|
||||
if reasoning_piece:
|
||||
collected_reasoning += reasoning_piece
|
||||
|
||||
# Stream text content to user, stripping inline <think> blocks
|
||||
if delta.content:
|
||||
_think_buf += delta.content
|
||||
visible, _think_buf = _strip_think_blocks(_think_buf)
|
||||
if visible:
|
||||
collected_content += visible
|
||||
yield ApiTextDeltaEvent(text=visible)
|
||||
|
||||
# Accumulate tool calls
|
||||
if delta.tool_calls:
|
||||
for tc_delta in delta.tool_calls:
|
||||
idx = tc_delta.index
|
||||
if idx not in collected_tool_calls:
|
||||
collected_tool_calls[idx] = {
|
||||
"id": tc_delta.id or "",
|
||||
"name": "",
|
||||
"arguments": "",
|
||||
}
|
||||
entry = collected_tool_calls[idx]
|
||||
if tc_delta.id:
|
||||
entry["id"] = tc_delta.id
|
||||
if tc_delta.function:
|
||||
if tc_delta.function.name:
|
||||
entry["name"] = tc_delta.function.name
|
||||
if tc_delta.function.arguments:
|
||||
entry["arguments"] += tc_delta.function.arguments
|
||||
|
||||
# Usage in chunk (if provider sends it)
|
||||
if chunk.usage:
|
||||
usage_data = {
|
||||
"input_tokens": chunk.usage.prompt_tokens or 0,
|
||||
"output_tokens": chunk.usage.completion_tokens or 0,
|
||||
}
|
||||
|
||||
# Build the final ConversationMessage
|
||||
content: list[ContentBlock] = []
|
||||
if collected_content:
|
||||
content.append(TextBlock(text=collected_content))
|
||||
|
||||
for _idx in sorted(collected_tool_calls.keys()):
|
||||
tc = collected_tool_calls[_idx]
|
||||
# Skip phantom/empty tool calls that some providers send
|
||||
if not tc["name"]:
|
||||
continue
|
||||
try:
|
||||
args = json.loads(tc["arguments"])
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
args = {}
|
||||
content.append(ToolUseBlock(
|
||||
id=tc["id"],
|
||||
name=tc["name"],
|
||||
input=args,
|
||||
))
|
||||
|
||||
final_message = ConversationMessage(role="assistant", content=content)
|
||||
|
||||
# Stash reasoning for thinking models so _convert_assistant_message
|
||||
# can replay it when the message is sent back to the API
|
||||
if collected_reasoning:
|
||||
final_message._reasoning = collected_reasoning # type: ignore[attr-defined]
|
||||
|
||||
yield ApiMessageCompleteEvent(
|
||||
message=final_message,
|
||||
usage=UsageSnapshot(
|
||||
input_tokens=usage_data.get("input_tokens", 0),
|
||||
output_tokens=usage_data.get("output_tokens", 0),
|
||||
),
|
||||
stop_reason=finish_reason,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _is_retryable(exc: Exception) -> bool:
|
||||
status = getattr(exc, "status_code", None)
|
||||
if status and status in {429, 500, 502, 503}:
|
||||
return True
|
||||
if isinstance(exc, (ConnectionError, TimeoutError, OSError)):
|
||||
return True
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def _translate_error(exc: Exception) -> OpenHarnessApiError:
|
||||
status = getattr(exc, "status_code", None)
|
||||
msg = str(exc)
|
||||
if status == 401 or status == 403:
|
||||
return AuthenticationFailure(msg)
|
||||
if status == 429:
|
||||
return RateLimitFailure(msg)
|
||||
return RequestFailure(msg)
|
||||
|
||||
|
||||
# Matches complete <think>…</think> blocks (DOTALL so newlines are included).
|
||||
_THINK_RE = re.compile(r"<think>.*?</think>", re.DOTALL)
|
||||
_THINK_OPEN_TAG = "<think>"
|
||||
|
||||
|
||||
def _strip_think_blocks(buf: str) -> tuple[str, str]:
|
||||
"""Strip complete ``<think>…</think>`` blocks and return ``(visible_text, leftover)``.
|
||||
|
||||
Complete pairs are removed via regex. An unclosed ``<think>`` is held in
|
||||
*leftover* so it can be re-evaluated once the closing tag arrives in the
|
||||
next streaming chunk.
|
||||
"""
|
||||
# Remove fully-closed blocks.
|
||||
cleaned = _THINK_RE.sub("", buf)
|
||||
|
||||
# Hold back any unclosed <think> for the next chunk.
|
||||
open_idx = cleaned.find(_THINK_OPEN_TAG)
|
||||
if open_idx != -1:
|
||||
return cleaned[:open_idx], cleaned[open_idx:]
|
||||
|
||||
# Streaming providers may split the opening tag itself across chunk
|
||||
# boundaries (e.g. ``"<thi"`` then ``"nk>..."``). Hold back the longest
|
||||
# suffix that could still become ``<think>`` on the next chunk.
|
||||
max_prefix = min(len(cleaned), len(_THINK_OPEN_TAG) - 1)
|
||||
for prefix_len in range(max_prefix, 0, -1):
|
||||
if _THINK_OPEN_TAG.startswith(cleaned[-prefix_len:]):
|
||||
return cleaned[:-prefix_len], cleaned[-prefix_len:]
|
||||
|
||||
return cleaned, ""
|
||||
+30
-151
@@ -2,32 +2,10 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
|
||||
from openharness.auth.external import describe_external_binding
|
||||
from openharness.auth.storage import load_external_binding
|
||||
from openharness.api.registry import detect_provider_from_registry
|
||||
from openharness.config.settings import Settings
|
||||
|
||||
_AUTH_KIND: dict[str, str] = {
|
||||
"anthropic": "api_key",
|
||||
"openai_compat": "api_key",
|
||||
"copilot": "oauth_device",
|
||||
"openai_codex": "external_oauth",
|
||||
"anthropic_claude": "external_oauth",
|
||||
}
|
||||
|
||||
_VOICE_REASON: dict[str, str] = {
|
||||
"anthropic": (
|
||||
"voice mode shell exists, but live voice auth/streaming is not configured in this build"
|
||||
),
|
||||
"openai_compat": "voice mode is not wired for OpenAI-compatible providers in this build",
|
||||
"copilot": "voice mode is not supported for GitHub Copilot",
|
||||
"openai_codex": "voice mode is not supported for Codex subscription auth",
|
||||
"anthropic_claude": "voice mode is not supported for Claude subscription auth",
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ProviderInfo:
|
||||
@@ -40,147 +18,48 @@ class ProviderInfo:
|
||||
|
||||
|
||||
def detect_provider(settings: Settings) -> ProviderInfo:
|
||||
"""Infer the active provider and rough capability set using the registry."""
|
||||
if settings.provider == "openai_codex":
|
||||
"""Infer the active provider and rough capability set."""
|
||||
base_url = (settings.base_url or "").lower()
|
||||
model = settings.model.lower()
|
||||
if "moonshot" in base_url or model.startswith("kimi"):
|
||||
return ProviderInfo(
|
||||
name="openai-codex",
|
||||
auth_kind="external_oauth",
|
||||
voice_supported=False,
|
||||
voice_reason=_VOICE_REASON["openai_codex"],
|
||||
)
|
||||
if settings.provider == "anthropic_claude":
|
||||
return ProviderInfo(
|
||||
name="claude-subscription",
|
||||
auth_kind="external_oauth",
|
||||
voice_supported=False,
|
||||
voice_reason=_VOICE_REASON["anthropic_claude"],
|
||||
)
|
||||
if settings.api_format == "copilot":
|
||||
return ProviderInfo(
|
||||
name="github_copilot",
|
||||
auth_kind="oauth_device",
|
||||
voice_supported=False,
|
||||
voice_reason=_VOICE_REASON["copilot"],
|
||||
)
|
||||
|
||||
spec = detect_provider_from_registry(
|
||||
model=settings.model,
|
||||
api_key=settings.api_key or None,
|
||||
base_url=settings.base_url,
|
||||
)
|
||||
|
||||
if spec is not None:
|
||||
backend = spec.backend_type
|
||||
return ProviderInfo(
|
||||
name=spec.name,
|
||||
auth_kind=_AUTH_KIND.get(backend, "api_key"),
|
||||
voice_supported=False,
|
||||
voice_reason=_VOICE_REASON.get(backend, "voice mode is not supported for this provider"),
|
||||
)
|
||||
|
||||
# Fallback: use api_format to pick a sensible default
|
||||
if settings.api_format == "openai":
|
||||
return ProviderInfo(
|
||||
name="openai-compatible",
|
||||
name="moonshot-anthropic-compatible",
|
||||
auth_kind="api_key",
|
||||
voice_supported=False,
|
||||
voice_reason=_VOICE_REASON["openai_compat"],
|
||||
voice_reason="voice mode requires a Claude.ai-style authenticated voice backend",
|
||||
)
|
||||
if "bedrock" in base_url:
|
||||
return ProviderInfo(
|
||||
name="bedrock-compatible",
|
||||
auth_kind="aws",
|
||||
voice_supported=False,
|
||||
voice_reason="voice mode is not wired for Bedrock in this build",
|
||||
)
|
||||
if "vertex" in base_url or "aiplatform" in base_url:
|
||||
return ProviderInfo(
|
||||
name="vertex-compatible",
|
||||
auth_kind="gcp",
|
||||
voice_supported=False,
|
||||
voice_reason="voice mode is not wired for Vertex in this build",
|
||||
)
|
||||
if base_url:
|
||||
return ProviderInfo(
|
||||
name="anthropic-compatible",
|
||||
auth_kind="api_key",
|
||||
voice_supported=False,
|
||||
voice_reason="voice mode currently requires a dedicated Claude.ai-style provider",
|
||||
)
|
||||
return ProviderInfo(
|
||||
name="anthropic",
|
||||
auth_kind="api_key",
|
||||
voice_supported=False,
|
||||
voice_reason=_VOICE_REASON["anthropic"],
|
||||
voice_reason="voice mode shell exists, but live voice auth/streaming is not configured in this build",
|
||||
)
|
||||
|
||||
|
||||
def auth_status(settings: Settings) -> str:
|
||||
"""Return a compact auth status string."""
|
||||
if settings.api_format == "copilot":
|
||||
from openharness.api.copilot_auth import load_copilot_auth
|
||||
|
||||
auth_info = load_copilot_auth()
|
||||
if not auth_info:
|
||||
return "missing (run 'oh auth copilot-login')"
|
||||
if auth_info.enterprise_url:
|
||||
return f"configured (enterprise: {auth_info.enterprise_url})"
|
||||
if settings.api_key:
|
||||
return "configured"
|
||||
try:
|
||||
resolved = settings.resolve_auth()
|
||||
except ValueError as exc:
|
||||
if settings.provider == "openai_codex":
|
||||
return "missing (run 'oh auth codex-login')"
|
||||
if settings.provider == "anthropic_claude":
|
||||
binding = load_external_binding("anthropic_claude")
|
||||
if binding is not None:
|
||||
external_state = describe_external_binding(binding)
|
||||
if external_state.state != "missing":
|
||||
return external_state.state
|
||||
message = str(exc)
|
||||
if "third-party" in message:
|
||||
return "invalid base_url"
|
||||
return "missing (run 'oh auth claude-login')"
|
||||
return "missing"
|
||||
if resolved.source.startswith("external:"):
|
||||
return f"configured ({resolved.source.removeprefix('external:')})"
|
||||
return "configured"
|
||||
return "missing"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Multimodal (vision) capability detection
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Known multimodal model patterns (lowercase, regex).
|
||||
# These models can accept image input natively.
|
||||
_MULTIMODAL_MODEL_PATTERNS: list[re.Pattern[str]] = [
|
||||
# Anthropic Claude 3+ (all Claude 3 and later support images)
|
||||
re.compile(r"^claude-3(?:\.\d+)?(?:-sonnet|-opus|-haiku)?"),
|
||||
re.compile(r"^claude-(?:sonnet|opus|haiku)-\d"),
|
||||
# OpenAI GPT-4o / o-series
|
||||
re.compile(r"^gpt-4o"),
|
||||
re.compile(r"^o[1349]-"),
|
||||
# Google Gemini
|
||||
re.compile(r"^gemini-(?:pro-)?vision"),
|
||||
re.compile(r"^gemini-2\.\d+"),
|
||||
# Qwen / DashScope VL series
|
||||
re.compile(r"^qwen-vl"),
|
||||
re.compile(r"^qwen2\.5?-vl"),
|
||||
re.compile(r"^qvq-"),
|
||||
# DeepSeek VL
|
||||
re.compile(r"^deepseek-vl"),
|
||||
re.compile(r"^deepseek-vision"),
|
||||
# Open-source multimodal
|
||||
re.compile(r"^llava"),
|
||||
re.compile(r"^cogvlm"),
|
||||
re.compile(r"^internvl"),
|
||||
re.compile(r"^glm-4v"),
|
||||
# Moonshot / Kimi (k2.5 supports images)
|
||||
re.compile(r"^kimi-k2\.5"),
|
||||
# StepFun (阶跃星辰) — Step-2 and Step-1v support images
|
||||
re.compile(r"^step-2"),
|
||||
re.compile(r"^step-1v"),
|
||||
# MiniMax VL
|
||||
re.compile(r"^minimax-vl"),
|
||||
# Zhipu GLM-4V
|
||||
re.compile(r"^glm-4v"),
|
||||
# Mistral Pixtral
|
||||
re.compile(r"^pixtral"),
|
||||
# Groq vision models (llama-3.2-vision, etc.)
|
||||
re.compile(r"vision"),
|
||||
# Generic: model names containing "vl" or "vision" as a word boundary
|
||||
re.compile(r"(?:^|[-\s/])vl(?:$|[-\s])"),
|
||||
]
|
||||
|
||||
|
||||
def is_model_multimodal(model: str) -> bool:
|
||||
"""Return True when the model name indicates multimodal (vision) capability.
|
||||
|
||||
This is a heuristic based on known model naming conventions. It errs on
|
||||
the side of returning False for unknown models so that the image-to-text
|
||||
fallback tool is used rather than silently failing.
|
||||
"""
|
||||
normalized = model.strip().lower()
|
||||
# Strip provider prefix like "anthropic/" or "openai/"
|
||||
if "/" in normalized:
|
||||
normalized = normalized.split("/", 1)[-1]
|
||||
return any(pattern.search(normalized) is not None for pattern in _MULTIMODAL_MODEL_PATTERNS)
|
||||
|
||||
@@ -1,437 +0,0 @@
|
||||
"""
|
||||
LLM Provider Registry — single source of truth for provider metadata.
|
||||
|
||||
Adding a new provider:
|
||||
1. Add a ProviderSpec to PROVIDERS below.
|
||||
Done. Detection, display, and config all derive from here.
|
||||
|
||||
Order matters — it controls match priority. Gateways and cloud providers first,
|
||||
standard providers by keyword, local/special providers last.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ProviderSpec:
|
||||
"""One LLM provider's metadata.
|
||||
|
||||
backend_type:
|
||||
"anthropic" — Anthropic SDK (default for claude-* models)
|
||||
"openai_compat" — OpenAI-compatible REST API
|
||||
"copilot" — GitHub Copilot OAuth flow
|
||||
"""
|
||||
|
||||
# Identity
|
||||
name: str # canonical name, e.g. "dashscope"
|
||||
keywords: tuple[str, ...] # model-name substrings for detection (lowercase)
|
||||
env_key: str # primary API key environment variable
|
||||
display_name: str = "" # shown in status / diagnostics
|
||||
|
||||
# Routing
|
||||
backend_type: str = "openai_compat" # "anthropic" | "openai_compat" | "copilot"
|
||||
default_base_url: str = "" # fallback base URL for this provider
|
||||
|
||||
# Auto-detection signals
|
||||
detect_by_key_prefix: str = "" # match api_key prefix, e.g. "sk-or-"
|
||||
detect_by_base_keyword: str = "" # match substring in base_url
|
||||
|
||||
# Classification flags
|
||||
is_gateway: bool = False # routes any model (OpenRouter, AiHubMix, …)
|
||||
is_local: bool = False # local deployment (vLLM, Ollama)
|
||||
is_oauth: bool = False # uses OAuth instead of API key
|
||||
|
||||
@property
|
||||
def label(self) -> str:
|
||||
return self.display_name or self.name.title()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# PROVIDERS registry — order = detection priority.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
PROVIDERS: tuple[ProviderSpec, ...] = (
|
||||
# === GitHub Copilot (OAuth, detected by api_format="copilot") ============
|
||||
ProviderSpec(
|
||||
name="github_copilot",
|
||||
keywords=("copilot",),
|
||||
env_key="",
|
||||
display_name="GitHub Copilot",
|
||||
backend_type="copilot",
|
||||
default_base_url="",
|
||||
detect_by_key_prefix="",
|
||||
detect_by_base_keyword="",
|
||||
is_gateway=False,
|
||||
is_local=False,
|
||||
is_oauth=True,
|
||||
),
|
||||
# === Gateways (detected by api_key prefix / base_url keyword) ============
|
||||
# OpenRouter: global gateway, keys start with "sk-or-"
|
||||
ProviderSpec(
|
||||
name="openrouter",
|
||||
keywords=("openrouter",),
|
||||
env_key="OPENROUTER_API_KEY",
|
||||
display_name="OpenRouter",
|
||||
backend_type="openai_compat",
|
||||
default_base_url="https://openrouter.ai/api/v1",
|
||||
detect_by_key_prefix="sk-or-",
|
||||
detect_by_base_keyword="openrouter",
|
||||
is_gateway=True,
|
||||
is_local=False,
|
||||
is_oauth=False,
|
||||
),
|
||||
# AiHubMix: OpenAI-compatible gateway
|
||||
ProviderSpec(
|
||||
name="aihubmix",
|
||||
keywords=("aihubmix",),
|
||||
env_key="OPENAI_API_KEY",
|
||||
display_name="AiHubMix",
|
||||
backend_type="openai_compat",
|
||||
default_base_url="https://aihubmix.com/v1",
|
||||
detect_by_key_prefix="",
|
||||
detect_by_base_keyword="aihubmix",
|
||||
is_gateway=True,
|
||||
is_local=False,
|
||||
is_oauth=False,
|
||||
),
|
||||
# SiliconFlow (硅基流动): OpenAI-compatible gateway
|
||||
ProviderSpec(
|
||||
name="siliconflow",
|
||||
keywords=("siliconflow",),
|
||||
env_key="OPENAI_API_KEY",
|
||||
display_name="SiliconFlow",
|
||||
backend_type="openai_compat",
|
||||
default_base_url="https://api.siliconflow.cn/v1",
|
||||
detect_by_key_prefix="",
|
||||
detect_by_base_keyword="siliconflow",
|
||||
is_gateway=True,
|
||||
is_local=False,
|
||||
is_oauth=False,
|
||||
),
|
||||
# VolcEngine (火山引擎 / Ark): OpenAI-compatible gateway
|
||||
ProviderSpec(
|
||||
name="volcengine",
|
||||
keywords=("volcengine", "volces", "ark"),
|
||||
env_key="OPENAI_API_KEY",
|
||||
display_name="VolcEngine",
|
||||
backend_type="openai_compat",
|
||||
default_base_url="https://ark.cn-beijing.volces.com/api/v3",
|
||||
detect_by_key_prefix="",
|
||||
detect_by_base_keyword="volces",
|
||||
is_gateway=True,
|
||||
is_local=False,
|
||||
is_oauth=False,
|
||||
),
|
||||
# ModelScope: OpenAI-compatible inference API
|
||||
ProviderSpec(
|
||||
name="modelscope",
|
||||
keywords=("modelscope",),
|
||||
env_key="MODELSCOPE_API_KEY",
|
||||
display_name="ModelScope",
|
||||
backend_type="openai_compat",
|
||||
default_base_url="https://api-inference.modelscope.cn/v1",
|
||||
detect_by_key_prefix="",
|
||||
detect_by_base_keyword="modelscope",
|
||||
is_gateway=False,
|
||||
is_local=False,
|
||||
is_oauth=False,
|
||||
),
|
||||
# === Standard cloud providers (matched by model-name keyword) ============
|
||||
# Anthropic: native SDK for claude-* models
|
||||
ProviderSpec(
|
||||
name="anthropic",
|
||||
keywords=("anthropic", "claude"),
|
||||
env_key="ANTHROPIC_API_KEY",
|
||||
display_name="Anthropic",
|
||||
backend_type="anthropic",
|
||||
default_base_url="",
|
||||
detect_by_key_prefix="",
|
||||
detect_by_base_keyword="",
|
||||
is_gateway=False,
|
||||
is_local=False,
|
||||
is_oauth=False,
|
||||
),
|
||||
# OpenAI: gpt-* models
|
||||
ProviderSpec(
|
||||
name="openai",
|
||||
keywords=("openai", "gpt", "o1", "o3", "o4"),
|
||||
env_key="OPENAI_API_KEY",
|
||||
display_name="OpenAI",
|
||||
backend_type="openai_compat",
|
||||
default_base_url="",
|
||||
detect_by_key_prefix="",
|
||||
detect_by_base_keyword="",
|
||||
is_gateway=False,
|
||||
is_local=False,
|
||||
is_oauth=False,
|
||||
),
|
||||
# DeepSeek
|
||||
ProviderSpec(
|
||||
name="deepseek",
|
||||
keywords=("deepseek",),
|
||||
env_key="DEEPSEEK_API_KEY",
|
||||
display_name="DeepSeek",
|
||||
backend_type="openai_compat",
|
||||
default_base_url="https://api.deepseek.com/v1",
|
||||
detect_by_key_prefix="",
|
||||
detect_by_base_keyword="deepseek",
|
||||
is_gateway=False,
|
||||
is_local=False,
|
||||
is_oauth=False,
|
||||
),
|
||||
# Google Gemini
|
||||
ProviderSpec(
|
||||
name="gemini",
|
||||
keywords=("gemini",),
|
||||
env_key="GEMINI_API_KEY",
|
||||
display_name="Gemini",
|
||||
backend_type="openai_compat",
|
||||
default_base_url="https://generativelanguage.googleapis.com/v1beta/openai",
|
||||
detect_by_key_prefix="",
|
||||
detect_by_base_keyword="googleapis",
|
||||
is_gateway=False,
|
||||
is_local=False,
|
||||
is_oauth=False,
|
||||
),
|
||||
# DashScope (Qwen / 阿里云)
|
||||
ProviderSpec(
|
||||
name="dashscope",
|
||||
keywords=("qwen", "dashscope"),
|
||||
env_key="DASHSCOPE_API_KEY",
|
||||
display_name="DashScope",
|
||||
backend_type="openai_compat",
|
||||
default_base_url="https://dashscope.aliyuncs.com/compatible-mode/v1",
|
||||
detect_by_key_prefix="",
|
||||
detect_by_base_keyword="dashscope",
|
||||
is_gateway=False,
|
||||
is_local=False,
|
||||
is_oauth=False,
|
||||
),
|
||||
# Moonshot / Kimi
|
||||
ProviderSpec(
|
||||
name="moonshot",
|
||||
keywords=("moonshot", "kimi"),
|
||||
env_key="MOONSHOT_API_KEY",
|
||||
display_name="Moonshot",
|
||||
backend_type="openai_compat",
|
||||
default_base_url="https://api.moonshot.ai/v1",
|
||||
detect_by_key_prefix="",
|
||||
detect_by_base_keyword="moonshot",
|
||||
is_gateway=False,
|
||||
is_local=False,
|
||||
is_oauth=False,
|
||||
),
|
||||
# MiniMax
|
||||
ProviderSpec(
|
||||
name="minimax",
|
||||
keywords=("minimax",),
|
||||
env_key="MINIMAX_API_KEY",
|
||||
display_name="MiniMax",
|
||||
backend_type="openai_compat",
|
||||
default_base_url="https://api.minimax.io/v1",
|
||||
detect_by_key_prefix="",
|
||||
detect_by_base_keyword="minimax",
|
||||
is_gateway=False,
|
||||
is_local=False,
|
||||
is_oauth=False,
|
||||
),
|
||||
# Zhipu AI / GLM
|
||||
ProviderSpec(
|
||||
name="zhipu",
|
||||
keywords=("zhipu", "glm", "chatglm"),
|
||||
env_key="ZHIPUAI_API_KEY",
|
||||
display_name="Zhipu AI",
|
||||
backend_type="openai_compat",
|
||||
default_base_url="https://open.bigmodel.cn/api/paas/v4",
|
||||
detect_by_key_prefix="",
|
||||
detect_by_base_keyword="bigmodel",
|
||||
is_gateway=False,
|
||||
is_local=False,
|
||||
is_oauth=False,
|
||||
),
|
||||
# Groq
|
||||
ProviderSpec(
|
||||
name="groq",
|
||||
keywords=("groq",),
|
||||
env_key="GROQ_API_KEY",
|
||||
display_name="Groq",
|
||||
backend_type="openai_compat",
|
||||
default_base_url="https://api.groq.com/openai/v1",
|
||||
detect_by_key_prefix="gsk_",
|
||||
detect_by_base_keyword="groq",
|
||||
is_gateway=False,
|
||||
is_local=False,
|
||||
is_oauth=False,
|
||||
),
|
||||
# Mistral
|
||||
ProviderSpec(
|
||||
name="mistral",
|
||||
keywords=("mistral", "mixtral", "codestral"),
|
||||
env_key="MISTRAL_API_KEY",
|
||||
display_name="Mistral",
|
||||
backend_type="openai_compat",
|
||||
default_base_url="https://api.mistral.ai/v1",
|
||||
detect_by_key_prefix="",
|
||||
detect_by_base_keyword="mistral",
|
||||
is_gateway=False,
|
||||
is_local=False,
|
||||
is_oauth=False,
|
||||
),
|
||||
# StepFun (阶跃星辰)
|
||||
ProviderSpec(
|
||||
name="stepfun",
|
||||
keywords=("step-", "stepfun"),
|
||||
env_key="STEPFUN_API_KEY",
|
||||
display_name="StepFun",
|
||||
backend_type="openai_compat",
|
||||
default_base_url="https://api.stepfun.com/v1",
|
||||
detect_by_key_prefix="",
|
||||
detect_by_base_keyword="stepfun",
|
||||
is_gateway=False,
|
||||
is_local=False,
|
||||
is_oauth=False,
|
||||
),
|
||||
# Baidu / ERNIE
|
||||
ProviderSpec(
|
||||
name="baidu",
|
||||
keywords=("ernie", "baidu"),
|
||||
env_key="QIANFAN_ACCESS_KEY",
|
||||
display_name="Baidu",
|
||||
backend_type="openai_compat",
|
||||
default_base_url="https://qianfan.baidubce.com/v2",
|
||||
detect_by_key_prefix="",
|
||||
detect_by_base_keyword="baidubce",
|
||||
is_gateway=False,
|
||||
is_local=False,
|
||||
is_oauth=False,
|
||||
),
|
||||
# === Cloud platform providers (detected by base_url) ====================
|
||||
# AWS Bedrock
|
||||
ProviderSpec(
|
||||
name="bedrock",
|
||||
keywords=("bedrock",),
|
||||
env_key="AWS_ACCESS_KEY_ID",
|
||||
display_name="AWS Bedrock",
|
||||
backend_type="openai_compat",
|
||||
default_base_url="",
|
||||
detect_by_key_prefix="",
|
||||
detect_by_base_keyword="bedrock",
|
||||
is_gateway=False,
|
||||
is_local=False,
|
||||
is_oauth=False,
|
||||
),
|
||||
# Google Vertex AI
|
||||
ProviderSpec(
|
||||
name="vertex",
|
||||
keywords=("vertex",),
|
||||
env_key="GOOGLE_APPLICATION_CREDENTIALS",
|
||||
display_name="Vertex AI",
|
||||
backend_type="openai_compat",
|
||||
default_base_url="",
|
||||
detect_by_key_prefix="",
|
||||
detect_by_base_keyword="aiplatform",
|
||||
is_gateway=False,
|
||||
is_local=False,
|
||||
is_oauth=False,
|
||||
),
|
||||
# === Local deployments (matched by keyword or base_url) =================
|
||||
# Ollama
|
||||
ProviderSpec(
|
||||
name="ollama",
|
||||
keywords=("ollama",),
|
||||
env_key="",
|
||||
display_name="Ollama",
|
||||
backend_type="openai_compat",
|
||||
default_base_url="http://localhost:11434/v1",
|
||||
detect_by_key_prefix="",
|
||||
detect_by_base_keyword="localhost:11434",
|
||||
is_gateway=False,
|
||||
is_local=True,
|
||||
is_oauth=False,
|
||||
),
|
||||
# vLLM / any OpenAI-compatible local server
|
||||
ProviderSpec(
|
||||
name="vllm",
|
||||
keywords=("vllm",),
|
||||
env_key="",
|
||||
display_name="vLLM/Local",
|
||||
backend_type="openai_compat",
|
||||
default_base_url="",
|
||||
detect_by_key_prefix="",
|
||||
detect_by_base_keyword="",
|
||||
is_gateway=False,
|
||||
is_local=True,
|
||||
is_oauth=False,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Lookup helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def find_by_name(name: str) -> ProviderSpec | None:
|
||||
"""Find a provider spec by canonical name, e.g. "dashscope"."""
|
||||
for spec in PROVIDERS:
|
||||
if spec.name == name:
|
||||
return spec
|
||||
return None
|
||||
|
||||
|
||||
def _match_by_model(model: str) -> ProviderSpec | None:
|
||||
"""Match a standard/gateway provider by model-name keyword (case-insensitive)."""
|
||||
model_lower = model.lower()
|
||||
model_normalized = model_lower.replace("-", "_")
|
||||
model_prefix = model_lower.split("/", 1)[0] if "/" in model_lower else ""
|
||||
normalized_prefix = model_prefix.replace("-", "_")
|
||||
|
||||
std_specs = [s for s in PROVIDERS if not s.is_local and not s.is_oauth]
|
||||
|
||||
# Prefer an explicit provider-prefix match (e.g. "deepseek/..." → deepseek spec)
|
||||
for spec in std_specs:
|
||||
if model_prefix and normalized_prefix == spec.name:
|
||||
return spec
|
||||
|
||||
# Fall back to keyword scan
|
||||
for spec in std_specs:
|
||||
if any(
|
||||
kw in model_lower or kw.replace("-", "_") in model_normalized
|
||||
for kw in spec.keywords
|
||||
):
|
||||
return spec
|
||||
return None
|
||||
|
||||
|
||||
def detect_provider_from_registry(
|
||||
model: str,
|
||||
api_key: str | None = None,
|
||||
base_url: str | None = None,
|
||||
) -> ProviderSpec | None:
|
||||
"""Detect the best-matching ProviderSpec for the given inputs.
|
||||
|
||||
Detection priority:
|
||||
1. api_key prefix (e.g. "sk-or-" → OpenRouter)
|
||||
2. base_url keyword (e.g. "aihubmix" in URL → AiHubMix)
|
||||
3. model name keyword (e.g. "qwen" → DashScope)
|
||||
"""
|
||||
# 1. api_key prefix
|
||||
if api_key:
|
||||
for spec in PROVIDERS:
|
||||
if spec.detect_by_key_prefix and api_key.startswith(spec.detect_by_key_prefix):
|
||||
return spec
|
||||
|
||||
# 2. base_url keyword
|
||||
if base_url:
|
||||
base_lower = base_url.lower()
|
||||
for spec in PROVIDERS:
|
||||
if spec.detect_by_base_keyword and spec.detect_by_base_keyword in base_lower:
|
||||
return spec
|
||||
|
||||
# 3. model keyword
|
||||
if model:
|
||||
return _match_by_model(model)
|
||||
|
||||
return None
|
||||
@@ -1,29 +0,0 @@
|
||||
"""Unified authentication management for OpenHarness."""
|
||||
|
||||
from openharness.auth.flows import ApiKeyFlow, BrowserFlow, DeviceCodeFlow
|
||||
from openharness.auth.manager import AuthManager
|
||||
from openharness.auth.storage import (
|
||||
clear_provider_credentials,
|
||||
decrypt,
|
||||
encrypt,
|
||||
load_credential,
|
||||
load_external_binding,
|
||||
store_credential,
|
||||
store_external_binding,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"AuthManager",
|
||||
"ApiKeyFlow",
|
||||
"BrowserFlow",
|
||||
"DeviceCodeFlow",
|
||||
"store_credential",
|
||||
"load_credential",
|
||||
"store_external_binding",
|
||||
"load_external_binding",
|
||||
"clear_provider_credentials",
|
||||
# Deprecated — use _obfuscate/_deobfuscate directly if needed.
|
||||
# Kept for backward compatibility; will be removed in a future version.
|
||||
"encrypt",
|
||||
"decrypt",
|
||||
]
|
||||
@@ -1,610 +0,0 @@
|
||||
"""Integration with external CLI-managed subscription credentials."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import platform
|
||||
import re
|
||||
import subprocess
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from openharness.auth.storage import ExternalAuthBinding
|
||||
from openharness.utils.fs import atomic_write_text
|
||||
|
||||
CODEX_PROVIDER = "openai_codex"
|
||||
CLAUDE_PROVIDER = "anthropic_claude"
|
||||
CLAUDE_CODE_VERSION_FALLBACK = "2.1.92"
|
||||
CLAUDE_OAUTH_CLIENT_ID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e"
|
||||
CLAUDE_OAUTH_TOKEN_ENDPOINTS = (
|
||||
"https://platform.claude.com/v1/oauth/token",
|
||||
"https://console.anthropic.com/v1/oauth/token",
|
||||
)
|
||||
CLAUDE_COMMON_BETAS = (
|
||||
"interleaved-thinking-2025-05-14",
|
||||
"fine-grained-tool-streaming-2025-05-14",
|
||||
)
|
||||
CLAUDE_AI_OAUTH_SCOPES = (
|
||||
"user:profile",
|
||||
"user:inference",
|
||||
"user:sessions:claude_code",
|
||||
"user:mcp_servers",
|
||||
"user:file_upload",
|
||||
)
|
||||
CLAUDE_OAUTH_ONLY_BETAS = (
|
||||
"claude-code-20250219",
|
||||
"oauth-2025-04-20",
|
||||
)
|
||||
CLAUDE_KEYCHAIN_SERVICE = "Claude Code-credentials"
|
||||
_KEYCHAIN_BINDING_PREFIX = "keychain:"
|
||||
|
||||
_claude_code_version_cache: str | None = None
|
||||
_claude_code_session_id: str | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ExternalAuthCredential:
|
||||
"""Normalized external credential used at runtime."""
|
||||
|
||||
provider: str
|
||||
value: str
|
||||
auth_kind: str
|
||||
source_path: Path
|
||||
managed_by: str
|
||||
profile_label: str = ""
|
||||
refresh_token: str = ""
|
||||
expires_at_ms: int | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ExternalAuthState:
|
||||
"""Human-readable state for an external auth source."""
|
||||
|
||||
configured: bool
|
||||
state: str
|
||||
source: str
|
||||
detail: str = ""
|
||||
|
||||
|
||||
def default_binding_for_provider(provider: str) -> ExternalAuthBinding:
|
||||
"""Return the default external auth source for *provider*."""
|
||||
if provider == CODEX_PROVIDER:
|
||||
codex_home = Path(os.environ.get("CODEX_HOME", "~/.codex")).expanduser()
|
||||
return ExternalAuthBinding(
|
||||
provider=provider,
|
||||
source_path=str(codex_home / "auth.json"),
|
||||
source_kind="codex_auth_json",
|
||||
managed_by="codex-cli",
|
||||
profile_label="Codex CLI",
|
||||
)
|
||||
if provider == CLAUDE_PROVIDER:
|
||||
configured_dir = os.environ.get("CLAUDE_CONFIG_DIR", "").strip()
|
||||
if configured_dir:
|
||||
return ExternalAuthBinding(
|
||||
provider=provider,
|
||||
source_path=str(Path(configured_dir).expanduser() / ".credentials.json"),
|
||||
source_kind="claude_credentials_json",
|
||||
managed_by="claude-cli",
|
||||
profile_label="Claude CLI",
|
||||
)
|
||||
if platform.system() == "Darwin":
|
||||
return ExternalAuthBinding(
|
||||
provider=provider,
|
||||
source_path=f"{_KEYCHAIN_BINDING_PREFIX}{CLAUDE_KEYCHAIN_SERVICE}",
|
||||
source_kind="claude_credentials_keychain",
|
||||
managed_by="claude-cli",
|
||||
profile_label="Claude CLI",
|
||||
)
|
||||
claude_home = Path(os.environ.get("CLAUDE_HOME", "~/.claude")).expanduser()
|
||||
return ExternalAuthBinding(
|
||||
provider=provider,
|
||||
source_path=str(claude_home / ".credentials.json"),
|
||||
source_kind="claude_credentials_json",
|
||||
managed_by="claude-cli",
|
||||
profile_label="Claude CLI",
|
||||
)
|
||||
raise ValueError(f"Unsupported external auth provider: {provider}")
|
||||
|
||||
|
||||
def load_external_credential(
|
||||
binding: ExternalAuthBinding,
|
||||
*,
|
||||
refresh_if_needed: bool = False,
|
||||
) -> ExternalAuthCredential:
|
||||
"""Read a runtime credential from an external auth binding."""
|
||||
if binding.provider == CODEX_PROVIDER:
|
||||
source_path = Path(binding.source_path).expanduser()
|
||||
if not source_path.exists():
|
||||
raise ValueError(f"External auth source not found: {source_path}")
|
||||
try:
|
||||
payload = json.loads(source_path.read_text(encoding="utf-8"))
|
||||
except json.JSONDecodeError as exc:
|
||||
raise ValueError(f"Invalid JSON in external auth source: {source_path}") from exc
|
||||
return _load_codex_credential(payload, source_path, binding)
|
||||
if binding.provider == CLAUDE_PROVIDER:
|
||||
payload, source_path, keychain_service, keychain_account = _load_claude_payload(binding)
|
||||
return _load_claude_credential(
|
||||
payload,
|
||||
source_path,
|
||||
binding,
|
||||
refresh_if_needed=refresh_if_needed,
|
||||
keychain_service=keychain_service,
|
||||
keychain_account=keychain_account,
|
||||
)
|
||||
raise ValueError(f"Unsupported external auth provider: {binding.provider}")
|
||||
|
||||
|
||||
def _load_codex_credential(
|
||||
payload: dict[str, Any],
|
||||
source_path: Path,
|
||||
binding: ExternalAuthBinding,
|
||||
) -> ExternalAuthCredential:
|
||||
tokens = payload.get("tokens")
|
||||
access_token = ""
|
||||
refresh_token = ""
|
||||
if isinstance(tokens, dict):
|
||||
access_token = str(tokens.get("access_token", "") or "")
|
||||
refresh_token = str(tokens.get("refresh_token", "") or "")
|
||||
if not access_token:
|
||||
access_token = str(payload.get("OPENAI_API_KEY", "") or "")
|
||||
if not access_token:
|
||||
raise ValueError("Codex auth source does not contain an access token.")
|
||||
|
||||
email = _decode_json_web_token_claim(access_token, ["https://api.openai.com/profile", "email"])
|
||||
expires_at_ms = _decode_jwt_expiry(access_token)
|
||||
return ExternalAuthCredential(
|
||||
provider=CODEX_PROVIDER,
|
||||
value=access_token,
|
||||
auth_kind="api_key",
|
||||
source_path=source_path,
|
||||
managed_by=binding.managed_by,
|
||||
profile_label=email or binding.profile_label,
|
||||
refresh_token=refresh_token,
|
||||
expires_at_ms=expires_at_ms,
|
||||
)
|
||||
|
||||
|
||||
def _load_claude_credential(
|
||||
payload: dict[str, Any],
|
||||
source_path: Path,
|
||||
binding: ExternalAuthBinding,
|
||||
*,
|
||||
refresh_if_needed: bool,
|
||||
keychain_service: str | None = None,
|
||||
keychain_account: str | None = None,
|
||||
) -> ExternalAuthCredential:
|
||||
claude_oauth = payload.get("claudeAiOauth")
|
||||
if not isinstance(claude_oauth, dict):
|
||||
raise ValueError("Claude auth source does not contain claudeAiOauth.")
|
||||
|
||||
access_token = str(claude_oauth.get("accessToken", "") or "")
|
||||
refresh_token = str(claude_oauth.get("refreshToken", "") or "")
|
||||
expires_at_raw = claude_oauth.get("expiresAt")
|
||||
if not access_token:
|
||||
raise ValueError("Claude auth source does not contain an access token.")
|
||||
|
||||
expires_at_ms = _coerce_int(expires_at_raw)
|
||||
credential = ExternalAuthCredential(
|
||||
provider=CLAUDE_PROVIDER,
|
||||
value=access_token,
|
||||
auth_kind="auth_token",
|
||||
source_path=source_path,
|
||||
managed_by=binding.managed_by,
|
||||
profile_label=keychain_account or binding.profile_label,
|
||||
refresh_token=refresh_token,
|
||||
expires_at_ms=expires_at_ms,
|
||||
)
|
||||
if refresh_if_needed and is_credential_expired(credential):
|
||||
if not refresh_token:
|
||||
raise ValueError(
|
||||
f"Claude credentials at {source_path} are expired and cannot be refreshed."
|
||||
)
|
||||
refreshed = refresh_claude_oauth_credential(refresh_token)
|
||||
if binding.source_kind == "claude_credentials_keychain":
|
||||
_write_claude_credentials_to_keychain(
|
||||
service=keychain_service or CLAUDE_KEYCHAIN_SERVICE,
|
||||
account=keychain_account or os.environ.get("USER", ""),
|
||||
payload=payload,
|
||||
access_token=str(refreshed["access_token"]),
|
||||
refresh_token=str(refreshed["refresh_token"]),
|
||||
expires_at_ms=int(refreshed["expires_at_ms"]),
|
||||
)
|
||||
else:
|
||||
write_claude_credentials(
|
||||
source_path,
|
||||
access_token=str(refreshed["access_token"]),
|
||||
refresh_token=str(refreshed["refresh_token"]),
|
||||
expires_at_ms=int(refreshed["expires_at_ms"]),
|
||||
)
|
||||
credential = ExternalAuthCredential(
|
||||
provider=CLAUDE_PROVIDER,
|
||||
value=str(refreshed["access_token"]),
|
||||
auth_kind="auth_token",
|
||||
source_path=source_path,
|
||||
managed_by=binding.managed_by,
|
||||
profile_label=keychain_account or binding.profile_label,
|
||||
refresh_token=str(refreshed["refresh_token"]),
|
||||
expires_at_ms=int(refreshed["expires_at_ms"]),
|
||||
)
|
||||
return credential
|
||||
|
||||
|
||||
def _load_claude_payload(
|
||||
binding: ExternalAuthBinding,
|
||||
) -> tuple[dict[str, Any], Path, str | None, str | None]:
|
||||
if binding.source_kind == "claude_credentials_keychain":
|
||||
return _read_claude_credentials_from_keychain(binding)
|
||||
|
||||
source_path = Path(binding.source_path).expanduser()
|
||||
if not source_path.exists():
|
||||
raise ValueError(f"External auth source not found: {source_path}")
|
||||
try:
|
||||
payload = json.loads(source_path.read_text(encoding="utf-8"))
|
||||
except json.JSONDecodeError as exc:
|
||||
raise ValueError(f"Invalid JSON in external auth source: {source_path}") from exc
|
||||
return payload, source_path, None, None
|
||||
|
||||
|
||||
def _read_claude_credentials_from_keychain(
|
||||
binding: ExternalAuthBinding,
|
||||
) -> tuple[dict[str, Any], Path, str, str | None]:
|
||||
service = binding.source_path.removeprefix(_KEYCHAIN_BINDING_PREFIX).strip() or CLAUDE_KEYCHAIN_SERVICE
|
||||
try:
|
||||
raw_payload = subprocess.check_output(
|
||||
["security", "find-generic-password", "-w", "-s", service],
|
||||
text=True,
|
||||
)
|
||||
metadata = subprocess.check_output(
|
||||
["security", "find-generic-password", "-s", service],
|
||||
text=True,
|
||||
)
|
||||
except subprocess.CalledProcessError as exc:
|
||||
raise ValueError(f"Claude Keychain credential not found for service: {service}") from exc
|
||||
|
||||
try:
|
||||
payload = json.loads(raw_payload)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise ValueError(f"Invalid JSON in Claude Keychain secret for service: {service}") from exc
|
||||
|
||||
keychain_path = _extract_keychain_path(metadata) or (Path.home() / "Library/Keychains/login.keychain-db")
|
||||
account = _extract_keychain_attr(metadata, "acct")
|
||||
return payload, keychain_path, service, account
|
||||
|
||||
|
||||
def _extract_keychain_path(metadata: str) -> Path | None:
|
||||
match = re.search(r'^keychain:\s+"([^"]+)"$', metadata, re.MULTILINE)
|
||||
if not match:
|
||||
return None
|
||||
return Path(match.group(1))
|
||||
|
||||
|
||||
def _extract_keychain_attr(metadata: str, attr_name: str) -> str | None:
|
||||
match = re.search(rf'"{re.escape(attr_name)}"<blob>="([^"]*)"', metadata)
|
||||
if not match:
|
||||
return None
|
||||
return match.group(1)
|
||||
|
||||
|
||||
def describe_external_binding(binding: ExternalAuthBinding) -> ExternalAuthState:
|
||||
"""Return a human-readable state for an external auth binding."""
|
||||
source_path = Path(binding.source_path).expanduser()
|
||||
if binding.source_kind != "claude_credentials_keychain" and not source_path.exists():
|
||||
return ExternalAuthState(
|
||||
configured=False,
|
||||
state="missing",
|
||||
source="missing",
|
||||
detail=f"external auth source not found: {source_path}",
|
||||
)
|
||||
try:
|
||||
credential = load_external_credential(binding, refresh_if_needed=False)
|
||||
except ValueError as exc:
|
||||
detail = str(exc)
|
||||
if "not found" in detail.lower():
|
||||
return ExternalAuthState(
|
||||
configured=False,
|
||||
state="missing",
|
||||
source="missing",
|
||||
detail=detail,
|
||||
)
|
||||
return ExternalAuthState(
|
||||
configured=False,
|
||||
state="invalid",
|
||||
source="external",
|
||||
detail=detail,
|
||||
)
|
||||
resolved_source = credential.source_path
|
||||
if binding.provider == CLAUDE_PROVIDER and is_credential_expired(credential):
|
||||
if credential.refresh_token:
|
||||
return ExternalAuthState(
|
||||
configured=True,
|
||||
state="refreshable",
|
||||
source="external",
|
||||
detail=f"expired token can be refreshed from {resolved_source}",
|
||||
)
|
||||
return ExternalAuthState(
|
||||
configured=False,
|
||||
state="expired",
|
||||
source="external",
|
||||
detail=f"expired token at {resolved_source}",
|
||||
)
|
||||
return ExternalAuthState(
|
||||
configured=True,
|
||||
state="configured",
|
||||
source="external",
|
||||
detail=str(resolved_source),
|
||||
)
|
||||
|
||||
|
||||
def is_credential_expired(credential: ExternalAuthCredential, *, now_ms: int | None = None) -> bool:
|
||||
"""Return True when the external credential is definitely expired."""
|
||||
if credential.expires_at_ms is None:
|
||||
return False
|
||||
if now_ms is None:
|
||||
import time
|
||||
|
||||
now_ms = int(time.time() * 1000)
|
||||
return credential.expires_at_ms <= now_ms
|
||||
|
||||
|
||||
def get_claude_code_version() -> str:
|
||||
"""Return the locally installed Claude Code version or a fallback."""
|
||||
global _claude_code_version_cache
|
||||
if _claude_code_version_cache is not None:
|
||||
return _claude_code_version_cache
|
||||
for command in ("claude", "claude-code"):
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[command, "--version"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5,
|
||||
check=False,
|
||||
)
|
||||
except Exception:
|
||||
continue
|
||||
version = (result.stdout or "").strip().split(" ", 1)[0]
|
||||
if result.returncode == 0 and version and version[0].isdigit():
|
||||
_claude_code_version_cache = version
|
||||
return version
|
||||
_claude_code_version_cache = CLAUDE_CODE_VERSION_FALLBACK
|
||||
return _claude_code_version_cache
|
||||
|
||||
|
||||
def get_claude_code_session_id() -> str:
|
||||
"""Return a stable Claude Code-style session identifier for this process."""
|
||||
global _claude_code_session_id
|
||||
if _claude_code_session_id is None:
|
||||
_claude_code_session_id = str(uuid.uuid4())
|
||||
return _claude_code_session_id
|
||||
|
||||
|
||||
def claude_oauth_betas() -> list[str]:
|
||||
"""Return Claude OAuth betas as a list for SDK beta endpoints."""
|
||||
return list(CLAUDE_COMMON_BETAS + CLAUDE_OAUTH_ONLY_BETAS)
|
||||
|
||||
|
||||
def claude_attribution_header() -> str:
|
||||
"""Return the Claude Code billing attribution prefix used in system prompts."""
|
||||
version = get_claude_code_version()
|
||||
return (
|
||||
"x-anthropic-billing-header: "
|
||||
f"cc_version={version}; cc_entrypoint=cli;"
|
||||
)
|
||||
|
||||
|
||||
def claude_oauth_headers() -> dict[str, str]:
|
||||
"""Return Claude Code-style headers for subscription OAuth traffic."""
|
||||
all_betas = ",".join(claude_oauth_betas())
|
||||
return {
|
||||
"anthropic-beta": all_betas,
|
||||
"user-agent": f"claude-cli/{get_claude_code_version()} (external, cli)",
|
||||
"x-app": "cli",
|
||||
"X-Claude-Code-Session-Id": get_claude_code_session_id(),
|
||||
}
|
||||
|
||||
|
||||
def refresh_claude_oauth_credential(
|
||||
refresh_token: str,
|
||||
*,
|
||||
scopes: list[str] | tuple[str, ...] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Refresh a Claude OAuth token without mutating local files."""
|
||||
if not refresh_token:
|
||||
raise ValueError("refresh_token is required")
|
||||
|
||||
requested_scopes = list(scopes or CLAUDE_AI_OAUTH_SCOPES)
|
||||
payload = json.dumps(
|
||||
{
|
||||
"grant_type": "refresh_token",
|
||||
"refresh_token": refresh_token,
|
||||
"client_id": CLAUDE_OAUTH_CLIENT_ID,
|
||||
"scope": " ".join(requested_scopes),
|
||||
}
|
||||
).encode("utf-8")
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"User-Agent": f"claude-cli/{get_claude_code_version()} (external, cli)",
|
||||
}
|
||||
last_error: Exception | None = None
|
||||
for endpoint in CLAUDE_OAUTH_TOKEN_ENDPOINTS:
|
||||
request = urllib.request.Request(endpoint, data=payload, headers=headers, method="POST")
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=10) as response:
|
||||
result = json.loads(response.read().decode("utf-8"))
|
||||
except urllib.error.HTTPError as exc:
|
||||
body = ""
|
||||
try:
|
||||
body = exc.read().decode("utf-8", errors="replace").strip()
|
||||
except Exception:
|
||||
body = ""
|
||||
if "invalid_grant" in body:
|
||||
last_error = ValueError(
|
||||
"Claude OAuth refresh token is invalid or expired. "
|
||||
"Run `claude auth login` to refresh the official Claude CLI "
|
||||
"credentials, then run `oh auth claude-login` again."
|
||||
)
|
||||
continue
|
||||
detail = f"{exc.code} {exc.reason}"
|
||||
if body:
|
||||
detail = f"{detail}: {body}"
|
||||
last_error = ValueError(f"Claude OAuth refresh failed at {endpoint}: {detail}")
|
||||
continue
|
||||
except Exception as exc:
|
||||
last_error = exc
|
||||
continue
|
||||
access_token = str(result.get("access_token", "") or "")
|
||||
if not access_token:
|
||||
raise ValueError("Claude OAuth refresh response missing access_token")
|
||||
next_refresh = str(result.get("refresh_token", refresh_token) or refresh_token)
|
||||
expires_in = int(result.get("expires_in", 3600) or 3600)
|
||||
return {
|
||||
"access_token": access_token,
|
||||
"refresh_token": next_refresh,
|
||||
"expires_at_ms": int(time.time() * 1000) + expires_in * 1000,
|
||||
"scopes": result.get("scope"),
|
||||
}
|
||||
if last_error is not None:
|
||||
raise ValueError(f"Claude OAuth refresh failed: {last_error}") from last_error
|
||||
raise ValueError("Claude OAuth refresh failed")
|
||||
|
||||
|
||||
def write_claude_credentials(
|
||||
source_path: Path,
|
||||
*,
|
||||
access_token: str,
|
||||
refresh_token: str,
|
||||
expires_at_ms: int,
|
||||
) -> None:
|
||||
"""Write refreshed Claude credentials back to the upstream credentials file."""
|
||||
existing: dict[str, Any] = {}
|
||||
if source_path.exists():
|
||||
try:
|
||||
existing = json.loads(source_path.read_text(encoding="utf-8"))
|
||||
except (json.JSONDecodeError, OSError):
|
||||
existing = {}
|
||||
existing["claudeAiOauth"] = _merge_claude_oauth_payload(
|
||||
existing.get("claudeAiOauth"),
|
||||
access_token=access_token,
|
||||
refresh_token=refresh_token,
|
||||
expires_at_ms=expires_at_ms,
|
||||
)
|
||||
atomic_write_text(
|
||||
source_path,
|
||||
json.dumps(existing, indent=2) + "\n",
|
||||
mode=0o600,
|
||||
)
|
||||
|
||||
|
||||
def _write_claude_credentials_to_keychain(
|
||||
*,
|
||||
service: str,
|
||||
account: str,
|
||||
payload: dict[str, Any],
|
||||
access_token: str,
|
||||
refresh_token: str,
|
||||
expires_at_ms: int,
|
||||
) -> None:
|
||||
next_payload = dict(payload)
|
||||
next_payload["claudeAiOauth"] = _merge_claude_oauth_payload(
|
||||
payload.get("claudeAiOauth"),
|
||||
access_token=access_token,
|
||||
refresh_token=refresh_token,
|
||||
expires_at_ms=expires_at_ms,
|
||||
)
|
||||
subprocess.run(
|
||||
[
|
||||
"security",
|
||||
"add-generic-password",
|
||||
"-U",
|
||||
"-s",
|
||||
service,
|
||||
"-a",
|
||||
account,
|
||||
"-w",
|
||||
json.dumps(next_payload, separators=(",", ":")),
|
||||
],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
|
||||
|
||||
def _merge_claude_oauth_payload(
|
||||
previous: Any,
|
||||
*,
|
||||
access_token: str,
|
||||
refresh_token: str,
|
||||
expires_at_ms: int,
|
||||
) -> dict[str, Any]:
|
||||
next_oauth: dict[str, Any] = {
|
||||
"accessToken": access_token,
|
||||
"refreshToken": refresh_token,
|
||||
"expiresAt": expires_at_ms,
|
||||
}
|
||||
if isinstance(previous, dict):
|
||||
for key in ("scopes", "rateLimitTier", "subscriptionType"):
|
||||
if key in previous:
|
||||
next_oauth[key] = previous[key]
|
||||
return next_oauth
|
||||
|
||||
|
||||
def is_third_party_anthropic_endpoint(base_url: str | None) -> bool:
|
||||
"""Return True for non-Anthropic endpoints using Anthropic-compatible APIs."""
|
||||
if not base_url:
|
||||
return False
|
||||
normalized = base_url.rstrip("/").lower()
|
||||
return "anthropic.com" not in normalized and "claude.com" not in normalized
|
||||
|
||||
|
||||
def _coerce_int(value: Any) -> int | None:
|
||||
if isinstance(value, bool):
|
||||
return None
|
||||
if isinstance(value, int):
|
||||
return value
|
||||
if isinstance(value, float):
|
||||
return int(value)
|
||||
if isinstance(value, str):
|
||||
trimmed = value.strip()
|
||||
if trimmed.isdigit():
|
||||
return int(trimmed)
|
||||
return None
|
||||
|
||||
|
||||
def _decode_jwt_expiry(token: str) -> int | None:
|
||||
exp = _decode_json_web_token_claim(token, ["exp"])
|
||||
if exp is None:
|
||||
return None
|
||||
if isinstance(exp, int):
|
||||
return exp * 1000
|
||||
if isinstance(exp, float):
|
||||
return int(exp * 1000)
|
||||
if isinstance(exp, str) and exp.strip().isdigit():
|
||||
return int(exp.strip()) * 1000
|
||||
return None
|
||||
|
||||
|
||||
def _decode_json_web_token_claim(token: str, path: list[str]) -> Any | None:
|
||||
parts = token.split(".")
|
||||
if len(parts) != 3:
|
||||
return None
|
||||
try:
|
||||
encoded = parts[1]
|
||||
padded = encoded + "=" * (-len(encoded) % 4)
|
||||
payload = json.loads(base64.urlsafe_b64decode(padded.encode("ascii")).decode("utf-8"))
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
current: Any = payload
|
||||
for key in path:
|
||||
if isinstance(current, dict) and key in current:
|
||||
current = current[key]
|
||||
else:
|
||||
return None
|
||||
return current
|
||||
@@ -1,189 +0,0 @@
|
||||
"""Authentication flows for various provider types.
|
||||
|
||||
Each flow is a self-contained class with a single ``run()`` method that
|
||||
performs the interactive authentication and returns the obtained credential.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import platform
|
||||
import subprocess
|
||||
import sys
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Any
|
||||
from urllib.parse import urlparse
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AuthFlow(ABC):
|
||||
"""Abstract base for all auth flows."""
|
||||
|
||||
@abstractmethod
|
||||
def run(self) -> str:
|
||||
"""Execute the flow and return the obtained credential value."""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ApiKeyFlow — directly prompt for and store an API key
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class ApiKeyFlow(AuthFlow):
|
||||
"""Prompt the user for an API key and persist it via :mod:`openharness.auth.storage`."""
|
||||
|
||||
def __init__(self, provider: str, prompt_text: str | None = None) -> None:
|
||||
self.provider = provider
|
||||
self.prompt_text = prompt_text or f"Enter your {provider} API key"
|
||||
|
||||
def run(self) -> str:
|
||||
import getpass
|
||||
|
||||
key = getpass.getpass(f"{self.prompt_text}: ").strip()
|
||||
if not key:
|
||||
raise ValueError("API key cannot be empty.")
|
||||
return key
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# DeviceCodeFlow — GitHub OAuth device-code flow (refactored from copilot_auth)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class DeviceCodeFlow(AuthFlow):
|
||||
"""GitHub OAuth device-code flow.
|
||||
|
||||
This is a refactored version of the logic previously inlined in
|
||||
``cli.py`` (``auth_copilot_login``). It can be used for any GitHub
|
||||
OAuth app that supports the device-code grant.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
client_id: str | None = None,
|
||||
github_domain: str = "github.com",
|
||||
enterprise_url: str | None = None,
|
||||
*,
|
||||
progress_callback: Any | None = None,
|
||||
) -> None:
|
||||
from openharness.api.copilot_auth import COPILOT_CLIENT_ID
|
||||
|
||||
self.client_id = client_id or COPILOT_CLIENT_ID
|
||||
self.enterprise_url = enterprise_url
|
||||
self.github_domain = github_domain if not enterprise_url else enterprise_url
|
||||
self.progress_callback = progress_callback
|
||||
|
||||
@staticmethod
|
||||
def _try_open_browser(url: str) -> bool:
|
||||
"""Attempt to open *url* in the default browser; return True if likely succeeded."""
|
||||
# Only http(s) URLs are valid here. ShellExecute / xdg-open / `open`
|
||||
# all resolve unrecognised tokens (e.g. ``file:``, ``javascript:``, a
|
||||
# bare executable name) against the registry or PATH, so refusing
|
||||
# everything else removes a class of unintended-action footguns.
|
||||
if urlparse(url).scheme not in {"http", "https"}:
|
||||
return False
|
||||
try:
|
||||
plat = platform.system()
|
||||
if plat == "Darwin":
|
||||
subprocess.Popen(["open", url], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
return True
|
||||
if plat == "Windows":
|
||||
# ShellExecute via os.startfile — does NOT route through
|
||||
# cmd.exe, so ``&`` / ``|`` / ``^`` in the URL cannot be
|
||||
# interpreted as command separators. Replaces the prior
|
||||
# ``subprocess.Popen([...], shell=True)`` form, which would
|
||||
# execute appended commands when a hostile or compromised
|
||||
# device-flow endpoint returned a URL like
|
||||
# ``https://x.com&calc.exe``.
|
||||
os.startfile(url) # type: ignore[attr-defined]
|
||||
return True
|
||||
# Linux / WSL
|
||||
proc = subprocess.Popen(
|
||||
["xdg-open", url],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
)
|
||||
try:
|
||||
proc.wait(timeout=2)
|
||||
return proc.returncode == 0
|
||||
except subprocess.TimeoutExpired:
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
return False
|
||||
|
||||
def run(self) -> str:
|
||||
from openharness.api.copilot_auth import poll_for_access_token, request_device_code
|
||||
|
||||
print("Starting GitHub device flow...", flush=True)
|
||||
dc = request_device_code(client_id=self.client_id, github_domain=self.github_domain)
|
||||
|
||||
print(flush=True)
|
||||
print(f" Open: {dc.verification_uri}", flush=True)
|
||||
print(f" Code: {dc.user_code}", flush=True)
|
||||
print(flush=True)
|
||||
|
||||
opened = self._try_open_browser(dc.verification_uri)
|
||||
if opened:
|
||||
print("(Browser opened — enter the code shown above.)", flush=True)
|
||||
else:
|
||||
print("Open the URL above in your browser and enter the code.", flush=True)
|
||||
print(flush=True)
|
||||
|
||||
if self.progress_callback is None:
|
||||
|
||||
def _default_progress(poll_num: int, elapsed: float) -> None:
|
||||
mins = int(elapsed) // 60
|
||||
secs = int(elapsed) % 60
|
||||
print(f"\r Polling... ({mins}m {secs:02d}s elapsed)", end="", flush=True)
|
||||
|
||||
self.progress_callback = _default_progress
|
||||
|
||||
print("Waiting for authorisation...", flush=True)
|
||||
try:
|
||||
token = poll_for_access_token(
|
||||
dc.device_code,
|
||||
dc.interval,
|
||||
client_id=self.client_id,
|
||||
github_domain=self.github_domain,
|
||||
progress_callback=self.progress_callback,
|
||||
)
|
||||
except RuntimeError as exc:
|
||||
print(flush=True)
|
||||
print(f"Error: {exc}", file=sys.stderr, flush=True)
|
||||
raise
|
||||
|
||||
print(flush=True)
|
||||
return token
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# BrowserFlow — open a URL and wait for the user to complete auth
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class BrowserFlow(AuthFlow):
|
||||
"""Open a browser URL and wait for the user to complete authentication.
|
||||
|
||||
After the user completes the browser flow they are expected to paste
|
||||
back a token/code — this simple implementation prompts for that value.
|
||||
"""
|
||||
|
||||
def __init__(self, auth_url: str, prompt_text: str = "Paste the token from your browser") -> None:
|
||||
self.auth_url = auth_url
|
||||
self.prompt_text = prompt_text
|
||||
|
||||
def run(self) -> str:
|
||||
import getpass
|
||||
|
||||
print(f"Opening browser for authentication: {self.auth_url}", flush=True)
|
||||
opened = DeviceCodeFlow._try_open_browser(self.auth_url)
|
||||
if not opened:
|
||||
print(f"Could not open browser automatically. Visit: {self.auth_url}", flush=True)
|
||||
|
||||
token = getpass.getpass(f"{self.prompt_text}: ").strip()
|
||||
if not token:
|
||||
raise ValueError("No token provided.")
|
||||
return token
|
||||
@@ -1,482 +0,0 @@
|
||||
"""Unified authentication manager for OpenHarness providers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from openharness.config.settings import (
|
||||
ProviderProfile,
|
||||
auth_source_provider_name,
|
||||
auth_source_uses_api_key,
|
||||
builtin_provider_profile_names,
|
||||
credential_storage_provider_name,
|
||||
default_auth_source_for_provider,
|
||||
display_label_for_profile,
|
||||
display_model_setting,
|
||||
)
|
||||
from openharness.auth.storage import (
|
||||
clear_provider_credentials,
|
||||
load_external_binding,
|
||||
load_credential,
|
||||
store_credential,
|
||||
)
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
# Providers that OpenHarness knows about.
|
||||
_KNOWN_PROVIDERS = [
|
||||
"anthropic",
|
||||
"anthropic_claude",
|
||||
"openai",
|
||||
"openai_codex",
|
||||
"copilot",
|
||||
"dashscope",
|
||||
"bedrock",
|
||||
"vertex",
|
||||
"moonshot",
|
||||
"gemini",
|
||||
"minimax",
|
||||
"modelscope",
|
||||
]
|
||||
|
||||
_AUTH_SOURCES = [
|
||||
"anthropic_api_key",
|
||||
"openai_api_key",
|
||||
"codex_subscription",
|
||||
"claude_subscription",
|
||||
"copilot_oauth",
|
||||
"dashscope_api_key",
|
||||
"bedrock_api_key",
|
||||
"vertex_api_key",
|
||||
"moonshot_api_key",
|
||||
"gemini_api_key",
|
||||
"minimax_api_key",
|
||||
"modelscope_api_key",
|
||||
]
|
||||
|
||||
_PROFILE_BY_PROVIDER = {
|
||||
"anthropic": "claude-api",
|
||||
"anthropic_claude": "claude-subscription",
|
||||
"openai": "openai-compatible",
|
||||
"openai_codex": "codex",
|
||||
"copilot": "copilot",
|
||||
"moonshot": "moonshot",
|
||||
"gemini": "gemini",
|
||||
"minimax": "minimax",
|
||||
"modelscope": "modelscope",
|
||||
}
|
||||
|
||||
|
||||
class AuthManager:
|
||||
"""Central authority for provider authentication state.
|
||||
|
||||
Reads/writes credentials via :mod:`openharness.auth.storage` and keeps
|
||||
track of the currently active provider via settings.
|
||||
"""
|
||||
|
||||
def __init__(self, settings: Any | None = None) -> None:
|
||||
# Lazy-load settings when not provided so that the manager can be
|
||||
# instantiated without importing the full config subsystem.
|
||||
self._settings = settings
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Internal helpers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@property
|
||||
def settings(self) -> Any:
|
||||
if self._settings is None:
|
||||
from openharness.config import load_settings
|
||||
|
||||
self._settings = load_settings()
|
||||
return self._settings
|
||||
|
||||
def _provider_from_settings(self) -> str:
|
||||
"""Return the provider name derived from the active profile."""
|
||||
_, profile = self.settings.resolve_profile()
|
||||
return profile.provider
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Public API
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def get_active_provider(self) -> str:
|
||||
"""Return the name of the currently active provider."""
|
||||
return self._provider_from_settings()
|
||||
|
||||
def get_active_profile(self) -> str:
|
||||
"""Return the active provider profile name."""
|
||||
return self.settings.resolve_profile()[0]
|
||||
|
||||
def list_profiles(self) -> dict[str, ProviderProfile]:
|
||||
"""Return the configured provider profiles."""
|
||||
return self.settings.merged_profiles()
|
||||
|
||||
def get_auth_source_statuses(self) -> dict[str, Any]:
|
||||
"""Return auth source configuration status."""
|
||||
import os
|
||||
|
||||
from openharness.auth.external import describe_external_binding
|
||||
|
||||
active_profile_name, active_profile = self.settings.resolve_profile()
|
||||
result: dict[str, Any] = {}
|
||||
for source in _AUTH_SOURCES:
|
||||
configured = False
|
||||
origin = "missing"
|
||||
state = "missing"
|
||||
detail = ""
|
||||
storage_provider = auth_source_provider_name(source)
|
||||
if source == "anthropic_api_key":
|
||||
if os.environ.get("ANTHROPIC_API_KEY"):
|
||||
configured = True
|
||||
origin = "env"
|
||||
state = "configured"
|
||||
elif load_credential(storage_provider, "api_key") or getattr(self.settings, "api_key", ""):
|
||||
configured = True
|
||||
origin = "file"
|
||||
state = "configured"
|
||||
elif source == "openai_api_key":
|
||||
if os.environ.get("OPENAI_API_KEY"):
|
||||
configured = True
|
||||
origin = "env"
|
||||
state = "configured"
|
||||
elif load_credential(storage_provider, "api_key"):
|
||||
configured = True
|
||||
origin = "file"
|
||||
state = "configured"
|
||||
elif source in {"codex_subscription", "claude_subscription"}:
|
||||
binding = load_external_binding(storage_provider)
|
||||
if binding is not None:
|
||||
external_state = describe_external_binding(binding)
|
||||
configured = external_state.configured
|
||||
origin = external_state.source
|
||||
state = external_state.state
|
||||
detail = external_state.detail
|
||||
elif source == "copilot_oauth":
|
||||
from openharness.api.copilot_auth import load_copilot_auth
|
||||
|
||||
if load_copilot_auth():
|
||||
configured = True
|
||||
origin = "file"
|
||||
state = "configured"
|
||||
elif source == "modelscope_api_key":
|
||||
if os.environ.get("MODELSCOPE_API_KEY"):
|
||||
configured = True
|
||||
origin = "env"
|
||||
state = "configured"
|
||||
elif load_credential(storage_provider, "api_key"):
|
||||
configured = True
|
||||
origin = "file"
|
||||
state = "configured"
|
||||
elif load_credential(storage_provider, "api_key"):
|
||||
configured = True
|
||||
origin = "file"
|
||||
state = "configured"
|
||||
result[source] = {
|
||||
"configured": configured,
|
||||
"source": origin,
|
||||
"state": state,
|
||||
"detail": detail,
|
||||
"active": source == active_profile.auth_source,
|
||||
"active_profile": active_profile_name,
|
||||
}
|
||||
return result
|
||||
|
||||
def get_auth_status(self) -> dict[str, Any]:
|
||||
"""Return authentication status for all known providers.
|
||||
|
||||
Returns a dict keyed by provider name with the following structure::
|
||||
|
||||
{
|
||||
"anthropic": {
|
||||
"configured": True,
|
||||
"source": "env", # "env", "file", "keyring", or "missing"
|
||||
"active": True,
|
||||
},
|
||||
...
|
||||
}
|
||||
"""
|
||||
import os
|
||||
|
||||
active = self.get_active_provider()
|
||||
result: dict[str, Any] = {}
|
||||
|
||||
for provider in _KNOWN_PROVIDERS:
|
||||
configured = False
|
||||
source = "missing"
|
||||
|
||||
if provider == "anthropic":
|
||||
if os.environ.get("ANTHROPIC_API_KEY"):
|
||||
configured = True
|
||||
source = "env"
|
||||
elif load_credential("anthropic", "api_key") or getattr(self.settings, "api_key", ""):
|
||||
configured = True
|
||||
source = "file"
|
||||
|
||||
elif provider == "anthropic_claude":
|
||||
binding = load_external_binding(provider)
|
||||
if binding is not None:
|
||||
configured = True
|
||||
source = "external"
|
||||
|
||||
elif provider == "openai":
|
||||
if os.environ.get("OPENAI_API_KEY"):
|
||||
configured = True
|
||||
source = "env"
|
||||
elif load_credential("openai", "api_key"):
|
||||
configured = True
|
||||
source = "file"
|
||||
|
||||
elif provider == "openai_codex":
|
||||
binding = load_external_binding(provider)
|
||||
if binding is not None:
|
||||
configured = True
|
||||
source = "external"
|
||||
|
||||
elif provider == "copilot":
|
||||
from openharness.api.copilot_auth import load_copilot_auth
|
||||
|
||||
if load_copilot_auth():
|
||||
configured = True
|
||||
source = "file"
|
||||
|
||||
elif provider == "dashscope":
|
||||
if os.environ.get("DASHSCOPE_API_KEY"):
|
||||
configured = True
|
||||
source = "env"
|
||||
elif load_credential("dashscope", "api_key"):
|
||||
configured = True
|
||||
source = "file"
|
||||
|
||||
elif provider == "moonshot":
|
||||
if os.environ.get("MOONSHOT_API_KEY"):
|
||||
configured = True
|
||||
source = "env"
|
||||
elif load_credential("moonshot", "api_key"):
|
||||
configured = True
|
||||
source = "file"
|
||||
|
||||
elif provider == "minimax":
|
||||
if os.environ.get("MINIMAX_API_KEY"):
|
||||
configured = True
|
||||
source = "env"
|
||||
elif load_credential("minimax", "api_key"):
|
||||
configured = True
|
||||
source = "file"
|
||||
|
||||
elif provider == "modelscope":
|
||||
if os.environ.get("MODELSCOPE_API_KEY"):
|
||||
configured = True
|
||||
source = "env"
|
||||
elif load_credential("modelscope", "api_key"):
|
||||
configured = True
|
||||
source = "file"
|
||||
|
||||
elif provider in ("bedrock", "vertex"):
|
||||
# These typically use environment-level credentials (AWS/GCP).
|
||||
cred = load_credential(provider, "api_key")
|
||||
if cred:
|
||||
configured = True
|
||||
source = "file"
|
||||
|
||||
result[provider] = {
|
||||
"configured": configured,
|
||||
"source": source,
|
||||
"active": provider == active,
|
||||
}
|
||||
|
||||
return result
|
||||
|
||||
def get_profile_statuses(self) -> dict[str, Any]:
|
||||
"""Return the available provider profiles and whether their auth is configured."""
|
||||
active = self.get_active_profile()
|
||||
auth_sources = self.get_auth_source_statuses()
|
||||
statuses: dict[str, Any] = {}
|
||||
for name, profile in self.list_profiles().items():
|
||||
source_status = auth_sources.get(profile.auth_source, {})
|
||||
configured = bool(source_status.get("configured"))
|
||||
auth_state = str(source_status.get("state", "missing"))
|
||||
if auth_source_uses_api_key(profile.auth_source):
|
||||
storage_provider = credential_storage_provider_name(name, profile)
|
||||
configured = bool(load_credential(storage_provider, "api_key")) or configured
|
||||
if not configured and name == active and getattr(self.settings, "api_key", ""):
|
||||
configured = True
|
||||
auth_state = "configured" if configured else "missing"
|
||||
statuses[name] = {
|
||||
"label": display_label_for_profile(name, profile),
|
||||
"provider": profile.provider,
|
||||
"api_format": profile.api_format,
|
||||
"auth_source": profile.auth_source,
|
||||
"configured": configured,
|
||||
"auth_state": auth_state,
|
||||
"active": name == active,
|
||||
"base_url": profile.base_url,
|
||||
"model": display_model_setting(profile),
|
||||
"credential_slot": profile.credential_slot,
|
||||
}
|
||||
return statuses
|
||||
|
||||
def save_settings(self) -> None:
|
||||
"""Persist the in-memory settings."""
|
||||
from openharness.config import save_settings
|
||||
|
||||
save_settings(self.settings)
|
||||
|
||||
def use_profile(self, name: str) -> None:
|
||||
"""Activate a provider profile."""
|
||||
profiles = self.settings.merged_profiles()
|
||||
if name not in profiles:
|
||||
raise ValueError(f"Unknown provider profile: {name!r}")
|
||||
updated = self.settings.model_copy(update={"active_profile": name}).materialize_active_profile()
|
||||
self._settings = updated
|
||||
self.save_settings()
|
||||
log.info("Switched active profile to %s", name)
|
||||
|
||||
def upsert_profile(self, name: str, profile: ProviderProfile) -> None:
|
||||
"""Create or replace a provider profile."""
|
||||
profiles = self.settings.merged_profiles()
|
||||
profiles[name] = profile
|
||||
updated = self.settings.model_copy(update={"profiles": profiles})
|
||||
self._settings = updated.materialize_active_profile()
|
||||
self.save_settings()
|
||||
|
||||
def update_profile(
|
||||
self,
|
||||
name: str,
|
||||
*,
|
||||
label: str | None = None,
|
||||
provider: str | None = None,
|
||||
api_format: str | None = None,
|
||||
base_url: str | None = None,
|
||||
auth_source: str | None = None,
|
||||
default_model: str | None = None,
|
||||
last_model: str | None = None,
|
||||
credential_slot: str | None = None,
|
||||
allowed_models: list[str] | None = None,
|
||||
context_window_tokens: int | None = None,
|
||||
auto_compact_threshold_tokens: int | None = None,
|
||||
) -> None:
|
||||
"""Update a profile in-place."""
|
||||
profiles = self.settings.merged_profiles()
|
||||
if name not in profiles:
|
||||
raise ValueError(f"Unknown provider profile: {name!r}")
|
||||
current = profiles[name]
|
||||
next_provider = provider or current.provider
|
||||
next_format = api_format or current.api_format
|
||||
updates = {
|
||||
"label": label or current.label,
|
||||
"provider": next_provider,
|
||||
"api_format": next_format,
|
||||
"base_url": base_url if base_url is not None else current.base_url,
|
||||
"auth_source": auth_source or current.auth_source or default_auth_source_for_provider(next_provider, next_format),
|
||||
"default_model": default_model or current.default_model,
|
||||
"last_model": last_model if last_model is not None else current.last_model,
|
||||
"credential_slot": credential_slot if credential_slot is not None else current.credential_slot,
|
||||
"allowed_models": allowed_models if allowed_models is not None else current.allowed_models,
|
||||
"context_window_tokens": (
|
||||
context_window_tokens
|
||||
if context_window_tokens is not None
|
||||
else current.context_window_tokens
|
||||
),
|
||||
"auto_compact_threshold_tokens": (
|
||||
auto_compact_threshold_tokens
|
||||
if auto_compact_threshold_tokens is not None
|
||||
else current.auto_compact_threshold_tokens
|
||||
),
|
||||
}
|
||||
profiles[name] = current.model_copy(update=updates)
|
||||
updated = self.settings.model_copy(update={"profiles": profiles})
|
||||
self._settings = updated.materialize_active_profile()
|
||||
self.save_settings()
|
||||
|
||||
def remove_profile(self, name: str) -> None:
|
||||
"""Remove a non-built-in provider profile."""
|
||||
if name == self.get_active_profile():
|
||||
raise ValueError("Cannot remove the active profile.")
|
||||
if name in builtin_provider_profile_names():
|
||||
raise ValueError(f"Cannot remove built-in profile: {name}")
|
||||
profiles = self.settings.merged_profiles()
|
||||
if name not in profiles:
|
||||
raise ValueError(f"Unknown provider profile: {name!r}")
|
||||
del profiles[name]
|
||||
updated = self.settings.model_copy(update={"profiles": profiles})
|
||||
self._settings = updated.materialize_active_profile()
|
||||
self.save_settings()
|
||||
|
||||
def switch_auth_source(self, auth_source: str, *, profile_name: str | None = None) -> None:
|
||||
"""Switch the auth source for a profile."""
|
||||
if auth_source not in _AUTH_SOURCES:
|
||||
raise ValueError(f"Unknown auth source: {auth_source!r}. Known auth sources: {_AUTH_SOURCES}")
|
||||
target = profile_name or self.get_active_profile()
|
||||
self.update_profile(target, auth_source=auth_source)
|
||||
|
||||
def switch_provider(self, name: str) -> None:
|
||||
"""Backward-compatible switch entrypoint for profile/provider/auth source names."""
|
||||
if name in _AUTH_SOURCES:
|
||||
self.switch_auth_source(name)
|
||||
return
|
||||
profiles = self.list_profiles()
|
||||
if name in profiles:
|
||||
self.use_profile(name)
|
||||
return
|
||||
if name in _KNOWN_PROVIDERS:
|
||||
self.use_profile(_PROFILE_BY_PROVIDER.get(name, "openai-compatible" if name == "openai" else "claude-api"))
|
||||
return
|
||||
raise ValueError(
|
||||
f"Unknown provider or auth source: {name!r}. "
|
||||
f"Known providers: {_KNOWN_PROVIDERS}; auth sources: {_AUTH_SOURCES}"
|
||||
)
|
||||
|
||||
def store_credential(self, provider: str, key: str, value: str) -> None:
|
||||
"""Store a credential for the given provider."""
|
||||
store_credential(provider, key, value)
|
||||
# Keep the flattened active settings snapshot aligned for compatibility.
|
||||
if key == "api_key" and provider == auth_source_provider_name(self.settings.resolve_profile()[1].auth_source):
|
||||
try:
|
||||
updated = self.settings.model_copy(update={"api_key": value})
|
||||
self._settings = updated.materialize_active_profile()
|
||||
self.save_settings()
|
||||
except Exception as exc:
|
||||
log.warning("Could not sync api_key to settings: %s", exc)
|
||||
|
||||
def store_profile_credential(self, profile_name: str, key: str, value: str) -> None:
|
||||
"""Store a credential using the active storage namespace for a profile."""
|
||||
profile = self.list_profiles().get(profile_name)
|
||||
if profile is None:
|
||||
raise ValueError(f"Unknown provider profile: {profile_name!r}")
|
||||
storage_provider = credential_storage_provider_name(profile_name, profile)
|
||||
store_credential(storage_provider, key, value)
|
||||
if key == "api_key" and profile_name == self.get_active_profile():
|
||||
try:
|
||||
updated = self.settings.model_copy(update={"api_key": value})
|
||||
self._settings = updated.materialize_active_profile()
|
||||
self.save_settings()
|
||||
except Exception as exc:
|
||||
log.warning("Could not sync api_key to settings: %s", exc)
|
||||
|
||||
def clear_credential(self, provider: str) -> None:
|
||||
"""Remove all stored credentials for the given provider."""
|
||||
clear_provider_credentials(provider)
|
||||
# Also clear api_key in settings if this is the active provider.
|
||||
if provider == auth_source_provider_name(self.settings.resolve_profile()[1].auth_source):
|
||||
try:
|
||||
updated = self.settings.model_copy(update={"api_key": ""})
|
||||
self._settings = updated.materialize_active_profile()
|
||||
self.save_settings()
|
||||
except Exception as exc:
|
||||
log.warning("Could not clear api_key from settings: %s", exc)
|
||||
|
||||
def clear_profile_credential(self, profile_name: str) -> None:
|
||||
"""Remove credentials stored for a specific profile."""
|
||||
profile = self.list_profiles().get(profile_name)
|
||||
if profile is None:
|
||||
raise ValueError(f"Unknown provider profile: {profile_name!r}")
|
||||
clear_provider_credentials(credential_storage_provider_name(profile_name, profile))
|
||||
if profile_name == self.get_active_profile():
|
||||
try:
|
||||
updated = self.settings.model_copy(update={"api_key": ""})
|
||||
self._settings = updated.materialize_active_profile()
|
||||
self.save_settings()
|
||||
except Exception as exc:
|
||||
log.warning("Could not clear api_key from settings: %s", exc)
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user