From a6b799bdddac82b29aba1ebaffc9a532487cba4a Mon Sep 17 00:00:00 2001 From: JordanTheJet Date: Wed, 17 Jun 2026 10:43:04 -0500 Subject: [PATCH 1/2] ci(clawpatch): review every PR with zeroclaw as the brain (#2) * ci(clawpatch): review every PR with zeroclaw as the brain Adds a GitHub Actions workflow that, on each PR, installs the prebuilt zeroclaw binary + acpx, points acpx at `zeroclaw acp` (stdio ACP), and runs `clawpatch ci` with provider=acpx so changed code is reviewed by ZeroClaw's own coding agent (Claude-backed). Non-blocking; findings post to the job summary + an artifact. Requires the ANTHROPIC_OAUTH_TOKEN repo secret. * ci: daily auto-sync fork master with upstream zeroclaw-labs/zeroclaw --- .github/clawpatch/factory-zc.config.toml | 26 ++++++++ .github/workflows/clawpatch-review.yml | 85 ++++++++++++++++++++++++ .github/workflows/sync-upstream.yml | 35 ++++++++++ clawpatch.config.json | 30 +++++++++ 4 files changed, 176 insertions(+) create mode 100644 .github/clawpatch/factory-zc.config.toml create mode 100644 .github/workflows/clawpatch-review.yml create mode 100644 .github/workflows/sync-upstream.yml create mode 100644 clawpatch.config.json diff --git a/.github/clawpatch/factory-zc.config.toml b/.github/clawpatch/factory-zc.config.toml new file mode 100644 index 00000000000..8b171adadda --- /dev/null +++ b/.github/clawpatch/factory-zc.config.toml @@ -0,0 +1,26 @@ +# Isolated ZeroClaw config for the clawpatch CI "brain" (the agent that reviews PRs). +# __ZC_TOKEN__ is replaced at CI time from the ANTHROPIC_OAUTH_TOKEN repo secret. +# The Anthropic provider auto-detects the sk-ant-oat01- prefix and uses the OAuth path. +schema_version = 3 + +[providers.models.anthropic.factory] +model = "claude-sonnet-4-6" +api_key = "__ZC_TOKEN__" + +[agents.factory] +model_provider = "anthropic.factory" +enabled = true +risk_profile = "default" +runtime_profile = "default" + +# Review is read-only; a default (supervised) risk profile is sufficient for CI. +[risk_profiles.default] + +[runtime_profiles.default] +agentic = true +max_tool_iterations = 50 + +[acp] +default_agent = "factory" +max_sessions = 5 +session_timeout_secs = 3600 diff --git a/.github/workflows/clawpatch-review.yml b/.github/workflows/clawpatch-review.yml new file mode 100644 index 00000000000..e178e240a9e --- /dev/null +++ b/.github/workflows/clawpatch-review.yml @@ -0,0 +1,85 @@ +name: clawpatch (zeroclaw self-review) + +# Reviews the code changed in each PR using ZeroClaw's OWN coding agent as the brain: +# clawpatch (provider=acpx) -> acpx -> `zeroclaw acp` (stdio JSON-RPC) -> ZeroClaw agent -> Claude +# +# Requirements: +# - Repo secret ANTHROPIC_OAUTH_TOKEN: a Claude subscription OAuth token (sk-ant-oat01-..., +# minted with `claude setup-token`). ZeroClaw's Anthropic provider auto-detects the prefix. +# +# Behavior: non-blocking. Findings are posted to the job summary and uploaded as an artifact. +# This is a best-effort first cut — validate on a real PR run and tune timeouts/model as needed. + +on: + pull_request: + branches: [master] + +permissions: + contents: read + +concurrency: + group: clawpatch-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + review: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - uses: actions/setup-node@v4 + with: + node-version: '22' + + - name: Install zeroclaw (prebuilt) + acpx + clawpatch + run: | + set -euo pipefail + ZC_VERSION="v0.8.0" + curl -fsSL "https://github.com/zeroclaw-labs/zeroclaw/releases/download/${ZC_VERSION}/zeroclaw-x86_64-unknown-linux-gnu.tar.gz" | tar -xz + sudo install -m 0755 zeroclaw /usr/local/bin/zeroclaw + zeroclaw --version + # acpx pinned to clawpatch's tested range; clawpatch from npm (swap for your fork if desired) + npm install -g 'acpx@^0.8.0' clawpatch + acpx --version + clawpatch --version + + - name: Configure the zeroclaw ACP brain + env: + ZC_TOKEN: ${{ secrets.ANTHROPIC_OAUTH_TOKEN }} + run: | + set -euo pipefail + if [ -z "${ZC_TOKEN:-}" ]; then + echo "::warning::ANTHROPIC_OAUTH_TOKEN secret is not set — the review step will be skipped." + fi + ZC_DIR="$RUNNER_TEMP/factory-zc" + mkdir -p "$ZC_DIR" + umask 077 + sed "s|__ZC_TOKEN__|${ZC_TOKEN:-}|" .github/clawpatch/factory-zc.config.toml > "$ZC_DIR/config.toml" + mkdir -p "$HOME/.acpx" + printf '{"defaultAgent":"zeroclaw","agents":{"zeroclaw":{"command":"zeroclaw","args":["acp","--config-dir","%s"]}}}\n' "$ZC_DIR" > "$HOME/.acpx/config.json" + echo "ZC_DIR=$ZC_DIR" >> "$GITHUB_ENV" + + - name: Review changed code via zeroclaw + continue-on-error: true + env: + CLAWPATCH_PROVIDER: acpx + CLAWPATCH_MODEL: zeroclaw + CLAWPATCH_ACPX_TIMEOUT_MS: '600000' + run: | + set -uo pipefail + clawpatch init || true + clawpatch ci --since "origin/${{ github.base_ref }}" --output clawpatch-report.md || true + if [ -s clawpatch-report.md ]; then + { echo '## 🦞 clawpatch — zeroclaw self-review'; echo; cat clawpatch-report.md; } >> "$GITHUB_STEP_SUMMARY" + else + echo 'clawpatch produced no report (no changed features, or the brain was unavailable).' >> "$GITHUB_STEP_SUMMARY" + fi + + - uses: actions/upload-artifact@v4 + if: always() + with: + name: clawpatch-report + path: clawpatch-report.md + if-no-files-found: ignore diff --git a/.github/workflows/sync-upstream.yml b/.github/workflows/sync-upstream.yml new file mode 100644 index 00000000000..11e7ac946d0 --- /dev/null +++ b/.github/workflows/sync-upstream.yml @@ -0,0 +1,35 @@ +name: sync fork with upstream + +# Keeps this fork's `master` current with zeroclaw-labs/zeroclaw by merging upstream +# in daily. Fork-only files (the clawpatch CI) have no upstream counterpart, so the +# merge is clean; if a real conflict ever appears the run fails so you can resolve it. +on: + schedule: + - cron: "17 7 * * *" + workflow_dispatch: {} + +permissions: + contents: write + +jobs: + sync: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Merge upstream/master into master + run: | + set -euo pipefail + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git remote add upstream https://github.com/zeroclaw-labs/zeroclaw.git + git fetch upstream master + BEFORE=$(git rev-parse HEAD) + git merge --no-edit upstream/master + if [ "$(git rev-parse HEAD)" != "$BEFORE" ]; then + git push origin HEAD:master + echo "synced: merged upstream/master into master" + else + echo "already up to date with upstream/master" + fi diff --git a/clawpatch.config.json b/clawpatch.config.json new file mode 100644 index 00000000000..8961805b070 --- /dev/null +++ b/clawpatch.config.json @@ -0,0 +1,30 @@ +{ + "schemaVersion": 1, + "stateDir": ".clawpatch", + "include": ["**/*"], + "exclude": [ + "node_modules/**", + "dist/**", + "build/**", + "target/**", + ".build/**", + ".git/**", + ".clawpatch/**", + ".context/**" + ], + "provider": { + "name": "acpx", + "model": "zeroclaw", + "reasoningEffort": null, + "codexConfig": {} + }, + "commands": { "typecheck": null, "lint": null, "format": null, "test": null }, + "review": { + "maxContextFiles": 24, + "maxOwnedFiles": 12, + "maxFindingsPerFeature": 10, + "minConfidenceToFix": "medium" + }, + "git": { "requireCleanWorktreeForFix": true, "commit": false, "openPr": false }, + "registryVerifier": { "enabled": false } +} From 040581926e5850d6a25ac890d7ee36032949eb22 Mon Sep 17 00:00:00 2001 From: jordanthejet Date: Fri, 19 Jun 2026 18:05:58 -0400 Subject: [PATCH 2/2] feat(zerocode): add mid-session agent switching (Ctrl+G) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The agent was chosen once at session start; the only way to change it was to quit and relaunch. Ctrl+N restarts with the same agent and Ctrl+S only switches between existing sessions, so there was no path back to the agent picker from a live session. Add a SwitchAgent action (Ctrl+G) that opens an agent-picker overlay from an active session, mirroring the existing session-switch overlay. Enter starts a fresh session against the chosen agent; Esc returns to the current session untouched; re-picking the current agent is a no-op so a stray confirm never discards the live session. A click only moves the selection (Enter confirms) for the same reason. Details: - restart/switch share start_fresh_session, which commits agent_alias only after the daemon confirms the new session — a failed create no longer strands the alias on an agent the live session isn't running. - agent_overlay is mirrored into wants_text_input() so global keys (?, Ctrl+R) treat the picker the same as the session picker regardless of any leftover draft. - Switching warns via the info bar when a draft or queued messages are cleared, instead of dropping them silently. The agent list is a transient on-demand snapshot of agents_status — the live agent of record remains ChatState::agent_alias; nothing is cached. --- apps/zerocode/locales/en/zerocode.ftl | 3 + apps/zerocode/src/chat.rs | 230 +++++++++++++++++++++++++- apps/zerocode/src/keymap/actions.rs | 1 + 3 files changed, 229 insertions(+), 5 deletions(-) diff --git a/apps/zerocode/locales/en/zerocode.ftl b/apps/zerocode/locales/en/zerocode.ftl index bfcb5ff3e74..4cc8955f599 100644 --- a/apps/zerocode/locales/en/zerocode.ftl +++ b/apps/zerocode/locales/en/zerocode.ftl @@ -346,6 +346,8 @@ zc-chat-error-fetch-agents = Failed to fetch agents: { $error } zc-chat-error-create-session = Failed to create session: { $error } zc-chat-session-restarted = New session started. zc-chat-session-restart-error = Failed to start a new session: { $error } +zc-chat-agent-switched = Switched to agent { $alias }. +zc-chat-agent-switched-cleared = Switched to agent { $alias }. Your draft and queued messages were discarded. zc-chat-thinking-visible = Thinking output: visible zc-chat-thinking-hidden = Thinking output: hidden @@ -376,6 +378,7 @@ zc-chat-help-navigate = Navigate zc-chat-help-select-agent = Select agent zc-chat-help-quit = Quit zc-chat-help-switch-session = Switch session +zc-chat-help-switch-agent = Switch agent zc-chat-help-close = Close zc-chat-help-cancel = Cancel zc-chat-help-approve = Approve diff --git a/apps/zerocode/src/chat.rs b/apps/zerocode/src/chat.rs index 72cda87318c..d1be5a0600e 100644 --- a/apps/zerocode/src/chat.rs +++ b/apps/zerocode/src/chat.rs @@ -404,13 +404,40 @@ impl Chat { state: &mut ChatState, ) -> Option { let alias = state.agent_alias.clone(); + Self::start_fresh_session( + rpc, + pane_kind, + state, + alias, + crate::i18n::t("zc-chat-session-restarted"), + ) + .await + } + + /// Close the live session and open a fresh one bound to `target_alias`. + /// + /// `target_alias` is committed to `state.agent_alias` only after the daemon + /// confirms the new session, so a failed create never strands the alias + /// pointing at an agent the live session isn't actually running. Restarting + /// (same agent) and switching agents both route through here; `restart` + /// passes the current alias, the agent picker passes the chosen one. + /// `success_notice` is the info-bar message shown once the session is live. + async fn start_fresh_session( + rpc: &Arc, + pane_kind: PaneKind, + state: &mut ChatState, + target_alias: String, + success_notice: String, + ) -> Option { if pane_kind == PaneKind::Acp && rpc.transport() == crate::client::Transport::Wss { - // For WSS ACP, go through the CWD picker for new sessions too. + // For WSS ACP, go through the CWD picker for new sessions too. The + // new alias rides on the picker and is applied when the session is + // built on confirm, so a cancel never mutates the live agent here. let _ = rpc.session_close(&state.session_id).await; // Remote ACP picker must start from a path the daemon understands. let start_dir = std::path::PathBuf::from("/"); return Some(ChatPhase::PickCwd { - agent_alias: alias, + agent_alias: target_alias, explorer: FileExplorerState::new_dir_picker_remote(start_dir, Arc::clone(rpc)), }); } @@ -422,20 +449,23 @@ impl Chat { }; let cwd_str = local_cwd.as_deref().and_then(|p| p.to_str()); let new_session = if pane_kind == PaneKind::Acp { - rpc.session_new_acp(&alias, cwd_str, None).await + rpc.session_new_acp(&target_alias, cwd_str, None).await } else { - rpc.session_new(&alias, cwd_str).await + rpc.session_new(&target_alias, cwd_str).await }; match new_session { Ok(s) => { let old_session_id = state.session_id.clone(); let _ = rpc.session_close(&old_session_id).await; state.reset_for_session(s.session_id, None); + // Commit the alias only now that the new session exists, so the + // alias and the live session can never disagree on a failure. + state.agent_alias = target_alias; if pane_kind == PaneKind::Acp { state.cwd = s.workspace_dir; } Self::refresh_model_identity(rpc, state).await; - state.set_info_notice(crate::i18n::t("zc-chat-session-restarted")); + state.set_info_notice(success_notice); } Err(e) => { state.set_info_notice(crate::i18n::t_args( @@ -864,6 +894,67 @@ impl Chat { SessionOverlay::None => { /* handled below */ } } + // ── Agent overlay key handling ─────────────────────────── + // While the agent picker is open it captures every key: navigate the + // list, Enter switches to the chosen agent (a fresh session against the + // new alias, same mechanics as a restart), Esc closes and returns to + // the current session untouched. + if matches!(state.agent_overlay, AgentOverlay::List { .. }) { + use crate::keymap::{Chord, ModalAction}; + let mut chosen: Option = None; + if let AgentOverlay::List { agents, list_state } = &mut state.agent_overlay { + match ModalAction::from_chord(&key) { + Some(ModalAction::Cancel) => { + state.agent_overlay = AgentOverlay::None; + } + Some(ModalAction::Confirm) => { + if let Some(i) = list_state.selected() { + chosen = agents.get(i).cloned(); + } + state.agent_overlay = AgentOverlay::None; + } + _ => { + if Chord::key(crossterm::event::KeyCode::Up).matches(&key) { + let i = list_state.selected().unwrap_or(0); + list_state.select(Some(i.saturating_sub(1))); + } else if Chord::key(crossterm::event::KeyCode::Down).matches(&key) { + let i = list_state.selected().unwrap_or(0); + if i + 1 < agents.len() { + list_state.select(Some(i + 1)); + } + } + } + } + } + // Switch after the overlay borrow is released. `start_fresh_session` + // mints a new session against the chosen agent and commits the alias + // only on success. Re-picking the current agent is a no-op so it + // never throws away the active session. + if let Some(alias) = chosen + && alias != state.agent_alias + { + // A fresh session starts empty: warn when a draft or queued + // messages are about to be cleared instead of dropping them + // silently. + let had_pending = + state.queue_len() > 0 || !state.input_bar.input().trim().is_empty(); + let notice_key = if had_pending { + "zc-chat-agent-switched-cleared" + } else { + "zc-chat-agent-switched" + }; + let notice = crate::i18n::t_args(notice_key, &[("alias", &alias)]); + let rpc = self.rpc.clone(); + let pane_kind = self.pane_kind; + if let Some(next_phase) = + Self::start_fresh_session(&rpc, pane_kind, state, alias, notice).await + { + self.phase = next_phase; + } + } + return false; + } + { use crate::keymap::ChatTabAction as QAction; let qaction = QAction::from_chord(&key); @@ -1178,6 +1269,30 @@ impl Chat { list_state: ls, }; } + Some(ChatTabAction::SwitchAgent) if !state.turn_in_flight => { + // Snapshot the enabled agents on demand — the same source the + // startup picker reads. Preselect the agent the session already + // runs so Enter on an unchanged choice is a cheap no-op. + let agents = match self.rpc.agents_status().await { + Ok(result) => result + .agents + .into_iter() + .filter(|a| a.enabled) + .map(|a| a.alias) + .collect::>(), + Err(_) => Vec::new(), + }; + let mut ls = ListState::default(); + if !agents.is_empty() { + let cur = state.agent_alias.clone(); + let idx = agents.iter().position(|a| *a == cur).unwrap_or(0); + ls.select(Some(idx)); + } + state.agent_overlay = AgentOverlay::List { + agents, + list_state: ls, + }; + } Some(ChatTabAction::ToggleThoughts) if state.input_bar.input().is_empty() && state.pending_approval().is_none() @@ -1633,6 +1748,43 @@ impl Chat { return; } + // Agent picker overlay intercepts all mouse events when open. A + // click only moves the selection (Enter confirms) so a stray click + // never silently restarts the session against a different agent. + if let AgentOverlay::List { agents, list_state } = &mut state.agent_overlay { + let col = mouse.column; + let row = mouse.row; + let overlay_area = session_list_overlay_area(area); + + match mouse.kind { + MouseEventKind::Down(crossterm::event::MouseButton::Left) => { + if !mouse::in_rect(col, row, overlay_area) { + state.agent_overlay = AgentOverlay::None; + } else { + let count = agents.len(); + if let Some(idx) = mouse::list_click_index( + row, + overlay_area, + list_state.offset(), + count, + ) { + list_state.select(Some(idx)); + } + } + } + MouseEventKind::ScrollUp | MouseEventKind::ScrollDown + if mouse::in_rect(col, row, overlay_area) => + { + let up = matches!(mouse.kind, MouseEventKind::ScrollUp); + let count = agents.len(); + let i = list_state.selected().unwrap_or(0); + list_state.select(Some(mouse::list_scroll(i, count, up, 1))); + } + _ => {} + } + return; + } + use crossterm::event::KeyModifiers as KM; let col = mouse.column; let row = mouse.row; @@ -1815,6 +1967,12 @@ impl Chat { if !matches!(s.session_overlay, SessionOverlay::None) { return false; } + // Agent picker is modal exactly like the session picker: + // claim a fixed posture so global keys behave the same way + // whether or not the input bar still holds a draft. + if !matches!(s.agent_overlay, AgentOverlay::None) { + return false; + } // Browse mode: single-char bindings active. if s.in_browse_mode() { return false; @@ -1858,6 +2016,13 @@ impl crate::widgets::HelpContext for Chat { } SessionOverlay::None => {} } + if let AgentOverlay::List { .. } = &state.agent_overlay { + return HelpNode::entries(vec![ + E::new(vec!["↑", "↓"], crate::i18n::t("zc-chat-help-navigate")), + E::key("Enter", crate::i18n::t("zc-chat-help-switch-agent")), + E::key("Esc", crate::i18n::t("zc-chat-help-close")), + ]); + } if state.pending_approval().is_some() { return HelpNode::entries(vec![ E::key("Enter", crate::i18n::t("zc-chat-help-approve")), @@ -1916,6 +2081,10 @@ impl crate::widgets::HelpContext for Chat { chord_label(ChatTabAction::SwitchSession), crate::i18n::t("zc-chat-help-session-list"), ), + E::key( + chord_label(ChatTabAction::SwitchAgent), + crate::i18n::t("zc-chat-help-switch-agent"), + ), E::spacer(), E::key( chord_label(ChatTabAction::PauseResumeQueue), @@ -2156,6 +2325,13 @@ fn render(f: &mut Frame, state: &mut ChatState, area: Rect) { SessionOverlay::None => {} } + match &state.agent_overlay { + AgentOverlay::List { agents, list_state } => { + render_agent_list_overlay(f, area, agents, list_state); + } + AgentOverlay::None => {} + } + // Model / model_provider picker overlay (drawn on top of content). match &state.model_picker { ModelPickerOverlay::Loading => { @@ -2882,6 +3058,34 @@ fn render_session_list_overlay( f.render_stateful_widget(list, inner, &mut ls); } +fn render_agent_list_overlay(f: &mut Frame, area: Rect, agents: &[String], list_state: &ListState) { + // Reuse the session-overlay geometry: the two are mutually exclusive, so a + // shared centered box keeps the picker family visually consistent. + let overlay_area = session_list_overlay_area(area); + + f.render_widget(Clear, overlay_area); + + let block = Block::default() + .borders(Borders::ALL) + .title(Span::styled( + " Agents (Enter=switch, Esc=close) ", + theme::overlay_border_style(), + )) + .style(theme::overlay_border_style()); + + let inner = block.inner(overlay_area); + f.render_widget(block, overlay_area); + + let items: Vec = agents + .iter() + .map(|a| ListItem::new(Span::styled(a.clone(), theme::body_style()))) + .collect(); + + let list = List::new(items).highlight_style(theme::list_highlight_style()); + let mut ls = *list_state; + f.render_stateful_widget(list, inner, &mut ls); +} + /// Render a single-row context usage bar showing token consumption. /// /// Shows: `ctx: 12,345 / 200,000 [████████░░░░░░░░░░░░] 6%` @@ -3328,6 +3532,20 @@ enum SessionOverlay { }, } +/// Mid-session agent picker. Opened with `SwitchAgent` from an active +/// session; confirming starts a fresh session against the chosen agent, +/// cancelling returns to the current session untouched. The agent list is a +/// transient on-demand snapshot of the daemon's `agents_status` — the live +/// agent of record stays `ChatState::agent_alias`; nothing is cached here. +#[derive(Debug)] +enum AgentOverlay { + None, + List { + agents: Vec, + list_state: ListState, + }, +} + /// Active model / model_provider picker overlay. `None` when no picker is open. /// The model_provider variant is two-stage: pick a model_provider, then (after a /// catalog fetch) pick a model from it. @@ -3464,6 +3682,7 @@ pub struct ChatState { /// Active scrollbar drag anchor. scrollbar_drag: Option, session_overlay: SessionOverlay, + agent_overlay: AgentOverlay, scroll_offset: u16, pinned_to_bottom: bool, last_total_rows: u16, @@ -3547,6 +3766,7 @@ impl ChatState { scrollbar_track_rect: None, scrollbar_drag: None, session_overlay: SessionOverlay::None, + agent_overlay: AgentOverlay::None, scroll_offset: 0, pinned_to_bottom: true, last_total_rows: 0, diff --git a/apps/zerocode/src/keymap/actions.rs b/apps/zerocode/src/keymap/actions.rs index 8a5cad61b38..0a885b372db 100644 --- a/apps/zerocode/src/keymap/actions.rs +++ b/apps/zerocode/src/keymap/actions.rs @@ -156,6 +156,7 @@ keyactions! { ToggleThoughts [Chord::char('t')] => "toggle thoughts", NewSession [Chord::ctrl('n')] => "new session", SwitchSession [Chord::ctrl('s')] => "switch session", + SwitchAgent [Chord::ctrl('g')] => "switch agent", DeleteSession [] => "delete session", CancelTurn [Chord::ctrl('d')] => "cancel turn", ApprovalApprove [Chord::key(KeyCode::Enter)] => "approve",