Compare commits
206 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9b2efd795c | |||
| 257916db67 | |||
| 1763a2f708 | |||
| 256f7fc2dd | |||
| a41011de19 | |||
| bf5931e7c1 | |||
| a68d0f984d | |||
| 033dce67b1 | |||
| 4c3bf5bbc9 | |||
| 63ac368975 | |||
| fea0b75bc4 | |||
| c281d34c4a | |||
| f5608951e1 | |||
| 92e298852c | |||
| 27bb93b810 | |||
| ee0af9a24c | |||
| 9a24b8dfe2 | |||
| 4321bd824d | |||
| 175fe501a6 | |||
| 254ed90aed | |||
| 09bc641aad | |||
| 6243793ec0 | |||
| 330ece7a62 | |||
| 6747d12385 | |||
| 7a0bfe9122 | |||
| 595a9ab3a4 | |||
| 4ba71f2d4a | |||
| 889d9dcbda | |||
| a2888506ba | |||
| 0531963435 | |||
| c6b7552008 | |||
| 635d77fad4 | |||
| 7b44b7dab1 | |||
| 484502168d | |||
| 8b2ec8300d | |||
| a1fef67cdc | |||
| 9cff3a05b0 | |||
| c38e2fc066 | |||
| 653369d983 | |||
| f61f70d7b1 | |||
| b93b2cfb30 | |||
| efb4b71eb3 | |||
| c70811cc40 | |||
| 73fb0fa0bf | |||
| a75053a740 | |||
| 5b46a2f7b3 | |||
| 1929ad8051 | |||
| f61901e842 | |||
| 0c6b81d61f | |||
| 225e87f7d7 | |||
| 18fc6dea13 | |||
| a0f8552c69 | |||
| b22d5e9368 | |||
| 0e557a65d3 | |||
| ffc50221df | |||
| a07837c657 | |||
| 807e3ecd47 | |||
| d2489758f0 | |||
| 373147907a | |||
| e0860bd3bd | |||
| 2ab4fdb2f7 | |||
| 0f7e9a20f4 | |||
| 57a9134997 | |||
| d5b1977fab | |||
| 30d4fb3ebf | |||
| 626ad2daf4 | |||
| ffa7d9c06a | |||
| 0528a7a2c2 | |||
| ebb402cfd3 | |||
| 8f30deffe9 | |||
| 52a0551569 | |||
| a98292b814 | |||
| 03f4c47ebb | |||
| 4e33118c48 | |||
| 5f97b2416e | |||
| a857455fd0 | |||
| 7873f0d109 | |||
| f10d8875ee | |||
| 56a954a0a9 | |||
| d611d6ddf6 | |||
| e536394812 | |||
| 47e0e93387 | |||
| c835d7cf2e | |||
| d9002c3cb2 | |||
| 11faa13d63 | |||
| 1bcdc62a50 | |||
| cc825efb83 | |||
| df348b6335 | |||
| 1498a87806 | |||
| 726b3ee028 | |||
| 438e373097 | |||
| 380bab4d7b | |||
| 6bfc5c50d0 | |||
| 9a8a14331d | |||
| 25f2043f53 | |||
| 1fb617d4f4 | |||
| b618f639d7 | |||
| a24a28b38a | |||
| bed1e4777e | |||
| be66e015bf | |||
| 90ec299221 | |||
| 593dcbcaeb | |||
| 0305dfb3ec | |||
| b731c3ede1 | |||
| 3e09dde114 | |||
| 801f6b321c | |||
| 1325770b28 | |||
| 6c83a4c978 | |||
| e7eab63852 | |||
| f683b4aed2 | |||
| 835588daa0 | |||
| 0bba07fe19 | |||
| 75c0481dba | |||
| 25d0bc7ab1 | |||
| 9078071803 | |||
| c4075ffea3 | |||
| 699bab3d3a | |||
| 4dd7b76114 | |||
| 559ba76f23 | |||
| 8d48829c20 | |||
| 6ef371f62f | |||
| ee97f8e78d | |||
| 1158027082 | |||
| a2bb51483d | |||
| 55dc030208 | |||
| df7fde80e8 | |||
| badfb6b592 | |||
| cd68ff1e25 | |||
| b11a83df66 | |||
| 1c00020d36 | |||
| d4eae153d3 | |||
| 35fb12d5da | |||
| 738da98b34 | |||
| 04f8747ab6 | |||
| 91c0e32035 | |||
| 3b395500a2 | |||
| 59017e0988 | |||
| 3186851c47 | |||
| c881c7deed | |||
| 78160047f0 | |||
| 992cac66d0 | |||
| dea9ee8386 | |||
| 2a91f66a3c | |||
| 67617e6eb5 | |||
| c0d3de2f95 | |||
| 944ccf9b22 | |||
| de9f9c05ca | |||
| 9caf7004a6 | |||
| fab40c6eab | |||
| 4090728163 | |||
| d38796518a | |||
| 7d2a0104ba | |||
| 0c84d85630 | |||
| ce84a6a1a2 | |||
| a2dea036e0 | |||
| f8e83c8409 | |||
| e537ba9867 | |||
| ed0b5f02ab | |||
| 46a618bae8 | |||
| 1ffc4a44fd | |||
| 45fb335db0 | |||
| dd1d235450 | |||
| 63ce793cba | |||
| 1f5087c819 | |||
| 7f25a84d8e | |||
| 71c62c46a9 | |||
| cdb7256e6b | |||
| 7f22107170 | |||
| 9f97283320 | |||
| 4857439e50 | |||
| 39ffc199a8 | |||
| a6c63fdab9 | |||
| 508937c996 | |||
| 242e3d90dd | |||
| 327c6ed2ff | |||
| bd4df81f63 | |||
| e3bf45020c | |||
| a21011001a | |||
| 76b2b48e5f | |||
| 33d0f0db7e | |||
| 9b75800fde | |||
| d40496465c | |||
| a5c29495d4 | |||
| 565e6f16d2 | |||
| 78b1d64585 | |||
| b40007753f | |||
| a3f1b23fb6 | |||
| 7a29e66fdb | |||
| 6cd53b3730 | |||
| b9965a127d | |||
| 7209b69212 | |||
| 12758e7683 | |||
| 8b6b3b26d0 | |||
| e2c2bc58e3 | |||
| b65b5b809a | |||
| 373c97e5f6 | |||
| c99fe036ef | |||
| 093caf31b6 | |||
| d3d4108a1e | |||
| 4b855bce14 | |||
| 019812515b | |||
| 64c0380805 | |||
| 04556430b7 | |||
| 2931bc0f96 | |||
| 1ed40da24d | |||
| f24ca87d16 |
@@ -0,0 +1,59 @@
|
||||
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
|
||||
@@ -0,0 +1,53 @@
|
||||
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
|
||||
@@ -0,0 +1,53 @@
|
||||
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
|
||||
@@ -8,6 +8,37 @@ 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.
|
||||
@@ -17,9 +48,25 @@ The format is based on Keep a Changelog, and this project currently tracks chang
|
||||
- `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`.
|
||||
@@ -29,13 +76,27 @@ The format is based on Keep a Changelog, and this project currently tracks chang
|
||||
- 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
|
||||
|
||||
### Added
|
||||
|
||||
@@ -153,6 +153,14 @@ 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
|
||||
@@ -190,26 +198,42 @@ OpenHarness is an open-source Python implementation designed for **researchers,
|
||||
|
||||
### 1. Install
|
||||
|
||||
#### Linux / macOS / WSL
|
||||
|
||||
```bash
|
||||
# One-click install (Linux / macOS / WSL)
|
||||
# One-click install
|
||||
curl -fsSL https://raw.githubusercontent.com/HKUDS/OpenHarness/main/scripts/install.sh | bash
|
||||
|
||||
# Or via pip
|
||||
pip install openharness-ai
|
||||
```
|
||||
|
||||
#### 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
|
||||
|
||||
```bash
|
||||
oh setup # interactive wizard — pick a provider, authenticate, done
|
||||
# On Windows PowerShell, use: openh setup
|
||||
```
|
||||
|
||||
Supports **Claude / OpenAI / Copilot / Codex / Moonshot(Kimi) / GLM / MiniMax** and any compatible endpoint.
|
||||
Supports **Claude / OpenAI / Copilot / Codex / Moonshot(Kimi) / GLM / MiniMax / NVIDIA NIM** and any compatible endpoint.
|
||||
|
||||
### 3. Run
|
||||
|
||||
```bash
|
||||
oh
|
||||
# On Windows PowerShell, use: openh
|
||||
```
|
||||
|
||||
<p align="center">
|
||||
@@ -241,6 +265,43 @@ 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:
|
||||
@@ -286,6 +347,8 @@ Any provider implementing the OpenAI `/v1/chat/completions` style API works:
|
||||
| **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 |
|
||||
|
||||
@@ -310,6 +373,44 @@ oh provider add my-endpoint \
|
||||
|
||||
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.
|
||||
@@ -440,7 +541,47 @@ Available Skills:
|
||||
- ... 40+ more
|
||||
```
|
||||
|
||||
**Compatible with [anthropics/skills](https://github.com/anthropics/skills)** — just copy `.md` files to `~/.openharness/skills/`.
|
||||
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.
|
||||
|
||||
### 🔌 Plugin System
|
||||
|
||||
@@ -698,6 +839,16 @@ 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).
|
||||
|
||||
@@ -20,6 +20,12 @@
|
||||
|
||||
## 最新更新
|
||||
|
||||
### 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`
|
||||
@@ -104,6 +110,7 @@ oh setup
|
||||
- DeepSeek
|
||||
- GitHub Models
|
||||
- SiliconFlow
|
||||
- Google Gemini
|
||||
- Groq
|
||||
- Ollama
|
||||
- 其他 OpenAI-compatible endpoint
|
||||
@@ -168,6 +175,44 @@ 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 兼容性概览
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,32 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,2 @@
|
||||
node_modules
|
||||
dist
|
||||
@@ -0,0 +1,15 @@
|
||||
<!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
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
@@ -0,0 +1,290 @@
|
||||
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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
/**
|
||||
* 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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
/**
|
||||
* 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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,546 @@
|
||||
/* ─── 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%; }
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
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>,
|
||||
);
|
||||
@@ -0,0 +1,84 @@
|
||||
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
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"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"]
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [{ "path": "./tsconfig.app.json" }]
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
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
@@ -0,0 +1,16 @@
|
||||
<!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>
|
||||
@@ -0,0 +1,59 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
+155
-24
@@ -1,6 +1,7 @@
|
||||
import React, {useEffect, useMemo, useState} from 'react';
|
||||
import React, {useDeferredValue, useEffect, useMemo, useRef, 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';
|
||||
@@ -11,7 +12,7 @@ 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} from './types.js';
|
||||
import type {FrontendConfig, ImageAttachmentPayload} from './types.js';
|
||||
|
||||
const rawReturnSubmit = process.env.OPENHARNESS_FRONTEND_RAW_RETURN === '1';
|
||||
const scriptedSteps = (() => {
|
||||
@@ -64,12 +65,22 @@ function AppInner({config}: {config: FrontendConfig}): React.JSX.Element {
|
||||
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;
|
||||
@@ -78,10 +89,51 @@ function AppInner({config}: {config: FrontendConfig}): React.JSX.Element {
|
||||
}
|
||||
}, [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 = session.transcript.length - 1; i >= 0; i--) {
|
||||
const item = session.transcript[i];
|
||||
for (let i = deferredTranscript.length - 1; i >= 0; i--) {
|
||||
const item = deferredTranscript[i];
|
||||
if (item.role === 'tool') {
|
||||
return item.tool_name ?? 'tool';
|
||||
}
|
||||
@@ -90,7 +142,7 @@ function AppInner({config}: {config: FrontendConfig}): React.JSX.Element {
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}, [session.transcript]);
|
||||
}, [deferredTranscript]);
|
||||
|
||||
// Command hints
|
||||
const commandHints = useMemo(() => {
|
||||
@@ -102,6 +154,7 @@ 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);
|
||||
@@ -169,14 +222,25 @@ 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 → exit
|
||||
// Ctrl+C interrupts a running turn; when idle it exits the TUI.
|
||||
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;
|
||||
@@ -244,7 +308,7 @@ function AppInner({config}: {config: FrontendConfig}): React.JSX.Element {
|
||||
session.setModal(null);
|
||||
return;
|
||||
}
|
||||
if (chunk.toLowerCase() === 'n' || key.escape) {
|
||||
if (chunk.toLowerCase() === 'n' || isEscape) {
|
||||
session.sendRequest({
|
||||
type: 'permission_response',
|
||||
request_id: session.modal.request_id,
|
||||
@@ -256,16 +320,64 @@ 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) {
|
||||
@@ -289,20 +401,25 @@ function AppInner({config}: {config: FrontendConfig}): React.JSX.Element {
|
||||
if (key.tab) {
|
||||
const selected = commandHints[pickerIndex];
|
||||
if (selected) {
|
||||
setInput(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);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (key.escape) {
|
||||
if (isEscape) {
|
||||
setInput('');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (key.escape) {
|
||||
if (isEscape) {
|
||||
const now = Date.now();
|
||||
if (input && now - lastEscapeAt < 500) {
|
||||
if ((input || imageAttachments.length > 0) && now - lastEscapeAt < 500) {
|
||||
setInput('');
|
||||
setImageAttachments([]);
|
||||
setHistoryIndex(-1);
|
||||
setLastEscapeAt(0);
|
||||
return;
|
||||
@@ -342,20 +459,28 @@ function AppInner({config}: {config: FrontendConfig}): React.JSX.Element {
|
||||
setModalInput('');
|
||||
return;
|
||||
}
|
||||
if (!value.trim() || session.busy || !session.ready) {
|
||||
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('');
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Check if it's an interactive command
|
||||
if (handleCommand(value)) {
|
||||
if (imageAttachments.length === 0 && handleCommand(value)) {
|
||||
setHistory((items) => [...items, value]);
|
||||
setHistoryIndex(-1);
|
||||
setInput('');
|
||||
return;
|
||||
}
|
||||
session.sendRequest({type: 'submit_line', line: value});
|
||||
setHistory((items) => [...items, value]);
|
||||
session.sendRequest({type: 'submit_line', line: value, images: imagePayloads()});
|
||||
if (value.trim()) {
|
||||
setHistory((items) => [...items, value]);
|
||||
}
|
||||
setHistoryIndex(-1);
|
||||
setInput('');
|
||||
setImageAttachments([]);
|
||||
session.setBusy(true);
|
||||
};
|
||||
|
||||
@@ -380,9 +505,10 @@ function AppInner({config}: {config: FrontendConfig}): React.JSX.Element {
|
||||
{/* Conversation area */}
|
||||
<Box flexDirection="column" flexGrow={1}>
|
||||
<ConversationView
|
||||
items={session.transcript}
|
||||
assistantBuffer={session.assistantBuffer}
|
||||
showWelcome={session.ready}
|
||||
items={deferredTranscript}
|
||||
assistantBuffer={deferredAssistantBuffer}
|
||||
showWelcome={session.ready && outputStyle !== 'codex'}
|
||||
outputStyle={outputStyle}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
@@ -411,18 +537,18 @@ function AppInner({config}: {config: FrontendConfig}): React.JSX.Element {
|
||||
) : null}
|
||||
|
||||
{/* Todo panel */}
|
||||
{session.ready && session.todoMarkdown ? (
|
||||
<TodoPanel markdown={session.todoMarkdown} />
|
||||
{session.ready && deferredTodoMarkdown ? (
|
||||
<TodoPanel markdown={deferredTodoMarkdown} />
|
||||
) : null}
|
||||
|
||||
{/* Swarm panel */}
|
||||
{session.ready && (session.swarmTeammates.length > 0 || session.swarmNotifications.length > 0) ? (
|
||||
<SwarmPanel teammates={session.swarmTeammates} notifications={session.swarmNotifications} />
|
||||
{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={session.status} tasks={session.tasks} activeToolName={session.busy ? currentToolName : undefined} />
|
||||
<StatusBar status={deferredStatus} tasks={deferredTasks} activeToolName={session.busy ? currentToolName : undefined} />
|
||||
) : null}
|
||||
|
||||
{/* Input — show loading indicator until backend is ready */}
|
||||
@@ -439,6 +565,8 @@ function AppInner({config}: {config: FrontendConfig}): React.JSX.Element {
|
||||
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}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -446,10 +574,13 @@ function AppInner({config}: {config: FrontendConfig}): React.JSX.Element {
|
||||
{session.ready && !session.modal && !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}>ctrl+c</Text> exit
|
||||
<Text color={theme.colors.primary}>{session.busy ? '/stop' : 'esc'}</Text> stop{' '}
|
||||
<Text color={theme.colors.primary}>ctrl+c</Text> {session.busy ? 'stop' : 'exit'}
|
||||
</Text>
|
||||
</Box>
|
||||
) : null}
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
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';
|
||||
|
||||
export function CommandPicker({
|
||||
function CommandPickerInner({
|
||||
hints,
|
||||
selectedIndex,
|
||||
}: {
|
||||
@@ -31,3 +31,5 @@ export function CommandPicker({
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export const CommandPicker = React.memo(CommandPickerInner);
|
||||
|
||||
@@ -24,7 +24,7 @@ export function Composer({
|
||||
</Box>
|
||||
<Box marginTop={1}>
|
||||
<Text dimColor>
|
||||
enter=submit tab=complete ctrl-p/ctrl-n=history history_index={String(historyIndex)}
|
||||
shift+enter=newline enter=submit tab=complete ctrl-p/ctrl-n=history history_index={String(historyIndex)}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import React from 'react';
|
||||
import {Box, Text} from 'ink';
|
||||
|
||||
import {useTheme} from '../theme/ThemeContext.js';
|
||||
@@ -6,44 +7,113 @@ import {MarkdownText} from './MarkdownText.js';
|
||||
import {ToolCallDisplay} from './ToolCallDisplay.js';
|
||||
import {WelcomeBanner} from './WelcomeBanner.js';
|
||||
|
||||
export function ConversationView({
|
||||
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({
|
||||
items,
|
||||
assistantBuffer,
|
||||
showWelcome,
|
||||
outputStyle,
|
||||
}: {
|
||||
items: TranscriptItem[];
|
||||
assistantBuffer: string;
|
||||
showWelcome: boolean;
|
||||
outputStyle: string;
|
||||
}): React.JSX.Element {
|
||||
const {theme} = useTheme();
|
||||
// Show the most recent items that fit the viewport
|
||||
const isCodexStyle = outputStyle === 'codex';
|
||||
const visible = items.slice(-40);
|
||||
const grouped = groupToolPairs(visible);
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" flexGrow={1}>
|
||||
{showWelcome && items.length === 0 ? <WelcomeBanner /> : null}
|
||||
|
||||
{visible.map((item, index) => (
|
||||
<MessageRow key={index} item={item} theme={theme} />
|
||||
))}
|
||||
{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}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{assistantBuffer ? (
|
||||
<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} />
|
||||
isCodexStyle ? (
|
||||
<Box flexDirection="row" marginTop={0}>
|
||||
<Text>{assistantBuffer}</Text>
|
||||
</Box>
|
||||
</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>
|
||||
)
|
||||
) : null}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function MessageRow({item, theme}: {item: TranscriptItem; theme: ReturnType<typeof useTheme>['theme']}): React.JSX.Element {
|
||||
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';
|
||||
|
||||
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>
|
||||
@@ -54,6 +124,13 @@ function MessageRow({item, theme}: {item: TranscriptItem; theme: ReturnType<type
|
||||
);
|
||||
|
||||
case 'assistant':
|
||||
if (isCodexStyle) {
|
||||
return (
|
||||
<Box marginTop={0} marginBottom={0}>
|
||||
<Text>{item.text}</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Box marginTop={1} marginBottom={0} flexDirection="column">
|
||||
<Text>
|
||||
@@ -67,9 +144,19 @@ function MessageRow({item, theme}: {item: TranscriptItem; theme: ReturnType<type
|
||||
|
||||
case 'tool':
|
||||
case 'tool_result':
|
||||
return <ToolCallDisplay item={item} />;
|
||||
return <ToolCallDisplay item={item} outputStyle={outputStyle} />;
|
||||
|
||||
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>
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
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/);
|
||||
});
|
||||
@@ -8,6 +8,7 @@ const WAIT_FRAMES = [
|
||||
'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);
|
||||
@@ -85,7 +86,105 @@ function QuestionModal({
|
||||
);
|
||||
}
|
||||
|
||||
export function ModalHost({
|
||||
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({
|
||||
modal,
|
||||
modalInput,
|
||||
setModalInput,
|
||||
@@ -120,6 +219,9 @@ export function ModalHost({
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
if (modal?.kind === 'edit_diff') {
|
||||
return <EditDiffModal modal={modal} />;
|
||||
}
|
||||
if (modal?.kind === 'question') {
|
||||
return (
|
||||
<QuestionModal
|
||||
@@ -147,3 +249,5 @@ export function ModalHost({
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export const ModalHost = React.memo(ModalHostInner);
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
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);
|
||||
});
|
||||
@@ -0,0 +1,244 @@
|
||||
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,13 +1,200 @@
|
||||
import React from 'react';
|
||||
import {Box, Text} from 'ink';
|
||||
import TextInput from 'ink-text-input';
|
||||
import React, {useEffect, useRef, useState} from 'react';
|
||||
import {Box, Text, useInput, useStdin} from 'ink';
|
||||
import chalk from 'chalk';
|
||||
|
||||
import {useTheme} from '../theme/ThemeContext.js';
|
||||
import {Spinner} from './Spinner.js';
|
||||
|
||||
const RUNNING_HINT_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
|
||||
|
||||
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,
|
||||
@@ -17,6 +204,8 @@ export function PromptInput({
|
||||
toolName,
|
||||
suppressSubmit,
|
||||
statusLabel,
|
||||
imageAttachmentLabels = [],
|
||||
clipboardStatus,
|
||||
}: {
|
||||
busy: boolean;
|
||||
input: string;
|
||||
@@ -25,8 +214,11 @@ export function PromptInput({
|
||||
toolName?: string;
|
||||
suppressSubmit?: boolean;
|
||||
statusLabel?: string;
|
||||
imageAttachmentLabels?: string[];
|
||||
clipboardStatus?: string | null;
|
||||
}): React.JSX.Element {
|
||||
const {theme} = useTheme();
|
||||
const promptPrefix = busy ? '… ' : '> ';
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
@@ -35,17 +227,28 @@ export function PromptInput({
|
||||
<Box>
|
||||
<Spinner label={statusLabel ?? (toolName ? `Running ${toolName}...` : 'Running...')} />
|
||||
</Box>
|
||||
<Box>
|
||||
<Text color={theme.colors.warning} bold>
|
||||
{RUNNING_HINT_FRAMES.join(' ')} Agent is working…
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
) : null}
|
||||
<Box>
|
||||
<Text color={theme.colors.primary} bold>{busy ? '… ' : '> '}</Text>
|
||||
<TextInput value={input} onChange={setInput} onSubmit={suppressSubmit || busy ? noop : onSubmit} />
|
||||
</Box>
|
||||
{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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -14,16 +14,18 @@ const VERBS = [
|
||||
'Considering',
|
||||
];
|
||||
|
||||
const WINDOWS_SAFE_FRAMES = ['-', '\\', '|', '/'];
|
||||
|
||||
export function Spinner({label}: {label?: string}): React.JSX.Element {
|
||||
const {theme} = useTheme();
|
||||
const frames = theme.icons.spinner;
|
||||
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);
|
||||
}, 80);
|
||||
}, 100);
|
||||
return () => clearInterval(timer);
|
||||
}, [frames.length]);
|
||||
|
||||
|
||||
@@ -54,7 +54,7 @@ function PlanModeIndicator({
|
||||
);
|
||||
}
|
||||
|
||||
export function StatusBar({
|
||||
function StatusBarInner({
|
||||
status,
|
||||
tasks,
|
||||
activeToolName,
|
||||
@@ -109,6 +109,8 @@ export function StatusBar({
|
||||
);
|
||||
}
|
||||
|
||||
export const StatusBar = React.memo(StatusBarInner);
|
||||
|
||||
function formatNum(n: number): string {
|
||||
if (n >= 1000) {
|
||||
return `${(n / 1000).toFixed(1)}k`;
|
||||
|
||||
@@ -36,7 +36,7 @@ function formatDuration(seconds: number): string {
|
||||
return `${m}m${s}s`;
|
||||
}
|
||||
|
||||
export function SwarmPanel({
|
||||
function SwarmPanelInner({
|
||||
teammates,
|
||||
notifications,
|
||||
collapsed: initialCollapsed = false,
|
||||
@@ -123,3 +123,5 @@ export function SwarmPanel({
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export const SwarmPanel = React.memo(SwarmPanelInner);
|
||||
|
||||
@@ -18,7 +18,7 @@ function parseTodoItems(markdown: string): TodoItem[] {
|
||||
return items;
|
||||
}
|
||||
|
||||
export function TodoPanel({
|
||||
function TodoPanelInner({
|
||||
markdown,
|
||||
compact: initialCompact = false,
|
||||
}: {
|
||||
@@ -86,4 +86,6 @@ export function TodoPanel({
|
||||
);
|
||||
}
|
||||
|
||||
export const TodoPanel = React.memo(TodoPanelInner);
|
||||
|
||||
export {parseTodoItems};
|
||||
|
||||
@@ -4,34 +4,107 @@ import {Box, Text} from 'ink';
|
||||
import {useTheme} from '../theme/ThemeContext.js';
|
||||
import type {TranscriptItem} from '../types.js';
|
||||
|
||||
export function ToolCallDisplay({item}: {item: TranscriptItem}): React.JSX.Element {
|
||||
export function ToolCallDisplay({
|
||||
item,
|
||||
resultItem,
|
||||
outputStyle,
|
||||
}: {
|
||||
item: TranscriptItem;
|
||||
resultItem?: TranscriptItem;
|
||||
outputStyle?: string;
|
||||
}): React.JSX.Element {
|
||||
const {theme} = useTheme();
|
||||
const isCodexStyle = outputStyle === 'codex';
|
||||
|
||||
if (item.role === 'tool') {
|
||||
const toolName = item.tool_name ?? 'tool';
|
||||
const summary = summarizeInput(toolName, item.tool_input, item.text);
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
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 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') {
|
||||
const lines = item.text.split('\n');
|
||||
const maxLines = 12;
|
||||
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 display = lines.length > maxLines ? [...lines.slice(0, maxLines), `... (${lines.length - maxLines} more lines)`] : lines;
|
||||
const color = item.is_error ? theme.colors.error : undefined;
|
||||
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>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Box marginLeft={4} flexDirection="column">
|
||||
{display.map((line, i) => (
|
||||
<Text key={i} color={color} dimColor={!item.is_error}>
|
||||
{line}
|
||||
</Text>
|
||||
<Text key={i} color={theme.colors.error}>{line}</Text>
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
@@ -66,7 +139,6 @@ 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,4 +1,4 @@
|
||||
import {useEffect, useMemo, useRef, useState} from 'react';
|
||||
import {startTransition, useEffect, useMemo, useRef, useState} from 'react';
|
||||
import {spawn, type ChildProcessWithoutNullStreams} from 'node:child_process';
|
||||
import readline from 'node:readline';
|
||||
|
||||
@@ -15,8 +15,9 @@ import type {
|
||||
} from '../types.js';
|
||||
|
||||
const PROTOCOL_PREFIX = 'OHJSON:';
|
||||
const ASSISTANT_DELTA_FLUSH_MS = 33;
|
||||
const ASSISTANT_DELTA_FLUSH_CHARS = 256;
|
||||
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);
|
||||
|
||||
@@ -36,6 +37,7 @@ export function useBackendSession(config: FrontendConfig, onExit: (code?: number
|
||||
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('');
|
||||
@@ -48,6 +50,8 @@ export function useBackendSession(config: FrontendConfig, onExit: (code?: number
|
||||
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;
|
||||
@@ -56,7 +60,30 @@ export function useBackendSession(config: FrontendConfig, onExit: (code?: number
|
||||
}
|
||||
pendingAssistantDeltaRef.current = '';
|
||||
assistantBufferRef.current += pending;
|
||||
setAssistantBuffer(assistantBufferRef.current);
|
||||
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 => {
|
||||
@@ -69,6 +96,14 @@ export function useBackendSession(config: FrontendConfig, onExit: (code?: number
|
||||
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;
|
||||
if (!child || child.stdin.destroyed) {
|
||||
@@ -93,7 +128,7 @@ export function useBackendSession(config: FrontendConfig, onExit: (code?: number
|
||||
const reader = readline.createInterface({input: child.stdout});
|
||||
reader.on('line', (line) => {
|
||||
if (!line.startsWith(PROTOCOL_PREFIX)) {
|
||||
setTranscript((items) => [...items, {role: 'log', text: line}]);
|
||||
queueTranscriptItem({role: 'log', text: line});
|
||||
return;
|
||||
}
|
||||
const event = JSON.parse(line.slice(PROTOCOL_PREFIX.length)) as BackendEvent;
|
||||
@@ -101,7 +136,8 @@ export function useBackendSession(config: FrontendConfig, onExit: (code?: number
|
||||
});
|
||||
|
||||
child.on('exit', (code) => {
|
||||
setTranscript((items) => [...items, {role: 'system', text: `backend exited with code ${code ?? 0}`}]);
|
||||
flushTranscriptItems();
|
||||
queueTranscriptItem({role: 'system', text: `backend exited with code ${code ?? 0}`});
|
||||
process.exitCode = code ?? 0;
|
||||
onExit(code);
|
||||
});
|
||||
@@ -125,6 +161,7 @@ export function useBackendSession(config: FrontendConfig, onExit: (code?: number
|
||||
clearTimeout(assistantFlushTimerRef.current);
|
||||
assistantFlushTimerRef.current = null;
|
||||
}
|
||||
clearPendingTranscriptItems();
|
||||
};
|
||||
process.on('exit', killChild);
|
||||
process.on('SIGINT', killChild);
|
||||
@@ -144,17 +181,27 @@ export function useBackendSession(config: FrontendConfig, onExit: (code?: number
|
||||
setReady(true);
|
||||
const statusSnapshot = stableStringify(event.state ?? {});
|
||||
lastStatusSnapshotRef.current = statusSnapshot;
|
||||
setStatus(event.state ?? {});
|
||||
const nextStatus = event.state ?? {};
|
||||
statusRef.current = nextStatus;
|
||||
startTransition(() => {
|
||||
setStatus(nextStatus);
|
||||
});
|
||||
const tasksSnapshot = stableStringify(event.tasks ?? []);
|
||||
lastTasksSnapshotRef.current = tasksSnapshot;
|
||||
setTasks(event.tasks ?? []);
|
||||
startTransition(() => {
|
||||
setTasks(event.tasks ?? []);
|
||||
});
|
||||
setCommands(event.commands ?? []);
|
||||
const mcpSnapshot = stableStringify(event.mcp_servers ?? []);
|
||||
lastMcpSnapshotRef.current = mcpSnapshot;
|
||||
setMcpServers(event.mcp_servers ?? []);
|
||||
startTransition(() => {
|
||||
setMcpServers(event.mcp_servers ?? []);
|
||||
});
|
||||
const bridgeSnapshot = stableStringify(event.bridge_sessions ?? []);
|
||||
lastBridgeSnapshotRef.current = bridgeSnapshot;
|
||||
setBridgeSessions(event.bridge_sessions ?? []);
|
||||
startTransition(() => {
|
||||
setBridgeSessions(event.bridge_sessions ?? []);
|
||||
});
|
||||
if (config.initial_prompt && !sentInitialPrompt.current) {
|
||||
sentInitialPrompt.current = true;
|
||||
sendRequest({type: 'submit_line', line: config.initial_prompt});
|
||||
@@ -166,17 +213,25 @@ export function useBackendSession(config: FrontendConfig, onExit: (code?: number
|
||||
const statusSnapshot = stableStringify(event.state ?? {});
|
||||
if (statusSnapshot !== lastStatusSnapshotRef.current) {
|
||||
lastStatusSnapshotRef.current = statusSnapshot;
|
||||
setStatus(event.state ?? {});
|
||||
const nextStatus = event.state ?? {};
|
||||
statusRef.current = nextStatus;
|
||||
startTransition(() => {
|
||||
setStatus(nextStatus);
|
||||
});
|
||||
}
|
||||
const mcpSnapshot = stableStringify(event.mcp_servers ?? []);
|
||||
if (mcpSnapshot !== lastMcpSnapshotRef.current) {
|
||||
lastMcpSnapshotRef.current = mcpSnapshot;
|
||||
setMcpServers(event.mcp_servers ?? []);
|
||||
startTransition(() => {
|
||||
setMcpServers(event.mcp_servers ?? []);
|
||||
});
|
||||
}
|
||||
const bridgeSnapshot = stableStringify(event.bridge_sessions ?? []);
|
||||
if (bridgeSnapshot !== lastBridgeSnapshotRef.current) {
|
||||
lastBridgeSnapshotRef.current = bridgeSnapshot;
|
||||
setBridgeSessions(event.bridge_sessions ?? []);
|
||||
startTransition(() => {
|
||||
setBridgeSessions(event.bridge_sessions ?? []);
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -184,12 +239,14 @@ export function useBackendSession(config: FrontendConfig, onExit: (code?: number
|
||||
const tasksSnapshot = stableStringify(event.tasks ?? []);
|
||||
if (tasksSnapshot !== lastTasksSnapshotRef.current) {
|
||||
lastTasksSnapshotRef.current = tasksSnapshot;
|
||||
setTasks(event.tasks ?? []);
|
||||
startTransition(() => {
|
||||
setTasks(event.tasks ?? []);
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (event.type === 'transcript_item' && event.item) {
|
||||
setTranscript((items) => [...items, event.item as TranscriptItem]);
|
||||
queueTranscriptItem(event.item as TranscriptItem);
|
||||
return;
|
||||
}
|
||||
if (event.type === 'status') {
|
||||
@@ -197,7 +254,7 @@ export function useBackendSession(config: FrontendConfig, onExit: (code?: number
|
||||
if (!message) {
|
||||
return;
|
||||
}
|
||||
setTranscript((items) => [...items, {role: 'status', text: message}]);
|
||||
queueTranscriptItem({role: 'status', text: message});
|
||||
if (busy) {
|
||||
setBusyLabel(message);
|
||||
}
|
||||
@@ -233,7 +290,7 @@ export function useBackendSession(config: FrontendConfig, onExit: (code?: number
|
||||
setBusyLabel('Compaction failed. Continuing without it…');
|
||||
}
|
||||
if (event.message) {
|
||||
setTranscript((items) => [...items, {role: 'status', text: event.message!}]);
|
||||
queueTranscriptItem({role: 'status', text: event.message!});
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -242,6 +299,13 @@ export function useBackendSession(config: FrontendConfig, onExit: (code?: number
|
||||
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();
|
||||
@@ -260,17 +324,28 @@ export function useBackendSession(config: FrontendConfig, onExit: (code?: number
|
||||
clearTimeout(assistantFlushTimerRef.current);
|
||||
assistantFlushTimerRef.current = null;
|
||||
}
|
||||
flushAssistantDelta();
|
||||
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;
|
||||
setTranscript((items) => [...items, {role: 'assistant', text}]);
|
||||
startTransition(() => {
|
||||
setTranscript((items) => [...items, {role: 'assistant', text}]);
|
||||
});
|
||||
clearAssistantDelta();
|
||||
setBusy(false);
|
||||
// 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);
|
||||
return;
|
||||
}
|
||||
if (event.type === 'line_complete') {
|
||||
// If the line ended without an assistant_complete (e.g. errors), make sure we
|
||||
// don't leave stale streaming text on screen.
|
||||
// Final end-of-turn: clear everything, stop spinner.
|
||||
clearAssistantDelta();
|
||||
setBusy(false);
|
||||
setBusyLabel(undefined);
|
||||
@@ -278,7 +353,10 @@ export function useBackendSession(config: FrontendConfig, onExit: (code?: number
|
||||
}
|
||||
if ((event.type === 'tool_started' || event.type === 'tool_completed') && event.item) {
|
||||
if (event.type === 'tool_started') {
|
||||
setBusyLabel(event.tool_name ? `Running ${event.tool_name}...` : 'Running...');
|
||||
setBusy(true);
|
||||
setBusyLabel(`Running ${event.tool_name ?? 'tool'}...`);
|
||||
} else {
|
||||
setBusyLabel('Processing...');
|
||||
}
|
||||
const enrichedItem: TranscriptItem = {
|
||||
...event.item,
|
||||
@@ -286,10 +364,12 @@ export function useBackendSession(config: FrontendConfig, onExit: (code?: number
|
||||
tool_input: event.item.tool_input ?? undefined,
|
||||
is_error: event.item.is_error ?? event.is_error ?? undefined,
|
||||
};
|
||||
setTranscript((items) => [...items, enrichedItem]);
|
||||
queueTranscriptItem(enrichedItem);
|
||||
return;
|
||||
}
|
||||
if (event.type === 'clear_transcript') {
|
||||
flushTranscriptItems();
|
||||
clearPendingTranscriptItems();
|
||||
setTranscript([]);
|
||||
clearAssistantDelta();
|
||||
setBusyLabel(undefined);
|
||||
@@ -309,7 +389,8 @@ export function useBackendSession(config: FrontendConfig, onExit: (code?: number
|
||||
return;
|
||||
}
|
||||
if (event.type === 'error') {
|
||||
setTranscript((items) => [...items, {role: 'system', text: `error: ${event.message ?? 'unknown error'}`}]);
|
||||
flushTranscriptItems();
|
||||
queueTranscriptItem({role: 'system', text: `error: ${event.message ?? 'unknown error'}`});
|
||||
clearAssistantDelta();
|
||||
setBusy(false);
|
||||
setBusyLabel(undefined);
|
||||
@@ -317,22 +398,34 @@ export function useBackendSession(config: FrontendConfig, onExit: (code?: number
|
||||
}
|
||||
if (event.type === 'todo_update') {
|
||||
if (event.todo_markdown != null) {
|
||||
setTodoMarkdown(event.todo_markdown);
|
||||
startTransition(() => {
|
||||
setTodoMarkdown(event.todo_markdown);
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (event.type === 'swarm_status') {
|
||||
if (event.swarm_teammates != null) {
|
||||
setSwarmTeammates(event.swarm_teammates);
|
||||
startTransition(() => {
|
||||
setSwarmTeammates(event.swarm_teammates);
|
||||
});
|
||||
}
|
||||
if (event.swarm_notifications != null) {
|
||||
setSwarmNotifications((prev) => [...prev, ...event.swarm_notifications!].slice(-20));
|
||||
startTransition(() => {
|
||||
setSwarmNotifications((prev) => [...prev, ...event.swarm_notifications!].slice(-20));
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (event.type === 'plan_mode_change') {
|
||||
if (event.plan_mode != null) {
|
||||
setStatus((s) => ({...s, permission_mode: event.plan_mode}));
|
||||
startTransition(() => {
|
||||
setStatus((s) => {
|
||||
const next = {...s, permission_mode: event.plan_mode};
|
||||
statusRef.current = next;
|
||||
return next;
|
||||
});
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -361,6 +454,7 @@ export function useBackendSession(config: FrontendConfig, onExit: (code?: number
|
||||
setModal,
|
||||
setSelectRequest,
|
||||
setBusy,
|
||||
setBusyLabel,
|
||||
sendRequest,
|
||||
}),
|
||||
[assistantBuffer, bridgeSessions, busy, busyLabel, commands, mcpServers, modal, ready, selectRequest, status, swarmNotifications, swarmTeammates, tasks, todoMarkdown, transcript]
|
||||
|
||||
@@ -39,17 +39,19 @@ process.on('uncaughtException', (err: NodeJS.ErrnoException) => {
|
||||
|
||||
const config = JSON.parse(process.env.OPENHARNESS_FRONTEND_CONFIG ?? '{}') as FrontendConfig;
|
||||
|
||||
// Restore terminal cursor visibility on exit (Ink hides it by default)
|
||||
const restoreCursor = (): void => {
|
||||
process.stdout.write('\x1B[?25h');
|
||||
// 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', restoreCursor);
|
||||
process.on('exit', restoreTerminal);
|
||||
process.on('SIGINT', () => {
|
||||
restoreCursor();
|
||||
restoreTerminal();
|
||||
process.exit(130);
|
||||
});
|
||||
process.on('SIGTERM', () => {
|
||||
restoreCursor();
|
||||
restoreTerminal();
|
||||
process.exit(143);
|
||||
});
|
||||
|
||||
|
||||
@@ -11,6 +11,12 @@ export type TranscriptItem = {
|
||||
is_error?: boolean;
|
||||
};
|
||||
|
||||
export type ImageAttachmentPayload = {
|
||||
media_type: string;
|
||||
data: string;
|
||||
source_path?: string;
|
||||
};
|
||||
|
||||
export type TaskSnapshot = {
|
||||
id: string;
|
||||
type: string;
|
||||
|
||||
+1
-1
@@ -2,4 +2,4 @@
|
||||
|
||||
__all__ = ["__version__"]
|
||||
|
||||
__version__ = "0.1.6"
|
||||
__version__ = "0.1.9"
|
||||
|
||||
+97
-5
@@ -25,6 +25,7 @@ from ohmo.runtime import launch_ohmo_react_tui, run_ohmo_backend, run_ohmo_print
|
||||
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,
|
||||
@@ -208,10 +209,10 @@ def _prompt_channels(existing: GatewayConfig) -> tuple[list[str], dict[str, dict
|
||||
else:
|
||||
enabled.append(channel)
|
||||
allow_from_raw = _text_prompt(
|
||||
f"{channel} allow_from (comma separated, '*' for everyone)",
|
||||
default=",".join(prior.get("allow_from", ["*"])) or "*",
|
||||
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()] or ["*"]
|
||||
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(
|
||||
@@ -269,6 +270,14 @@ def _prompt_channels(existing: GatewayConfig) -> tuple[list[str], dict[str, dict
|
||||
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", "")),
|
||||
@@ -289,6 +298,29 @@ def _prompt_channels(existing: GatewayConfig) -> tuple[list[str], dict[str, dict
|
||||
"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
|
||||
|
||||
@@ -306,6 +338,22 @@ def _run_gateway_config_wizard(workspace: str | Path) -> GatewayConfig:
|
||||
"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,
|
||||
@@ -313,6 +361,8 @@ def _run_gateway_config_wizard(workspace: str | Path) -> GatewayConfig:
|
||||
"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)
|
||||
@@ -326,8 +376,24 @@ def _print_gateway_config_summary(config: GatewayConfig) -> None:
|
||||
+ ", ".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:
|
||||
@@ -342,18 +408,42 @@ def _maybe_restart_gateway(*, cwd: str | Path, workspace: str | Path) -> None:
|
||||
print(f"ohmo gateway restarted (pid={pid})")
|
||||
|
||||
|
||||
def _configure_gateway_logging(workspace: str | Path | None = None) -> None:
|
||||
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,
|
||||
@@ -572,9 +662,11 @@ def user_edit_cmd(
|
||||
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)
|
||||
_configure_gateway_logging(workspace, console=console_log, log_file=log_file)
|
||||
service = OhmoGatewayService(cwd, workspace)
|
||||
raise SystemExit(asyncio.run(service.run_foreground()))
|
||||
|
||||
|
||||
+173
-2
@@ -5,10 +5,13 @@ 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
|
||||
|
||||
@@ -59,10 +62,14 @@ class OhmoGatewayBridge:
|
||||
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] = {}
|
||||
@@ -77,6 +84,17 @@ class OhmoGatewayBridge:
|
||||
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",
|
||||
@@ -92,6 +110,13 @@ class OhmoGatewayBridge:
|
||||
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",
|
||||
@@ -148,6 +173,58 @@ class OhmoGatewayBridge:
|
||||
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,
|
||||
@@ -169,11 +246,24 @@ class OhmoGatewayBridge:
|
||||
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
|
||||
@@ -190,7 +280,8 @@ class OhmoGatewayBridge:
|
||||
channel=message.channel,
|
||||
chat_id=message.chat_id,
|
||||
content=update.text,
|
||||
metadata=update.metadata,
|
||||
media=list(getattr(update, "media", None) or (update.metadata or {}).get("_media") or []),
|
||||
metadata={**inbound_meta, **(update.metadata or {})},
|
||||
)
|
||||
)
|
||||
except asyncio.CancelledError:
|
||||
@@ -232,7 +323,8 @@ class OhmoGatewayBridge:
|
||||
channel=message.channel,
|
||||
chat_id=message.chat_id,
|
||||
content=reply,
|
||||
metadata={"_session_key": session_key},
|
||||
media=final_media,
|
||||
metadata={**inbound_meta, **final_metadata, "_session_key": session_key},
|
||||
)
|
||||
)
|
||||
|
||||
@@ -241,3 +333,82 @@ class OhmoGatewayBridge:
|
||||
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
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
"""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())
|
||||
@@ -15,6 +15,8 @@ class GatewayConfig(BaseModel):
|
||||
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)
|
||||
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
"""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)
|
||||
@@ -0,0 +1,139 @@
|
||||
"""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])
|
||||
+13
-2
@@ -6,15 +6,26 @@ from openharness.channels.bus.events import InboundMessage
|
||||
|
||||
|
||||
def session_key_for_message(message: InboundMessage) -> str:
|
||||
"""Route sessions by chat and thread when available."""
|
||||
"""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}"
|
||||
|
||||
|
||||
+536
-41
@@ -9,11 +9,17 @@ import mimetypes
|
||||
from pathlib import Path
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import string
|
||||
|
||||
from openharness.channels.bus.events import InboundMessage
|
||||
from openharness.commands import CommandContext
|
||||
from openharness.engine.messages import ConversationMessage, ImageBlock, TextBlock
|
||||
from openharness.commands import CommandContext, CommandResult, lookup_skill_slash_command
|
||||
from openharness.engine.messages import (
|
||||
ConversationMessage,
|
||||
ImageBlock,
|
||||
TextBlock,
|
||||
sanitize_conversation_messages,
|
||||
)
|
||||
from openharness.engine.query import MaxTurnsExceeded
|
||||
from openharness.engine.stream_events import (
|
||||
AssistantTextDelta,
|
||||
@@ -27,9 +33,14 @@ from openharness.engine.stream_events import (
|
||||
from openharness.prompts import build_runtime_system_prompt
|
||||
from openharness.ui.runtime import RuntimeBundle, _last_user_text, build_runtime, close_runtime, start_runtime
|
||||
|
||||
from ohmo.gateway.config import load_gateway_config
|
||||
from ohmo.gateway.group_tool import CreateFeishuGroup, OhmoCreateFeishuGroupTool, PublishGroupWelcome
|
||||
from ohmo.gateway.provider_commands import handle_gateway_model_command, handle_gateway_provider_command
|
||||
from ohmo.group_registry import load_managed_group_record, normalize_cwd
|
||||
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_plugins_dir, get_skills_dir, initialize_workspace
|
||||
from ohmo.workspace import get_memory_dir, get_plugins_dir, get_sessions_dir, get_skills_dir, initialize_workspace
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -52,6 +63,25 @@ _CHANNEL_THINKING_PHRASES_EN = (
|
||||
_TEXT_PREVIEW_BYTES = 4096
|
||||
_TEXT_PREVIEW_CHARS = 900
|
||||
_BINARY_HEAD_BYTES = 32
|
||||
_FINAL_REPLY_IMAGE_PATH_RE = re.compile(
|
||||
r"(?P<path>(?:[A-Za-z]:[\\/]|/)[^\r\n`\"'<>|?*\x00]+?\.(?:png|jpe?g|webp|gif|bmp))",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_IMAGE_FALLBACK_NOTE = (
|
||||
"[Image attachment omitted because the active model does not support image input. "
|
||||
"Use the attachment paths and summaries above if needed.]"
|
||||
)
|
||||
_NO_GROUP_REQUEST = object()
|
||||
_GROUP_TOOL_NAME = "ohmo_create_feishu_group"
|
||||
_GROUP_AGENT_PROMPT_PREFIX = "The user invoked `/group` from a Feishu private chat."
|
||||
_GROUP_AGENT_PROMPT_REQUEST_MARKER = "User /group request:"
|
||||
_GROUP_METADATA_KEYS = (
|
||||
"task_focus_state",
|
||||
"recent_work_log",
|
||||
"recent_verified_work",
|
||||
"compact_checkpoints",
|
||||
"compact_last",
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -61,6 +91,7 @@ class GatewayStreamUpdate:
|
||||
kind: str
|
||||
text: str
|
||||
metadata: dict[str, object]
|
||||
media: list[str] | None = None
|
||||
|
||||
|
||||
class OhmoSessionRuntimePool:
|
||||
@@ -74,13 +105,18 @@ class OhmoSessionRuntimePool:
|
||||
provider_profile: str,
|
||||
model: str | None = None,
|
||||
max_turns: int | None = None,
|
||||
create_feishu_group: CreateFeishuGroup | None = None,
|
||||
publish_group_welcome: PublishGroupWelcome | None = None,
|
||||
) -> None:
|
||||
self._cwd = str(Path(cwd).resolve())
|
||||
self._workspace = workspace
|
||||
self._provider_profile = provider_profile
|
||||
self._model = model
|
||||
self._max_turns = max_turns
|
||||
self._create_feishu_group = create_feishu_group
|
||||
self._publish_group_welcome = publish_group_welcome
|
||||
self._workspace = initialize_workspace(workspace)
|
||||
self._gateway_config = load_gateway_config(self._workspace)
|
||||
self._session_backend = OhmoSessionBackend(self._workspace)
|
||||
self._bundles: dict[str, RuntimeBundle] = {}
|
||||
|
||||
@@ -88,18 +124,60 @@ class OhmoSessionRuntimePool:
|
||||
def active_sessions(self) -> int:
|
||||
return len(self._bundles)
|
||||
|
||||
async def get_bundle(self, session_key: str, latest_user_prompt: str | None = None) -> RuntimeBundle:
|
||||
def _remote_admin_allowed(self, command) -> bool:
|
||||
if not getattr(command, "remote_admin_opt_in", False):
|
||||
return False
|
||||
if not self._gateway_config.allow_remote_admin_commands:
|
||||
return False
|
||||
allowed = {
|
||||
str(name).strip().lower()
|
||||
for name in self._gateway_config.allowed_remote_admin_commands
|
||||
if str(name).strip()
|
||||
}
|
||||
return command.name.lower() in allowed
|
||||
|
||||
def _handle_gateway_scoped_command(self, command_name: str, args: str) -> tuple[str, bool] | None:
|
||||
lowered = command_name.lower()
|
||||
if lowered == "provider":
|
||||
result = handle_gateway_provider_command(args, workspace=self._workspace)
|
||||
elif lowered == "model":
|
||||
result = handle_gateway_model_command(args, workspace=self._workspace)
|
||||
else:
|
||||
return None
|
||||
if result[1]:
|
||||
self._gateway_config = load_gateway_config(self._workspace)
|
||||
self._provider_profile = self._gateway_config.provider_profile
|
||||
return result
|
||||
|
||||
async def get_bundle(
|
||||
self,
|
||||
session_key: str,
|
||||
latest_user_prompt: str | None = None,
|
||||
cwd: str | Path | None = None,
|
||||
) -> RuntimeBundle:
|
||||
"""Return an existing bundle or create a new one."""
|
||||
session_cwd = str(Path(cwd or self._cwd).expanduser().resolve())
|
||||
bundle = self._bundles.get(session_key)
|
||||
if bundle is not None:
|
||||
logger.info(
|
||||
"ohmo runtime reusing session session_key=%s session_id=%s prompt=%r",
|
||||
session_key,
|
||||
bundle.session_id,
|
||||
_content_snippet(latest_user_prompt or ""),
|
||||
)
|
||||
bundle.engine.set_system_prompt(self._runtime_system_prompt(bundle, latest_user_prompt))
|
||||
return bundle
|
||||
bundle_cwd = str(Path(getattr(bundle, "cwd", self._cwd)).resolve())
|
||||
if bundle_cwd != session_cwd:
|
||||
logger.info(
|
||||
"ohmo runtime recreating session for cwd change session_key=%s old_cwd=%s new_cwd=%s",
|
||||
session_key,
|
||||
bundle_cwd,
|
||||
session_cwd,
|
||||
)
|
||||
await close_runtime(bundle)
|
||||
self._bundles.pop(session_key, None)
|
||||
else:
|
||||
logger.info(
|
||||
"ohmo runtime reusing session session_key=%s session_id=%s prompt=%r",
|
||||
session_key,
|
||||
bundle.session_id,
|
||||
_content_snippet(latest_user_prompt or ""),
|
||||
)
|
||||
bundle.engine.set_system_prompt(self._runtime_system_prompt(bundle, latest_user_prompt))
|
||||
return bundle
|
||||
|
||||
snapshot = self._session_backend.load_latest_for_session_key(session_key)
|
||||
logger.info(
|
||||
@@ -109,20 +187,31 @@ class OhmoSessionRuntimePool:
|
||||
_content_snippet(latest_user_prompt or ""),
|
||||
)
|
||||
bundle = await build_runtime(
|
||||
cwd=session_cwd,
|
||||
model=self._model,
|
||||
max_turns=self._max_turns,
|
||||
system_prompt=build_ohmo_system_prompt(self._cwd, workspace=self._workspace, extra_prompt=None),
|
||||
system_prompt=build_ohmo_system_prompt(session_cwd, workspace=self._workspace, extra_prompt=None),
|
||||
active_profile=self._provider_profile,
|
||||
session_backend=self._session_backend,
|
||||
enforce_max_turns=self._max_turns is not None,
|
||||
restore_messages=snapshot.get("messages") if snapshot else None,
|
||||
restore_tool_metadata=snapshot.get("tool_metadata") if snapshot else None,
|
||||
restore_messages=_sanitize_snapshot_messages(snapshot.get("messages") if snapshot else None),
|
||||
restore_tool_metadata=_sanitize_group_command_metadata(snapshot.get("tool_metadata") if snapshot else None),
|
||||
extra_skill_dirs=(str(get_skills_dir(self._workspace)),),
|
||||
extra_plugin_roots=(str(get_plugins_dir(self._workspace)),),
|
||||
memory_backend=create_memory_command_backend(self._workspace),
|
||||
include_project_memory=False,
|
||||
autodream_context={
|
||||
"memory_dir": str(get_memory_dir(self._workspace)),
|
||||
"session_dir": str(get_sessions_dir(self._workspace)),
|
||||
"app_label": "ohmo personal memory",
|
||||
"runner_module": "ohmo",
|
||||
},
|
||||
)
|
||||
if snapshot and snapshot.get("session_id"):
|
||||
bundle.session_id = str(snapshot["session_id"])
|
||||
self._register_gateway_tools(bundle)
|
||||
await start_runtime(bundle)
|
||||
bundle.engine.set_system_prompt(self._runtime_system_prompt(bundle, latest_user_prompt))
|
||||
logger.info(
|
||||
"ohmo runtime started session_key=%s session_id=%s restored_messages=%s",
|
||||
session_key,
|
||||
@@ -136,7 +225,9 @@ class OhmoSessionRuntimePool:
|
||||
"""Submit an inbound channel message and yield progress + final reply updates."""
|
||||
user_message = _build_inbound_user_message(message)
|
||||
user_prompt = user_message.text
|
||||
bundle = await self.get_bundle(session_key, latest_user_prompt=user_prompt)
|
||||
command_prompt = (message.content or "").strip()
|
||||
session_cwd = self._cwd_for_message(message)
|
||||
bundle = await self.get_bundle(session_key, latest_user_prompt=user_prompt, cwd=session_cwd)
|
||||
logger.info(
|
||||
"ohmo runtime processing start channel=%s chat_id=%s session_key=%s session_id=%s content=%r",
|
||||
message.channel,
|
||||
@@ -146,24 +237,73 @@ class OhmoSessionRuntimePool:
|
||||
_content_snippet(user_prompt),
|
||||
)
|
||||
|
||||
parsed = bundle.commands.lookup(user_prompt)
|
||||
command_context: CommandContext | None = None
|
||||
|
||||
def get_command_context() -> CommandContext:
|
||||
nonlocal command_context
|
||||
if command_context is None:
|
||||
command_context = CommandContext(
|
||||
engine=bundle.engine,
|
||||
hooks_summary=getattr(bundle, "hook_summary", lambda: "")(),
|
||||
mcp_summary=getattr(bundle, "mcp_summary", lambda: "")(),
|
||||
plugin_summary=getattr(bundle, "plugin_summary", lambda: "")(),
|
||||
cwd=getattr(bundle, "cwd", str(self._cwd)),
|
||||
tool_registry=getattr(bundle, "tool_registry", None),
|
||||
app_state=getattr(bundle, "app_state", None),
|
||||
session_backend=getattr(bundle, "session_backend", self._session_backend),
|
||||
session_id=getattr(bundle, "session_id", None),
|
||||
extra_skill_dirs=getattr(bundle, "extra_skill_dirs", ()),
|
||||
extra_plugin_roots=getattr(bundle, "extra_plugin_roots", ()),
|
||||
memory_backend=create_memory_command_backend(self._workspace),
|
||||
include_project_memory=False,
|
||||
)
|
||||
return command_context
|
||||
|
||||
parsed = bundle.commands.lookup(command_prompt)
|
||||
if parsed is None and not message.media:
|
||||
parsed = lookup_skill_slash_command(command_prompt, get_command_context())
|
||||
if parsed is not None and not message.media:
|
||||
command, args = parsed
|
||||
command_name = str(getattr(command, "name", "") or "")
|
||||
remote_allowed = getattr(command, "remote_invocable", True)
|
||||
if not remote_allowed and self._remote_admin_allowed(command):
|
||||
remote_allowed = True
|
||||
logger.warning(
|
||||
"ohmo gateway remote administrative command accepted channel=%s chat_id=%s sender_id=%s command=%s",
|
||||
message.channel,
|
||||
message.chat_id,
|
||||
message.sender_id,
|
||||
command_name,
|
||||
)
|
||||
if not remote_allowed:
|
||||
result = CommandResult(
|
||||
message=f"/{command_name} is only available in the local OpenHarness UI."
|
||||
)
|
||||
async for update in self._stream_command_result(
|
||||
bundle=bundle,
|
||||
message=message,
|
||||
session_key=session_key,
|
||||
user_prompt=user_prompt,
|
||||
result=result,
|
||||
):
|
||||
yield update
|
||||
return
|
||||
gateway_result = self._handle_gateway_scoped_command(command_name, args)
|
||||
if gateway_result is not None:
|
||||
message_text, refresh_runtime = gateway_result
|
||||
result = CommandResult(message=message_text, refresh_runtime=refresh_runtime)
|
||||
async for update in self._stream_command_result(
|
||||
bundle=bundle,
|
||||
message=message,
|
||||
session_key=session_key,
|
||||
user_prompt=user_prompt,
|
||||
result=result,
|
||||
):
|
||||
yield update
|
||||
return
|
||||
result = await command.handler(
|
||||
args,
|
||||
CommandContext(
|
||||
engine=bundle.engine,
|
||||
hooks_summary=bundle.hook_summary(),
|
||||
mcp_summary=bundle.mcp_summary(),
|
||||
plugin_summary=bundle.plugin_summary(),
|
||||
cwd=bundle.cwd,
|
||||
tool_registry=bundle.tool_registry,
|
||||
app_state=bundle.app_state,
|
||||
session_backend=bundle.session_backend,
|
||||
session_id=bundle.session_id,
|
||||
extra_skill_dirs=bundle.extra_skill_dirs,
|
||||
extra_plugin_roots=bundle.extra_plugin_roots,
|
||||
),
|
||||
get_command_context(),
|
||||
)
|
||||
async for update in self._stream_command_result(
|
||||
bundle=bundle,
|
||||
@@ -270,6 +410,7 @@ class OhmoSessionRuntimePool:
|
||||
):
|
||||
bundle.engine.set_system_prompt(self._runtime_system_prompt(bundle, user_prompt))
|
||||
reply_parts: list[str] = []
|
||||
emitted_media: set[str] = set()
|
||||
yield GatewayStreamUpdate(
|
||||
kind="progress",
|
||||
text=_format_channel_progress(
|
||||
@@ -281,8 +422,43 @@ class OhmoSessionRuntimePool:
|
||||
),
|
||||
metadata={"_progress": True, "_session_key": session_key},
|
||||
)
|
||||
previous_group_request = self._set_group_request_context(bundle, message, session_key)
|
||||
try:
|
||||
async for event in bundle.engine.submit_message(user_message):
|
||||
if isinstance(event, ErrorEvent) and _should_retry_without_image_input(
|
||||
event.message,
|
||||
bundle.engine.messages,
|
||||
):
|
||||
logger.warning(
|
||||
"ohmo runtime image input rejected; retrying without image blocks session_key=%s session_id=%s message=%r",
|
||||
session_key,
|
||||
bundle.session_id,
|
||||
_content_snippet(event.message),
|
||||
)
|
||||
_strip_image_blocks_from_engine_history(bundle.engine)
|
||||
yield GatewayStreamUpdate(
|
||||
kind="progress",
|
||||
text=_format_channel_progress(
|
||||
channel=message.channel,
|
||||
kind="image_fallback",
|
||||
text=event.message,
|
||||
session_key=session_key,
|
||||
content=user_prompt,
|
||||
),
|
||||
metadata={"_progress": True, "_session_key": session_key, "_image_fallback": True},
|
||||
)
|
||||
async for retry_event in bundle.engine.continue_pending(max_turns=bundle.engine.max_turns):
|
||||
async for update in self._convert_stream_event(
|
||||
event=retry_event,
|
||||
bundle=bundle,
|
||||
message=message,
|
||||
session_key=session_key,
|
||||
content=user_prompt,
|
||||
reply_parts=reply_parts,
|
||||
):
|
||||
_remember_update_media(emitted_media, update)
|
||||
yield update
|
||||
break
|
||||
async for update in self._convert_stream_event(
|
||||
event=event,
|
||||
bundle=bundle,
|
||||
@@ -291,6 +467,7 @@ class OhmoSessionRuntimePool:
|
||||
content=user_prompt,
|
||||
reply_parts=reply_parts,
|
||||
):
|
||||
_remember_update_media(emitted_media, update)
|
||||
yield update
|
||||
except MaxTurnsExceeded as exc:
|
||||
yield GatewayStreamUpdate(
|
||||
@@ -298,8 +475,13 @@ class OhmoSessionRuntimePool:
|
||||
text=f"Stopped after {exc.max_turns} turns (max_turns).",
|
||||
metadata={"_session_key": session_key},
|
||||
)
|
||||
self._restore_group_request_context(bundle, previous_group_request)
|
||||
await self._save_snapshot(bundle, session_key, user_prompt)
|
||||
return
|
||||
except Exception:
|
||||
self._restore_group_request_context(bundle, previous_group_request)
|
||||
raise
|
||||
self._restore_group_request_context(bundle, previous_group_request)
|
||||
await self._save_snapshot(bundle, session_key, user_prompt)
|
||||
reply = "".join(reply_parts).strip()
|
||||
if reply:
|
||||
@@ -309,10 +491,15 @@ class OhmoSessionRuntimePool:
|
||||
bundle.session_id,
|
||||
_content_snippet(reply),
|
||||
)
|
||||
final_media = _extract_final_reply_media(reply, emitted_media)
|
||||
metadata: dict[str, object] = {"_session_key": session_key}
|
||||
if final_media:
|
||||
metadata.update({"_media": final_media, "_final_media_fallback": True})
|
||||
yield GatewayStreamUpdate(
|
||||
kind="final",
|
||||
text=reply,
|
||||
metadata={"_session_key": session_key},
|
||||
metadata=metadata,
|
||||
media=final_media or None,
|
||||
)
|
||||
|
||||
async def _convert_stream_event(
|
||||
@@ -408,6 +595,14 @@ class OhmoSessionRuntimePool:
|
||||
bundle.session_id,
|
||||
event.tool_name,
|
||||
)
|
||||
media = _extract_tool_media(event)
|
||||
if media:
|
||||
yield GatewayStreamUpdate(
|
||||
kind="media",
|
||||
text=_format_tool_media_caption(event, media),
|
||||
metadata={"_session_key": session_key, "_media": media, "_tool_media": True},
|
||||
media=media,
|
||||
)
|
||||
return
|
||||
if isinstance(event, ErrorEvent):
|
||||
logger.error(
|
||||
@@ -426,12 +621,20 @@ class OhmoSessionRuntimePool:
|
||||
reply_parts.append(event.message.text.strip())
|
||||
|
||||
async def _save_snapshot(self, bundle: RuntimeBundle, session_key: str, user_prompt: str) -> None:
|
||||
tool_metadata = getattr(bundle.engine, "tool_metadata", {}) or {}
|
||||
tool_metadata = _sanitize_group_command_metadata(getattr(bundle.engine, "tool_metadata", {}) or {})
|
||||
if isinstance(getattr(bundle.engine, "tool_metadata", None), dict) and isinstance(tool_metadata, dict):
|
||||
bundle.engine.tool_metadata.update(tool_metadata)
|
||||
messages = _sanitize_group_command_prompts(list(bundle.engine.messages))
|
||||
if messages != list(bundle.engine.messages):
|
||||
if hasattr(bundle.engine, "load_messages"):
|
||||
bundle.engine.load_messages(messages)
|
||||
else:
|
||||
bundle.engine.messages = messages
|
||||
self._session_backend.save_snapshot(
|
||||
cwd=self._cwd,
|
||||
cwd=getattr(bundle, "cwd", self._cwd),
|
||||
model=bundle.current_settings().model,
|
||||
system_prompt=self._runtime_system_prompt(bundle, user_prompt),
|
||||
messages=bundle.engine.messages,
|
||||
messages=messages,
|
||||
usage=bundle.engine.total_usage,
|
||||
session_id=bundle.session_id,
|
||||
session_key=session_key,
|
||||
@@ -450,23 +653,33 @@ class OhmoSessionRuntimePool:
|
||||
bundle: RuntimeBundle,
|
||||
latest_user_prompt: str | None,
|
||||
) -> RuntimeBundle:
|
||||
snapshot = list(bundle.engine.messages)
|
||||
snapshot = sanitize_conversation_messages(list(bundle.engine.messages))
|
||||
prior_session_id = bundle.session_id
|
||||
bundle_cwd = str(Path(getattr(bundle, "cwd", self._cwd)).resolve())
|
||||
await close_runtime(bundle)
|
||||
refreshed = await build_runtime(
|
||||
cwd=self._cwd,
|
||||
cwd=bundle_cwd,
|
||||
model=self._model,
|
||||
max_turns=self._max_turns,
|
||||
system_prompt=build_ohmo_system_prompt(self._cwd, workspace=self._workspace, extra_prompt=None),
|
||||
system_prompt=build_ohmo_system_prompt(bundle_cwd, workspace=self._workspace, extra_prompt=None),
|
||||
active_profile=self._provider_profile,
|
||||
session_backend=self._session_backend,
|
||||
enforce_max_turns=self._max_turns is not None,
|
||||
restore_messages=[message.model_dump(mode="json") for message in snapshot],
|
||||
restore_tool_metadata=getattr(bundle.engine, "tool_metadata", {}) or {},
|
||||
restore_messages=[message.model_dump(mode="json") for message in _sanitize_group_command_prompts(snapshot)],
|
||||
restore_tool_metadata=_sanitize_group_command_metadata(getattr(bundle.engine, "tool_metadata", {}) or {}),
|
||||
extra_skill_dirs=(str(get_skills_dir(self._workspace)),),
|
||||
extra_plugin_roots=(str(get_plugins_dir(self._workspace)),),
|
||||
memory_backend=create_memory_command_backend(self._workspace),
|
||||
include_project_memory=False,
|
||||
autodream_context={
|
||||
"memory_dir": str(get_memory_dir(self._workspace)),
|
||||
"session_dir": str(get_sessions_dir(self._workspace)),
|
||||
"app_label": "ohmo personal memory",
|
||||
"runner_module": "ohmo",
|
||||
},
|
||||
)
|
||||
refreshed.session_id = prior_session_id
|
||||
self._register_gateway_tools(refreshed)
|
||||
await start_runtime(refreshed)
|
||||
refreshed.engine.set_system_prompt(self._runtime_system_prompt(refreshed, latest_user_prompt))
|
||||
self._bundles[session_key] = refreshed
|
||||
@@ -479,17 +692,99 @@ class OhmoSessionRuntimePool:
|
||||
return refreshed
|
||||
|
||||
def _runtime_system_prompt(self, bundle: RuntimeBundle, latest_user_prompt: str | None) -> str:
|
||||
bundle_cwd = str(Path(getattr(bundle, "cwd", self._cwd)).resolve())
|
||||
if not hasattr(bundle, "current_settings"):
|
||||
return build_ohmo_system_prompt(bundle_cwd, workspace=self._workspace, extra_prompt=None)
|
||||
settings = bundle.current_settings()
|
||||
if not hasattr(settings, "system_prompt"):
|
||||
return build_ohmo_system_prompt(self._cwd, workspace=self._workspace, extra_prompt=None)
|
||||
return build_ohmo_system_prompt(bundle_cwd, workspace=self._workspace, extra_prompt=None)
|
||||
return build_runtime_system_prompt(
|
||||
settings,
|
||||
cwd=self._cwd,
|
||||
cwd=bundle_cwd,
|
||||
latest_user_prompt=latest_user_prompt,
|
||||
extra_skill_dirs=getattr(bundle, "extra_skill_dirs", ()),
|
||||
extra_plugin_roots=getattr(bundle, "extra_plugin_roots", ()),
|
||||
include_project_memory=False,
|
||||
)
|
||||
|
||||
def _cwd_for_message(self, message: InboundMessage) -> str:
|
||||
record = load_managed_group_record(
|
||||
workspace=self._workspace,
|
||||
channel=message.channel,
|
||||
chat_id=message.chat_id,
|
||||
)
|
||||
cwd = record.get("cwd") if record else None
|
||||
if not cwd:
|
||||
return self._cwd
|
||||
normalized = normalize_cwd(str(cwd))
|
||||
if not Path(normalized).is_dir():
|
||||
logger.warning(
|
||||
"ohmo managed group cwd does not exist channel=%s chat_id=%s cwd=%s",
|
||||
message.channel,
|
||||
message.chat_id,
|
||||
normalized,
|
||||
)
|
||||
return self._cwd
|
||||
return normalized
|
||||
|
||||
def _register_gateway_tools(self, bundle: RuntimeBundle) -> None:
|
||||
self._unregister_group_tool(bundle)
|
||||
|
||||
def _register_group_tool(self, bundle: RuntimeBundle) -> None:
|
||||
if self._create_feishu_group is None or not hasattr(bundle, "tool_registry"):
|
||||
return
|
||||
if bundle.tool_registry is None or bundle.tool_registry.get(_GROUP_TOOL_NAME) is not None:
|
||||
return
|
||||
bundle.tool_registry.register(
|
||||
OhmoCreateFeishuGroupTool(
|
||||
workspace=self._workspace,
|
||||
create_group=self._create_feishu_group,
|
||||
publish_group_welcome=self._publish_group_welcome,
|
||||
)
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _unregister_group_tool(bundle: RuntimeBundle) -> None:
|
||||
registry = getattr(bundle, "tool_registry", None)
|
||||
tools = getattr(registry, "_tools", None)
|
||||
if isinstance(tools, dict):
|
||||
tools.pop(_GROUP_TOOL_NAME, None)
|
||||
|
||||
def _set_group_request_context(
|
||||
self,
|
||||
bundle: RuntimeBundle,
|
||||
message: InboundMessage,
|
||||
session_key: str,
|
||||
) -> object:
|
||||
metadata = getattr(bundle.engine, "tool_metadata", {})
|
||||
previous = metadata.get("ohmo_group_request", _NO_GROUP_REQUEST)
|
||||
if not message.metadata.get("_ohmo_group_command"):
|
||||
metadata.pop("ohmo_group_request", None)
|
||||
metadata.pop("_suppress_next_user_goal", None)
|
||||
self._unregister_group_tool(bundle)
|
||||
return _NO_GROUP_REQUEST
|
||||
self._register_group_tool(bundle)
|
||||
metadata["_suppress_next_user_goal"] = True
|
||||
metadata["ohmo_group_request"] = {
|
||||
"channel": message.channel,
|
||||
"chat_type": str(message.metadata.get("chat_type") or "").strip().lower(),
|
||||
"sender_id": str(message.sender_id),
|
||||
"source_chat_id": str(message.chat_id),
|
||||
"source_session_key": session_key,
|
||||
"sender_display_name": message.metadata.get("sender_display_name"),
|
||||
"raw_request": message.metadata.get("_ohmo_group_raw_request") or "",
|
||||
"used": False,
|
||||
}
|
||||
return previous
|
||||
|
||||
@staticmethod
|
||||
def _restore_group_request_context(bundle: RuntimeBundle, previous: object) -> None:
|
||||
metadata = getattr(bundle.engine, "tool_metadata", {})
|
||||
del previous
|
||||
metadata.pop("ohmo_group_request", None)
|
||||
metadata.pop("_suppress_next_user_goal", None)
|
||||
OhmoSessionRuntimePool._unregister_group_tool(bundle)
|
||||
|
||||
|
||||
def _content_snippet(text: str, *, limit: int = 160) -> str:
|
||||
"""Return a compact single-line preview for logs."""
|
||||
@@ -499,6 +794,148 @@ def _content_snippet(text: str, *, limit: int = 160) -> str:
|
||||
return normalized[: limit - 3] + "..."
|
||||
|
||||
|
||||
def _sanitize_snapshot_messages(raw_messages: object) -> list[dict[str, object]] | None:
|
||||
"""Validate and sanitize restored messages from persisted ohmo snapshots."""
|
||||
if not raw_messages or not isinstance(raw_messages, list):
|
||||
return None
|
||||
messages: list[ConversationMessage] = []
|
||||
for raw in raw_messages:
|
||||
try:
|
||||
messages.append(ConversationMessage.model_validate(raw))
|
||||
except Exception:
|
||||
logger.warning("ohmo runtime skipped invalid restored message while sanitizing snapshot")
|
||||
return [message.model_dump(mode="json") for message in _sanitize_group_command_prompts(messages)]
|
||||
|
||||
|
||||
def _extract_tool_media(event: ToolExecutionCompleted) -> list[str]:
|
||||
"""Return local media paths produced by a tool completion event."""
|
||||
if event.is_error or not isinstance(event.metadata, dict):
|
||||
return []
|
||||
raw_paths = event.metadata.get("paths") or event.metadata.get("media")
|
||||
if isinstance(raw_paths, str):
|
||||
candidates = [raw_paths]
|
||||
elif isinstance(raw_paths, list):
|
||||
candidates = [str(item) for item in raw_paths if isinstance(item, str) and item.strip()]
|
||||
else:
|
||||
candidates = []
|
||||
media: list[str] = []
|
||||
seen: set[str] = set()
|
||||
for raw in candidates:
|
||||
path = Path(raw).expanduser()
|
||||
if not path.is_absolute():
|
||||
path = path.resolve()
|
||||
if not path.is_file():
|
||||
continue
|
||||
resolved = str(path)
|
||||
if resolved not in seen:
|
||||
seen.add(resolved)
|
||||
media.append(resolved)
|
||||
return media
|
||||
|
||||
|
||||
def _remember_update_media(seen: set[str], update: GatewayStreamUpdate) -> None:
|
||||
"""Track media already emitted during this gateway turn."""
|
||||
raw_media = update.media or (update.metadata or {}).get("_media") or []
|
||||
if isinstance(raw_media, str):
|
||||
candidates = [raw_media]
|
||||
elif isinstance(raw_media, list):
|
||||
candidates = [str(item) for item in raw_media if isinstance(item, str) and item.strip()]
|
||||
else:
|
||||
candidates = []
|
||||
for raw in candidates:
|
||||
try:
|
||||
path = Path(raw).expanduser()
|
||||
if not path.is_absolute():
|
||||
path = path.resolve()
|
||||
seen.add(str(path))
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
|
||||
def _extract_final_reply_media(reply: str, emitted_media: set[str]) -> list[str]:
|
||||
"""Return local image paths mentioned in final text that were not already emitted."""
|
||||
media: list[str] = []
|
||||
seen = set(emitted_media)
|
||||
for match in _FINAL_REPLY_IMAGE_PATH_RE.finditer(reply or ""):
|
||||
raw = match.group("path").strip(" \t\r\n\"'.,;:,。;:、)]}")
|
||||
if not raw:
|
||||
continue
|
||||
path = Path(raw).expanduser()
|
||||
if not path.is_absolute():
|
||||
continue
|
||||
if not path.is_file():
|
||||
continue
|
||||
resolved = str(path)
|
||||
if resolved in seen:
|
||||
continue
|
||||
seen.add(resolved)
|
||||
media.append(resolved)
|
||||
return media
|
||||
|
||||
|
||||
def _format_tool_media_caption(event: ToolExecutionCompleted, media: list[str]) -> str:
|
||||
"""Return a short caption for media generated by tools."""
|
||||
if event.tool_name == "image_generation":
|
||||
provider = ""
|
||||
if isinstance(event.metadata, dict):
|
||||
provider = str(event.metadata.get("provider") or "").strip()
|
||||
suffix = f" via {provider}" if provider else ""
|
||||
names = ", ".join(Path(path).name for path in media)
|
||||
return f"已生成图片{suffix}:{names}"
|
||||
names = ", ".join(Path(path).name for path in media)
|
||||
return f"已生成文件:{names}"
|
||||
|
||||
|
||||
def _sanitize_group_command_prompts(messages: list[ConversationMessage]) -> list[ConversationMessage]:
|
||||
"""Replace internal /group tool-driving prompts with durable user-facing history."""
|
||||
return [_sanitize_group_command_prompt(message) for message in messages]
|
||||
|
||||
|
||||
def _sanitize_group_command_prompt(message: ConversationMessage) -> ConversationMessage:
|
||||
changed = False
|
||||
content: list[TextBlock | ImageBlock] = []
|
||||
for block in message.content:
|
||||
if isinstance(block, TextBlock) and _GROUP_AGENT_PROMPT_PREFIX in block.text:
|
||||
content.append(TextBlock(text=_format_group_command_history_note(block.text)))
|
||||
changed = True
|
||||
else:
|
||||
content.append(block)
|
||||
if not changed:
|
||||
return message
|
||||
return message.model_copy(update={"content": content})
|
||||
|
||||
|
||||
def _format_group_command_history_note(prompt: str) -> str:
|
||||
raw_request = prompt
|
||||
if _GROUP_AGENT_PROMPT_REQUEST_MARKER in prompt:
|
||||
raw_request = prompt.split(_GROUP_AGENT_PROMPT_REQUEST_MARKER, 1)[1].strip()
|
||||
raw_request = raw_request.strip() or "(empty request)"
|
||||
return f"[Handled /group request]\nThe user asked ohmo to create a Feishu group:\n{raw_request}"
|
||||
|
||||
|
||||
def _sanitize_group_command_metadata(raw_metadata: object) -> object:
|
||||
"""Remove internal /group tool-driving text from compact carry-over metadata."""
|
||||
if not isinstance(raw_metadata, dict):
|
||||
return raw_metadata
|
||||
sanitized = dict(raw_metadata)
|
||||
for key in _GROUP_METADATA_KEYS:
|
||||
if key in sanitized:
|
||||
sanitized[key] = _sanitize_group_command_metadata_value(sanitized[key])
|
||||
return sanitized
|
||||
|
||||
|
||||
def _sanitize_group_command_metadata_value(value: object) -> object:
|
||||
if isinstance(value, str):
|
||||
if _GROUP_AGENT_PROMPT_PREFIX in value:
|
||||
return _format_group_command_history_note(value)
|
||||
return value
|
||||
if isinstance(value, dict):
|
||||
return {key: _sanitize_group_command_metadata_value(item) for key, item in value.items()}
|
||||
if isinstance(value, (list, tuple, set)):
|
||||
return [_sanitize_group_command_metadata_value(item) for item in value]
|
||||
return value
|
||||
|
||||
|
||||
def _summarize_tool_input(tool_name: str, tool_input: dict[str, object]) -> str:
|
||||
if not tool_input:
|
||||
return ""
|
||||
@@ -550,6 +987,10 @@ def _format_channel_progress(
|
||||
return "🛠️ " + text.replace("Using ", "正在使用 ", 1)
|
||||
return f"🛠️ {text}"
|
||||
return text if text.startswith("🛠️ ") else f"🛠️ {text}"
|
||||
if kind == "image_fallback":
|
||||
if prefers_chinese:
|
||||
return "🖼️ 当前模型不支持图片输入,我先改用附件路径和摘要继续。"
|
||||
return "🖼️ The active model does not support image input. I’ll retry with attachment paths and summaries."
|
||||
if kind == "status":
|
||||
normalized = text.strip()
|
||||
if normalized == "Auto-compacting conversation memory to keep things fast and focused.":
|
||||
@@ -627,6 +1068,60 @@ def _build_inbound_user_message(message: InboundMessage) -> ConversationMessage:
|
||||
return ConversationMessage.from_user_content(content)
|
||||
|
||||
|
||||
def _should_retry_without_image_input(error_message: str, messages: list[ConversationMessage]) -> bool:
|
||||
"""Return True when a provider rejects image input and history contains images."""
|
||||
if not _history_has_image_blocks(messages):
|
||||
return False
|
||||
normalized = error_message.lower()
|
||||
image_signal = any(
|
||||
phrase in normalized
|
||||
for phrase in (
|
||||
"image input",
|
||||
"image_url",
|
||||
"multimodal",
|
||||
"vision",
|
||||
"image content",
|
||||
)
|
||||
)
|
||||
rejection_signal = any(
|
||||
phrase in normalized
|
||||
for phrase in (
|
||||
"no endpoints found",
|
||||
"not support",
|
||||
"does not support",
|
||||
"unsupported",
|
||||
"cannot support",
|
||||
"can't support",
|
||||
)
|
||||
)
|
||||
return image_signal and rejection_signal
|
||||
|
||||
|
||||
def _history_has_image_blocks(messages: list[ConversationMessage]) -> bool:
|
||||
return any(any(isinstance(block, ImageBlock) for block in message.content) for message in messages)
|
||||
|
||||
|
||||
def _strip_image_blocks_from_engine_history(engine) -> None:
|
||||
messages = _strip_image_blocks_from_messages(list(engine.messages))
|
||||
if hasattr(engine, "load_messages"):
|
||||
engine.load_messages(messages)
|
||||
else:
|
||||
engine.messages = messages
|
||||
|
||||
|
||||
def _strip_image_blocks_from_messages(messages: list[ConversationMessage]) -> list[ConversationMessage]:
|
||||
return [_strip_image_blocks_from_message(message) for message in messages]
|
||||
|
||||
|
||||
def _strip_image_blocks_from_message(message: ConversationMessage) -> ConversationMessage:
|
||||
if not any(isinstance(block, ImageBlock) for block in message.content):
|
||||
return message
|
||||
content = [block for block in message.content if not isinstance(block, ImageBlock)]
|
||||
if not any(isinstance(block, TextBlock) for block in content):
|
||||
content.append(TextBlock(text=_IMAGE_FALLBACK_NOTE))
|
||||
return message.model_copy(update={"content": content})
|
||||
|
||||
|
||||
def _build_speaker_context(message: InboundMessage) -> str:
|
||||
"""Return a lightweight speaker header for group-chat messages."""
|
||||
metadata = message.metadata or {}
|
||||
|
||||
+168
-44
@@ -13,6 +13,9 @@ 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
|
||||
@@ -43,11 +46,19 @@ class OhmoGatewayService:
|
||||
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
|
||||
@@ -55,8 +66,11 @@ class OhmoGatewayService:
|
||||
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")
|
||||
),
|
||||
)
|
||||
self._manager = ChannelManager(build_channel_manager_config(self._config), self._bus)
|
||||
|
||||
@property
|
||||
def pid_file(self) -> Path:
|
||||
@@ -70,6 +84,13 @@ class OhmoGatewayService:
|
||||
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,
|
||||
@@ -77,7 +98,7 @@ class OhmoGatewayService:
|
||||
active_sessions=self._runtime_pool.active_sessions,
|
||||
provider_profile=self._config.provider_profile,
|
||||
enabled_channels=self._config.enabled_channels,
|
||||
last_error=last_error,
|
||||
last_error=last_error or self._channel_last_error(),
|
||||
)
|
||||
self.state_file.write_text(state.model_dump_json(indent=2) + "\n", encoding="utf-8")
|
||||
|
||||
@@ -100,6 +121,34 @@ class OhmoGatewayService:
|
||||
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 = [
|
||||
@@ -167,8 +216,18 @@ class OhmoGatewayService:
|
||||
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()
|
||||
@@ -177,6 +236,10 @@ class OhmoGatewayService:
|
||||
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):
|
||||
@@ -200,7 +263,22 @@ def start_gateway_process(cwd: str | Path | None = None, workspace: str | Path |
|
||||
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,
|
||||
@@ -212,56 +290,93 @@ def start_gateway_process(cwd: str | Path | None = None, workspace: str | Path |
|
||||
service._cwd,
|
||||
"--workspace",
|
||||
str(get_workspace_root(workspace)),
|
||||
"--no-console-log",
|
||||
],
|
||||
cwd=service._cwd,
|
||||
stdout=log_file,
|
||||
stderr=log_file,
|
||||
start_new_session=True,
|
||||
env=env,
|
||||
**popen_kwargs,
|
||||
)
|
||||
return process.pid
|
||||
|
||||
|
||||
def _pid_is_running(pid: int) -> bool:
|
||||
try:
|
||||
os.kill(pid, 0)
|
||||
except OSError:
|
||||
return False
|
||||
return True
|
||||
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))
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["ps", "-eo", "pid=,args="],
|
||||
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:
|
||||
continue
|
||||
if sys.platform == "win32":
|
||||
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
|
||||
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:
|
||||
@@ -281,9 +396,18 @@ def stop_gateway_process(cwd: str | Path | None = None, workspace: str | Path |
|
||||
if not unique_pids:
|
||||
service.pid_file.unlink(missing_ok=True)
|
||||
return False
|
||||
for pid in unique_pids:
|
||||
with contextlib.suppress(ProcessLookupError):
|
||||
os.kill(pid, signal.SIGTERM)
|
||||
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
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
"""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())
|
||||
+166
-16
@@ -5,13 +5,37 @@ 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(path for path in memory_dir.glob("*.md") if path.name != "MEMORY.md")
|
||||
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:
|
||||
@@ -19,30 +43,113 @@ def add_memory_entry(workspace: str | Path | None, title: str, content: str) ->
|
||||
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"
|
||||
path = memory_dir / f"{slug}.md"
|
||||
path.write_text(content.strip() + "\n", encoding="utf-8")
|
||||
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_path.read_text(encoding="utf-8") if index_path.exists() else "# Memory Index\n"
|
||||
if path.name not in existing:
|
||||
existing = existing.rstrip() + f"\n- [{title}]({path.name})\n"
|
||||
index_path.write_text(existing, encoding="utf-8")
|
||||
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:
|
||||
"""Delete a memory file and remove its index entry."""
|
||||
"""Soft-delete a memory file and remove its index entry."""
|
||||
memory_dir = get_memory_dir(workspace)
|
||||
matches = [path for path in memory_dir.glob("*.md") if path.stem == name or path.name == name]
|
||||
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
|
||||
path = matches[0]
|
||||
path.unlink(missing_ok=True)
|
||||
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]
|
||||
index_path.write_text("\n".join(lines).rstrip() + "\n", encoding="utf-8")
|
||||
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
|
||||
|
||||
|
||||
@@ -67,3 +174,46 @@ def load_memory_prompt(workspace: str | Path | None = None, *, max_files: int =
|
||||
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)
|
||||
|
||||
+23
-2
@@ -14,9 +14,10 @@ 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_plugins_dir, get_skills_dir, initialize_workspace
|
||||
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, ...]]:
|
||||
@@ -54,6 +55,14 @@ async def run_ohmo_backend(
|
||||
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",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@@ -160,13 +169,24 @@ async def run_ohmo_print_mode(
|
||||
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()
|
||||
@@ -174,6 +194,7 @@ async def run_ohmo_print_mode(
|
||||
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:
|
||||
@@ -192,6 +213,6 @@ async def run_ohmo_print_mode(
|
||||
clear_output=_clear_output,
|
||||
)
|
||||
await close_runtime(bundle)
|
||||
return 0
|
||||
return 1 if saw_error else 0
|
||||
finally:
|
||||
os.chdir(previous_cwd)
|
||||
|
||||
+14
-9
@@ -10,9 +10,13 @@ from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
from openharness.api.usage import UsageSnapshot
|
||||
from openharness.engine.messages import ConversationMessage
|
||||
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
|
||||
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
|
||||
|
||||
@@ -50,6 +54,7 @@ def save_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():
|
||||
@@ -72,11 +77,11 @@ def save_session_snapshot(
|
||||
}
|
||||
data = json.dumps(payload, indent=2) + "\n"
|
||||
latest_path = session_dir / "latest.json"
|
||||
latest_path.write_text(data, encoding="utf-8")
|
||||
atomic_write_text(latest_path, data)
|
||||
if session_key:
|
||||
_session_key_latest_path(workspace, session_key).write_text(data, encoding="utf-8")
|
||||
atomic_write_text(_session_key_latest_path(workspace, session_key), data)
|
||||
session_path = session_dir / f"session-{sid}.json"
|
||||
session_path.write_text(data, encoding="utf-8")
|
||||
atomic_write_text(session_path, data)
|
||||
return latest_path
|
||||
|
||||
|
||||
@@ -84,13 +89,13 @@ 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 json.loads(path.read_text(encoding="utf-8"))
|
||||
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 json.loads(path.read_text(encoding="utf-8"))
|
||||
return _sanitize_snapshot_payload(json.loads(path.read_text(encoding="utf-8")))
|
||||
return None
|
||||
|
||||
|
||||
@@ -119,7 +124,7 @@ def list_snapshots(workspace: str | Path | None = None, limit: int = 20) -> list
|
||||
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 json.loads(path.read_text(encoding="utf-8"))
|
||||
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
|
||||
@@ -139,7 +144,7 @@ def export_session_markdown(
|
||||
text = message.text.strip()
|
||||
if text:
|
||||
parts.append(text)
|
||||
path.write_text("\n".join(parts).strip() + "\n", encoding="utf-8")
|
||||
atomic_write_text(path, "\n".join(parts).strip() + "\n")
|
||||
return path
|
||||
|
||||
|
||||
|
||||
@@ -203,6 +203,10 @@ 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"
|
||||
|
||||
@@ -238,6 +242,7 @@ def ensure_workspace(workspace: str | Path | None = None) -> Path:
|
||||
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)
|
||||
@@ -283,6 +288,8 @@ def initialize_workspace(workspace: str | Path | None = None) -> Path:
|
||||
"send_tool_hints": True,
|
||||
"permission_mode": "default",
|
||||
"sandbox_enabled": False,
|
||||
"allow_remote_admin_commands": False,
|
||||
"allowed_remote_admin_commands": [],
|
||||
"log_level": "INFO",
|
||||
"channel_configs": {},
|
||||
},
|
||||
@@ -305,6 +312,7 @@ def workspace_health(workspace: str | Path | None = None) -> dict[str, bool]:
|
||||
"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(),
|
||||
|
||||
+2
-1
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "openharness-ai"
|
||||
version = "0.1.6"
|
||||
version = "0.1.9"
|
||||
description = "Open-source Python port of Claude Code - an AI-powered CLI coding assistant"
|
||||
readme = "README.md"
|
||||
license = "MIT"
|
||||
@@ -48,6 +48,7 @@ dev = [
|
||||
[project.scripts]
|
||||
openharness = "openharness.cli:app"
|
||||
oh = "openharness.cli:app"
|
||||
openh = "openharness.cli:app"
|
||||
ohmo = "ohmo.cli:app"
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
|
||||
@@ -0,0 +1,329 @@
|
||||
# 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 ""
|
||||
+32
-16
@@ -186,6 +186,7 @@ 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
|
||||
@@ -277,40 +278,51 @@ mkdir -p "$HOME/.openharness/plugins"
|
||||
success "Config directory ready: ~/.openharness/"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Step 8: Verify installation
|
||||
# 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 command -v oh &>/dev/null && command -v ohmo &>/dev/null; then
|
||||
OH_VERSION=$(oh --version 2>&1 || echo "(version check failed)")
|
||||
OHMO_VERSION=$(ohmo --help >/dev/null 2>&1 && echo "available" || echo "not available")
|
||||
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' not in PATH. Run via: python -m openharness or python -m ohmo"
|
||||
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 ${VENV_DIR}/bin is in PATH:"
|
||||
echo " export PATH=\"${VENV_DIR}/bin:\$PATH\""
|
||||
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 ${VENV_DIR}/bin to PATH and restart your shell."
|
||||
echo " Or add ${BIN_DIR} to PATH and restart your shell."
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Step 9: Add venv activation to shell profile
|
||||
# Step 10: Add command directory to shell profile
|
||||
# ---------------------------------------------------------------------------
|
||||
step "Setting up shell integration"
|
||||
|
||||
ACTIVATION_LINE="export PATH=\"$VENV_DIR/bin:\$PATH\""
|
||||
ACTIVATION_LINE="export PATH=\"$BIN_DIR:\$PATH\""
|
||||
FISH_CONFIG="$HOME/.config/fish/config.fish"
|
||||
FISH_BLOCK=$(cat <<EOF
|
||||
# OpenHarness
|
||||
if not contains -- "$VENV_DIR/bin" \$PATH
|
||||
set -gx PATH "$VENV_DIR/bin" \$PATH
|
||||
if not contains -- "$BIN_DIR" \$PATH
|
||||
set -gx PATH "$BIN_DIR" \$PATH
|
||||
end
|
||||
EOF
|
||||
)
|
||||
@@ -322,7 +334,7 @@ append_shell_path() {
|
||||
if [ ! -f "$rc_file" ]; then
|
||||
return
|
||||
fi
|
||||
if grep -q "$VENV_DIR/bin" "$rc_file" 2>/dev/null; then
|
||||
if grep -q "$BIN_DIR" "$rc_file" 2>/dev/null; then
|
||||
info "PATH already configured in $(basename "$rc_file")"
|
||||
configured_any=true
|
||||
return
|
||||
@@ -330,7 +342,7 @@ append_shell_path() {
|
||||
echo "" >> "$rc_file"
|
||||
echo "# OpenHarness" >> "$rc_file"
|
||||
echo "$ACTIVATION_LINE" >> "$rc_file"
|
||||
success "Added $VENV_DIR/bin to PATH in $(basename "$rc_file")"
|
||||
success "Added $BIN_DIR to PATH in $(basename "$rc_file")"
|
||||
configured_any=true
|
||||
}
|
||||
|
||||
@@ -339,13 +351,13 @@ append_shell_path "$HOME/.bashrc"
|
||||
append_shell_path "$HOME/.bash_profile"
|
||||
|
||||
mkdir -p "$(dirname "$FISH_CONFIG")"
|
||||
if [ -f "$FISH_CONFIG" ] && grep -q "$VENV_DIR/bin" "$FISH_CONFIG" 2>/dev/null; then
|
||||
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 $VENV_DIR/bin to PATH in $(basename "$FISH_CONFIG")"
|
||||
success "Added $BIN_DIR to PATH in $(basename "$FISH_CONFIG")"
|
||||
configured_any=true
|
||||
fi
|
||||
|
||||
@@ -369,3 +381,7 @@ 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 ""
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
"""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())
|
||||
@@ -0,0 +1,654 @@
|
||||
#!/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())
|
||||
@@ -45,6 +45,7 @@ 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)
|
||||
@@ -149,6 +150,10 @@ class AnthropicApiClient:
|
||||
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
|
||||
|
||||
@@ -78,6 +78,17 @@ def _convert_messages_to_codex(messages: list[ConversationMessage]) -> list[dict
|
||||
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():
|
||||
@@ -89,13 +100,6 @@ def _convert_messages_to_codex(messages: list[ConversationMessage]) -> list[dict
|
||||
})
|
||||
if user_content:
|
||||
result.append({"role": "user", "content": user_content})
|
||||
for block in msg.content:
|
||||
if isinstance(block, ToolResultBlock):
|
||||
result.append({
|
||||
"type": "function_call_output",
|
||||
"call_id": block.tool_use_id,
|
||||
"output": block.content,
|
||||
})
|
||||
continue
|
||||
|
||||
assistant_text = "".join(block.text for block in msg.content if isinstance(block, TextBlock))
|
||||
@@ -129,6 +133,15 @@ def _convert_tools_to_codex(tools: list[dict[str, Any]]) -> list[dict[str, Any]]
|
||||
]
|
||||
|
||||
|
||||
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):
|
||||
@@ -251,6 +264,9 @@ class CodexApiClient:
|
||||
}
|
||||
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] = []
|
||||
|
||||
@@ -26,6 +26,7 @@ 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__)
|
||||
|
||||
@@ -96,19 +97,14 @@ def _auth_file_path() -> Path:
|
||||
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()
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
payload: dict[str, Any] = {"github_token": token}
|
||||
if enterprise_url:
|
||||
payload["enterprise_url"] = enterprise_url
|
||||
path.write_text(
|
||||
atomic_write_text(
|
||||
path,
|
||||
json.dumps(payload, indent=2) + "\n",
|
||||
encoding="utf-8",
|
||||
mode=0o600,
|
||||
)
|
||||
# Best-effort permission restriction (ignored on Windows).
|
||||
try:
|
||||
path.chmod(0o600)
|
||||
except OSError:
|
||||
pass
|
||||
log.info("Copilot auth saved to %s", path)
|
||||
|
||||
|
||||
|
||||
@@ -128,3 +128,7 @@ class CopilotClient:
|
||||
)
|
||||
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()
|
||||
|
||||
@@ -5,7 +5,10 @@ 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
|
||||
|
||||
@@ -143,13 +146,43 @@ def _convert_user_content_to_openai(blocks: list[ContentBlock]) -> str | list[di
|
||||
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.
|
||||
|
||||
Providers with thinking models (e.g. Kimi k2.5) require a
|
||||
``reasoning_content`` field on every assistant message that contains
|
||||
tool calls. We stash the raw reasoning text on ``msg._reasoning``
|
||||
during parsing and replay it here.
|
||||
``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)]
|
||||
@@ -163,8 +196,9 @@ def _convert_assistant_message(msg: ConversationMessage) -> dict[str, Any]:
|
||||
reasoning = getattr(msg, "_reasoning", None)
|
||||
if reasoning:
|
||||
openai_msg["reasoning_content"] = reasoning
|
||||
elif tool_uses:
|
||||
# Thinking models require this field even if empty
|
||||
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:
|
||||
@@ -207,6 +241,22 @@ def _parse_assistant_response(response: Any) -> ConversationMessage:
|
||||
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.).
|
||||
|
||||
@@ -214,12 +264,22 @@ class OpenAICompatibleClient:
|
||||
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) -> None:
|
||||
kwargs: dict[str, Any] = {"api_key": api_key}
|
||||
if base_url:
|
||||
kwargs["base_url"] = base_url
|
||||
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
|
||||
@@ -278,6 +338,8 @@ class OpenAICompatibleClient:
|
||||
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:
|
||||
@@ -301,10 +363,13 @@ class OpenAICompatibleClient:
|
||||
if reasoning_piece:
|
||||
collected_reasoning += reasoning_piece
|
||||
|
||||
# Stream text content to user
|
||||
# Stream text content to user, stripping inline <think> blocks
|
||||
if delta.content:
|
||||
collected_content += delta.content
|
||||
yield ApiTextDeltaEvent(text=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:
|
||||
@@ -386,3 +451,34 @@ class OpenAICompatibleClient:
|
||||
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, ""
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
|
||||
from openharness.auth.external import describe_external_binding
|
||||
@@ -123,3 +124,63 @@ def auth_status(settings: Settings) -> str:
|
||||
if resolved.source.startswith("external:"):
|
||||
return f"configured ({resolved.source.removeprefix('external:')})"
|
||||
return "configured"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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)
|
||||
|
||||
@@ -124,6 +124,20 @@ PROVIDERS: tuple[ProviderSpec, ...] = (
|
||||
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(
|
||||
|
||||
@@ -6,10 +6,10 @@ from openharness.auth.storage import (
|
||||
clear_provider_credentials,
|
||||
decrypt,
|
||||
encrypt,
|
||||
load_external_binding,
|
||||
load_credential,
|
||||
store_external_binding,
|
||||
load_external_binding,
|
||||
store_credential,
|
||||
store_external_binding,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
@@ -22,6 +22,8 @@ __all__ = [
|
||||
"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",
|
||||
]
|
||||
|
||||
@@ -5,6 +5,8 @@ from __future__ import annotations
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import platform
|
||||
import re
|
||||
import subprocess
|
||||
import time
|
||||
import urllib.error
|
||||
@@ -15,6 +17,7 @@ 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"
|
||||
@@ -39,6 +42,8 @@ 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
|
||||
@@ -80,6 +85,23 @@ def default_binding_for_provider(provider: str) -> ExternalAuthBinding:
|
||||
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,
|
||||
@@ -97,23 +119,24 @@ def load_external_credential(
|
||||
refresh_if_needed: bool = False,
|
||||
) -> ExternalAuthCredential:
|
||||
"""Read a runtime credential from an external auth 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
|
||||
|
||||
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}")
|
||||
|
||||
@@ -154,6 +177,8 @@ def _load_claude_credential(
|
||||
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):
|
||||
@@ -172,7 +197,7 @@ def _load_claude_credential(
|
||||
auth_kind="auth_token",
|
||||
source_path=source_path,
|
||||
managed_by=binding.managed_by,
|
||||
profile_label=binding.profile_label,
|
||||
profile_label=keychain_account or binding.profile_label,
|
||||
refresh_token=refresh_token,
|
||||
expires_at_ms=expires_at_ms,
|
||||
)
|
||||
@@ -182,29 +207,95 @@ def _load_claude_credential(
|
||||
f"Claude credentials at {source_path} are expired and cannot be refreshed."
|
||||
)
|
||||
refreshed = refresh_claude_oauth_credential(refresh_token)
|
||||
write_claude_credentials(
|
||||
source_path,
|
||||
access_token=refreshed["access_token"],
|
||||
refresh_token=refreshed["refresh_token"],
|
||||
expires_at_ms=refreshed["expires_at_ms"],
|
||||
)
|
||||
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=binding.profile_label,
|
||||
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 not source_path.exists():
|
||||
if binding.source_kind != "claude_credentials_keychain" and not source_path.exists():
|
||||
return ExternalAuthState(
|
||||
configured=False,
|
||||
state="missing",
|
||||
@@ -214,31 +305,40 @@ def describe_external_binding(binding: ExternalAuthBinding) -> ExternalAuthState
|
||||
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=str(exc),
|
||||
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 {source_path}",
|
||||
detail=f"expired token can be refreshed from {resolved_source}",
|
||||
)
|
||||
return ExternalAuthState(
|
||||
configured=False,
|
||||
state="expired",
|
||||
source="external",
|
||||
detail=f"expired token at {source_path}",
|
||||
detail=f"expired token at {resolved_source}",
|
||||
)
|
||||
return ExternalAuthState(
|
||||
configured=True,
|
||||
state="configured",
|
||||
source="external",
|
||||
detail=str(source_path),
|
||||
detail=str(resolved_source),
|
||||
)
|
||||
|
||||
|
||||
@@ -389,7 +489,60 @@ def write_claude_credentials(
|
||||
existing = json.loads(source_path.read_text(encoding="utf-8"))
|
||||
except (json.JSONDecodeError, OSError):
|
||||
existing = {}
|
||||
previous = existing.get("claudeAiOauth")
|
||||
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,
|
||||
@@ -399,13 +552,7 @@ def write_claude_credentials(
|
||||
for key in ("scopes", "rateLimitTier", "subscriptionType"):
|
||||
if key in previous:
|
||||
next_oauth[key] = previous[key]
|
||||
existing["claudeAiOauth"] = next_oauth
|
||||
source_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
source_path.write_text(json.dumps(existing, indent=2) + "\n", encoding="utf-8")
|
||||
try:
|
||||
source_path.chmod(0o600)
|
||||
except OSError:
|
||||
pass
|
||||
return next_oauth
|
||||
|
||||
|
||||
def is_third_party_anthropic_endpoint(base_url: str | None) -> bool:
|
||||
|
||||
@@ -7,11 +7,13 @@ 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__)
|
||||
|
||||
@@ -76,18 +78,26 @@ class DeviceCodeFlow(AuthFlow):
|
||||
@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":
|
||||
subprocess.Popen(
|
||||
["start", "", url],
|
||||
shell=True,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
)
|
||||
# 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(
|
||||
@@ -102,6 +112,7 @@ class DeviceCodeFlow(AuthFlow):
|
||||
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
|
||||
|
||||
@@ -35,6 +35,9 @@ _KNOWN_PROVIDERS = [
|
||||
"bedrock",
|
||||
"vertex",
|
||||
"moonshot",
|
||||
"gemini",
|
||||
"minimax",
|
||||
"modelscope",
|
||||
]
|
||||
|
||||
_AUTH_SOURCES = [
|
||||
@@ -47,6 +50,9 @@ _AUTH_SOURCES = [
|
||||
"bedrock_api_key",
|
||||
"vertex_api_key",
|
||||
"moonshot_api_key",
|
||||
"gemini_api_key",
|
||||
"minimax_api_key",
|
||||
"modelscope_api_key",
|
||||
]
|
||||
|
||||
_PROFILE_BY_PROVIDER = {
|
||||
@@ -56,6 +62,9 @@ _PROFILE_BY_PROVIDER = {
|
||||
"openai_codex": "codex",
|
||||
"copilot": "copilot",
|
||||
"moonshot": "moonshot",
|
||||
"gemini": "gemini",
|
||||
"minimax": "minimax",
|
||||
"modelscope": "modelscope",
|
||||
}
|
||||
|
||||
|
||||
@@ -151,6 +160,15 @@ class AuthManager:
|
||||
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"
|
||||
@@ -239,6 +257,22 @@ class AuthManager:
|
||||
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")
|
||||
@@ -320,6 +354,8 @@ class AuthManager:
|
||||
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()
|
||||
@@ -338,6 +374,16 @@ class AuthManager:
|
||||
"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})
|
||||
|
||||
@@ -1,7 +1,16 @@
|
||||
"""Secure credential storage for OpenHarness.
|
||||
"""Credential storage for OpenHarness.
|
||||
|
||||
Default backend: ~/.openharness/credentials.json with mode 600.
|
||||
Optional backend: system keyring (if the `keyring` package is installed).
|
||||
Optional backend: system keyring (if the `keyring` package is installed
|
||||
and a usable backend is present).
|
||||
|
||||
Security model
|
||||
--------------
|
||||
When no keyring backend is available (common in containers, CI, and WSL),
|
||||
credentials are stored as **plain-text JSON** protected only by POSIX file
|
||||
permissions (mode 600). The ``_obfuscate`` / ``_deobfuscate`` helpers in
|
||||
this module are a lightweight XOR round-trip used elsewhere for non-secret
|
||||
data; they are **not** encryption and must not be used to protect secrets.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -13,6 +22,8 @@ from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from openharness.config.paths import get_config_dir
|
||||
from openharness.utils.file_lock import exclusive_file_lock
|
||||
from openharness.utils.fs import atomic_write_text
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
@@ -20,6 +31,10 @@ _CREDS_FILE_NAME = "credentials.json"
|
||||
_KEYRING_SERVICE = "openharness"
|
||||
|
||||
|
||||
def _creds_lock_path() -> Path:
|
||||
return _creds_path().with_suffix(".json.lock")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ExternalAuthBinding:
|
||||
"""Pointer to credentials managed by an external CLI."""
|
||||
@@ -53,12 +68,11 @@ def _load_creds_file() -> dict[str, Any]:
|
||||
|
||||
def _save_creds_file(data: dict[str, Any]) -> None:
|
||||
path = _creds_path()
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
|
||||
try:
|
||||
path.chmod(0o600)
|
||||
except OSError:
|
||||
pass
|
||||
atomic_write_text(
|
||||
path,
|
||||
json.dumps(data, indent=2) + "\n",
|
||||
mode=0o600,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -66,13 +80,34 @@ def _save_creds_file(data: dict[str, Any]) -> None:
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _keyring_available() -> bool:
|
||||
try:
|
||||
import keyring # noqa: F401
|
||||
_keyring_checked: bool = False
|
||||
_keyring_usable: bool = False
|
||||
|
||||
return True
|
||||
|
||||
def _keyring_available() -> bool:
|
||||
"""Return True when a usable system keyring backend is present.
|
||||
|
||||
The check is cached after the first call so the "Keyring load failed"
|
||||
warning is emitted at most once per process.
|
||||
"""
|
||||
global _keyring_checked, _keyring_usable # noqa: PLW0603
|
||||
if _keyring_checked:
|
||||
return _keyring_usable
|
||||
_keyring_checked = True
|
||||
try:
|
||||
import keyring
|
||||
|
||||
# Probe the backend — merely importing keyring is not enough because
|
||||
# the package may be installed without a functioning backend (e.g. on
|
||||
# headless Linux / WSL / containers).
|
||||
keyring.get_password(_KEYRING_SERVICE, "__probe__")
|
||||
_keyring_usable = True
|
||||
except ImportError:
|
||||
return False
|
||||
_keyring_usable = False
|
||||
except Exception as exc:
|
||||
log.info("System keyring unavailable, using file backend: %s", exc)
|
||||
_keyring_usable = False
|
||||
return _keyring_usable
|
||||
|
||||
|
||||
def _keyring_key(provider: str, key: str) -> str:
|
||||
@@ -102,9 +137,10 @@ def store_credential(provider: str, key: str, value: str, *, use_keyring: bool |
|
||||
except Exception as exc:
|
||||
log.warning("Keyring store failed, falling back to file: %s", exc)
|
||||
|
||||
data = _load_creds_file()
|
||||
data.setdefault(provider, {})[key] = value
|
||||
_save_creds_file(data)
|
||||
with exclusive_file_lock(_creds_lock_path()):
|
||||
data = _load_creds_file()
|
||||
data.setdefault(provider, {})[key] = value
|
||||
_save_creds_file(data)
|
||||
log.debug("Stored %s/%s in credentials file", provider, key)
|
||||
|
||||
|
||||
@@ -146,10 +182,11 @@ def clear_provider_credentials(provider: str, *, use_keyring: bool | None = None
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
data = _load_creds_file()
|
||||
if provider in data:
|
||||
del data[provider]
|
||||
_save_creds_file(data)
|
||||
with exclusive_file_lock(_creds_lock_path()):
|
||||
data = _load_creds_file()
|
||||
if provider in data:
|
||||
del data[provider]
|
||||
_save_creds_file(data)
|
||||
log.debug("Cleared credentials for provider: %s", provider)
|
||||
|
||||
|
||||
@@ -160,10 +197,11 @@ def list_stored_providers() -> list[str]:
|
||||
|
||||
def store_external_binding(binding: ExternalAuthBinding) -> None:
|
||||
"""Persist metadata describing an external auth source for *provider*."""
|
||||
data = _load_creds_file()
|
||||
entry = data.setdefault(binding.provider, {})
|
||||
entry["external_binding"] = asdict(binding)
|
||||
_save_creds_file(data)
|
||||
with exclusive_file_lock(_creds_lock_path()):
|
||||
data = _load_creds_file()
|
||||
entry = data.setdefault(binding.provider, {})
|
||||
entry["external_binding"] = asdict(binding)
|
||||
_save_creds_file(data)
|
||||
log.debug("Stored external auth binding for provider: %s", binding.provider)
|
||||
|
||||
|
||||
@@ -189,21 +227,25 @@ def load_external_binding(provider: str) -> ExternalAuthBinding | None:
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Encrypt/decrypt helpers (lightweight XOR obfuscation, not true encryption)
|
||||
# Obfuscation helpers (XOR round-trip — NOT encryption)
|
||||
# ---------------------------------------------------------------------------
|
||||
# These exist for lightweight obfuscation of non-secret data (e.g. session
|
||||
# tokens where the goal is to prevent casual reading, not resist attack).
|
||||
# Do NOT use for API keys or passwords — those belong in the keyring or in
|
||||
# the plain-text file protected by POSIX permissions.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _obfuscation_key() -> bytes:
|
||||
"""Return a per-user obfuscation key derived from the home directory path."""
|
||||
seed = str(Path.home()).encode() + b"openharness-v1"
|
||||
# Simple repeating key stretched to 32 bytes via SHA-256 for determinism.
|
||||
import hashlib
|
||||
|
||||
return hashlib.sha256(seed).digest()
|
||||
|
||||
|
||||
def encrypt(plaintext: str) -> str:
|
||||
"""Lightly obfuscate *plaintext* (base64-encoded XOR). Not cryptographic."""
|
||||
def _obfuscate(plaintext: str) -> str:
|
||||
"""Lightly obfuscate *plaintext* (base64-encoded XOR). **Not cryptographic.**"""
|
||||
import base64
|
||||
|
||||
key = _obfuscation_key()
|
||||
@@ -212,11 +254,16 @@ def encrypt(plaintext: str) -> str:
|
||||
return base64.urlsafe_b64encode(xored).decode("ascii")
|
||||
|
||||
|
||||
def decrypt(ciphertext: str) -> str:
|
||||
"""Reverse of :func:`encrypt`."""
|
||||
def _deobfuscate(ciphertext: str) -> str:
|
||||
"""Reverse of :func:`_obfuscate`."""
|
||||
import base64
|
||||
|
||||
key = _obfuscation_key()
|
||||
data = base64.urlsafe_b64decode(ciphertext.encode("ascii"))
|
||||
xored = bytes(b ^ key[i % len(key)] for i, b in enumerate(data))
|
||||
return xored.decode("utf-8")
|
||||
|
||||
|
||||
# Backward compatibility — deprecated, will be removed in a future version.
|
||||
encrypt = _obfuscate
|
||||
decrypt = _deobfuscate
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
"""Repo autopilot exports."""
|
||||
|
||||
from openharness.autopilot.service import RepoAutopilotStore
|
||||
from openharness.autopilot.types import (
|
||||
RepoAutopilotRegistry,
|
||||
RepoJournalEntry,
|
||||
RepoRunResult,
|
||||
RepoTaskCard,
|
||||
RepoTaskSource,
|
||||
RepoTaskStatus,
|
||||
RepoVerificationStep,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"RepoAutopilotRegistry",
|
||||
"RepoAutopilotStore",
|
||||
"RepoJournalEntry",
|
||||
"RepoRunResult",
|
||||
"RepoTaskCard",
|
||||
"RepoTaskSource",
|
||||
"RepoTaskStatus",
|
||||
"RepoVerificationStep",
|
||||
]
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,91 @@
|
||||
"""Repo autopilot data models."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Literal
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
RepoTaskStatus = Literal[
|
||||
"queued",
|
||||
"accepted",
|
||||
"preparing",
|
||||
"running",
|
||||
"verifying",
|
||||
"pr_open",
|
||||
"waiting_ci",
|
||||
"repairing",
|
||||
"completed",
|
||||
"merged",
|
||||
"failed",
|
||||
"rejected",
|
||||
"superseded",
|
||||
]
|
||||
RepoTaskSource = Literal[
|
||||
"ohmo_request",
|
||||
"manual_idea",
|
||||
"github_issue",
|
||||
"github_pr",
|
||||
"claude_code_candidate",
|
||||
]
|
||||
|
||||
|
||||
class RepoTaskCard(BaseModel):
|
||||
"""One normalized repo-level work item."""
|
||||
|
||||
id: str
|
||||
fingerprint: str
|
||||
title: str
|
||||
body: str = ""
|
||||
source_kind: RepoTaskSource
|
||||
source_ref: str = ""
|
||||
status: RepoTaskStatus = "queued"
|
||||
score: int = 0
|
||||
score_reasons: list[str] = Field(default_factory=list)
|
||||
labels: list[str] = Field(default_factory=list)
|
||||
metadata: dict[str, Any] = Field(default_factory=dict)
|
||||
created_at: float
|
||||
updated_at: float
|
||||
|
||||
|
||||
class RepoJournalEntry(BaseModel):
|
||||
"""Append-only repo journal event."""
|
||||
|
||||
timestamp: float
|
||||
kind: str
|
||||
summary: str
|
||||
task_id: str | None = None
|
||||
metadata: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class RepoAutopilotRegistry(BaseModel):
|
||||
"""Full registry payload."""
|
||||
|
||||
version: int = 1
|
||||
updated_at: float = 0.0
|
||||
cards: list[RepoTaskCard] = Field(default_factory=list)
|
||||
|
||||
|
||||
class RepoVerificationStep(BaseModel):
|
||||
"""One verification command result."""
|
||||
|
||||
command: str
|
||||
returncode: int
|
||||
status: Literal["success", "failed", "skipped", "error"]
|
||||
stdout: str = ""
|
||||
stderr: str = ""
|
||||
|
||||
|
||||
class RepoRunResult(BaseModel):
|
||||
"""Result of one autopilot execution attempt."""
|
||||
|
||||
card_id: str
|
||||
status: RepoTaskStatus
|
||||
assistant_summary: str = ""
|
||||
run_report_path: str = ""
|
||||
verification_report_path: str = ""
|
||||
verification_steps: list[RepoVerificationStep] = Field(default_factory=list)
|
||||
attempt_count: int = 0
|
||||
worktree_path: str = ""
|
||||
pr_number: int | None = None
|
||||
pr_url: str = ""
|
||||
@@ -41,6 +41,6 @@ async def spawn_session(
|
||||
command,
|
||||
cwd=resolved_cwd,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.STDOUT,
|
||||
)
|
||||
return SessionHandle(session_id=session_id, process=process, cwd=resolved_cwd)
|
||||
|
||||
@@ -15,6 +15,7 @@ from openharness.channels.bus.queue import MessageBus
|
||||
from openharness.channels.impl.base import BaseChannel
|
||||
from openharness.channels.impl.base import resolve_channel_media_dir
|
||||
from openharness.config.schema import FeishuConfig
|
||||
from openharness.utils.helpers import safe_filename
|
||||
|
||||
import importlib.util
|
||||
import logging
|
||||
@@ -38,6 +39,174 @@ class _FeishuSenderInfo:
|
||||
display_name: str
|
||||
|
||||
|
||||
def _clean_mention_value(value: Any) -> str:
|
||||
if value is None:
|
||||
return ""
|
||||
return str(value).strip()
|
||||
|
||||
|
||||
def _normalize_mention_name(value: str) -> str:
|
||||
return value.strip().lstrip("@").strip().lower()
|
||||
|
||||
|
||||
def _configured_bot_names(config: FeishuConfig) -> set[str]:
|
||||
raw_names = getattr(config, "bot_names", []) or []
|
||||
if isinstance(raw_names, str):
|
||||
items = [item.strip() for item in raw_names.split(",")]
|
||||
else:
|
||||
items = [str(item).strip() for item in raw_names]
|
||||
names = {_normalize_mention_name(item) for item in items if item.strip()}
|
||||
return names or {"ohmo"}
|
||||
|
||||
|
||||
def _mention_value(raw: Any, key: str) -> Any:
|
||||
if isinstance(raw, dict):
|
||||
return raw.get(key)
|
||||
return getattr(raw, key, None)
|
||||
|
||||
|
||||
def _mention_id_value(raw_id: Any, key: str) -> Any:
|
||||
if isinstance(raw_id, dict):
|
||||
return raw_id.get(key)
|
||||
return getattr(raw_id, key, None)
|
||||
|
||||
|
||||
def _extract_feishu_mentions(
|
||||
content_json: dict,
|
||||
raw_mentions: list[Any] | tuple[Any, ...] | None = None,
|
||||
) -> list[dict[str, str]]:
|
||||
"""Return normalized mention metadata from Feishu text/post payloads."""
|
||||
mentions: list[dict[str, str]] = []
|
||||
seen: set[tuple[str, str, str, str, str]] = set()
|
||||
|
||||
def add(raw: Any) -> None:
|
||||
mention_id = _mention_value(raw, "id") or {}
|
||||
record = {
|
||||
"key": _clean_mention_value(_mention_value(raw, "key")),
|
||||
"name": _clean_mention_value(_mention_value(raw, "name") or _mention_value(raw, "user_name")),
|
||||
"open_id": _clean_mention_value(
|
||||
_mention_value(raw, "open_id") or _mention_id_value(mention_id, "open_id")
|
||||
),
|
||||
"user_id": _clean_mention_value(
|
||||
_mention_value(raw, "user_id") or _mention_id_value(mention_id, "user_id")
|
||||
),
|
||||
"union_id": _clean_mention_value(
|
||||
_mention_value(raw, "union_id") or _mention_id_value(mention_id, "union_id")
|
||||
),
|
||||
}
|
||||
key = (record["key"], record["name"], record["open_id"], record["user_id"], record["union_id"])
|
||||
if any(record.values()) and key not in seen:
|
||||
seen.add(key)
|
||||
mentions.append(record)
|
||||
|
||||
if raw_mentions:
|
||||
for item in raw_mentions:
|
||||
add(item)
|
||||
|
||||
raw_mentions = content_json.get("mentions")
|
||||
if isinstance(raw_mentions, list):
|
||||
for item in raw_mentions:
|
||||
if isinstance(item, dict):
|
||||
add(item)
|
||||
|
||||
def walk(value: Any) -> None:
|
||||
if isinstance(value, dict):
|
||||
if value.get("tag") == "at":
|
||||
add(value)
|
||||
for child in value.values():
|
||||
walk(child)
|
||||
elif isinstance(value, list):
|
||||
for child in value:
|
||||
walk(child)
|
||||
|
||||
walk(content_json)
|
||||
return mentions
|
||||
|
||||
|
||||
def _feishu_mentions_bot(
|
||||
content_json: dict,
|
||||
text: str,
|
||||
config: FeishuConfig,
|
||||
*,
|
||||
mentions: list[dict[str, str]] | None = None,
|
||||
) -> bool:
|
||||
"""Best-effort bot mention detection for Feishu group messages."""
|
||||
bot_open_id = str(getattr(config, "bot_open_id", "") or "").strip()
|
||||
bot_names = _configured_bot_names(config)
|
||||
for mention in mentions if mentions is not None else _extract_feishu_mentions(content_json):
|
||||
ids = {mention.get("open_id", ""), mention.get("user_id", ""), mention.get("union_id", "")}
|
||||
if bot_open_id and bot_open_id in ids:
|
||||
return True
|
||||
name = _normalize_mention_name(mention.get("name", ""))
|
||||
if name and any(name == candidate or candidate in name for candidate in bot_names):
|
||||
return True
|
||||
|
||||
normalized_text = text.strip().lower()
|
||||
for name in bot_names:
|
||||
if re.search(rf"(^|\s)@{re.escape(name)}(?=$|\s|[::,,])", normalized_text):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
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 _is_ohmo_managed_feishu_group(chat_id: str) -> bool:
|
||||
workspace = os.environ.get("OHMO_WORKSPACE")
|
||||
if not workspace:
|
||||
return False
|
||||
try:
|
||||
from ohmo.group_registry import load_managed_group_record
|
||||
|
||||
return load_managed_group_record(
|
||||
workspace=workspace,
|
||||
channel="feishu",
|
||||
chat_id=chat_id,
|
||||
) is not None
|
||||
except Exception:
|
||||
logger.exception("Failed to load ohmo managed Feishu group metadata chat_id=%s", chat_id)
|
||||
return False
|
||||
|
||||
|
||||
def _should_process_feishu_group_message(
|
||||
*,
|
||||
chat_type: str,
|
||||
chat_id: str,
|
||||
mentions_bot: bool,
|
||||
config: FeishuConfig,
|
||||
) -> bool:
|
||||
if str(chat_type or "").strip().lower() != "group":
|
||||
return True
|
||||
policy = _normalize_feishu_group_policy(getattr(config, "group_policy", "managed_or_mention"))
|
||||
if policy == "open":
|
||||
return True
|
||||
if policy == "mention":
|
||||
return mentions_bot
|
||||
if policy == "managed":
|
||||
return _is_ohmo_managed_feishu_group(chat_id)
|
||||
if policy == "managed_or_mention":
|
||||
return mentions_bot or _is_ohmo_managed_feishu_group(chat_id)
|
||||
return mentions_bot
|
||||
|
||||
|
||||
class FeishuApiError(RuntimeError):
|
||||
"""Raised when Feishu returns an unsuccessful API response."""
|
||||
|
||||
|
||||
def _extract_share_card_content(content_json: dict, msg_type: str) -> str:
|
||||
"""Extract text representation from share cards and interactive messages."""
|
||||
parts = []
|
||||
@@ -263,6 +432,26 @@ class FeishuChannel(BaseChannel):
|
||||
self._sender_cache: OrderedDict[str, _FeishuSenderInfo] = OrderedDict()
|
||||
self._loop: asyncio.AbstractEventLoop | None = None
|
||||
|
||||
def _ensure_rest_client(self) -> bool:
|
||||
"""Initialize the Feishu REST client without starting the WebSocket receiver."""
|
||||
if self._client is not None:
|
||||
return True
|
||||
if not FEISHU_AVAILABLE:
|
||||
logger.error("Feishu SDK not installed. Run: pip install lark-oapi")
|
||||
return False
|
||||
if not self.config.app_id or not self.config.app_secret:
|
||||
logger.error("Feishu app_id and app_secret not configured")
|
||||
return False
|
||||
import lark_oapi as lark
|
||||
|
||||
self._client = lark.Client.builder() \
|
||||
.app_id(self.config.app_id) \
|
||||
.app_secret(self.config.app_secret) \
|
||||
.domain(self.config.domain) \
|
||||
.log_level(lark.LogLevel.INFO) \
|
||||
.build()
|
||||
return True
|
||||
|
||||
async def start(self) -> None:
|
||||
"""Start the Feishu bot with WebSocket long connection."""
|
||||
if not FEISHU_AVAILABLE:
|
||||
@@ -277,12 +466,8 @@ class FeishuChannel(BaseChannel):
|
||||
self._running = True
|
||||
self._loop = asyncio.get_running_loop()
|
||||
|
||||
# Create Lark client for sending messages
|
||||
self._client = lark.Client.builder() \
|
||||
.app_id(self.config.app_id) \
|
||||
.app_secret(self.config.app_secret) \
|
||||
.log_level(lark.LogLevel.INFO) \
|
||||
.build()
|
||||
if not self._ensure_rest_client():
|
||||
return
|
||||
|
||||
# Create event handler (only register message receive, ignore other events)
|
||||
event_handler = lark.EventDispatcherHandler.builder(
|
||||
@@ -297,6 +482,7 @@ class FeishuChannel(BaseChannel):
|
||||
self.config.app_id,
|
||||
self.config.app_secret,
|
||||
event_handler=event_handler,
|
||||
domain=self.config.domain,
|
||||
log_level=lark.LogLevel.INFO
|
||||
)
|
||||
|
||||
@@ -749,27 +935,55 @@ class FeishuChannel(BaseChannel):
|
||||
filename = f"{file_key[:16]}{ext}"
|
||||
|
||||
if data and filename:
|
||||
file_path = media_dir / filename
|
||||
safe_name = safe_filename(filename)
|
||||
if not safe_name:
|
||||
logger.warning("Rejected %s download with unsafe filename %r", msg_type, filename)
|
||||
return None, f"[{msg_type}: download failed]"
|
||||
|
||||
media_root = media_dir.resolve()
|
||||
file_path = (media_root / safe_name).resolve()
|
||||
if not file_path.is_relative_to(media_root):
|
||||
logger.warning("Rejected %s download outside media directory: %r", msg_type, filename)
|
||||
return None, f"[{msg_type}: download failed]"
|
||||
|
||||
file_path.write_bytes(data)
|
||||
logger.debug("Downloaded %s to %s", msg_type, file_path)
|
||||
return str(file_path), f"[{msg_type}: {filename}]"
|
||||
return str(file_path), f"[{msg_type}: {safe_name}]"
|
||||
|
||||
return None, f"[{msg_type}: download failed]"
|
||||
|
||||
def _send_message_sync(self, receive_id_type: str, receive_id: str, msg_type: str, content: str) -> bool:
|
||||
"""Send a single message (text/image/file/interactive) synchronously."""
|
||||
def _send_message_sync(self, receive_id_type: str, receive_id: str, msg_type: str, content: str, reply_in_thread: str | None = None) -> bool:
|
||||
"""Send a single message (text/image/file/interactive) synchronously.
|
||||
|
||||
When *reply_in_thread* (a message_id) is provided, the message is
|
||||
posted as a reply inside the originating thread via
|
||||
``ReplyMessageRequest`` instead of a standalone message.
|
||||
"""
|
||||
from lark_oapi.api.im.v1 import CreateMessageRequest, CreateMessageRequestBody
|
||||
try:
|
||||
request = CreateMessageRequest.builder() \
|
||||
.receive_id_type(receive_id_type) \
|
||||
.request_body(
|
||||
CreateMessageRequestBody.builder()
|
||||
.receive_id(receive_id)
|
||||
.msg_type(msg_type)
|
||||
.content(content)
|
||||
.build()
|
||||
).build()
|
||||
response = self._client.im.v1.message.create(request)
|
||||
if reply_in_thread:
|
||||
from lark_oapi.api.im.v1 import ReplyMessageRequest, ReplyMessageRequestBody
|
||||
request = ReplyMessageRequest.builder() \
|
||||
.message_id(reply_in_thread) \
|
||||
.request_body(
|
||||
ReplyMessageRequestBody.builder()
|
||||
.msg_type(msg_type)
|
||||
.content(content)
|
||||
.reply_in_thread(True)
|
||||
.build()
|
||||
).build()
|
||||
response = self._client.im.v1.message.reply(request)
|
||||
else:
|
||||
request = CreateMessageRequest.builder() \
|
||||
.receive_id_type(receive_id_type) \
|
||||
.request_body(
|
||||
CreateMessageRequestBody.builder()
|
||||
.receive_id(receive_id)
|
||||
.msg_type(msg_type)
|
||||
.content(content)
|
||||
.build()
|
||||
).build()
|
||||
response = self._client.im.v1.message.create(request)
|
||||
if not response.success():
|
||||
logger.error(
|
||||
"Failed to send Feishu %s message: code=%s, msg=%s, log_id=%s",
|
||||
@@ -782,28 +996,109 @@ class FeishuChannel(BaseChannel):
|
||||
logger.error("Error sending Feishu %s message: %s", msg_type, e)
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def _format_response_error(action: str, response: Any) -> str:
|
||||
log_id = response.get_log_id() if hasattr(response, "get_log_id") else ""
|
||||
return (
|
||||
f"{action} failed: code={getattr(response, 'code', '')}, "
|
||||
f"msg={getattr(response, 'msg', '')}, log_id={log_id}"
|
||||
)
|
||||
|
||||
def _create_managed_group_sync(self, user_open_id: str, name: str) -> str:
|
||||
"""Create a Feishu group containing the user and the app bot."""
|
||||
from lark_oapi.api.im.v1 import CreateChatRequest, CreateChatRequestBody
|
||||
|
||||
if not self._client:
|
||||
raise RuntimeError("Feishu client not initialized")
|
||||
request = (
|
||||
CreateChatRequest.builder()
|
||||
.user_id_type("open_id")
|
||||
.set_bot_manager(True)
|
||||
.request_body(
|
||||
CreateChatRequestBody.builder()
|
||||
.name(name)
|
||||
.user_id_list([user_open_id])
|
||||
.chat_mode("group")
|
||||
.chat_type("private")
|
||||
.build()
|
||||
)
|
||||
.build()
|
||||
)
|
||||
response = self._client.im.v1.chat.create(request)
|
||||
if not response.success():
|
||||
raise FeishuApiError(self._format_response_error("create Feishu group", response))
|
||||
chat_id = getattr(getattr(response, "data", None), "chat_id", None)
|
||||
if not chat_id:
|
||||
raise FeishuApiError("create Feishu group failed: response missing chat_id")
|
||||
logger.info("Created Feishu managed group name=%r chat_id=%s", name, chat_id)
|
||||
return str(chat_id)
|
||||
|
||||
async def create_managed_group(self, *, user_open_id: str, name: str) -> str:
|
||||
"""Create a Feishu group for a single user and the ohmo bot."""
|
||||
loop = asyncio.get_running_loop()
|
||||
return await loop.run_in_executor(None, self._create_managed_group_sync, user_open_id, name)
|
||||
|
||||
def _rename_group_sync(self, chat_id: str, name: str) -> None:
|
||||
"""Rename a Feishu group."""
|
||||
from lark_oapi.api.im.v1 import UpdateChatRequest, UpdateChatRequestBody
|
||||
|
||||
if not self._client:
|
||||
raise RuntimeError("Feishu client not initialized")
|
||||
request = (
|
||||
UpdateChatRequest.builder()
|
||||
.user_id_type("open_id")
|
||||
.chat_id(chat_id)
|
||||
.request_body(UpdateChatRequestBody.builder().name(name).build())
|
||||
.build()
|
||||
)
|
||||
response = self._client.im.v1.chat.update(request)
|
||||
if not response.success():
|
||||
raise FeishuApiError(self._format_response_error("rename Feishu group", response))
|
||||
logger.info("Renamed Feishu group chat_id=%s name=%r", chat_id, name)
|
||||
|
||||
async def rename_group(self, *, chat_id: str, name: str) -> None:
|
||||
"""Rename a Feishu group."""
|
||||
loop = asyncio.get_running_loop()
|
||||
await loop.run_in_executor(None, self._rename_group_sync, chat_id, name)
|
||||
|
||||
async def send(self, msg: OutboundMessage) -> None:
|
||||
"""Send a message through Feishu, including media (images/files) if present."""
|
||||
if not self._client:
|
||||
if not self._ensure_rest_client():
|
||||
logger.warning("Feishu client not initialized")
|
||||
return
|
||||
|
||||
try:
|
||||
receive_id_type = "chat_id" if msg.chat_id.startswith("oc_") else "open_id"
|
||||
loop = asyncio.get_running_loop()
|
||||
chat_type = str(msg.metadata.get("chat_type") or "").lower()
|
||||
reply_mid = (
|
||||
msg.metadata.get("message_id")
|
||||
if chat_type == "group" or msg.metadata.get("thread_id") or msg.metadata.get("root_id")
|
||||
else None
|
||||
)
|
||||
|
||||
failed_media: list[str] = []
|
||||
sent_media: list[str] = []
|
||||
for file_path in msg.media:
|
||||
if not os.path.isfile(file_path):
|
||||
logger.warning("Media file not found: %s", file_path)
|
||||
failed_media.append(file_path)
|
||||
continue
|
||||
ext = os.path.splitext(file_path)[1].lower()
|
||||
if ext in self._IMAGE_EXTS:
|
||||
key = await loop.run_in_executor(None, self._upload_image_sync, file_path)
|
||||
if key:
|
||||
await loop.run_in_executor(
|
||||
ok = await loop.run_in_executor(
|
||||
None, self._send_message_sync,
|
||||
receive_id_type, msg.chat_id, "image", json.dumps({"image_key": key}, ensure_ascii=False),
|
||||
reply_mid,
|
||||
)
|
||||
if ok:
|
||||
sent_media.append(file_path)
|
||||
else:
|
||||
failed_media.append(file_path)
|
||||
else:
|
||||
failed_media.append(file_path)
|
||||
else:
|
||||
key = await loop.run_in_executor(None, self._upload_file_sync, file_path)
|
||||
if key:
|
||||
@@ -813,10 +1108,26 @@ class FeishuChannel(BaseChannel):
|
||||
media_type = "media"
|
||||
else:
|
||||
media_type = "file"
|
||||
await loop.run_in_executor(
|
||||
ok = await loop.run_in_executor(
|
||||
None, self._send_message_sync,
|
||||
receive_id_type, msg.chat_id, media_type, json.dumps({"file_key": key}, ensure_ascii=False),
|
||||
reply_mid,
|
||||
)
|
||||
if ok:
|
||||
sent_media.append(file_path)
|
||||
else:
|
||||
failed_media.append(file_path)
|
||||
else:
|
||||
failed_media.append(file_path)
|
||||
|
||||
if failed_media:
|
||||
names = ", ".join(os.path.basename(path) for path in failed_media)
|
||||
failure_body = json.dumps({"text": f"文件发送失败:{names}"}, ensure_ascii=False)
|
||||
await loop.run_in_executor(
|
||||
None, self._send_message_sync,
|
||||
receive_id_type, msg.chat_id, "text", failure_body,
|
||||
reply_mid,
|
||||
)
|
||||
|
||||
if msg.content and msg.content.strip():
|
||||
fmt = self._detect_msg_format(msg.content)
|
||||
@@ -827,6 +1138,7 @@ class FeishuChannel(BaseChannel):
|
||||
await loop.run_in_executor(
|
||||
None, self._send_message_sync,
|
||||
receive_id_type, msg.chat_id, "text", text_body,
|
||||
reply_mid,
|
||||
)
|
||||
|
||||
elif fmt == "post":
|
||||
@@ -835,6 +1147,7 @@ class FeishuChannel(BaseChannel):
|
||||
await loop.run_in_executor(
|
||||
None, self._send_message_sync,
|
||||
receive_id_type, msg.chat_id, "post", post_body,
|
||||
reply_mid,
|
||||
)
|
||||
|
||||
else:
|
||||
@@ -845,6 +1158,7 @@ class FeishuChannel(BaseChannel):
|
||||
await loop.run_in_executor(
|
||||
None, self._send_message_sync,
|
||||
receive_id_type, msg.chat_id, "interactive", json.dumps(card, ensure_ascii=False),
|
||||
reply_mid,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
@@ -928,9 +1242,6 @@ class FeishuChannel(BaseChannel):
|
||||
chat_type = message.chat_type
|
||||
msg_type = message.message_type
|
||||
|
||||
# Add reaction
|
||||
await self._add_reaction(message_id, self.config.react_emoji)
|
||||
|
||||
# Parse content
|
||||
content_parts = []
|
||||
media_paths = []
|
||||
@@ -977,9 +1288,38 @@ class FeishuChannel(BaseChannel):
|
||||
|
||||
if not content and not media_paths:
|
||||
return
|
||||
mentions = _extract_feishu_mentions(content_json, getattr(message, "mentions", None))
|
||||
mentions_bot = chat_type == "group" and _feishu_mentions_bot(
|
||||
content_json,
|
||||
content,
|
||||
self.config,
|
||||
mentions=mentions,
|
||||
)
|
||||
if not _should_process_feishu_group_message(
|
||||
chat_type=chat_type,
|
||||
chat_id=chat_id,
|
||||
mentions_bot=mentions_bot,
|
||||
config=self.config,
|
||||
):
|
||||
logger.info(
|
||||
"Feishu group message ignored by policy chat_id=%s policy=%s mentions_bot=%s mention_names=%s mention_open_ids=%s",
|
||||
chat_id,
|
||||
getattr(self.config, "group_policy", "managed_or_mention"),
|
||||
mentions_bot,
|
||||
[item.get("name", "") for item in mentions],
|
||||
[item.get("open_id", "") for item in mentions],
|
||||
)
|
||||
return
|
||||
|
||||
# Add reaction only after policy says this message will be handled.
|
||||
await self._add_reaction(message_id, self.config.react_emoji)
|
||||
|
||||
# Forward to message bus
|
||||
reply_to = chat_id if chat_type == "group" else sender_id
|
||||
|
||||
# thread_id enables per-topic session routing in router.py
|
||||
thread_id = getattr(message, "thread_id", None) or getattr(message, "root_id", None)
|
||||
|
||||
await self._handle_message(
|
||||
sender_id=sender_id,
|
||||
chat_id=reply_to,
|
||||
@@ -987,8 +1327,12 @@ class FeishuChannel(BaseChannel):
|
||||
media=media_paths,
|
||||
metadata={
|
||||
"message_id": message_id,
|
||||
"thread_id": thread_id,
|
||||
"root_id": getattr(message, "root_id", None),
|
||||
"chat_type": chat_type,
|
||||
"msg_type": msg_type,
|
||||
"mentions": mentions,
|
||||
"mentions_bot": mentions_bot,
|
||||
"sender_display_name": sender_display_name,
|
||||
"sender_label": sender_display_name or sender_id,
|
||||
}
|
||||
|
||||
@@ -155,9 +155,9 @@ class ChannelManager:
|
||||
def _validate_allow_from(self) -> None:
|
||||
for name, ch in self.channels.items():
|
||||
if getattr(ch.config, "allow_from", None) == []:
|
||||
raise SystemExit(
|
||||
f'Error: "{name}" has empty allowFrom (denies all). '
|
||||
f'Set ["*"] to allow everyone, or add specific user IDs.'
|
||||
logger.warning(
|
||||
'%s channel has empty allow_from; remote access is denied until an operator explicitly adds allowed identities or chooses ["*"].',
|
||||
name,
|
||||
)
|
||||
|
||||
async def _start_channel(self, name: str, channel: BaseChannel) -> None:
|
||||
@@ -165,7 +165,8 @@ class ChannelManager:
|
||||
try:
|
||||
await channel.start()
|
||||
except Exception as e:
|
||||
logger.error("Failed to start channel %s: %s", name, e)
|
||||
setattr(channel, "last_error", str(e))
|
||||
logger.exception("Failed to start channel %s", name)
|
||||
|
||||
async def start_all(self) -> None:
|
||||
"""Start all channels and the outbound dispatcher."""
|
||||
|
||||
@@ -179,8 +179,11 @@ class SlackChannel(BaseChannel):
|
||||
except Exception as e:
|
||||
logger.debug("Slack reactions_add failed: %s", e)
|
||||
|
||||
# Thread-scoped session key for channel/group messages
|
||||
session_key = f"slack:{chat_id}:{thread_ts}" if thread_ts and channel_type != "im" else None
|
||||
# Preserve Slack thread metadata for reply routing, but let the ohmo
|
||||
# gateway router derive the session key. The router includes sender
|
||||
# identity for shared chats; passing a senderless Slack override here
|
||||
# would make different people in the same thread share one session.
|
||||
chat_type = "p2p" if channel_type == "im" else "group"
|
||||
|
||||
try:
|
||||
await self._handle_message(
|
||||
@@ -188,13 +191,14 @@ class SlackChannel(BaseChannel):
|
||||
chat_id=chat_id,
|
||||
content=text,
|
||||
metadata={
|
||||
"thread_ts": thread_ts,
|
||||
"chat_type": chat_type,
|
||||
"slack": {
|
||||
"event": event,
|
||||
"thread_ts": thread_ts,
|
||||
"channel_type": channel_type,
|
||||
},
|
||||
},
|
||||
session_key=session_key,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("Error handling Slack message from %s", sender_id)
|
||||
|
||||
@@ -19,6 +19,14 @@ from openharness.utils.helpers import split_message
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
TELEGRAM_MAX_MESSAGE_LEN = 4000 # Telegram message character limit
|
||||
_TELEGRAM_URL_LOGGERS = ("httpx", "httpcore", "telegram.ext")
|
||||
|
||||
|
||||
def silence_telegram_token_url_loggers() -> None:
|
||||
"""Prevent Telegram bot tokens from appearing in dependency INFO logs."""
|
||||
for name in _TELEGRAM_URL_LOGGERS:
|
||||
logging.getLogger(name).setLevel(logging.WARNING)
|
||||
|
||||
|
||||
|
||||
def _markdown_to_telegram_html(text: str) -> str:
|
||||
@@ -111,6 +119,8 @@ class TelegramChannel(BaseChannel):
|
||||
self.config: TelegramConfig = config
|
||||
self.groq_api_key = groq_api_key
|
||||
self._app: Application | None = None
|
||||
self.last_error: str | None = None
|
||||
self.polling_started = False
|
||||
self._chat_ids: dict[str, int] = {} # Map sender_id to chat_id for replies
|
||||
self._typing_tasks: dict[str, asyncio.Task] = {} # chat_id -> typing loop task
|
||||
self._media_group_buffers: dict[str, dict] = {}
|
||||
@@ -123,6 +133,9 @@ class TelegramChannel(BaseChannel):
|
||||
return
|
||||
|
||||
self._running = True
|
||||
self.last_error = None
|
||||
self.polling_started = False
|
||||
silence_telegram_token_url_loggers()
|
||||
|
||||
# Build the application with larger connection pool to avoid pool-timeout on long runs
|
||||
req = HTTPXRequest(connection_pool_size=16, pool_timeout=5.0, connect_timeout=30.0, read_timeout=30.0)
|
||||
@@ -165,8 +178,10 @@ class TelegramChannel(BaseChannel):
|
||||
# Start polling (this runs until stopped)
|
||||
await self._app.updater.start_polling(
|
||||
allowed_updates=["message"],
|
||||
drop_pending_updates=True # Ignore old messages on startup
|
||||
drop_pending_updates=False,
|
||||
)
|
||||
self.polling_started = True
|
||||
logger.info("Telegram polling started")
|
||||
|
||||
# Keep running until stopped
|
||||
while self._running:
|
||||
@@ -175,6 +190,7 @@ class TelegramChannel(BaseChannel):
|
||||
async def stop(self) -> None:
|
||||
"""Stop the Telegram bot."""
|
||||
self._running = False
|
||||
self.polling_started = False
|
||||
|
||||
# Cancel all typing indicators
|
||||
for chat_id in list(self._typing_tasks):
|
||||
@@ -301,7 +317,7 @@ class TelegramChannel(BaseChannel):
|
||||
|
||||
user = update.effective_user
|
||||
await update.message.reply_text(
|
||||
f"👋 Hi {user.first_name}! I'm nanobot.\n\n"
|
||||
f"👋 Hi {user.first_name}! I'm {self.config.bot_name}.\n\n"
|
||||
"Send me a message and I'll respond!\n"
|
||||
"Type /help to see available commands."
|
||||
)
|
||||
@@ -311,7 +327,7 @@ class TelegramChannel(BaseChannel):
|
||||
if not update.message:
|
||||
return
|
||||
await update.message.reply_text(
|
||||
"🐈 nanobot commands:\n"
|
||||
f"🐈 {self.config.bot_name} commands:\n"
|
||||
"/new — Start a new conversation\n"
|
||||
"/stop — Stop the current task\n"
|
||||
"/help — Show available commands"
|
||||
@@ -492,6 +508,7 @@ class TelegramChannel(BaseChannel):
|
||||
|
||||
async def _on_error(self, update: object, context: ContextTypes.DEFAULT_TYPE) -> None:
|
||||
"""Log polling / handler errors instead of silently swallowing them."""
|
||||
self.last_error = str(context.error)
|
||||
logger.error("Telegram error: %s", context.error)
|
||||
|
||||
def _get_extension(self, media_type: str, mime_type: str | None) -> str:
|
||||
|
||||
+1153
-11
File diff suppressed because it is too large
Load Diff
@@ -4,14 +4,18 @@ from openharness.commands.registry import (
|
||||
CommandContext,
|
||||
CommandRegistry,
|
||||
CommandResult,
|
||||
MemoryCommandBackend,
|
||||
SlashCommand,
|
||||
create_default_command_registry,
|
||||
lookup_skill_slash_command,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"CommandContext",
|
||||
"CommandRegistry",
|
||||
"CommandResult",
|
||||
"MemoryCommandBackend",
|
||||
"SlashCommand",
|
||||
"create_default_command_registry",
|
||||
"lookup_skill_slash_command",
|
||||
]
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -114,3 +114,47 @@ def get_project_issue_file(cwd: str | Path) -> Path:
|
||||
def get_project_pr_comments_file(cwd: str | Path) -> Path:
|
||||
"""Return the per-project PR comments context file."""
|
||||
return get_project_config_dir(cwd) / "pr_comments.md"
|
||||
|
||||
|
||||
def get_project_autopilot_dir(cwd: str | Path) -> Path:
|
||||
"""Return the per-project autopilot state directory."""
|
||||
autopilot_dir = get_project_config_dir(cwd) / "autopilot"
|
||||
autopilot_dir.mkdir(parents=True, exist_ok=True)
|
||||
return autopilot_dir
|
||||
|
||||
|
||||
def get_project_autopilot_registry_path(cwd: str | Path) -> Path:
|
||||
"""Return the autopilot task registry path."""
|
||||
return get_project_autopilot_dir(cwd) / "registry.json"
|
||||
|
||||
|
||||
def get_project_repo_journal_path(cwd: str | Path) -> Path:
|
||||
"""Return the append-only repo journal path."""
|
||||
return get_project_autopilot_dir(cwd) / "repo_journal.jsonl"
|
||||
|
||||
|
||||
def get_project_active_repo_context_path(cwd: str | Path) -> Path:
|
||||
"""Return the synthesized active repo context path."""
|
||||
return get_project_autopilot_dir(cwd) / "active_repo_context.md"
|
||||
|
||||
|
||||
def get_project_autopilot_policy_path(cwd: str | Path) -> Path:
|
||||
"""Return the autopilot policy path."""
|
||||
return get_project_autopilot_dir(cwd) / "autopilot_policy.yaml"
|
||||
|
||||
|
||||
def get_project_verification_policy_path(cwd: str | Path) -> Path:
|
||||
"""Return the verification policy path."""
|
||||
return get_project_autopilot_dir(cwd) / "verification_policy.yaml"
|
||||
|
||||
|
||||
def get_project_release_policy_path(cwd: str | Path) -> Path:
|
||||
"""Return the release policy path."""
|
||||
return get_project_autopilot_dir(cwd) / "release_policy.yaml"
|
||||
|
||||
|
||||
def get_project_autopilot_runs_dir(cwd: str | Path) -> Path:
|
||||
"""Return the autopilot run artifacts directory."""
|
||||
runs_dir = get_project_autopilot_dir(cwd) / "runs"
|
||||
runs_dir.mkdir(parents=True, exist_ok=True)
|
||||
return runs_dir
|
||||
|
||||
@@ -25,12 +25,18 @@ class ProviderConfigs(_CompatModel):
|
||||
|
||||
class BaseChannelConfig(_CompatModel):
|
||||
enabled: bool = False
|
||||
allow_from: list[str] = Field(default_factory=lambda: ["*"])
|
||||
# Secure default: enabling a channel does not automatically trust every
|
||||
# remote sender. Operators must explicitly allow specific identities, or
|
||||
# intentionally set ["*"] when they want open access.
|
||||
allow_from: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class TelegramConfig(BaseChannelConfig):
|
||||
token: str = ""
|
||||
chat_id: str | None = None
|
||||
proxy: str | None = None
|
||||
reply_to_message: bool = True
|
||||
bot_name: str = "ohmo"
|
||||
|
||||
|
||||
class SlackConfig(BaseChannelConfig):
|
||||
@@ -48,6 +54,12 @@ class FeishuConfig(BaseChannelConfig):
|
||||
app_secret: str = ""
|
||||
encrypt_key: str = ""
|
||||
verification_token: str = ""
|
||||
# Group reply policy is enforced by ohmo gateway because managed-group
|
||||
# metadata lives outside the generic Feishu channel adapter.
|
||||
group_policy: str = "managed_or_mention"
|
||||
bot_open_id: str = ""
|
||||
bot_names: list[str] = Field(default_factory=lambda: ["ohmo", "openclaw", "openharness"])
|
||||
domain: str = "https://open.feishu.cn" # use https://open.larksuite.com for Lark international
|
||||
|
||||
|
||||
class DingTalkConfig(BaseChannelConfig):
|
||||
@@ -105,4 +117,3 @@ class ChannelConfigs(_CompatModel):
|
||||
class Config(_CompatModel):
|
||||
channels: ChannelConfigs = Field(default_factory=ChannelConfigs)
|
||||
providers: ProviderConfigs = Field(default_factory=ProviderConfigs)
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
@@ -20,6 +21,23 @@ from pydantic import BaseModel, Field
|
||||
from openharness.hooks.schemas import HookDefinition
|
||||
from openharness.mcp.types import McpServerConfig
|
||||
from openharness.permissions.modes import PermissionMode
|
||||
from openharness.utils.file_lock import exclusive_file_lock
|
||||
from openharness.utils.fs import atomic_write_text
|
||||
|
||||
|
||||
# ANSI escape sequence pattern
|
||||
_ANSI_ESCAPE_PATTERN = re.compile(r"\x1b\[[0-9;]*m")
|
||||
|
||||
|
||||
def strip_ansi_escape_sequences(text: str) -> str:
|
||||
"""Remove ANSI escape sequences from text.
|
||||
|
||||
This is used to clean environment variables that may contain terminal
|
||||
formatting codes (e.g., '[1m' for bold) which can corrupt API requests.
|
||||
"""
|
||||
if not text:
|
||||
return text
|
||||
return _ANSI_ESCAPE_PATTERN.sub("", text)
|
||||
|
||||
|
||||
class PathRuleConfig(BaseModel):
|
||||
@@ -45,6 +63,15 @@ class MemorySettings(BaseModel):
|
||||
enabled: bool = True
|
||||
max_files: int = 5
|
||||
max_entrypoint_lines: int = 200
|
||||
max_entrypoint_bytes: int = 25_000
|
||||
context_window_tokens: int | None = None
|
||||
auto_compact_threshold_tokens: int | None = None
|
||||
auto_extract_enabled: bool = False
|
||||
auto_extract_max_records: int = 3
|
||||
session_memory_enabled: bool = True
|
||||
auto_dream_enabled: bool = False
|
||||
auto_dream_min_hours: float = 24.0
|
||||
auto_dream_min_sessions: int = 5
|
||||
|
||||
|
||||
class SandboxNetworkSettings(BaseModel):
|
||||
@@ -63,14 +90,35 @@ class SandboxFilesystemSettings(BaseModel):
|
||||
deny_write: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class DockerSandboxSettings(BaseModel):
|
||||
"""Docker-specific sandbox configuration."""
|
||||
|
||||
image: str = "openharness-sandbox:latest"
|
||||
auto_build_image: bool = True
|
||||
cpu_limit: float = 0.0
|
||||
memory_limit: str = ""
|
||||
extra_mounts: list[str] = Field(default_factory=list)
|
||||
extra_env: dict[str, str] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class SandboxSettings(BaseModel):
|
||||
"""Sandbox-runtime integration settings."""
|
||||
|
||||
enabled: bool = False
|
||||
backend: str = "srt"
|
||||
fail_if_unavailable: bool = False
|
||||
enabled_platforms: list[str] = Field(default_factory=list)
|
||||
network: SandboxNetworkSettings = Field(default_factory=SandboxNetworkSettings)
|
||||
filesystem: SandboxFilesystemSettings = Field(default_factory=SandboxFilesystemSettings)
|
||||
docker: DockerSandboxSettings = Field(default_factory=DockerSandboxSettings)
|
||||
|
||||
|
||||
class WebSettings(BaseModel):
|
||||
"""Outbound web tool configuration."""
|
||||
|
||||
proxy: str | None = None
|
||||
resolution_mode: str = "auto"
|
||||
synthetic_dns_cidrs: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class ProviderProfile(BaseModel):
|
||||
@@ -85,6 +133,8 @@ class ProviderProfile(BaseModel):
|
||||
last_model: str | None = None
|
||||
credential_slot: str | None = None
|
||||
allowed_models: list[str] = Field(default_factory=list)
|
||||
context_window_tokens: int | None = None
|
||||
auto_compact_threshold_tokens: int | None = None
|
||||
|
||||
@property
|
||||
def resolved_model(self) -> str:
|
||||
@@ -189,6 +239,46 @@ def default_provider_profiles() -> dict[str, ProviderProfile]:
|
||||
default_model="kimi-k2.5",
|
||||
base_url="https://api.moonshot.cn/v1",
|
||||
),
|
||||
"gemini": ProviderProfile(
|
||||
label="Google Gemini",
|
||||
provider="gemini",
|
||||
api_format="openai",
|
||||
auth_source="gemini_api_key",
|
||||
default_model="gemini-2.5-flash",
|
||||
base_url="https://generativelanguage.googleapis.com/v1beta/openai",
|
||||
),
|
||||
"minimax": ProviderProfile(
|
||||
label="MiniMax",
|
||||
provider="minimax",
|
||||
api_format="openai",
|
||||
auth_source="minimax_api_key",
|
||||
default_model="MiniMax-M2.7",
|
||||
base_url="https://api.minimax.io/v1",
|
||||
),
|
||||
"nvidia": ProviderProfile(
|
||||
label="NVIDIA NIM",
|
||||
provider="nvidia",
|
||||
api_format="openai",
|
||||
auth_source="nvidia_api_key",
|
||||
default_model="openai/gpt-oss-120b",
|
||||
base_url="https://integrate.api.nvidia.com/v1",
|
||||
),
|
||||
"qwen": ProviderProfile(
|
||||
label="Qwen (DashScope)",
|
||||
provider="dashscope",
|
||||
api_format="openai",
|
||||
auth_source="dashscope_api_key",
|
||||
default_model="qwen-plus",
|
||||
base_url="https://dashscope.aliyuncs.com/compatible-mode/v1",
|
||||
),
|
||||
"modelscope": ProviderProfile(
|
||||
label="ModelScope",
|
||||
provider="modelscope",
|
||||
api_format="openai",
|
||||
auth_source="modelscope_api_key",
|
||||
default_model="deepseek-ai/DeepSeek-V4-Flash",
|
||||
base_url="https://api-inference.modelscope.cn/v1",
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@@ -275,6 +365,10 @@ def auth_source_provider_name(auth_source: str) -> str:
|
||||
"bedrock_api_key": "bedrock",
|
||||
"vertex_api_key": "vertex",
|
||||
"moonshot_api_key": "moonshot",
|
||||
"gemini_api_key": "gemini",
|
||||
"minimax_api_key": "minimax",
|
||||
"nvidia_api_key": "nvidia",
|
||||
"modelscope_api_key": "modelscope",
|
||||
}
|
||||
return mapping.get(auth_source, auth_source)
|
||||
|
||||
@@ -284,6 +378,30 @@ def auth_source_uses_api_key(auth_source: str) -> bool:
|
||||
return auth_source.endswith("_api_key")
|
||||
|
||||
|
||||
def auth_source_env_var_candidates(auth_source: str) -> tuple[str, ...]:
|
||||
"""Return env vars to probe for an auth source in precedence order."""
|
||||
mapping = {
|
||||
"anthropic_api_key": ("OPENHARNESS_ANTHROPIC_API_KEY", "ANTHROPIC_API_KEY"),
|
||||
"openai_api_key": ("OPENHARNESS_OPENAI_API_KEY", "OPENAI_API_KEY"),
|
||||
"dashscope_api_key": ("OPENHARNESS_DASHSCOPE_API_KEY", "DASHSCOPE_API_KEY"),
|
||||
"moonshot_api_key": ("OPENHARNESS_MOONSHOT_API_KEY", "MOONSHOT_API_KEY"),
|
||||
"gemini_api_key": ("OPENHARNESS_GEMINI_API_KEY", "GEMINI_API_KEY"),
|
||||
"minimax_api_key": ("OPENHARNESS_MINIMAX_API_KEY", "MINIMAX_API_KEY"),
|
||||
"nvidia_api_key": ("OPENHARNESS_NVIDIA_API_KEY", "NVIDIA_API_KEY"),
|
||||
"modelscope_api_key": ("OPENHARNESS_MODELSCOPE_API_KEY", "MODELSCOPE_API_KEY"),
|
||||
}
|
||||
return mapping.get(auth_source, ())
|
||||
|
||||
|
||||
def resolve_auth_env_value(auth_source: str) -> tuple[str, str] | None:
|
||||
"""Return the first configured env var/value pair for an auth source."""
|
||||
for env_var in auth_source_env_var_candidates(auth_source):
|
||||
env_value = os.environ.get(env_var, "")
|
||||
if env_value:
|
||||
return env_var, env_value
|
||||
return None
|
||||
|
||||
|
||||
def credential_storage_provider_name(profile_name: str, profile: ProviderProfile) -> str:
|
||||
"""Return the storage namespace used for this profile's credential.
|
||||
|
||||
@@ -312,6 +430,14 @@ def default_auth_source_for_provider(provider: str, api_format: str | None = Non
|
||||
return "vertex_api_key"
|
||||
if provider == "moonshot":
|
||||
return "moonshot_api_key"
|
||||
if provider == "gemini":
|
||||
return "gemini_api_key"
|
||||
if provider == "minimax":
|
||||
return "minimax_api_key"
|
||||
if provider == "nvidia":
|
||||
return "nvidia_api_key"
|
||||
if provider == "modelscope":
|
||||
return "modelscope_api_key"
|
||||
if provider == "openai" or api_format == "openai":
|
||||
return "openai_api_key"
|
||||
return "anthropic_api_key"
|
||||
@@ -380,6 +506,63 @@ def _profile_from_flat_settings(settings: "Settings") -> tuple[str, ProviderProf
|
||||
return name, profile
|
||||
|
||||
|
||||
class ImageGenerationConfig(BaseModel):
|
||||
"""Configuration for the image_generation tool."""
|
||||
|
||||
provider: str = "auto"
|
||||
model: str = "gpt-image-2"
|
||||
api_key: str = ""
|
||||
base_url: str = ""
|
||||
codex_model: str = "gpt-5.4"
|
||||
codex_base_url: str = ""
|
||||
|
||||
@classmethod
|
||||
def from_env(cls) -> "ImageGenerationConfig":
|
||||
"""Load image generation config from environment variables."""
|
||||
return cls(
|
||||
provider=os.environ.get("OPENHARNESS_IMAGE_GENERATION_PROVIDER", "auto").strip()
|
||||
or "auto",
|
||||
model=os.environ.get("OPENHARNESS_IMAGE_GENERATION_MODEL", "gpt-image-2").strip()
|
||||
or "gpt-image-2",
|
||||
api_key=os.environ.get("OPENHARNESS_IMAGE_GENERATION_API_KEY", "").strip(),
|
||||
base_url=os.environ.get("OPENHARNESS_IMAGE_GENERATION_BASE_URL", "").strip(),
|
||||
codex_model=os.environ.get("OPENHARNESS_IMAGE_GENERATION_CODEX_MODEL", "gpt-5.4").strip()
|
||||
or "gpt-5.4",
|
||||
codex_base_url=os.environ.get("OPENHARNESS_IMAGE_GENERATION_CODEX_BASE_URL", "").strip(),
|
||||
)
|
||||
|
||||
@property
|
||||
def is_configured(self) -> bool:
|
||||
"""Return True when either a key provider or Codex provider is selected."""
|
||||
return bool(self.api_key or self.provider in {"auto", "codex"})
|
||||
|
||||
|
||||
class VisionModelConfig(BaseModel):
|
||||
"""Configuration for the vision model used by the image_to_text tool.
|
||||
|
||||
When the active model does not support multimodal input, the agent loop
|
||||
automatically falls back to this vision model to describe images.
|
||||
"""
|
||||
|
||||
model: str = ""
|
||||
api_key: str = ""
|
||||
base_url: str = ""
|
||||
|
||||
@classmethod
|
||||
def from_env(cls) -> "VisionModelConfig":
|
||||
"""Load vision model config from environment variables."""
|
||||
return cls(
|
||||
model=os.environ.get("OPENHARNESS_VISION_MODEL", "").strip(),
|
||||
api_key=os.environ.get("OPENHARNESS_VISION_API_KEY", "").strip(),
|
||||
base_url=os.environ.get("OPENHARNESS_VISION_BASE_URL", "").strip(),
|
||||
)
|
||||
|
||||
@property
|
||||
def is_configured(self) -> bool:
|
||||
"""Return True when both model and api_key are set."""
|
||||
return bool(self.model and self.api_key)
|
||||
|
||||
|
||||
class Settings(BaseModel):
|
||||
"""Main settings model for OpenHarness."""
|
||||
|
||||
@@ -388,6 +571,9 @@ class Settings(BaseModel):
|
||||
model: str = "claude-sonnet-4-6"
|
||||
max_tokens: int = 16384
|
||||
base_url: str | None = None
|
||||
timeout: float = 30.0
|
||||
context_window_tokens: int | None = None
|
||||
auto_compact_threshold_tokens: int | None = None
|
||||
api_format: str = "anthropic" # "anthropic", "openai", or "copilot"
|
||||
provider: str = ""
|
||||
active_profile: str = "claude-api"
|
||||
@@ -400,7 +586,13 @@ class Settings(BaseModel):
|
||||
hooks: dict[str, list[HookDefinition]] = Field(default_factory=dict)
|
||||
memory: MemorySettings = Field(default_factory=MemorySettings)
|
||||
sandbox: SandboxSettings = Field(default_factory=SandboxSettings)
|
||||
web: WebSettings = Field(default_factory=WebSettings)
|
||||
enabled_plugins: dict[str, bool] = Field(default_factory=dict)
|
||||
allow_project_plugins: bool = False
|
||||
allow_project_skills: bool = True
|
||||
project_skill_dirs: list[str] = Field(
|
||||
default_factory=lambda: [".openharness/skills", ".agents/skills", ".claude/skills"]
|
||||
)
|
||||
mcp_servers: dict[str, McpServerConfig] = Field(default_factory=dict)
|
||||
|
||||
# UI
|
||||
@@ -413,6 +605,12 @@ class Settings(BaseModel):
|
||||
passes: int = 1
|
||||
verbose: bool = False
|
||||
|
||||
# Vision model (image-to-text fallback)
|
||||
vision: VisionModelConfig = Field(default_factory=VisionModelConfig)
|
||||
|
||||
# Image generation model
|
||||
image_generation: ImageGenerationConfig = Field(default_factory=ImageGenerationConfig)
|
||||
|
||||
def merged_profiles(self) -> dict[str, ProviderProfile]:
|
||||
"""Return the saved profiles merged over the built-in catalog."""
|
||||
merged = default_provider_profiles()
|
||||
@@ -431,7 +629,7 @@ class Settings(BaseModel):
|
||||
def resolve_profile(self, name: str | None = None) -> tuple[str, ProviderProfile]:
|
||||
"""Return the active provider profile."""
|
||||
profiles = self.merged_profiles()
|
||||
profile_name = (name or self.active_profile or "").strip() or "claude-api"
|
||||
profile_name = (name or self.active_profile or os.environ.get("OPENHARNESS_PROFILE") or "").strip() or "claude-api"
|
||||
if profile_name not in profiles:
|
||||
fallback_name, fallback = _profile_from_flat_settings(self)
|
||||
profiles[fallback_name] = fallback
|
||||
@@ -449,6 +647,8 @@ class Settings(BaseModel):
|
||||
"provider": profile.provider,
|
||||
"api_format": profile.api_format,
|
||||
"base_url": profile.base_url,
|
||||
"context_window_tokens": profile.context_window_tokens,
|
||||
"auto_compact_threshold_tokens": profile.auto_compact_threshold_tokens,
|
||||
"model": resolve_model_setting(
|
||||
configured_model,
|
||||
profile.provider,
|
||||
@@ -466,9 +666,25 @@ class Settings(BaseModel):
|
||||
directly before the profile layer is used everywhere.
|
||||
"""
|
||||
profile_name, profile = self.resolve_profile()
|
||||
next_provider = (self.provider or "").strip() or profile.provider
|
||||
next_api_format = (self.api_format or "").strip() or profile.api_format
|
||||
next_base_url = self.base_url if self.base_url is not None else profile.base_url
|
||||
profile_from_env = bool(os.environ.get("OPENHARNESS_PROFILE"))
|
||||
flat_profile_fields_match_profile = profile_from_env or (
|
||||
(self.provider or "").strip() == profile.provider
|
||||
and (self.api_format or "").strip() == profile.api_format
|
||||
and self.base_url == profile.base_url
|
||||
)
|
||||
next_provider = profile.provider if flat_profile_fields_match_profile else (self.provider or "").strip() or profile.provider
|
||||
next_api_format = profile.api_format if flat_profile_fields_match_profile else (self.api_format or "").strip() or profile.api_format
|
||||
next_base_url = profile.base_url if flat_profile_fields_match_profile else (self.base_url if self.base_url is not None else profile.base_url)
|
||||
next_context_window_tokens = (
|
||||
self.context_window_tokens
|
||||
if self.context_window_tokens is not None
|
||||
else profile.context_window_tokens
|
||||
)
|
||||
next_auto_compact_threshold_tokens = (
|
||||
self.auto_compact_threshold_tokens
|
||||
if self.auto_compact_threshold_tokens is not None
|
||||
else profile.auto_compact_threshold_tokens
|
||||
)
|
||||
flat_model = (self.model or "").strip()
|
||||
resolved_profile_model = resolve_model_setting(
|
||||
(profile.last_model or "").strip() or profile.default_model,
|
||||
@@ -492,6 +708,8 @@ class Settings(BaseModel):
|
||||
"base_url": next_base_url,
|
||||
"auth_source": next_auth_source,
|
||||
"last_model": next_model,
|
||||
"context_window_tokens": next_context_window_tokens,
|
||||
"auto_compact_threshold_tokens": next_auto_compact_threshold_tokens,
|
||||
}
|
||||
)
|
||||
profiles = self.merged_profiles()
|
||||
@@ -527,19 +745,15 @@ class Settings(BaseModel):
|
||||
if self.api_key:
|
||||
return self.api_key
|
||||
|
||||
env_key = os.environ.get("ANTHROPIC_API_KEY", "")
|
||||
if env_key:
|
||||
return env_key
|
||||
|
||||
# Also check OPENAI_API_KEY for openai-format providers
|
||||
openai_key = os.environ.get("OPENAI_API_KEY", "")
|
||||
if openai_key:
|
||||
return openai_key
|
||||
env_resolved = resolve_auth_env_value(profile.auth_source)
|
||||
if env_resolved:
|
||||
_, env_value = env_resolved
|
||||
return env_value
|
||||
|
||||
raise ValueError(
|
||||
"No API key found. Set ANTHROPIC_API_KEY (or OPENAI_API_KEY for openai-format "
|
||||
"providers) environment variable, or configure api_key in "
|
||||
"~/.openharness/settings.json"
|
||||
"No API key found. Set an OPENHARNESS_* provider API key "
|
||||
"(preferred) or the matching native provider environment variable, "
|
||||
"or configure api_key in ~/.openharness/settings.json"
|
||||
)
|
||||
|
||||
def resolve_auth(self) -> ResolvedAuth:
|
||||
@@ -548,6 +762,15 @@ class Settings(BaseModel):
|
||||
provider = profile.provider.strip()
|
||||
auth_source = profile.auth_source.strip() or default_auth_source_for_provider(provider, profile.api_format)
|
||||
if auth_source in {"codex_subscription", "claude_subscription"}:
|
||||
env_auth_token = os.environ.get("ANTHROPIC_AUTH_TOKEN", "").strip()
|
||||
if auth_source == "claude_subscription" and env_auth_token:
|
||||
return ResolvedAuth(
|
||||
provider=provider,
|
||||
auth_kind="oauth",
|
||||
value=env_auth_token,
|
||||
source="env:ANTHROPIC_AUTH_TOKEN",
|
||||
state="configured",
|
||||
)
|
||||
from openharness.auth.external import (
|
||||
is_third_party_anthropic_endpoint,
|
||||
load_external_credential,
|
||||
@@ -606,22 +829,16 @@ class Settings(BaseModel):
|
||||
|
||||
storage_provider = credential_storage_provider_name(profile_name, profile)
|
||||
|
||||
env_var = {
|
||||
"anthropic_api_key": "ANTHROPIC_API_KEY",
|
||||
"openai_api_key": "OPENAI_API_KEY",
|
||||
"dashscope_api_key": "DASHSCOPE_API_KEY",
|
||||
"moonshot_api_key": "MOONSHOT_API_KEY",
|
||||
}.get(auth_source)
|
||||
if env_var:
|
||||
env_value = os.environ.get(env_var, "")
|
||||
if env_value:
|
||||
return ResolvedAuth(
|
||||
provider=provider or storage_provider,
|
||||
auth_kind="api_key",
|
||||
value=env_value,
|
||||
source=f"env:{env_var}",
|
||||
state="configured",
|
||||
)
|
||||
env_resolved = resolve_auth_env_value(auth_source)
|
||||
if env_resolved:
|
||||
env_var, env_value = env_resolved
|
||||
return ResolvedAuth(
|
||||
provider=provider or storage_provider,
|
||||
auth_kind="api_key",
|
||||
value=env_value,
|
||||
source=f"env:{env_var}",
|
||||
state="configured",
|
||||
)
|
||||
|
||||
explicit_key = "" if profile.credential_slot else self.api_key
|
||||
if explicit_key:
|
||||
@@ -651,63 +868,172 @@ class Settings(BaseModel):
|
||||
def merge_cli_overrides(self, **overrides: Any) -> Settings:
|
||||
"""Return a new Settings with CLI overrides applied (non-None values only)."""
|
||||
updates = {k: v for k, v in overrides.items() if v is not None}
|
||||
merged = self.model_copy(update=updates)
|
||||
permission_mode = updates.pop("permission_mode", None)
|
||||
|
||||
def apply_permission_mode(settings: Settings) -> Settings:
|
||||
if permission_mode is None:
|
||||
return settings
|
||||
return settings.model_copy(
|
||||
update={
|
||||
"permission": settings.permission.model_copy(
|
||||
update={"mode": PermissionMode(str(permission_mode))}
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
# Strip ANSI escape sequences from model name if present
|
||||
if "model" in updates and isinstance(updates["model"], str):
|
||||
updates["model"] = strip_ansi_escape_sequences(updates["model"])
|
||||
if "effort" in updates and isinstance(updates["effort"], str):
|
||||
updates["effort"] = "xhigh" if updates["effort"].strip().lower() == "max" else updates["effort"].strip().lower()
|
||||
merged = apply_permission_mode(self.model_copy(update=updates))
|
||||
if not updates:
|
||||
return merged
|
||||
profile_keys = {"model", "base_url", "api_format", "provider", "api_key", "active_profile", "profiles"}
|
||||
profile_keys = {
|
||||
"model",
|
||||
"base_url",
|
||||
"api_format",
|
||||
"provider",
|
||||
"api_key",
|
||||
"active_profile",
|
||||
"profiles",
|
||||
"context_window_tokens",
|
||||
"auto_compact_threshold_tokens",
|
||||
}
|
||||
profile_updates = profile_keys.intersection(updates)
|
||||
if not profile_updates:
|
||||
return merged
|
||||
if profile_updates.issubset({"active_profile"}):
|
||||
return merged.materialize_active_profile()
|
||||
if "active_profile" in profile_updates:
|
||||
switch_updates = {
|
||||
key: value
|
||||
for key, value in updates.items()
|
||||
if key not in profile_keys or key in {"active_profile", "profiles"}
|
||||
}
|
||||
switched = apply_permission_mode(self.model_copy(update=switch_updates)).materialize_active_profile()
|
||||
remaining_profile_updates = {
|
||||
key: value
|
||||
for key, value in updates.items()
|
||||
if key in profile_keys and key not in {"active_profile", "profiles"}
|
||||
}
|
||||
if not remaining_profile_updates:
|
||||
return switched
|
||||
return (
|
||||
switched.model_copy(update=remaining_profile_updates)
|
||||
.sync_active_profile_from_flat_fields()
|
||||
.materialize_active_profile()
|
||||
)
|
||||
return merged.sync_active_profile_from_flat_fields().materialize_active_profile()
|
||||
|
||||
|
||||
def _apply_env_overrides(settings: Settings) -> Settings:
|
||||
"""Apply supported environment variable overrides over loaded settings."""
|
||||
updates: dict[str, Any] = {}
|
||||
model = os.environ.get("ANTHROPIC_MODEL") or os.environ.get("OPENHARNESS_MODEL")
|
||||
if model:
|
||||
updates["model"] = model
|
||||
"""Apply supported environment variable overrides over loaded settings.
|
||||
|
||||
base_url = (
|
||||
os.environ.get("ANTHROPIC_BASE_URL")
|
||||
or os.environ.get("OPENAI_BASE_URL")
|
||||
or os.environ.get("OPENHARNESS_BASE_URL")
|
||||
)
|
||||
if base_url:
|
||||
updates["base_url"] = base_url
|
||||
Provider-scoped env vars (``ANTHROPIC_BASE_URL``, ``ANTHROPIC_MODEL``,
|
||||
``OPENAI_BASE_URL``) only apply when the active profile does *not*
|
||||
explicitly configure the corresponding field. ``OPENHARNESS_*`` env vars
|
||||
always override (explicit user intent).
|
||||
"""
|
||||
updates: dict[str, Any] = {}
|
||||
|
||||
# Resolve the active profile to check for explicit settings.
|
||||
_, active_profile = settings.resolve_profile()
|
||||
profile_has_base_url = active_profile.base_url is not None
|
||||
profile_explicit_model = (active_profile.last_model or "").strip()
|
||||
profile_has_explicit_model = bool(profile_explicit_model) and profile_explicit_model.lower() not in {"", "default"}
|
||||
|
||||
# --- model ---
|
||||
openharness_model = os.environ.get("OPENHARNESS_MODEL")
|
||||
if openharness_model:
|
||||
updates["model"] = strip_ansi_escape_sequences(openharness_model)
|
||||
elif not profile_has_explicit_model:
|
||||
anthropic_model = os.environ.get("ANTHROPIC_MODEL")
|
||||
if anthropic_model:
|
||||
updates["model"] = strip_ansi_escape_sequences(anthropic_model)
|
||||
|
||||
# --- base_url ---
|
||||
openharness_base = os.environ.get("OPENHARNESS_BASE_URL")
|
||||
if openharness_base:
|
||||
updates["base_url"] = openharness_base
|
||||
elif not profile_has_base_url:
|
||||
generic_base = os.environ.get("ANTHROPIC_BASE_URL") or os.environ.get("OPENAI_BASE_URL")
|
||||
if generic_base:
|
||||
updates["base_url"] = generic_base
|
||||
|
||||
max_tokens = os.environ.get("OPENHARNESS_MAX_TOKENS")
|
||||
if max_tokens:
|
||||
updates["max_tokens"] = int(max_tokens)
|
||||
|
||||
timeout = os.environ.get("OPENHARNESS_TIMEOUT")
|
||||
if timeout:
|
||||
updates["timeout"] = float(timeout)
|
||||
|
||||
max_turns = os.environ.get("OPENHARNESS_MAX_TURNS")
|
||||
if max_turns:
|
||||
updates["max_turns"] = int(max_turns)
|
||||
|
||||
api_key = os.environ.get("ANTHROPIC_API_KEY") or os.environ.get("OPENAI_API_KEY")
|
||||
if api_key:
|
||||
context_window_tokens = os.environ.get("OPENHARNESS_CONTEXT_WINDOW_TOKENS")
|
||||
if context_window_tokens:
|
||||
updates["context_window_tokens"] = int(context_window_tokens)
|
||||
|
||||
auto_compact_threshold_tokens = os.environ.get("OPENHARNESS_AUTO_COMPACT_THRESHOLD_TOKENS")
|
||||
if auto_compact_threshold_tokens:
|
||||
updates["auto_compact_threshold_tokens"] = int(auto_compact_threshold_tokens)
|
||||
|
||||
provider = os.environ.get("OPENHARNESS_PROVIDER")
|
||||
api_format = os.environ.get("OPENHARNESS_API_FORMAT")
|
||||
env_auth_source = active_profile.auth_source
|
||||
if provider or api_format:
|
||||
env_auth_source = default_auth_source_for_provider(
|
||||
provider or active_profile.provider,
|
||||
api_format or active_profile.api_format,
|
||||
)
|
||||
|
||||
env_resolved = resolve_auth_env_value(env_auth_source)
|
||||
if env_resolved:
|
||||
_, api_key = env_resolved
|
||||
updates["api_key"] = api_key
|
||||
|
||||
api_format = os.environ.get("OPENHARNESS_API_FORMAT")
|
||||
if api_format:
|
||||
updates["api_format"] = api_format
|
||||
|
||||
provider = os.environ.get("OPENHARNESS_PROVIDER")
|
||||
if provider:
|
||||
updates["provider"] = provider
|
||||
|
||||
sandbox_enabled = os.environ.get("OPENHARNESS_SANDBOX_ENABLED")
|
||||
sandbox_fail = os.environ.get("OPENHARNESS_SANDBOX_FAIL_IF_UNAVAILABLE")
|
||||
sandbox_backend = os.environ.get("OPENHARNESS_SANDBOX_BACKEND")
|
||||
sandbox_docker_image = os.environ.get("OPENHARNESS_SANDBOX_DOCKER_IMAGE")
|
||||
sandbox_updates: dict[str, Any] = {}
|
||||
if sandbox_enabled is not None:
|
||||
sandbox_updates["enabled"] = _parse_bool_env(sandbox_enabled)
|
||||
if sandbox_fail is not None:
|
||||
sandbox_updates["fail_if_unavailable"] = _parse_bool_env(sandbox_fail)
|
||||
if sandbox_backend is not None:
|
||||
sandbox_updates["backend"] = sandbox_backend
|
||||
if sandbox_docker_image is not None:
|
||||
sandbox_updates["docker"] = settings.sandbox.docker.model_copy(
|
||||
update={"image": sandbox_docker_image}
|
||||
)
|
||||
if sandbox_updates:
|
||||
updates["sandbox"] = settings.sandbox.model_copy(update=sandbox_updates)
|
||||
|
||||
web_updates: dict[str, Any] = {}
|
||||
web_proxy = os.environ.get("OPENHARNESS_WEB_PROXY")
|
||||
if web_proxy:
|
||||
web_updates["proxy"] = web_proxy
|
||||
web_resolution_mode = os.environ.get("OPENHARNESS_WEB_RESOLUTION_MODE")
|
||||
if web_resolution_mode:
|
||||
web_updates["resolution_mode"] = web_resolution_mode
|
||||
web_synthetic_dns_cidrs = os.environ.get("OPENHARNESS_WEB_SYNTHETIC_DNS_CIDRS")
|
||||
if web_synthetic_dns_cidrs:
|
||||
web_updates["synthetic_dns_cidrs"] = [
|
||||
entry.strip()
|
||||
for entry in web_synthetic_dns_cidrs.split(",")
|
||||
if entry.strip()
|
||||
]
|
||||
if web_updates:
|
||||
updates["web"] = settings.web.model_copy(update=web_updates)
|
||||
|
||||
if not updates:
|
||||
return settings
|
||||
return settings.model_copy(update=updates)
|
||||
@@ -735,6 +1061,9 @@ def load_settings(config_path: Path | None = None) -> Settings:
|
||||
if config_path.exists():
|
||||
raw = json.loads(config_path.read_text(encoding="utf-8"))
|
||||
settings = Settings.model_validate(raw)
|
||||
env_profile = os.environ.get("OPENHARNESS_PROFILE")
|
||||
if env_profile:
|
||||
settings = settings.model_copy(update={"active_profile": env_profile.strip()})
|
||||
if "profiles" not in raw or "active_profile" not in raw:
|
||||
profile_name, profile = _profile_from_flat_settings(settings)
|
||||
merged_profiles = settings.merged_profiles()
|
||||
@@ -747,7 +1076,11 @@ def load_settings(config_path: Path | None = None) -> Settings:
|
||||
)
|
||||
return _apply_env_overrides(settings.materialize_active_profile())
|
||||
|
||||
return _apply_env_overrides(Settings().materialize_active_profile())
|
||||
settings = Settings()
|
||||
env_profile = os.environ.get("OPENHARNESS_PROFILE")
|
||||
if env_profile:
|
||||
settings = settings.model_copy(update={"active_profile": env_profile.strip()})
|
||||
return _apply_env_overrides(settings.materialize_active_profile())
|
||||
|
||||
|
||||
def save_settings(settings: Settings, config_path: Path | None = None) -> None:
|
||||
@@ -763,8 +1096,9 @@ def save_settings(settings: Settings, config_path: Path | None = None) -> None:
|
||||
config_path = get_config_file_path()
|
||||
|
||||
settings = settings.sync_active_profile_from_flat_fields().materialize_active_profile()
|
||||
config_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
config_path.write_text(
|
||||
settings.model_dump_json(indent=2) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
lock_path = config_path.with_suffix(config_path.suffix + ".lock")
|
||||
with exclusive_file_lock(lock_path):
|
||||
atomic_write_text(
|
||||
config_path,
|
||||
settings.model_dump_json(indent=2) + "\n",
|
||||
)
|
||||
|
||||
@@ -546,7 +546,7 @@ _BUILTIN_AGENTS: list[AgentDefinition] = [
|
||||
),
|
||||
tools=["Glob", "Grep", "Read", "WebFetch", "WebSearch"],
|
||||
system_prompt=_CLAUDE_CODE_GUIDE_SYSTEM_PROMPT,
|
||||
model="haiku",
|
||||
model="inherit",
|
||||
permission_mode="dontAsk",
|
||||
subagent_type="claude-code-guide",
|
||||
source="builtin",
|
||||
@@ -565,7 +565,7 @@ _BUILTIN_AGENTS: list[AgentDefinition] = [
|
||||
),
|
||||
disallowed_tools=["agent", "exit_plan_mode", "file_edit", "file_write", "notebook_edit"],
|
||||
system_prompt=_EXPLORE_SYSTEM_PROMPT,
|
||||
model="haiku",
|
||||
model="inherit",
|
||||
omit_claude_md=True,
|
||||
subagent_type="Explore",
|
||||
source="builtin",
|
||||
|
||||
@@ -6,6 +6,7 @@ import os
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Optional
|
||||
from xml.sax.saxutils import escape, unescape
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -109,12 +110,12 @@ def format_task_notification(n: TaskNotification) -> str:
|
||||
"""Serialize a TaskNotification to the canonical XML envelope."""
|
||||
parts = [
|
||||
"<task-notification>",
|
||||
f"<task-id>{n.task_id}</task-id>",
|
||||
f"<status>{n.status}</status>",
|
||||
f"<summary>{n.summary}</summary>",
|
||||
f"<task-id>{escape(n.task_id)}</task-id>",
|
||||
f"<status>{escape(n.status)}</status>",
|
||||
f"<summary>{escape(n.summary)}</summary>",
|
||||
]
|
||||
if n.result is not None:
|
||||
parts.append(f"<result>{n.result}</result>")
|
||||
parts.append(f"<result>{escape(n.result)}</result>")
|
||||
if n.usage:
|
||||
parts.append("<usage>")
|
||||
for key in _USAGE_FIELDS:
|
||||
@@ -130,7 +131,7 @@ def parse_task_notification(xml: str) -> TaskNotification:
|
||||
|
||||
def _extract(tag: str) -> Optional[str]:
|
||||
m = re.search(rf"<{tag}>(.*?)</{tag}>", xml, re.DOTALL)
|
||||
return m.group(1).strip() if m else None
|
||||
return unescape(m.group(1).strip()) if m else None
|
||||
|
||||
task_id = _extract("task-id") or ""
|
||||
status = _extract("status") or ""
|
||||
|
||||
@@ -8,7 +8,7 @@ from pathlib import Path
|
||||
from typing import Any, Annotated, Literal
|
||||
from uuid import uuid4
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
|
||||
|
||||
class TextBlock(BaseModel):
|
||||
@@ -53,6 +53,7 @@ class ToolResultBlock(BaseModel):
|
||||
tool_use_id: str
|
||||
content: str
|
||||
is_error: bool = False
|
||||
result_metadata: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
ContentBlock = Annotated[
|
||||
@@ -67,6 +68,14 @@ class ConversationMessage(BaseModel):
|
||||
role: Literal["user", "assistant"]
|
||||
content: list[ContentBlock] = Field(default_factory=list)
|
||||
|
||||
@field_validator("content", mode="before")
|
||||
@classmethod
|
||||
def _normalize_content(cls, value: Any) -> list[Any]:
|
||||
"""Normalize legacy/null payloads before block validation."""
|
||||
if value is None:
|
||||
return []
|
||||
return value
|
||||
|
||||
@classmethod
|
||||
def from_user_text(cls, text: str) -> "ConversationMessage":
|
||||
"""Construct a user message from raw text."""
|
||||
@@ -96,6 +105,71 @@ class ConversationMessage(BaseModel):
|
||||
"content": [serialize_content_block(block) for block in self.content],
|
||||
}
|
||||
|
||||
def is_effectively_empty(self) -> bool:
|
||||
"""Return True when the message carries no useful content."""
|
||||
if self.content:
|
||||
for block in self.content:
|
||||
if isinstance(block, TextBlock) and block.text.strip():
|
||||
return False
|
||||
if isinstance(block, (ImageBlock, ToolUseBlock, ToolResultBlock)):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def sanitize_conversation_messages(messages: list[ConversationMessage]) -> list[ConversationMessage]:
|
||||
"""Normalize restored conversation history into a provider-safe sequence.
|
||||
|
||||
This drops legacy empty assistant messages and trims malformed trailing tool
|
||||
turns, such as an assistant ``tool_use`` message that never received a
|
||||
matching user ``tool_result`` response. Those broken tails can happen when a
|
||||
session is interrupted mid-turn and would later cause OpenAI-compatible
|
||||
providers to reject the resumed conversation.
|
||||
"""
|
||||
sanitized: list[ConversationMessage] = []
|
||||
pending_tool_use_ids: set[str] = set()
|
||||
pending_tool_use_index: int | None = None
|
||||
|
||||
for message in messages:
|
||||
if message.role == "assistant" and message.is_effectively_empty():
|
||||
continue
|
||||
|
||||
tool_uses = message.tool_uses if message.role == "assistant" else []
|
||||
tool_results = [
|
||||
block for block in message.content if isinstance(block, ToolResultBlock)
|
||||
] if message.role == "user" else []
|
||||
|
||||
matched_pending_tool_results = False
|
||||
if pending_tool_use_ids:
|
||||
result_ids = {block.tool_use_id for block in tool_results}
|
||||
if message.role != "user" or not pending_tool_use_ids.issubset(result_ids):
|
||||
if pending_tool_use_index is not None and pending_tool_use_index < len(sanitized):
|
||||
sanitized.pop(pending_tool_use_index)
|
||||
pending_tool_use_ids = set()
|
||||
pending_tool_use_index = None
|
||||
else:
|
||||
matched_pending_tool_results = True
|
||||
pending_tool_use_ids = set()
|
||||
pending_tool_use_index = None
|
||||
|
||||
if message.role == "user" and tool_results and not matched_pending_tool_results:
|
||||
content = [
|
||||
block for block in message.content if not isinstance(block, ToolResultBlock)
|
||||
]
|
||||
if not content:
|
||||
continue
|
||||
message = ConversationMessage(role="user", content=content)
|
||||
|
||||
sanitized.append(message)
|
||||
|
||||
if tool_uses:
|
||||
pending_tool_use_ids = {block.id for block in tool_uses}
|
||||
pending_tool_use_index = len(sanitized) - 1
|
||||
|
||||
if pending_tool_use_ids and pending_tool_use_index is not None and pending_tool_use_index < len(sanitized):
|
||||
sanitized.pop(pending_tool_use_index)
|
||||
|
||||
return sanitized
|
||||
|
||||
|
||||
def serialize_content_block(block: ContentBlock) -> dict[str, Any]:
|
||||
"""Convert a local content block into the provider wire format."""
|
||||
|
||||
+359
-12
@@ -4,10 +4,12 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import re
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, AsyncIterator, Awaitable, Callable
|
||||
from uuid import uuid4
|
||||
|
||||
from openharness.api.client import (
|
||||
ApiMessageCompleteEvent,
|
||||
@@ -16,8 +18,15 @@ from openharness.api.client import (
|
||||
ApiTextDeltaEvent,
|
||||
SupportsStreamingMessages,
|
||||
)
|
||||
from openharness.api.provider import is_model_multimodal
|
||||
from openharness.api.usage import UsageSnapshot
|
||||
from openharness.engine.messages import ConversationMessage, ToolResultBlock
|
||||
from openharness.config.paths import get_data_dir
|
||||
from openharness.engine.messages import (
|
||||
ConversationMessage,
|
||||
ImageBlock,
|
||||
TextBlock,
|
||||
ToolResultBlock,
|
||||
)
|
||||
from openharness.engine.stream_events import (
|
||||
AssistantTextDelta,
|
||||
AssistantTurnComplete,
|
||||
@@ -30,11 +39,13 @@ from openharness.engine.stream_events import (
|
||||
)
|
||||
from openharness.hooks import HookEvent, HookExecutor
|
||||
from openharness.permissions.checker import PermissionChecker
|
||||
from openharness.services.tool_outputs import tool_output_inline_chars, tool_output_preview_chars
|
||||
from openharness.tools.base import ToolExecutionContext
|
||||
from openharness.tools.base import ToolRegistry
|
||||
|
||||
AUTO_COMPACT_STATUS_MESSAGE = "Auto-compacting conversation memory to keep things fast and focused."
|
||||
REACTIVE_COMPACT_STATUS_MESSAGE = "Prompt too long; compacting conversation memory and retrying."
|
||||
MAX_SAFE_COMPLETION_TOKENS = 128_000
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
@@ -45,6 +56,7 @@ AskUserPrompt = Callable[[str], Awaitable[str]]
|
||||
MAX_TRACKED_READ_FILES = 6
|
||||
MAX_TRACKED_SKILLS = 8
|
||||
MAX_TRACKED_ASYNC_AGENT_EVENTS = 8
|
||||
MAX_TRACKED_ASYNC_AGENT_TASKS = 12
|
||||
MAX_TRACKED_WORK_LOG = 10
|
||||
MAX_TRACKED_USER_GOALS = 5
|
||||
MAX_TRACKED_ACTIVE_ARTIFACTS = 8
|
||||
@@ -57,16 +69,63 @@ def _is_prompt_too_long_error(exc: Exception) -> bool:
|
||||
needle in text
|
||||
for needle in (
|
||||
"prompt too long",
|
||||
"context_length_exceeded",
|
||||
"context length",
|
||||
"maximum context",
|
||||
"context window",
|
||||
"input tokens exceed",
|
||||
"messages resulted in",
|
||||
"reduce the length of the messages",
|
||||
"configured limit",
|
||||
"too many tokens",
|
||||
"too large for the model",
|
||||
"maximum context length",
|
||||
"exceed_context",
|
||||
"exceeds the available context size",
|
||||
"available context size",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _bounded_completion_tokens(max_tokens: int, context_window_tokens: int | None = None) -> int:
|
||||
"""Return a conservative per-request output token cap.
|
||||
|
||||
Some OpenAI-compatible providers reject very large ``max_tokens`` before
|
||||
the request reaches model-side context management. Keep oversized user
|
||||
config from making every turn fail while preserving normal defaults.
|
||||
"""
|
||||
limit = MAX_SAFE_COMPLETION_TOKENS
|
||||
if context_window_tokens is not None and context_window_tokens > 0:
|
||||
limit = min(limit, int(context_window_tokens))
|
||||
return max(1, min(int(max_tokens), limit))
|
||||
|
||||
|
||||
def _extract_completion_token_limit(exc: Exception) -> int | None:
|
||||
"""Parse provider errors such as "supports at most 128000 completion tokens"."""
|
||||
text = str(exc).lower().replace(",", "")
|
||||
patterns = (
|
||||
r"supports at most\s+(\d+)\s+completion tokens",
|
||||
r"at most\s+(\d+)\s+completion tokens",
|
||||
r"max(?:imum)?(?:_completion)?[_\s-]tokens.*?(?:<=|less than or equal to|at most)\s+(\d+)",
|
||||
)
|
||||
for pattern in patterns:
|
||||
match = re.search(pattern, text)
|
||||
if match:
|
||||
try:
|
||||
return max(1, int(match.group(1)))
|
||||
except ValueError:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def _is_completion_token_limit_error(exc: Exception) -> bool:
|
||||
text = str(exc).lower()
|
||||
return (
|
||||
("max_tokens" in text or "max_completion_tokens" in text)
|
||||
and ("too large" in text or "at most" in text or "completion tokens" in text)
|
||||
)
|
||||
|
||||
|
||||
class MaxTurnsExceeded(RuntimeError):
|
||||
"""Raised when the agent exceeds the configured max_turns for one user prompt."""
|
||||
|
||||
@@ -86,6 +145,9 @@ class QueryContext:
|
||||
model: str
|
||||
system_prompt: str
|
||||
max_tokens: int
|
||||
effort: str | None = None
|
||||
context_window_tokens: int | None = None
|
||||
auto_compact_threshold_tokens: int | None = None
|
||||
permission_prompt: PermissionPrompt | None = None
|
||||
ask_user_prompt: AskUserPrompt | None = None
|
||||
max_turns: int | None = 200
|
||||
@@ -261,6 +323,55 @@ def _remember_async_agent_activity(
|
||||
del bucket[:-MAX_TRACKED_ASYNC_AGENT_EVENTS]
|
||||
|
||||
|
||||
def _parse_spawned_agent_identity(
|
||||
output: str,
|
||||
metadata: dict[str, object] | None = None,
|
||||
) -> tuple[str, str] | None:
|
||||
if isinstance(metadata, dict):
|
||||
agent_id = str(metadata.get("agent_id") or "").strip()
|
||||
task_id = str(metadata.get("task_id") or "").strip()
|
||||
if agent_id and task_id:
|
||||
return agent_id, task_id
|
||||
match = re.search(r"Spawned agent (.+?) \(task_id=(\S+?)(?:[,)]|$)", output.strip())
|
||||
if match is None:
|
||||
return None
|
||||
return match.group(1).strip(), match.group(2).strip()
|
||||
|
||||
|
||||
def _remember_async_agent_task(
|
||||
tool_metadata: dict[str, object] | None,
|
||||
*,
|
||||
tool_name: str,
|
||||
tool_input: dict[str, object],
|
||||
output: str,
|
||||
result_metadata: dict[str, object] | None = None,
|
||||
) -> None:
|
||||
if tool_name != "agent":
|
||||
return
|
||||
identity = _parse_spawned_agent_identity(output, result_metadata)
|
||||
if identity is None:
|
||||
return
|
||||
agent_id, task_id = identity
|
||||
bucket = _tool_metadata_bucket(tool_metadata, "async_agent_tasks")
|
||||
description = str(tool_input.get("description") or tool_input.get("prompt") or "").strip()
|
||||
entry = {
|
||||
"agent_id": agent_id,
|
||||
"task_id": task_id,
|
||||
"description": description[:240],
|
||||
"status": "spawned",
|
||||
"notification_sent": False,
|
||||
"spawned_at": time.time(),
|
||||
}
|
||||
bucket[:] = [
|
||||
existing
|
||||
for existing in bucket
|
||||
if not isinstance(existing, dict) or str(existing.get("task_id") or "") != task_id
|
||||
]
|
||||
bucket.append(entry)
|
||||
if len(bucket) > MAX_TRACKED_ASYNC_AGENT_TASKS:
|
||||
del bucket[:-MAX_TRACKED_ASYNC_AGENT_TASKS]
|
||||
|
||||
|
||||
def _remember_work_log(
|
||||
tool_metadata: dict[str, object] | None,
|
||||
*,
|
||||
@@ -287,6 +398,7 @@ def _record_tool_carryover(
|
||||
tool_name: str,
|
||||
tool_input: dict[str, object],
|
||||
tool_output: str,
|
||||
tool_result_metadata: dict[str, object] | None,
|
||||
is_error: bool,
|
||||
resolved_file_path: str | None,
|
||||
) -> None:
|
||||
@@ -324,6 +436,13 @@ def _record_tool_carryover(
|
||||
tool_input=tool_input,
|
||||
output=tool_output,
|
||||
)
|
||||
_remember_async_agent_task(
|
||||
context.tool_metadata,
|
||||
tool_name=tool_name,
|
||||
tool_input=tool_input,
|
||||
output=tool_output,
|
||||
result_metadata=tool_result_metadata,
|
||||
)
|
||||
description = str(tool_input.get("description") or tool_input.get("prompt") or tool_name).strip()
|
||||
_remember_verified_work(
|
||||
context.tool_metadata,
|
||||
@@ -391,6 +510,126 @@ def _record_tool_carryover(
|
||||
_remember_work_log(context.tool_metadata, entry="Exited plan mode")
|
||||
|
||||
|
||||
def _tool_artifact_dir() -> Path:
|
||||
artifact_dir = get_data_dir() / "tool_artifacts"
|
||||
artifact_dir.mkdir(parents=True, exist_ok=True)
|
||||
return artifact_dir
|
||||
|
||||
|
||||
def _safe_tool_artifact_name(tool_name: str) -> str:
|
||||
normalized = re.sub(r"[^A-Za-z0-9_.-]+", "_", tool_name.strip())
|
||||
return (normalized or "tool")[:80]
|
||||
|
||||
|
||||
def _offload_tool_output_if_needed(
|
||||
*,
|
||||
tool_name: str,
|
||||
tool_use_id: str,
|
||||
output: str,
|
||||
) -> tuple[str, Path | None]:
|
||||
inline_limit = tool_output_inline_chars()
|
||||
if len(output) <= inline_limit:
|
||||
return output, None
|
||||
|
||||
artifact_path = (
|
||||
_tool_artifact_dir()
|
||||
/ f"{time.strftime('%Y%m%d-%H%M%S')}-{_safe_tool_artifact_name(tool_name)}-{uuid4().hex[:12]}.txt"
|
||||
)
|
||||
artifact_path.write_text(output, encoding="utf-8", errors="replace")
|
||||
preview = output[:tool_output_preview_chars()]
|
||||
omitted = max(0, len(output) - len(preview))
|
||||
inline = (
|
||||
"[Tool output truncated]\n"
|
||||
f"Tool: {tool_name}\n"
|
||||
f"Tool use id: {tool_use_id}\n"
|
||||
f"Original size: {len(output)} chars\n"
|
||||
f"Full output saved to: {artifact_path}\n"
|
||||
f"Inline preview: first {len(preview)} chars"
|
||||
)
|
||||
if omitted:
|
||||
inline += f" ({omitted} chars omitted)"
|
||||
if preview:
|
||||
inline += f"\n\nPreview:\n{preview}"
|
||||
return inline, artifact_path
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Image preprocessing — convert ImageBlocks to text for non-multimodal models
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_IMAGE_PREPROCESS_STATUS = "Converting image to text description via vision model…"
|
||||
|
||||
|
||||
async def _preprocess_images_in_messages(
|
||||
messages: list[ConversationMessage],
|
||||
context: QueryContext,
|
||||
) -> AsyncIterator[StreamEvent]:
|
||||
"""Scan messages for ImageBlocks and convert them to text if the active
|
||||
model does not support multimodal input.
|
||||
|
||||
Yields status events during conversion so the UI stays responsive.
|
||||
"""
|
||||
if is_model_multimodal(context.model):
|
||||
return
|
||||
|
||||
vision_config = context.tool_metadata.get("vision_model_config")
|
||||
if not vision_config:
|
||||
# No vision model configured — skip preprocessing.
|
||||
return
|
||||
|
||||
# Collect all ImageBlocks with their parent message index and block index
|
||||
pending: list[tuple[int, int, ImageBlock]] = []
|
||||
for msg_idx, msg in enumerate(messages):
|
||||
if msg.role != "user":
|
||||
continue
|
||||
for blk_idx, block in enumerate(msg.content):
|
||||
if isinstance(block, ImageBlock):
|
||||
pending.append((msg_idx, blk_idx, block))
|
||||
|
||||
if not pending:
|
||||
return
|
||||
|
||||
yield StatusEvent(message=_IMAGE_PREPROCESS_STATUS)
|
||||
|
||||
# Process images in parallel
|
||||
async def _describe(msg_idx: int, blk_idx: int, block: ImageBlock) -> tuple[int, int, str]:
|
||||
tool = context.tool_registry.get("image_to_text")
|
||||
if tool is None:
|
||||
return msg_idx, blk_idx, "[Image: could not describe — image_to_text tool not available]"
|
||||
|
||||
# Build tool input
|
||||
tool_input_data: dict[str, object] = {
|
||||
"image_data": block.data,
|
||||
"media_type": block.media_type,
|
||||
"prompt": "Describe this image in detail, including any text, "
|
||||
"UI elements, code, diagrams, or visual information present.",
|
||||
}
|
||||
|
||||
try:
|
||||
parsed = tool.input_model.model_validate(tool_input_data)
|
||||
except Exception:
|
||||
return msg_idx, blk_idx, "[Image: could not parse image data]"
|
||||
|
||||
exec_context = ToolExecutionContext(
|
||||
cwd=context.cwd,
|
||||
metadata={
|
||||
"vision_model_config": vision_config,
|
||||
**(context.tool_metadata or {}),
|
||||
},
|
||||
)
|
||||
result = await tool.execute(parsed, exec_context)
|
||||
if result.is_error:
|
||||
return msg_idx, blk_idx, f"[Image description failed: {result.output}]"
|
||||
return msg_idx, blk_idx, result.output
|
||||
|
||||
results = await asyncio.gather(*[_describe(mi, bi, blk) for mi, bi, blk in pending])
|
||||
|
||||
# Replace ImageBlocks with TextBlocks in-place
|
||||
for msg_idx, blk_idx, description in results:
|
||||
msg = messages[msg_idx]
|
||||
msg.content[blk_idx] = TextBlock(text=description)
|
||||
|
||||
|
||||
async def run_query(
|
||||
context: QueryContext,
|
||||
messages: list[ConversationMessage],
|
||||
@@ -411,6 +650,11 @@ async def run_query(
|
||||
compact_state = AutoCompactState()
|
||||
reactive_compact_attempted = False
|
||||
last_compaction_result: tuple[list[ConversationMessage], bool] = (messages, False)
|
||||
effective_max_tokens = _bounded_completion_tokens(
|
||||
context.max_tokens,
|
||||
context.context_window_tokens,
|
||||
)
|
||||
reported_token_clamp = False
|
||||
|
||||
async def _stream_compaction(
|
||||
*,
|
||||
@@ -435,6 +679,8 @@ async def run_query(
|
||||
trigger=trigger,
|
||||
hook_executor=context.hook_executor,
|
||||
carryover_metadata=context.tool_metadata,
|
||||
context_window_tokens=context.context_window_tokens,
|
||||
auto_compact_threshold_tokens=context.auto_compact_threshold_tokens,
|
||||
)
|
||||
)
|
||||
while True:
|
||||
@@ -453,12 +699,28 @@ async def run_query(
|
||||
turn_count = 0
|
||||
while context.max_turns is None or turn_count < context.max_turns:
|
||||
turn_count += 1
|
||||
if effective_max_tokens != context.max_tokens and not reported_token_clamp:
|
||||
reported_token_clamp = True
|
||||
yield StatusEvent(
|
||||
message=(
|
||||
"Requested max_tokens="
|
||||
f"{context.max_tokens} exceeds the safe per-request output cap; "
|
||||
f"using {effective_max_tokens}."
|
||||
)
|
||||
), None
|
||||
# --- auto-compact check before calling the model ---------------
|
||||
async for event, usage in _stream_compaction(trigger="auto"):
|
||||
yield event, usage
|
||||
messages, was_compacted = last_compaction_result
|
||||
compacted_messages, was_compacted = last_compaction_result
|
||||
if compacted_messages is not messages:
|
||||
messages[:] = compacted_messages
|
||||
# ---------------------------------------------------------------
|
||||
|
||||
# --- image preprocessing: convert ImageBlocks to text for non-vision models ---
|
||||
async for event in _preprocess_images_in_messages(messages, context):
|
||||
yield event, None
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
final_message: ConversationMessage | None = None
|
||||
usage = UsageSnapshot()
|
||||
|
||||
@@ -468,8 +730,9 @@ async def run_query(
|
||||
model=context.model,
|
||||
messages=messages,
|
||||
system_prompt=context.system_prompt,
|
||||
max_tokens=context.max_tokens,
|
||||
max_tokens=effective_max_tokens,
|
||||
tools=context.tool_registry.to_api_schema(),
|
||||
effort=context.effort,
|
||||
)
|
||||
):
|
||||
if isinstance(event, ApiTextDeltaEvent):
|
||||
@@ -489,12 +752,27 @@ async def run_query(
|
||||
usage = event.usage
|
||||
except Exception as exc:
|
||||
error_msg = str(exc)
|
||||
if _is_completion_token_limit_error(exc):
|
||||
supported_limit = _extract_completion_token_limit(exc)
|
||||
if supported_limit is not None and effective_max_tokens > supported_limit:
|
||||
previous_max_tokens = effective_max_tokens
|
||||
effective_max_tokens = supported_limit
|
||||
yield StatusEvent(
|
||||
message=(
|
||||
f"Model rejected max_tokens={previous_max_tokens}; "
|
||||
f"retrying with provider limit {effective_max_tokens}."
|
||||
)
|
||||
), None
|
||||
turn_count = max(0, turn_count - 1)
|
||||
continue
|
||||
if not reactive_compact_attempted and _is_prompt_too_long_error(exc):
|
||||
reactive_compact_attempted = True
|
||||
yield StatusEvent(message=REACTIVE_COMPACT_STATUS_MESSAGE), None
|
||||
async for event, usage in _stream_compaction(trigger="reactive", force=True):
|
||||
yield event, usage
|
||||
messages, was_compacted = last_compaction_result
|
||||
compacted_messages, was_compacted = last_compaction_result
|
||||
if compacted_messages is not messages:
|
||||
messages[:] = compacted_messages
|
||||
if was_compacted:
|
||||
continue
|
||||
if "connect" in error_msg.lower() or "timeout" in error_msg.lower() or "network" in error_msg.lower():
|
||||
@@ -511,6 +789,16 @@ async def run_query(
|
||||
if messages and messages[-1].role == "user" and messages[-1].text.startswith("# Coordinator User Context"):
|
||||
coordinator_context_message = messages.pop()
|
||||
|
||||
if final_message.role == "assistant" and final_message.is_effectively_empty():
|
||||
log.warning("dropping empty assistant message from provider response")
|
||||
yield ErrorEvent(
|
||||
message=(
|
||||
"Model returned an empty assistant message. "
|
||||
"The turn was ignored to keep the session healthy."
|
||||
)
|
||||
), usage
|
||||
return
|
||||
|
||||
messages.append(final_message)
|
||||
yield AssistantTurnComplete(message=final_message, usage=usage), usage
|
||||
|
||||
@@ -518,6 +806,14 @@ async def run_query(
|
||||
messages.append(coordinator_context_message)
|
||||
|
||||
if not final_message.tool_uses:
|
||||
if context.hook_executor is not None:
|
||||
await context.hook_executor.execute(
|
||||
HookEvent.STOP,
|
||||
{
|
||||
"event": HookEvent.STOP.value,
|
||||
"stop_reason": "tool_uses_empty",
|
||||
},
|
||||
)
|
||||
return
|
||||
|
||||
tool_calls = final_message.tool_uses
|
||||
@@ -526,11 +822,20 @@ async def run_query(
|
||||
# Single tool: sequential (stream events immediately)
|
||||
tc = tool_calls[0]
|
||||
yield ToolExecutionStarted(tool_name=tc.name, tool_input=tc.input), None
|
||||
result = await _execute_tool_call(context, tc.name, tc.id, tc.input)
|
||||
try:
|
||||
result = await _execute_tool_call(context, tc.name, tc.id, tc.input)
|
||||
except Exception as exc:
|
||||
log.exception("tool execution raised: name=%s id=%s", tc.name, tc.id)
|
||||
result = ToolResultBlock(
|
||||
tool_use_id=tc.id,
|
||||
content=f"Tool {tc.name} failed: {type(exc).__name__}: {exc}",
|
||||
is_error=True,
|
||||
)
|
||||
yield ToolExecutionCompleted(
|
||||
tool_name=tc.name,
|
||||
output=result.content,
|
||||
is_error=result.is_error,
|
||||
metadata=result.result_metadata,
|
||||
), None
|
||||
tool_results = [result]
|
||||
else:
|
||||
@@ -541,14 +846,35 @@ async def run_query(
|
||||
async def _run(tc):
|
||||
return await _execute_tool_call(context, tc.name, tc.id, tc.input)
|
||||
|
||||
results = await asyncio.gather(*[_run(tc) for tc in tool_calls])
|
||||
tool_results = list(results)
|
||||
# Use return_exceptions=True so a single failing tool does not abandon
|
||||
# its siblings as cancelled coroutines and leave the conversation with
|
||||
# un-replied tool_use blocks (Anthropic's API rejects the next request
|
||||
# on the session if any tool_use is missing a matching tool_result).
|
||||
raw_results = await asyncio.gather(
|
||||
*[_run(tc) for tc in tool_calls], return_exceptions=True
|
||||
)
|
||||
tool_results = []
|
||||
for tc, result in zip(tool_calls, raw_results):
|
||||
if isinstance(result, BaseException):
|
||||
log.exception(
|
||||
"tool execution raised: name=%s id=%s",
|
||||
tc.name,
|
||||
tc.id,
|
||||
exc_info=result,
|
||||
)
|
||||
result = ToolResultBlock(
|
||||
tool_use_id=tc.id,
|
||||
content=f"Tool {tc.name} failed: {type(result).__name__}: {result}",
|
||||
is_error=True,
|
||||
)
|
||||
tool_results.append(result)
|
||||
|
||||
for tc, result in zip(tool_calls, tool_results):
|
||||
yield ToolExecutionCompleted(
|
||||
tool_name=tc.name,
|
||||
output=result.content,
|
||||
is_error=result.is_error,
|
||||
metadata=result.result_metadata,
|
||||
), None
|
||||
|
||||
messages.append(ConversationMessage(role="user", content=tool_results))
|
||||
@@ -598,7 +924,8 @@ async def _execute_tool_call(
|
||||
)
|
||||
|
||||
# Normalize common tool inputs before permission checks so path rules apply
|
||||
# consistently across built-in tools that use either `file_path` or `path`.
|
||||
# consistently across built-in tools that use `file_path`, `path`, or
|
||||
# directory-scoped roots such as `glob`/`grep`.
|
||||
_file_path = _resolve_permission_file_path(context.cwd, tool_input, parsed_input)
|
||||
_command = _extract_permission_command(tool_input, parsed_input)
|
||||
log.debug("permission check: %s read_only=%s path=%s cmd=%s",
|
||||
@@ -612,12 +939,22 @@ async def _execute_tool_call(
|
||||
if not decision.allowed:
|
||||
if decision.requires_confirmation and context.permission_prompt is not None:
|
||||
log.debug("permission prompt for %s: %s", tool_name, decision.reason)
|
||||
if context.hook_executor is not None:
|
||||
await context.hook_executor.execute(
|
||||
HookEvent.NOTIFICATION,
|
||||
{
|
||||
"event": HookEvent.NOTIFICATION.value,
|
||||
"notification_type": "permission_prompt",
|
||||
"tool_name": tool_name,
|
||||
"reason": decision.reason,
|
||||
},
|
||||
)
|
||||
confirmed = await context.permission_prompt(tool_name, decision.reason)
|
||||
if not confirmed:
|
||||
log.debug("permission denied by user for %s", tool_name)
|
||||
return ToolResultBlock(
|
||||
tool_use_id=tool_use_id,
|
||||
content=f"Permission denied for {tool_name}",
|
||||
content=decision.reason or f"Permission denied for {tool_name}",
|
||||
is_error=True,
|
||||
)
|
||||
else:
|
||||
@@ -639,21 +976,31 @@ async def _execute_tool_call(
|
||||
"ask_user_prompt": context.ask_user_prompt,
|
||||
**(context.tool_metadata or {}),
|
||||
},
|
||||
hook_executor=context.hook_executor,
|
||||
),
|
||||
)
|
||||
elapsed = time.monotonic() - t0
|
||||
log.debug("executed %s in %.2fs err=%s output_len=%d",
|
||||
tool_name, elapsed, result.is_error, len(result.output or ""))
|
||||
inline_output, artifact_path = _offload_tool_output_if_needed(
|
||||
tool_name=tool_name,
|
||||
tool_use_id=tool_use_id,
|
||||
output=result.output,
|
||||
)
|
||||
if artifact_path is not None:
|
||||
_remember_active_artifact(context.tool_metadata, str(artifact_path))
|
||||
tool_result = ToolResultBlock(
|
||||
tool_use_id=tool_use_id,
|
||||
content=result.output,
|
||||
content=inline_output,
|
||||
is_error=result.is_error,
|
||||
result_metadata=dict(result.metadata or {}),
|
||||
)
|
||||
_record_tool_carryover(
|
||||
context,
|
||||
tool_name=tool_name,
|
||||
tool_input=tool_input,
|
||||
tool_output=tool_result.content,
|
||||
tool_result_metadata=result.metadata,
|
||||
is_error=tool_result.is_error,
|
||||
resolved_file_path=_file_path,
|
||||
)
|
||||
@@ -676,7 +1023,7 @@ def _resolve_permission_file_path(
|
||||
raw_input: dict[str, object],
|
||||
parsed_input: object,
|
||||
) -> str | None:
|
||||
for key in ("file_path", "path"):
|
||||
for key in ("file_path", "path", "root"):
|
||||
value = raw_input.get(key)
|
||||
if isinstance(value, str) and value.strip():
|
||||
path = Path(value).expanduser()
|
||||
@@ -684,7 +1031,7 @@ def _resolve_permission_file_path(
|
||||
path = cwd / path
|
||||
return str(path.resolve())
|
||||
|
||||
for attr in ("file_path", "path"):
|
||||
for attr in ("file_path", "path", "root"):
|
||||
value = getattr(parsed_input, attr, None)
|
||||
if isinstance(value, str) and value.strip():
|
||||
path = Path(value).expanduser()
|
||||
|
||||
@@ -8,11 +8,13 @@ from typing import AsyncIterator
|
||||
from openharness.api.client import SupportsStreamingMessages
|
||||
from openharness.engine.cost_tracker import CostTracker
|
||||
from openharness.coordinator.coordinator_mode import get_coordinator_user_context
|
||||
from openharness.engine.messages import ConversationMessage, TextBlock, ToolResultBlock
|
||||
from openharness.engine.messages import ConversationMessage, TextBlock, ToolResultBlock, sanitize_conversation_messages
|
||||
from openharness.engine.query import AskUserPrompt, PermissionPrompt, QueryContext, remember_user_goal, run_query
|
||||
from openharness.engine.stream_events import AssistantTurnComplete, StreamEvent
|
||||
from openharness.hooks import HookExecutor
|
||||
from openharness.config.settings import Settings
|
||||
from openharness.hooks import HookEvent, HookExecutor
|
||||
from openharness.permissions.checker import PermissionChecker
|
||||
from openharness.services.autodream.service import schedule_auto_dream
|
||||
from openharness.tools.base import ToolRegistry
|
||||
|
||||
|
||||
@@ -29,11 +31,14 @@ class QueryEngine:
|
||||
model: str,
|
||||
system_prompt: str,
|
||||
max_tokens: int = 4096,
|
||||
context_window_tokens: int | None = None,
|
||||
auto_compact_threshold_tokens: int | None = None,
|
||||
max_turns: int | None = 8,
|
||||
permission_prompt: PermissionPrompt | None = None,
|
||||
ask_user_prompt: AskUserPrompt | None = None,
|
||||
hook_executor: HookExecutor | None = None,
|
||||
tool_metadata: dict[str, object] | None = None,
|
||||
settings: Settings | None = None,
|
||||
) -> None:
|
||||
self._api_client = api_client
|
||||
self._tool_registry = tool_registry
|
||||
@@ -42,11 +47,15 @@ class QueryEngine:
|
||||
self._model = model
|
||||
self._system_prompt = system_prompt
|
||||
self._max_tokens = max_tokens
|
||||
self._effort = settings.effort if settings is not None else None
|
||||
self._context_window_tokens = context_window_tokens
|
||||
self._auto_compact_threshold_tokens = auto_compact_threshold_tokens
|
||||
self._max_turns = max_turns
|
||||
self._permission_prompt = permission_prompt
|
||||
self._ask_user_prompt = ask_user_prompt
|
||||
self._hook_executor = hook_executor
|
||||
self._tool_metadata = tool_metadata or {}
|
||||
self._settings = settings
|
||||
self._messages: list[ConversationMessage] = []
|
||||
self._cost_tracker = CostTracker()
|
||||
|
||||
@@ -98,6 +107,10 @@ class QueryEngine:
|
||||
"""Update the active model for future turns."""
|
||||
self._model = model
|
||||
|
||||
def set_effort(self, effort: str | None) -> None:
|
||||
"""Update the active reasoning effort for future turns."""
|
||||
self._effort = effort
|
||||
|
||||
def set_api_client(self, api_client: SupportsStreamingMessages) -> None:
|
||||
"""Update the active API client for future turns."""
|
||||
self._api_client = api_client
|
||||
@@ -125,6 +138,77 @@ class QueryEngine:
|
||||
"""Replace the in-memory conversation history."""
|
||||
self._messages = list(messages)
|
||||
|
||||
def _schedule_auto_dream(self) -> None:
|
||||
"""Fire-and-forget background memory consolidation after a user turn."""
|
||||
if self._settings is None:
|
||||
return
|
||||
context = self._tool_metadata.get("autodream_context")
|
||||
kwargs = dict(context) if isinstance(context, dict) else {}
|
||||
schedule_auto_dream(
|
||||
cwd=self._cwd,
|
||||
settings=self._settings,
|
||||
model=self._model,
|
||||
current_session_id=str(self._tool_metadata.get("session_id") or ""),
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
def _prepare_session_memory(self) -> None:
|
||||
"""Expose file-backed session memory to compaction when enabled."""
|
||||
|
||||
if self._settings is None or not self._settings.memory.session_memory_enabled:
|
||||
return
|
||||
if not self._settings.memory.enabled:
|
||||
return
|
||||
from openharness.services.session_memory import prepare_session_memory_metadata
|
||||
|
||||
prepare_session_memory_metadata(
|
||||
self._cwd,
|
||||
self._tool_metadata,
|
||||
session_id=str(self._tool_metadata.get("session_id") or "default"),
|
||||
)
|
||||
|
||||
async def _update_session_memory(self) -> None:
|
||||
"""Persist a session checkpoint after a user turn."""
|
||||
|
||||
if self._settings is None or not self._settings.memory.session_memory_enabled:
|
||||
return
|
||||
if not self._settings.memory.enabled:
|
||||
return
|
||||
from openharness.services.session_memory import update_session_memory_file
|
||||
|
||||
update_session_memory_file(
|
||||
self._cwd,
|
||||
list(self._messages),
|
||||
tool_metadata=self._tool_metadata,
|
||||
session_id=str(self._tool_metadata.get("session_id") or "default"),
|
||||
)
|
||||
|
||||
async def _extract_durable_memories(self) -> None:
|
||||
"""Run the optional durable memory extraction pass."""
|
||||
|
||||
if self._settings is None or not self._settings.memory.auto_extract_enabled:
|
||||
return
|
||||
if not self._settings.memory.enabled:
|
||||
return
|
||||
from openharness.services.memory_extract import extract_memories_from_turn
|
||||
|
||||
try:
|
||||
result = await extract_memories_from_turn(
|
||||
cwd=self._cwd,
|
||||
api_client=self._api_client,
|
||||
model=self._model,
|
||||
messages=list(self._messages),
|
||||
max_records=self._settings.memory.auto_extract_max_records,
|
||||
)
|
||||
except Exception as exc:
|
||||
self._tool_metadata["memory_extract_last_error"] = str(exc)
|
||||
return
|
||||
self._tool_metadata["memory_extract_last"] = {
|
||||
"skipped": result.skipped,
|
||||
"reason": result.reason,
|
||||
"written_paths": [str(path) for path in result.written_paths],
|
||||
}
|
||||
|
||||
def has_pending_continuation(self) -> bool:
|
||||
"""Return True when the conversation ends with tool results awaiting a follow-up model turn."""
|
||||
if not self._messages:
|
||||
@@ -147,9 +231,19 @@ class QueryEngine:
|
||||
if isinstance(prompt, ConversationMessage)
|
||||
else ConversationMessage.from_user_text(prompt)
|
||||
)
|
||||
if user_message.text.strip():
|
||||
if user_message.text.strip() and not self._tool_metadata.pop("_suppress_next_user_goal", False):
|
||||
remember_user_goal(self._tool_metadata, user_message.text)
|
||||
self._prepare_session_memory()
|
||||
self._messages = sanitize_conversation_messages(self._messages)
|
||||
self._messages.append(user_message)
|
||||
if self._hook_executor is not None:
|
||||
await self._hook_executor.execute(
|
||||
HookEvent.USER_PROMPT_SUBMIT,
|
||||
{
|
||||
"event": HookEvent.USER_PROMPT_SUBMIT.value,
|
||||
"prompt": user_message.text,
|
||||
},
|
||||
)
|
||||
context = QueryContext(
|
||||
api_client=self._api_client,
|
||||
tool_registry=self._tool_registry,
|
||||
@@ -158,6 +252,9 @@ class QueryEngine:
|
||||
model=self._model,
|
||||
system_prompt=self._system_prompt,
|
||||
max_tokens=self._max_tokens,
|
||||
effort=self._effort,
|
||||
context_window_tokens=self._context_window_tokens,
|
||||
auto_compact_threshold_tokens=self._auto_compact_threshold_tokens,
|
||||
max_turns=self._max_turns,
|
||||
permission_prompt=self._permission_prompt,
|
||||
ask_user_prompt=self._ask_user_prompt,
|
||||
@@ -168,15 +265,22 @@ class QueryEngine:
|
||||
coordinator_context = self._build_coordinator_context_message()
|
||||
if coordinator_context is not None:
|
||||
query_messages.append(coordinator_context)
|
||||
async for event, usage in run_query(context, query_messages):
|
||||
if isinstance(event, AssistantTurnComplete):
|
||||
self._messages = list(query_messages)
|
||||
if usage is not None:
|
||||
self._cost_tracker.add(usage)
|
||||
yield event
|
||||
try:
|
||||
async for event, usage in run_query(context, query_messages):
|
||||
if isinstance(event, AssistantTurnComplete):
|
||||
self._messages = list(query_messages)
|
||||
if usage is not None:
|
||||
self._cost_tracker.add(usage)
|
||||
yield event
|
||||
finally:
|
||||
await self._update_session_memory()
|
||||
await self._extract_durable_memories()
|
||||
self._schedule_auto_dream()
|
||||
|
||||
async def continue_pending(self, *, max_turns: int | None = None) -> AsyncIterator[StreamEvent]:
|
||||
"""Continue an interrupted tool loop without appending a new user message."""
|
||||
self._prepare_session_memory()
|
||||
self._messages = sanitize_conversation_messages(self._messages)
|
||||
context = QueryContext(
|
||||
api_client=self._api_client,
|
||||
tool_registry=self._tool_registry,
|
||||
@@ -185,6 +289,9 @@ class QueryEngine:
|
||||
model=self._model,
|
||||
system_prompt=self._system_prompt,
|
||||
max_tokens=self._max_tokens,
|
||||
effort=self._effort,
|
||||
context_window_tokens=self._context_window_tokens,
|
||||
auto_compact_threshold_tokens=self._auto_compact_threshold_tokens,
|
||||
max_turns=max_turns if max_turns is not None else self._max_turns,
|
||||
permission_prompt=self._permission_prompt,
|
||||
ask_user_prompt=self._ask_user_prompt,
|
||||
@@ -195,3 +302,5 @@ class QueryEngine:
|
||||
if usage is not None:
|
||||
self._cost_tracker.add(usage)
|
||||
yield event
|
||||
await self._update_session_memory()
|
||||
await self._extract_durable_memories()
|
||||
|
||||
@@ -39,6 +39,7 @@ class ToolExecutionCompleted:
|
||||
tool_name: str
|
||||
output: str
|
||||
is_error: bool = False
|
||||
metadata: dict[str, Any] | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
|
||||
@@ -14,3 +14,7 @@ class HookEvent(str, Enum):
|
||||
POST_COMPACT = "post_compact"
|
||||
PRE_TOOL_USE = "pre_tool_use"
|
||||
POST_TOOL_USE = "post_tool_use"
|
||||
USER_PROMPT_SUBMIT = "user_prompt_submit"
|
||||
NOTIFICATION = "notification"
|
||||
STOP = "stop"
|
||||
SUBAGENT_STOP = "subagent_stop"
|
||||
|
||||
@@ -18,8 +18,13 @@ class HookRegistry:
|
||||
self._hooks[event].append(hook)
|
||||
|
||||
def get(self, event: HookEvent) -> list[HookDefinition]:
|
||||
"""Return hooks registered for an event."""
|
||||
return list(self._hooks.get(event, []))
|
||||
"""Return hooks registered for an event, ordered by priority.
|
||||
|
||||
Hooks with a higher ``priority`` run first. ``sorted`` is stable, so
|
||||
hooks sharing the same priority keep their registration order.
|
||||
"""
|
||||
hooks = self._hooks.get(event, [])
|
||||
return sorted(hooks, key=lambda hook: -getattr(hook, "priority", 0))
|
||||
|
||||
def summary(self) -> str:
|
||||
"""Return a human-readable hook summary."""
|
||||
@@ -33,6 +38,9 @@ class HookRegistry:
|
||||
matcher = getattr(hook, "matcher", None)
|
||||
detail = getattr(hook, "command", None) or getattr(hook, "prompt", None) or getattr(hook, "url", None) or ""
|
||||
suffix = f" matcher={matcher}" if matcher else ""
|
||||
priority = getattr(hook, "priority", 0)
|
||||
if priority:
|
||||
suffix += f" priority={priority}"
|
||||
lines.append(f" - {hook.type}{suffix}: {detail}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user