Skip to content

feat(gateway): coding-agent UX — projects, shell mode, coding skill - #2702

Closed
ilblackdragon wants to merge 13 commits into
mainfrom
ip/coding-agent-ux
Closed

ilblackdragon wants to merge 13 commits into
mainfrom
ip/coding-agent-ux

Conversation

@ilblackdragon

@ilblackdragon ilblackdragon commented Apr 20, 2026 •

Copy link
Copy Markdown
Member

Summary

Turns the web gateway into a coding-agent front-end:

  • Projects: reuse the engine v2 Project (no new type) and extend it with a GitHubRepo newtype + typed metadata view (github_repo, default_branch) surfaced through EngineProjectInfo. Conversations bind to a project via conversations.metadata.project_id; the active-pointer lives as a workspace MemoryDoc at projects/_active.json.
  • Conversation chrome: a new bar above #tab-chat shows project name · folder · branch (dirty dot) · PR chip. Populated by a TTL'd ProjectContextCache that shells out to git / gh through the dispatcher — the list endpoint never blocks on the network.
  • `!`-shell mode: typing ! toggles a CSS badge and routes through POST /api/chat/send mode=shell. The backend resolves the thread's active project, dispatches shell with workdir = project.workspace_path, and broadcasts shell_command / shell_output SSE events. Commands + output persist as role=shell_command / role=shell_output messages so history replay rebuilds the turn and the next LLM turn sees them in context.
  • Coding skill: new skills/coding-repo/SKILL.md guides branch/test/PR discipline (PRs target staging, never --no-verify, never force-push to main/staging). The skills parser gains activation.context.require_project_field so the skill only fires when the active project has a GitHub repo configured — non-gated callers are unaffected (default context admits every skill).

All gateway write paths route through ToolDispatcher::dispatch() (four new project-admin tools). Read paths are annotated dispatch-exempt: read aggregation per `.claude/rules/tools.md`.

Test plan

  • cargo fmt --all
  • cargo clippy --all --benches --tests --examples --all-features -- -D warnings — zero warnings
  • cargo test -p ironclaw_skills --lib — 171 passing
  • cargo test -p ironclaw --lib — 5170+ passing (one pre-existing telegram test flakes on test-order; passes in isolation before and after these changes)
  • cargo test --test project_admin_integration --features libsql — 5 passing
  • IRONCLAW_LIVE_TESTS=1 cargo test --test live_coding_flow --features libsql -- --ignored — 4 passing against a real nearai/ironclaw clone
  • scripts/pre-commit-safety.sh — clean
  • scripts/check_gateway_boundaries.py — no new back-edges
  • Playwright: test_coding_project_flow.py — 5 scenarios (chrome render, shell-badge toggle, shell turn round-trip + reload, no-project 409, GitHubRepo HTTP-boundary validation). Runs against mock LLM; needs e2e venv + chromium (see tests/e2e/CLAUDE.md).
  • Manual browser smoke: point at /data/illia/ironclaw5 via the manage-projects modal, set active, confirm chrome populates; type !cargo check, confirm shell turn renders and persists across reload.

Follow-ups

Screenshot 2026-04-20 at 3 38 03 PM

🤖 Generated with Claude Code

Turns the web gateway into a coding-agent front-end: conversations
bind to a project (engine v2 `Project` reused with a `GitHubRepo`
newtype + typed `metadata` view), a `!`-prefix on the input routes
through `ToolDispatcher::dispatch("shell")` instead of the LLM, and a
new `coding-repo` skill guides branch/test/PR discipline when a
project has a GitHub repo configured.

Backend

- `GitHubRepo` newtype in `ironclaw_common` with validation + 8 tests.
- `EngineProjectInfo` widened with `workspace_path` + typed
  `ProjectMetadataView { github_repo, default_branch }`. Single
  `EngineProjectInfo::from_project` construction path so the gateway
  read and project-admin write paths cannot drift.
- Bridge helpers `create_engine_project`, `update_engine_project`,
  `set_conversation_project` with shared `ProjectUpsertFields`.
- Four dispatcher-compliant tools in `src/tools/builtin/project_admin.rs`:
  `project_create`, `project_update`, `project_set_active`,
  `project_assign_thread`. Active pointer persists as a workspace
  MemoryDoc at `projects/_active.json`.
- Gateway endpoints `POST/PATCH/DELETE /api/engine/projects`,
  `GET|POST /api/engine/projects/active`,
  `POST /api/chat/threads/{id}/project`. All mutations route through
  the dispatcher; the active-pointer read is annotated
  `// dispatch-exempt: read aggregation`.
- `ProjectContextCache` with per-field TTLs (branch 5s, dirty 5s,
  PR 60s) refreshes via the shell tool dispatcher so the thread-list
  endpoint never blocks on `gh`.
- `ThreadInfo.project: ThreadProjectContext` populated by a shared
  `thread_project_context` helper in `handlers/engine.rs`.
- `ChatSendMode::Shell` on `/api/chat/send`, `AppEvent::ShellCommand`
  + `AppEvent::ShellOutput` SSE events, `MessageRole::Shell`
  persistence, and `TurnInfo.shell` pairing in
  `build_turns_from_db_messages` so history replay restores shell
  turns as a distinct monospace card.
- `ironclaw_skills` parser extension: `activation.context.{require_project_field, include_git_context}`
  recognised with warn-and-continue semantics. New
  `prefilter_skills_with_context` variant applies the gate; legacy
  `prefilter_skills` delegates with the permissive default context.
- The `coding-repo` skill declares `require_project_field: github_repo`,
  so it only fires when the active project has a repo configured.
- Agent-loop wiring: `select_active_skills` now takes a
  `SkillActivationContext`; the gateway populates the project hints
  (`project_has_github_repo`, `project_has_workspace_path`) in
  `IncomingMessage.metadata` on every `/api/chat/send`.

UI

- Conversation-chrome bar above `#tab-chat`: project name · folder ·
  branch (dirty dot) · PR chip. Click opens a modal.
- Project management modal: create / edit / set-active / assign-thread
  with inline `GitHubRepo` validation.
- `!` input badge (CSS `::before` + JS toggle). `sendShellCommand`
  posts `mode=shell` to `/api/chat/send`.
- Distinct `.shell-turn` turn renderer: `$ cmd` header, scrollable
  stdout/stderr, exit-code chip (green 0 / red non-zero).
- History replay paints the same card from `TurnInfo.shell`.

Tests

- `tests/project_admin_integration.rs` — 5 caller-level scenarios
  exercising `ProjectMetadataView`, `GitHubRepo`, and the workspace-
  path resolver through the upsert surface the gateway actually calls.
- `tests/live_coding_flow.rs` — live-tier scenarios gated behind
  `IRONCLAW_LIVE_TESTS=1` that clone `nearai/ironclaw` into a tempdir
  and assert project round-trip, clean-clone branch/dirty invariants,
  shell-tool approval policy, and multi-project folder isolation.
- `tests/live/README.md` — invocation contract for the live tier.
- `tests/e2e/scenarios/test_coding_project_flow.py` — Playwright
  scenarios covering chrome render after create+active, the `!` badge
  toggle, the full shell turn round-trip with reload persistence, the
  no-project 409, and HTTP-boundary `GitHubRepo` rejection.
- 4 skills tests for context gating; existing suite unaffected.

Quality gate

- `cargo fmt` clean.
- `cargo clippy --all --benches --tests --examples --all-features -- -D warnings` — zero warnings.
- `cargo test -p ironclaw_skills --lib` — 171 passing.
- `cargo test -p ironclaw --lib` — 5170+ passing (one pre-existing
  telegram test flake, unrelated, passes in isolation).
- `scripts/pre-commit-safety.sh` — clean; three read-only aggregation
  sites explicitly annotated `dispatch-exempt`.
- `scripts/check_gateway_boundaries.py` — no new back-edges.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings April 20, 2026 03:17
@github-actions github-actions Bot added scope: agent Agent core (agent loop, router, scheduler) scope: channel/web Web gateway channel scope: tool Tool infrastructure scope: tool/builtin Built-in tools scope: docs Documentation size: XL 500+ changed lines risk: medium Business logic, config, or moderate-risk modules contributor: core 20+ merged PRs labels Apr 20, 2026

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Turns the web gateway into a coding-agent front-end by adding project context, ! shell-mode dispatch + rendering, and context-gated coding skills.

Changes:

  • Added project administration via ToolDispatcher (create/update/set-active/assign-thread) and surfaced per-thread project context to the UI.
  • Implemented ! shell mode end-to-end: /api/chat/send mode=shell, SSE events, persistence, and history replay as shell turns.
  • Added runtime skill activation context gating and introduced the coding-repo skill.

Reviewed changes

Copilot reviewed 42 out of 42 changed files in this pull request and generated 6 comments.

Show a summary per file
File Description
tests/ws_gateway_integration.rs Updates test gateway state init for new project_context_cache field.
tests/support/gateway_workflow_harness.rs Updates harness state init for new project_context_cache field.
tests/project_admin_integration.rs Adds integration tests for project metadata view, GitHubRepo validation, and workspace-path resolution.
tests/openai_compat_integration.rs Updates test gateway state init for new project_context_cache field.
tests/oauth_greeting_integration.rs Updates test gateway state init for new project_context_cache field.
tests/multi_tenant_integration.rs Updates test gateway state init for new project_context_cache field.
tests/live_coding_flow.rs Adds ignored live-tier Rust scenarios for project + git context + shell-mode behavior.
tests/live/README.md Documents live-tier test contract and invocation.
tests/e2e/scenarios/test_coding_project_flow.py Adds Playwright E2E scenarios for project chrome + shell mode + persistence + validation.
tests/e2e/CLAUDE.md Registers the new E2E scenario in the test index doc.
src/tools/registry.rs Adds registration for project-admin tools.
src/tools/builtin/project_admin.rs Implements dispatcher-compliant tools for project create/update/set-active/assign-thread.
src/tools/builtin/mod.rs Exposes the new project-admin tools from the builtin module.
src/main.rs Wires ProjectContextCache into the gateway when tool dispatch is enabled.
src/channels/web/util.rs Adds shell-turn reconstruction when building turns from persisted DB messages.
src/channels/web/types.rs Adds ChatSendMode, thread project context DTO, and TurnInfo.shell payload.
src/channels/web/tests/multi_tenant.rs Updates test state init for new project_context_cache field.
src/channels/web/test_helpers.rs Updates test builder state init for new project_context_cache field.
src/channels/web/server.rs Implements /api/chat/send shell-mode handling, adds project hints into message metadata, and includes project context in thread listing/new-thread responses.
src/channels/web/platform/ws.rs Updates platform ws tests for new project_context_cache field.
src/channels/web/platform/state.rs Adds project_context_cache to GatewayState.
src/channels/web/platform/router.rs Adds routes for project create/update/active and thread project assignment.
src/channels/web/platform/project_context_cache.rs Adds TTL cache that refreshes git/gh state via shell-tool dispatch.
src/channels/web/platform/mod.rs Exposes the new project_context_cache module.
src/channels/web/mod.rs Adds builder wiring for project_context_cache into the gateway channel state.
src/channels/web/handlers/settings.rs Updates handler tests for new project_context_cache field.
src/channels/web/handlers/engine.rs Adds dispatcher-routed project mutation handlers and read-only project resolution helpers.
src/channels/web/handlers/chat.rs Adds project context to thread list/new-thread responses.
src/bridge/router.rs Extends EngineProjectInfo with workspace_path + typed metadata view; adds create/update project bridge funcs and conversation project binding.
src/bridge/mod.rs Re-exports new bridge types/functions for project support.
src/app.rs Registers project-admin tools alongside memory tools using the workspace resolver.
src/agent/dispatcher.rs Builds SkillActivationContext from gateway-provided message metadata.
src/agent/agent_loop.rs Plumbs activation context into skill selection (prefilter_skills_with_context).
skills/coding-repo/SKILL.md Adds the new coding-repo skill, gated on having a GitHub-backed project.
crates/ironclaw_skills/src/types.rs Adds activation context criteria and runtime SkillActivationContext.
crates/ironclaw_skills/src/selector.rs Adds context-aware prefiltering and tests for context gating behavior.
crates/ironclaw_skills/src/lib.rs Re-exports new context types and new selector entrypoint.
crates/ironclaw_gateway/static/style.css Adds project chrome + modal styles and shell-mode turn/badge styling.
crates/ironclaw_gateway/static/app.js Implements Project UI (chrome + modal), shell-mode input routing, SSE rendering, and history shell-turn rendering.
crates/ironclaw_common/src/lib.rs Re-exports GitHubRepo newtype and error.
crates/ironclaw_common/src/identity.rs Adds GitHubRepo newtype with validation + tests.
crates/ironclaw_common/src/event.rs Adds shell_command / shell_output SSE event variants.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

.run_shell(
user_id,
workspace_path,
"gh pr view --json number,title,url,state",

Copilot AI Apr 20, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

refresh_pr runs gh pr view --json number,title,url,state, but parse_gh_pr relies on a draft flag. As written, the cache can never detect drafts because the command doesn't request the draft field. Include the draft field in the --json list (e.g., isDraft) so draft PRs are represented correctly in PrState.

Suggested change
"gh pr view --json number,title,url,state",
"gh pr view --json number,title,url,state,isDraft",

Copilot uses AI. Check for mistakes.
title: String,
url: String,
state: String,
#[serde(default)]

Copilot AI Apr 20, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

parse_gh_pr deserializes a field named is_draft, but gh's JSON uses camelCase (isDraft). Without a #[serde(rename = "isDraft")] (and requesting that field in the command), row.is_draft will always be false and draft PRs will be misclassified as Open.

Suggested change
#[serde(default)]
#[serde(default, rename = "isDraft")]

Copilot uses AI. Check for mistakes.
Comment on lines +158 to +161
/// The default value (no project, no workspace path) admits every
/// skill — callers with no project context pass it and no gate
/// triggers. Callers that do know the active project populate the
/// matching booleans so gated skills activate.

Copilot AI Apr 20, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The docs claim the default SkillActivationContext "admits every skill", but satisfies(Some("github_repo")) returns false when the default booleans are false, so gated skills are filtered out by default. Please update the doc comment to match the implemented behavior (or adjust satisfies if the intended contract is truly 'admit by default').

Suggested change
/// The default value (no project, no workspace path) admits every
/// skill — callers with no project context pass it and no gate
/// triggers. Callers that do know the active project populate the
/// matching booleans so gated skills activate.
/// The default value (no project, no workspace path) admits ungated
/// skills, but does not satisfy recognised project-field gates such as
/// `"github_repo"` or `"workspace_path"`. Unknown gate names still
/// admit by default for forward compatibility. Callers that do know
/// the active project populate the matching booleans so gated skills
/// activate.

Copilot uses AI. Check for mistakes.
Comment thread src/bridge/router.rs

/// Create a new project owned by `user_id` with the supplied fields.
///
/// `name` and `description` are required (via `fields.name` / `fields.description`).

Copilot AI Apr 20, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This doc comment says name and description are required, but the implementation only requires name (description defaults via unwrap_or_default()). Please align the documentation with the actual behavior so callers know which fields are mandatory.

Suggested change
/// `name` and `description` are required (via `fields.name` / `fields.description`).
/// `name` is required (via `fields.name`). `description` is optional and
/// defaults to an empty string when omitted.

Copilot uses AI. Check for mistakes.
Comment on lines +112 to +145
fn spawn_stale_refreshes(
self: &Arc<Self>,
project_id: ProjectId,
workspace_path: &Path,
user_id: &str,
snapshot: &CachedContext,
) {
let now = Instant::now();
let workspace_path = workspace_path.to_path_buf();

if is_stale(snapshot.branch_fetched_at, BRANCH_TTL, now) {
let this = Arc::clone(self);
let user = user_id.to_string();
let wp = workspace_path.clone();
tokio::spawn(async move {
let _ = this.refresh_branch(project_id, &wp, &user).await;
});
}
if is_stale(snapshot.dirty_fetched_at, DIRTY_TTL, now) {
let this = Arc::clone(self);
let user = user_id.to_string();
let wp = workspace_path.clone();
tokio::spawn(async move {
let _ = this.refresh_dirty(project_id, &wp, &user).await;
});
}
if is_stale(snapshot.pr_fetched_at, PR_TTL, now) {
let this = Arc::clone(self);
let user = user_id.to_string();
let wp = workspace_path;
tokio::spawn(async move {
let _ = this.refresh_pr(project_id, &wp, &user).await;
});
}

Copilot AI Apr 20, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

get() clones the current snapshot and spawn_stale_refreshes unconditionally tokio::spawns refresh tasks when fields are stale/missing. If multiple requests hit /api/chat/threads while entries are stale (or missing), this can fan out into many concurrent git/gh shell dispatches for the same project. Consider adding an in-flight guard (per project + field), or pessimistically updating the *_fetched_at timestamp before spawning, so repeated reads don't create a thundering herd of refresh jobs.

Copilot uses AI. Check for mistakes.
Comment thread src/channels/web/util.rs
Comment on lines +348 to +368
let parsed: serde_json::Value =
serde_json::from_str(&out.content).unwrap_or(serde_json::Value::Null);
(
parsed
.get("stdout")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string(),
parsed
.get("stderr")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string(),
parsed
.get("exit_code")
.and_then(|v| v.as_i64())
.unwrap_or(0) as i32,
parsed
.get("truncated")
.and_then(|v| v.as_bool())
.unwrap_or(false),

Copilot AI Apr 20, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

shell_output rows are parsed assuming the content is a JSON object; if the persisted content is a JSON string (or invalid JSON), serde_json::from_str(...).unwrap_or(Value::Null) causes stdout/stderr to be dropped and exit_code to default to 0, which incorrectly marks the turn as Completed. Consider parsing the shell_output content with the same defensive logic as parse_shell_tool_output (object vs string), and use a non-zero/unknown exit code when parsing fails so the UI doesn't render a false success badge.

Suggested change
let parsed: serde_json::Value =
serde_json::from_str(&out.content).unwrap_or(serde_json::Value::Null);
(
parsed
.get("stdout")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string(),
parsed
.get("stderr")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string(),
parsed
.get("exit_code")
.and_then(|v| v.as_i64())
.unwrap_or(0) as i32,
parsed
.get("truncated")
.and_then(|v| v.as_bool())
.unwrap_or(false),
let (stdout, stderr, exit_code, truncated) =
match serde_json::from_str::<serde_json::Value>(&out.content) {
Ok(serde_json::Value::Object(parsed)) => (
parsed
.get("stdout")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string(),
parsed
.get("stderr")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string(),
parsed
.get("exit_code")
.and_then(|v| v.as_i64())
.map(|v| v as i32)
.unwrap_or(1),
parsed
.get("truncated")
.and_then(|v| v.as_bool())
.unwrap_or(false),
),
Ok(serde_json::Value::String(stdout)) => (stdout, String::new(), 1, false),
Ok(other) => (other.to_string(), String::new(), 1, false),
Err(_) => (out.content.clone(), String::new(), 1, false),
};
(
stdout,
stderr,
exit_code,
truncated,

Copilot uses AI. Check for mistakes.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request implements coding project management and a direct shell execution mode (via the ! prefix) in the gateway, introducing a validated GitHubRepo type, a TTL-bound ProjectContextCache for git status, and new administration tools. The review identifies several performance and architectural concerns, most notably the O(N) I/O-bound project resolution within thread list loops and inefficient frontend API usage for thread refreshes. Additionally, the feedback highlights a thundering herd risk in the cache refresh logic and the use of blocking synchronous filesystem operations within asynchronous functions, which could stall the runtime.

Comment thread src/channels/web/handlers/chat.rs Outdated
Comment on lines +121 to +126
let project = crate::channels::web::handlers::engine::thread_project_context(
state.as_ref(),
&identity.user_id,
s.id,
)
.await;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

This loop performs O(N) project resolution for every thread in the list. Each call to thread_project_context involves multiple I/O-bound operations: a database query for conversation metadata, a workspace file read for the active project pointer, and an engine store lookup. For users with many threads, this will significantly degrade the performance of the thread list endpoint. Since the project chrome is typically only displayed for the active thread, consider resolving the project context lazily or only for the current thread. Please ensure this optimization is justified by profiling data before implementation to avoid premature optimization.

References
  1. Performance optimizations should be gated on profiling to ensure they address actual bottlenecks.
  2. Avoid premature optimization; prioritize readability and maintainability for non-critical paths.

Comment thread src/channels/web/server.rs Outdated
Comment on lines +1060 to +1065
let project = crate::channels::web::handlers::engine::thread_project_context(
state.as_ref(),
&user.user_id,
s.id,
)
.await;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Similar to the handler in chat.rs, this loop performs O(N) project resolution for every thread summary. This is highly inefficient as it triggers database queries and workspace reads for every thread in the list. This data is only used by the frontend for the currently active thread. Ensure this optimization is gated on profiling to confirm it is a performance bottleneck.

References
  1. Performance optimizations should be gated on profiling to ensure they address actual bottlenecks.
  2. Avoid premature optimization; prioritize readability and maintainability for non-critical paths.

Comment thread crates/ironclaw_gateway/static/app.js Outdated
Comment on lines +11465 to +11473
apiFetch('/api/chat/threads')
.then((data) => {
const all = []
.concat(data.assistant_thread ? [data.assistant_thread] : [])
.concat(data.threads || []);
const match = all.find((th) => th && th.id === currentThreadId);
refreshChromeFromThread((match && match.project) || null);
})
.catch(() => { /* chrome stays in its last state */ });

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

refreshCurrentThread fetches the entire thread list via /api/chat/threads just to find the project info for the current thread. This is inefficient, especially as the backend implementation of this endpoint is now significantly more expensive due to per-thread project resolution. Consider adding a dedicated endpoint for single-thread info or including the project context in the history response to avoid this O(N) frontend lookup.

Comment on lines +122 to +145
if is_stale(snapshot.branch_fetched_at, BRANCH_TTL, now) {
let this = Arc::clone(self);
let user = user_id.to_string();
let wp = workspace_path.clone();
tokio::spawn(async move {
let _ = this.refresh_branch(project_id, &wp, &user).await;
});
}
if is_stale(snapshot.dirty_fetched_at, DIRTY_TTL, now) {
let this = Arc::clone(self);
let user = user_id.to_string();
let wp = workspace_path.clone();
tokio::spawn(async move {
let _ = this.refresh_dirty(project_id, &wp, &user).await;
});
}
if is_stale(snapshot.pr_fetched_at, PR_TTL, now) {
let this = Arc::clone(self);
let user = user_id.to_string();
let wp = workspace_path;
tokio::spawn(async move {
let _ = this.refresh_pr(project_id, &wp, &user).await;
});
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The spawn_stale_refreshes method lacks in-flight tracking. If multiple requests hit the cache for the same project simultaneously, it will spawn numerous redundant background tasks, each executing shell commands. Consider using a mechanism to track and deduplicate active refresh tasks for the same project ID to prevent a thundering herd problem. Note that adding such a caching layer or optimization should be gated on profiling per repository guidelines.

References
  1. Performance optimizations, such as adding a caching layer, should be gated on profiling.

Comment thread src/channels/web/server.rs Outdated
Comment on lines +212 to +226
if let Err(e) = crate::bridge::sandbox::workspace_path::default_project_workspace_path(
&user.user_id,
project_id,
)
.parent()
.map(std::fs::create_dir_all)
.unwrap_or(Ok(()))
{
tracing::debug!(error = %e, "ensure parent projects dir (non-fatal)");
}
if !workspace_path.exists()
&& let Err(e) = std::fs::create_dir_all(&workspace_path)
{
tracing::warn!(path = %workspace_path.display(), error = %e, "failed to create project workspace dir");
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Using std::fs::create_dir_all and Path::exists inside an async function blocks the executor thread. These should be replaced with tokio::fs counterparts (e.g., tokio::fs::create_dir_all and tokio::fs::try_exists) to avoid stalling the gateway's async runtime during shell-mode dispatch.

ilblackdragon and others added 2 commits April 20, 2026 03:43
…d bounce, copy

Four follow-ups on the coding-agent UX PR:

1. **Project name consistency** — the auto-created `default` project
   already rendered as "General" in the Projects overview but showed
   as "default" in the new chrome bar + manage modal. Add a single
   `displayProjectName()` helper and apply it in both surfaces. The
   backend name stays `default` (stable lookup key in
   `bridge/router.rs`); the edit form locks the name field for that
   row so accidental renames can't break the backend resolver.

2. **Assistant spans all projects** — the pinned Assistant
   conversation is the home thread, but the earlier PR resolved an
   active project for it, so the chrome bar + folder chip lit up
   whenever the user selected Assistant. Skip
   `thread_project_context` when `s.id == assistant_id` in both
   `handlers/chat.rs` and `server.rs`; the Assistant row now stays
   project-less across the whole UI.

3. **Reload bounce on tab-refocus** — `SSE_RELOAD_THRESHOLD_MS` is
   10s, so any tab switch longer than that triggers a full
   `loadHistory()`. The existing code cleared the DOM to a skeleton
   before repainting and every message replayed its `slideUp`
   keyframe, producing a visible cascade on every refocus. Now skip
   the skeleton when there's already rendered content and stamp a
   `.history-replay` class on the chat container so the CSS
   suppresses per-message slide-in animations. `.finally` clears the
   flag so fresh streaming turns still animate.

4. **Copy button falls back to execCommand** — the clipboard API
   rejects silently in some environments (insecure origins,
   Firefox's stricter focus handling, Chrome extension sandboxes).
   Add an `execCommand('copy')` retry path with a hidden textarea,
   log failures to console, and strip the copy button's own label
   from the textContent fallback so "Copy" doesn't leak into the
   clipboard when the primary path fails.

Verified

- `cargo clippy --all --benches --tests --examples --all-features -- -D warnings` clean
- `scripts/pre-commit-safety.sh` clean

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Brings staging's gateway modularization (ironclaw#2599 stages 4c + 4d + 5)
and #2677 UserId collapse into the coding-agent UX branch:

Conflicts resolved:
- crates/ironclaw_common/src/lib.rs — combined GitHubRepo export with
  the new JobResultStatus export.
- src/channels/web/types.rs — kept both the staging `attachments` field
  and the branch's `ChatSendMode`/`mode` field on SendMessageRequest.
- src/channels/web/server.rs — accepted staging's thin shim; my chat
  handler deltas reapplied in the canonical `features/chat/mod.rs`.
- src/channels/web/handlers/chat.rs — deleted per staging (content moved
  into `features/chat/mod.rs`); my changes reapplied there.
- crates/ironclaw_gateway/static/app.js + style.css — deleted per
  staging (split into per-surface modules). ProjectUI IIFE migrated to
  `js/surfaces/projects.js`; `!` prefix detection added in
  `js/surfaces/chat.js::sendMessage`; shell SSE handlers in
  `js/core/sse.js`; shell turn + history-replay class in
  `js/core/history.js`; `copyMessage` fallback in `js/core/render.js`.
  Project chrome + shell-turn CSS appended to
  `styles/surfaces/projects.css`; `history-replay` suppression added in
  `styles/surfaces/chat.css`.

types.md applied to the coding-agent surface:
- `bridge::update_engine_project` now takes
  `ironclaw_engine::ProjectId` rather than `&str` — internal calls
  flow the typed id, eliminating the class of bug types.md prevents.
- `bridge::set_conversation_project` takes `Option<ProjectId>` for the
  same reason.
- `project_admin` tools parse raw UUIDs into `ProjectId` at the tool
  boundary via the new `parse_project_id` helper, so every downstream
  call site is typed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings April 20, 2026 04:36
@github-actions github-actions Bot added scope: channel Channel infrastructure scope: channel/cli TUI / CLI channel scope: channel/wasm WASM channel runtime scope: db Database trait / abstraction scope: db/postgres PostgreSQL backend scope: llm LLM integration scope: orchestrator Container orchestrator scope: worker Container worker scope: extensions Extension management scope: pairing Pairing mode scope: ci CI/CD workflows labels Apr 20, 2026

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 66 out of 211 changed files in this pull request and generated 5 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

// Mod+1-5: switch tabs
if (mod && e.key >= '1' && e.key <= '5') {
e.preventDefault();
const tabs = engineV2

Copilot AI Apr 20, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

engineV2 is not defined in the shared bootstrap state (the global flag elsewhere is engineV2Enabled). As written, this will throw a ReferenceError on the first Mod+1..5 keypress (or always take the wrong branch if a different global happens to exist). Use the existing global (engineV2Enabled) consistently in this shortcut handler.

Suggested change
const tabs = engineV2
const tabs = engineV2Enabled

Copilot uses AI. Check for mistakes.
Comment on lines +32 to +46
apiFetch('/api/gateway/status').then(function(data) {
activeWorkStore.setEngineV2Enabled(!!data.engine_v2);
applyEngineModeUi();
refreshPersistentActivityBar();

// Update restart button visibility
restartEnabled = data.restart_enabled || false;
updateRestartButtonVisibility();

// Apply engine v2 / v1 tab visibility once.
if (!engineModeApplied) {
engineV2Enabled = !!data.engine_v2_enabled;
applyEngineModeToTabs();
engineModeApplied = true;
}

Copilot AI Apr 20, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The status payload fields used to enable engine-v2 UI are inconsistent (data.engine_v2 vs data.engine_v2_enabled). This can cause engineV2Enabled (and therefore tab visibility/activity strip behavior) to flip incorrectly depending on which field is actually present. Pick a single canonical field name from the API response and use it consistently for both activeWorkStore.setEngineV2Enabled(...) and engineV2Enabled = ....

Copilot uses AI. Check for mistakes.
Comment on lines +21 to +32
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum JobResultStatus {
Completed,
Failed,
Cancelled,
/// Worker timeout / stuck-state path. Emitted when a job's context
/// is transitioned to `JobState::Stuck` (see `worker/job.rs`
/// `mark_stuck`). Distinct from `Failed` so the UI and analytics
/// can surface recovery-eligible runs separately from hard errors.
Stuck,
}

Copilot AI Apr 20, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The doc/comments (and FromStr) say legacy "error" should be accepted as an alias for Failed, but serde deserialization for JobResultStatus will currently reject "error" on the wire. If older producers still emit "error", AppEvent::JobResult deserialization will fail. Fix by adding a serde alias on the Failed variant (e.g., #[serde(alias = "error")]), or by implementing a custom Deserialize that maps "error" → Failed.

Copilot uses AI. Check for mistakes.
}

// Wire up Enter key on search input
document.getElementById('skill-search-input').addEventListener('keydown', function(e) {

Copilot AI Apr 20, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This unguarded getElementById(...).addEventListener(...) will throw at script-evaluation time if #skill-search-input is not present in the DOM (notably plausible given the new layout-level tab hiding and the fact that all split JS modules are concatenated and loaded globally). Use optional chaining (?.addEventListener) or a null check like other surfaces in this PR.

Suggested change
document.getElementById('skill-search-input').addEventListener('keydown', function(e) {
document.getElementById('skill-search-input')?.addEventListener('keydown', function(e) {

Copilot uses AI. Check for mistakes.
}

function createTokenForUser(userId, displayName) {
var tokenName = prompt('Token name for ' + displayName + ':', 'api-token');

Copilot AI Apr 20, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This introduces a hard-coded English prompt string in an otherwise i18n-driven UI (the file already uses I18n.t(...) extensively). To keep localization and consistency, move both the prompt text and default value to i18n keys (and keep displayName as an interpolated param).

Suggested change
var tokenName = prompt('Token name for ' + displayName + ':', 'api-token');
var tokenName = prompt(
I18n.t('users.tokenNamePrompt', { displayName: displayName }),
I18n.t('users.defaultTokenName')
);

Copilot uses AI. Check for mistakes.
…I `!` mode

Gateway
- `core/history.js::switchThread` / `switchToAssistant` / `createNewThread`
  now dispatch a `threadchange` `CustomEvent` on `window`.
  `surfaces/projects.js` listens for it and refreshes the project
  chrome. The old hook targeted a `window.switchToThread` that never
  existed (it was always file-scoped via bundle concatenation) so
  chrome stayed stale after every thread switch — including right
  after the user set an active project in the manage modal.
- `handlers/engine::resolve_thread_project` falls back to the user's
  auto-created `default` project when no workspace active-pointer is
  set. Fresh users now get `!ls` working on day one instead of a
  silent 409 from `ChatSendMode::Shell` due to "no project".

TUI (`ironclaw_tui` + `channels/tui`)
- New `AppState::shell_mode` flag + `TuiUserMessage::shell_mode` field.
- Typing `!` at the start of the input flips the prompt glyph from
  `›` to `!` (warning-yellow for visual clarity); removing the prefix
  flips it back. Toggling is driven by `update_input_overlays_from_input`.
- Esc in shell mode clears the buffer and cancels the mode instead of
  routing to the agent-level `/interrupt`. Esc with no shell mode
  behaves unchanged.
- Submit in shell mode strips the `!`, sends the bare command with
  `shell_mode: true`, and records `!cmd` in input history + the
  on-screen user turn so shell turns are visually distinct.
- `channels/tui::build_tui_incoming_message` propagates the flag into
  `IncomingMessage.metadata.shell_mode`.
- `Agent::handle_message` intercepts `metadata.shell_mode == true`,
  runs the `shell` tool via the existing `ToolRegistry`, and returns
  a preformatted `$ cmd` / stdout / stderr / exit-code block as a
  synthetic `HandleOutcome::Respond`. The normal agentic pipeline
  (LLM + tool loop) is bypassed entirely — shell mode is an
  explicit user action, not an agent decision.

Tests
- `cargo clippy --all --benches --tests --examples --all-features -- -D warnings` clean
- `cargo test -p ironclaw_tui --lib` — 173 passing (new shell-mode
  state field round-trips through existing tests)
- `cargo test -p ironclaw --lib channels::web` — 442 passing

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
ilblackdragon added a commit that referenced this pull request Apr 20, 2026
Synthesizes recurring patterns from ~30 merged PRs, 147 bot review
comments (Copilot/Gemini), human reviews, and ~50 issues filed in the
past 2 weeks. Each rule cites the motivating PR/issue numbers.

New files:
- error-handling.md — silent-failure taxonomy (unwrap_or_default, .ok()?,
  poisoned caches), persist-then-reload atomicity, channel-edge error
  mapping. (#2526, #2633, #2653, #2673, #2546, #2407, #2408)
- agent-evidence.md — side-effect claims must cite tool evidence,
  empty-fast outputs are errors, external-effect tools must read back,
  setup UI round-trip. (#2544, #2580, #2582, #2541, #2545, #2411, #2543,
  #2586)
- lifecycle.md — discovery vs. activation, terminal auth rejection,
  list_installed vs. list_active, deactivation unwinds, snapshot
  rehydrate must re-validate. (#2556, #2557, #2558, #2564, #2419,
  PR #2617, PR #2631)

Extended:
- types.md — from_trusted boundary rule, validated-newtype template with
  shared validate(&str), serde(try_from) required for validated types,
  wire-stable enums (no Debug; serde alias for migrations), canonical
  wire-contract field naming. (PR #2685, #2681, #2687, #2678, #2669,
  #2665, #2683, #2702)
- safety-and-sandbox.md — every new ingress scans pre-transform/pre-
  injection, bounded resources (interners/streams/fan-out caps), cache
  keys must be complete. (#2491, #2676, #2470, #2633, #2673, #2710,
  PR #2702)
- review-discipline.md — PR scope discipline, guardrail scripts are
  code (regression tests, grouped-import parsing, CI has_code inclusion),
  absolute-path ban in committed docs, stale comments after refactors.
  (PR #2668, #2628, #2680, #2687, #2647, #2689, #2701)

All new files carry paths: frontmatter so they auto-load only on
matching files.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
// the tool output never comes back.
if let Some(ref store) = state.store // dispatch-exempt: shell-mode persistence pair
&& let Err(e) = store
.add_conversation_message(thread_id, "shell_command", &format!("!{command}"))

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

High Severity

This path accepts an arbitrary thread_id and then writes directly to that conversation without first checking conversation_belongs_to_user(...). Because resolve_thread_project() falls back to the caller's active/default project when it can't resolve thread-owned metadata, a user who guesses another conversation UUID can append shell_command / shell_output rows to someone else's thread.

The same root issue exists on project_assign_thread: please add a shared ownership guard before any thread-scoped mutation (add_conversation_message, set_conversation_project, etc.), and cover it with a multi-tenant caller-level test.

// shape defensively — the cache should not panic on a shape drift.
if let Some(obj) = value.as_object() {
let stdout = obj
.get("stdout")

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

High Severity

ToolDispatcher::dispatch("shell", ...) returns the shell tool's current wire shape {output, exit_code, ...}, but this cache still reads stdout. That leaves res.stdout empty on success, which in turn makes branch refreshes come back as None and dirty checks look clean even when the repo is dirty.

Please parse output here (matching parse_shell_tool_output() in the chat handler) and add a test against the real shell-tool payload shape so the cache and tool contract stay in sync.

# Select and inject skills based on goal keywords
all_skills = __list_skills__()
explicit_skills, _rewritten_goal, missing_explicit_skills = extract_explicit_skills(all_skills, goal)
active_skills = select_skills(all_skills, goal, max_candidates=3, max_tokens=6000)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

High Severity

This still keyword-scores every skill returned by __list_skills__() with no equivalent of v1's prefilter_skills_with_context(...). As a result, a skill like coding-repo can still activate on engine v2 threads that have no github_repo project field set, even though the new manifest contract says it should be gated out.

Please port the activation.context.require_project_field filtering into the v2 selector before scoring, otherwise v1 and v2 will diverge on exactly the project-scoping rule this PR is introducing.

}
const fileInput = document.getElementById('image-file-input');
if (fileInput) {
fileInput.addEventListener('change', (e) => {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

High Severity

This file now binds two change listeners to the same #image-file-input: the older one above calls handleImageFiles(...), while this one calls handleAttachmentFiles(...). For an image selection/paste, the same file gets staged into both stagedImages and stagedAttachments, and sendMessage() later submits both body.images and body.attachments.

That silently double-sends the same image to the backend/model. Please collapse the upload path to a single staging pipeline and add a regression test that verifies one picked image produces exactly one uploaded attachment payload.

ilblackdragon added a commit that referenced this pull request Apr 20, 2026
* docs(rules): add review-driven guidance for Claude Code

Synthesizes recurring patterns from ~30 merged PRs, 147 bot review
comments (Copilot/Gemini), human reviews, and ~50 issues filed in the
past 2 weeks. Each rule cites the motivating PR/issue numbers.

New files:
- error-handling.md — silent-failure taxonomy (unwrap_or_default, .ok()?,
  poisoned caches), persist-then-reload atomicity, channel-edge error
  mapping. (#2526, #2633, #2653, #2673, #2546, #2407, #2408)
- agent-evidence.md — side-effect claims must cite tool evidence,
  empty-fast outputs are errors, external-effect tools must read back,
  setup UI round-trip. (#2544, #2580, #2582, #2541, #2545, #2411, #2543,
  #2586)
- lifecycle.md — discovery vs. activation, terminal auth rejection,
  list_installed vs. list_active, deactivation unwinds, snapshot
  rehydrate must re-validate. (#2556, #2557, #2558, #2564, #2419,
  PR #2617, PR #2631)

Extended:
- types.md — from_trusted boundary rule, validated-newtype template with
  shared validate(&str), serde(try_from) required for validated types,
  wire-stable enums (no Debug; serde alias for migrations), canonical
  wire-contract field naming. (PR #2685, #2681, #2687, #2678, #2669,
  #2665, #2683, #2702)
- safety-and-sandbox.md — every new ingress scans pre-transform/pre-
  injection, bounded resources (interners/streams/fan-out caps), cache
  keys must be complete. (#2491, #2676, #2470, #2633, #2673, #2710,
  PR #2702)
- review-discipline.md — PR scope discipline, guardrail scripts are
  code (regression tests, grouped-import parsing, CI has_code inclusion),
  absolute-path ban in committed docs, stale comments after refactors.
  (PR #2668, #2628, #2680, #2687, #2647, #2689, #2701)

All new files carry paths: frontmatter so they auto-load only on
matching files.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(rules): split agent-evidence into prompt + code rule

agent-evidence.md mixed two concerns: runtime agent instruction (what
the LLM should do when concluding a turn) and code-enforcement rules
(what the dispatcher, engine, and tools must implement). Rules under
.claude/rules/ only guide Claude Code when editing the repo — the
runtime agent never reads them.

Splits the two:

- crates/ironclaw_engine/prompts/codeact_postamble.md — new section
  "Evidence before claiming side effects". Sits next to the existing
  "FINAL() answer quality" guidance; loaded via include_str! in
  executor/prompt.rs (no Rust change needed).
- .claude/rules/tool-evidence.md — renamed from agent-evidence.md,
  keeps only the code invariants (engine v2 side-effect gate,
  empty-fast ToolError::EmptyResult, external-effect tools must read
  back, setup UI round-trip).

Prompt tests pass unchanged; the postamble addition is pure text.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* prompt: tighten evidence rule to FINAL() claims only, not tool use

Live-test validation of the "Evidence before claiming side effects"
section (added in the prior commit) showed it inhibited legitimate
tool use. With the original wording, `zizmor_scan_v2` live-recording
timed out at 302s with zero responses; reverting the postamble
restored healthy behavior (88s run, 8 shell calls including
`cargo install zizmor` and full workflow analysis).

The original phrasing conflated two things: what the agent should
claim and what tools it should call. The rule is only about the
claim. Re-tunes the section to:

- Open with an explicit "this does not restrict tool calls" scope.
- Drop the "<1ms = failure" heuristic (too broad — normal tools like
  `tool_info(schema)` are legitimately fast).
- Drop the full enumeration of forbidden side-effect verbs; keep the
  rule narrower and clearer.
- Shorten the code example (remove redundant early-return).

Re-tuned run: agent is active (shell calls, real reasoning), live
recording completes in ~9s. The remaining test failure is a
pre-existing assertion bug (exact `t == "shell"` match against tool
strings that now carry arguments like `"shell(cmd)"`) — reproduces
with the old postamble too.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test(live): fix tool-name assertions + re-record zizmor traces

The two `zizmor_scan*` live tests had four broken tool-name assertions
that silently failed to match: `tools.iter().any(|t| t == "shell")`
against a tool list that now contains `"shell(cmd)"` strings (tool
events carry args via `format_action_display_name` in
`src/bridge/router.rs`). Two of the four were negative assertions
checking for the absence of `tool_install` recovery loops — those
silently passed even when a recovery loop actually ran. `sandbox_live_e2e.rs:203`
already used the correct `t == "shell" || t.starts_with("shell(")`
pattern; applied it consistently to all four sites.

Verified live:

- `IRONCLAW_LIVE_TEST=1 cargo test --test e2e_live -- zizmor_scan --ignored --test-threads=1`
  → 2 passed, 0 failed, 51.78s. Agent installs and runs zizmor
  end-to-end, producing real findings (exit code 14, dangerous
  triggers, excessive permissions, etc.).

Traces re-recorded with the tuned postamble (commit 50d8517) and
scrubbed: replaced `/home/illia/.cargo/bin/zizmor` with
`/home/user/.cargo/bin/zizmor` per the developer-local-path ban in
`.claude/rules/review-discipline.md`. No credentials, PII, or
high-entropy secrets in either trace (only git SHAs from zizmor's
workflow analysis output).

Replay still passes: `cargo test --test e2e_live -- zizmor_scan --ignored`
→ 2/2 ok.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test(replay): update zizmor_scan_v2 insta snapshot

The engine v2 replay-snapshot gate (`engine_v2_tests::snapshot_zizmor_scan_v2`)
failed against the re-recorded trace from 1691efe because the old
snapshot encoded a broken run:

- final_state: Failed
- Missing Assistant message role
- 3 issues: thread_failure (error), no_response (warning), llm_error (error)
- 6 tool calls that never produced a final answer

The new trace completes cleanly:

- final_state: Done
- System / User / Assistant roles present
- 1 issue: mixed_mode (info)
- 3 shell tool calls + successful `FINAL()` with real findings

The snapshot was pinning a regression. Regenerated with
`INSTA_UPDATE=always cargo test --test e2e_engine_v2 -- snapshot_zizmor_scan_v2`;
passes on replay.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(rules): address PR #2714 review feedback

- review-discipline: reword "Doc Absolute Paths" as a review convention
  (pre-commit only scans .rs; the rule misleadingly claimed enforcement).
- safety-and-sandbox: broaden `paths:` frontmatter to include the actual
  ingress owners (`bridge`, `channels`, `workspace`, `agent`, engine
  crate) so the rule auto-loads where it applies.
- tool-evidence: mark the side-effect gate, empty-fast rule, and
  `unverified` flag as target/aspirational invariants — neither
  `ToolError::EmptyResult`, an `unverified` field on `ToolOutput`, nor a
  byte-count field on `ActionRecord` exist today. Point at concrete
  interim conventions (`ToolError::ExecutionFailed`, `unverified: true`
  in the JSON result body).
- types: scope "Validated newtypes must gate Deserialize" to *new*
  types, document the `CredentialName`/`ExtensionName` exception (they
  intentionally use `#[serde(transparent)]` + derived `Deserialize`
  under the `serde_does_not_revalidate` test). Clarify the
  `from_trusted` trust boundary (trusted = typed upstream, untrusted =
  raw JSON field even if the field *name* is "registry entry").
  Switch `new` template to `impl Into<String>` to avoid an unnecessary
  clone when an owned `String` is passed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(rules): simplify types.md + split doc-hygiene; address review round 2

- types: collapse two templates into one canonical validated-newtype
  shape. New types use `#[serde(try_from = "String")]` with a shared
  `validate(&str)` helper — no more dual "transparent for some /
  try_from for others" guidance. `CredentialName`/`ExtensionName` are
  documented as the sole legacy exception (locked in by the
  `serde_does_not_revalidate` test); new code must not copy their
  `transparent` + `from_trusted` pattern. Removes the long "Using
  `from_trusted` safely" section and the separate "Validated newtypes
  must gate Deserialize" subsection that contradicted the Don'ts list.
- doc-hygiene: new tiny rule file scoped to `**/*.md`, `**/*.py`,
  `docs/**` that carries the "no developer-local absolute paths in
  committed docs" convention. Removed from review-discipline.md where
  its `src/**/*.rs` scope meant the rule never loaded on the files it
  governed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test(live): match hyphenated tool-install in attempted_relevant_tool

The engine records `action_name` as the raw string the LLM emitted
(`crates/ironclaw_engine/src/executor/structured.rs:381`), and the
registry's lookup canonicalization only affects dispatch — not the
name that reaches `StatusUpdate::ToolStarted`. The two other predicates
in this file (`bad_recovery` at :420, `phase_b_recovery` at :531)
already defend against both forms; this one should too, for
consistency. Addresses PR #2714 review.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
ilblackdragon and others added 3 commits April 20, 2026 15:16
…issue skill

Extends the coding-agent UX with per-thread git worktrees, a
`thread_metadata_set` builtin that skills can use to stash per-thread
state, and a `/fix-issue` skill that drives issue-to-PR flows
end-to-end. Adds a live e2e test that exercises the full plumbing
against a real scratch repo.

Engine
- `thread_metadata_set` builtin tool (src/tools/builtin/thread_metadata.rs):
  validates a JSON `patch`, echoes it as its output payload, caps at
  8 KiB. The orchestrator reads the output post-execution and applies a
  replace-at-top-level-key merge to `thread.metadata` in the single and
  parallel action paths.
- Orchestrator injects a compact `thread_state: {...}` section into the
  leading system message before each LLM call so the model always sees
  current metadata and can patch it. Merges into the existing system
  message instead of appending a trailing one — NearAI/Qwen rejects
  "system message after non-system" otherwise.
- `ThreadExecutionContext.thread_metadata` passes a snapshot of the
  thread's metadata through the effect boundary, so host adapters can
  read per-thread state without a store round-trip.
- `FilesystemBackend::shell` now spawns `/bin/sh -c` with `cwd` set to
  the mount root (plus optional relative subdir). Was `Unsupported`
  before; the intercept fell through to host execution, which broke
  `/project/` routing for sandbox-free runs.

Bridge / sandbox
- `thread_worktree_subdir` extracts `thread.metadata.dev.worktree`
  (rejecting `..`/absolute paths) and `rewrite_path_for_worktree`
  splices that subdir into `/project/...` paths so per-thread worktrees
  are transparent to the agent. Explicit `/project/worktrees/...` paths
  stay unchanged (escape hatch).
- `effect_adapter` threads the subdir into `maybe_intercept`.
- `get_engine_thread_metadata` is a small bridge helper for chrome
  rendering.

Skills
- `skills/fix-issue/SKILL.md` — 8-step flow: parse issue ref → fetch via
  github http skill → clone + worktree → research → implement → test →
  commit + push → open draft PR. Directs the agent to keep
  `thread.metadata.dev` current after every milestone.
- `skills/coding-repo/SKILL.md` v0.2.0 — rewrites the body around
  per-thread worktrees and `thread_metadata_set` milestones; swaps the
  `gh` CLI examples for http against `api.github.com`; drops `gh` from
  `requires.bins`.

Gateway UI
- New chrome pills: `repo`, `branch`, `#issue`, `PR #n` — conditional on
  `project.metadata.kind === "dev"` signal and backed by
  `thread.metadata.dev.*`. Styles in projects.css.
- `ThreadProjectContext` gains `issue: Option<ThreadIssueContext>`
  sourced from thread metadata; `branch` / `pr` overrides prefer
  skill-written values over the git-polled cache when set.
- `chat.js` dispatches a synthetic `input` event after shell-mode
  submit so the shell-mode CSS class clears; `agent_loop.rs` wraps
  shell output in a fenced code block so the TUI renders newlines.

Live e2e test
- `tests/e2e_live_fix_issue_flow.rs` drives `/fix-issue` against the
  scratch repo `nearai/ironclaw-e2e-test` (issues enabled, issue #1
  seeded). Hard-asserts the plumbing (skill activation, issue fetch,
  worktree creation, file write, `thread_metadata_set`); treats
  `git push` and `POST /pulls` as soft signals while the LLM
  consistency-in-loop work continues.
- `prepare_dev_project` pre-seeds the bootstrap project with
  `workspace_path=<tempdir>` + `github_repo` + `default_branch=staging`
  so `/project/` routes correctly without Docker/sandbox.
- Accepts `GITHUB_TOKEN` env var (e.g. `$(gh auth token)`) as a
  plaintext seed, bypassing master-key drift between `~/.ironclaw/.env`
  and the developer's encrypted secrets.
- `docs/e2e-test-fixture-issue.md` captures the exact issue body to
  seed on the fixture repo.

Tests
- `cargo test --lib tools::builtin::thread_metadata` — 4 passing.
- `cargo test --lib bridge::sandbox` — 41 passing, including 7 new
  worktree-rewrite and metadata-extractor tests.
- `cargo test -p ironclaw_engine --lib workspace::filesystem` — 12
  passing, including 3 new `FilesystemBackend::shell` tests.
- `cargo test -p ironclaw_engine --lib` — 441 passing.
- Full `cargo clippy --all --benches --tests --examples --all-features
  -- -D warnings` — zero warnings.
- `IRONCLAW_LIVE_TEST=1 GITHUB_TOKEN=$(gh auth token) \
    cargo test --features libsql --test e2e_live_fix_issue_flow \
      -- --ignored --test-threads=1 --nocapture` — 1 passing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
# Conflicts:
#	crates/ironclaw_common/src/event.rs
#	src/channels/web/features/chat/mod.rs
Copilot AI review requested due to automatic review settings April 22, 2026 08:25

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 69 out of 69 changed files in this pull request and generated 8 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +277 to +288
fn parse_shell_output(value: &serde_json::Value) -> Option<ShellResult> {
// The shell tool returns a structured JSON object with stdout/stderr/
// exit_code; older builds may have returned a string. Accept either
// shape defensively — the cache should not panic on a shape drift.
if let Some(obj) = value.as_object() {
let stdout = obj
.get("stdout")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let exit_code = obj.get("exit_code").and_then(|v| v.as_i64()).unwrap_or(0) as i32;
return Some(ShellResult { stdout, exit_code });

Copilot AI Apr 22, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

parse_shell_output is reading stdout, but the shell tool returns { output, exit_code, … } (see src/tools/builtin/shell.rs). This makes branch/dirty/PR always compute from an empty string. Parse output (or support both output and stdout for compatibility).

Copilot uses AI. Check for mistakes.
Comment on lines +122 to +145
if is_stale(snapshot.branch_fetched_at, BRANCH_TTL, now) {
let this = Arc::clone(self);
let user = user_id.to_string();
let wp = workspace_path.clone();
tokio::spawn(async move {
let _ = this.refresh_branch(project_id, &wp, &user).await;
});
}
if is_stale(snapshot.dirty_fetched_at, DIRTY_TTL, now) {
let this = Arc::clone(self);
let user = user_id.to_string();
let wp = workspace_path.clone();
tokio::spawn(async move {
let _ = this.refresh_dirty(project_id, &wp, &user).await;
});
}
if is_stale(snapshot.pr_fetched_at, PR_TTL, now) {
let this = Arc::clone(self);
let user = user_id.to_string();
let wp = workspace_path;
tokio::spawn(async move {
let _ = this.refresh_pr(project_id, &wp, &user).await;
});
}

Copilot AI Apr 22, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

spawn_stale_refreshes spawns a new task on every call when a field is stale, with no in-flight dedupe per (project_id, field). Under /api/chat/threads polling this can create a thundering herd of git/gh calls. Consider tracking per-field refresh-in-progress (or last_refresh_started_at) to avoid concurrent duplicates.

Copilot uses AI. Check for mistakes.
Comment on lines +491 to +501
// 3. No per-thread override and no active pointer. Fall back to
// the user's shared "default" project (rendered as "General" in
// the UI) — this is the per-user bucket the engine auto-creates
// on first use, so fresh users without any `project_set_active`
// call still get a valid workspace for `!`-mode shell dispatches
// and chrome rendering. Without this fallback the very first
// `!ls` after login returned 409 and the gateway showed no
// output, which is what the user hit.
let projects = crate::bridge::list_engine_projects(user_id).await.ok()?;
let default_project = projects.into_iter().find(|p| p.name == "default")?;
Some((default_project, false))

Copilot AI Apr 22, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The doc comment for resolve_thread_project says precedence ends with None when there’s no override and no active pointer, but the implementation falls back to a "default" project. This changes the API semantics (and likely prevents the documented 409 "no project" shell-mode path). Either remove the fallback or update the docs/tests and error messaging to match the intended behavior.

Copilot uses AI. Check for mistakes.
Comment thread src/channels/web/util.rs
Comment on lines +667 to +679
} else {
(String::new(), String::new(), 0, false, None)
};
turns.push(TurnInfo {
turn_number,
user_message_id: Some(msg.id),
user_input: msg.content.clone(),
response: None,
state: if exit_code == 0 {
"Completed".to_string()
} else {
"Failed".to_string()
},

Copilot AI Apr 22, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When a shell_command has no following shell_output, this code sets exit_code to 0 and marks the turn Completed, which is misleading (it’s an incomplete/failed turn). Consider using a sentinel exit_code (e.g. -1) and setting state to Failed/InProgress when the output row is missing.

Copilot uses AI. Check for mistakes.
Comment on lines +5 to +9
//! The fixture repo is `nearai/ironclaw-e2e-test` — a dedicated scratch
//! repo the team owns so that every run of this test can open and then
//! close real PRs without polluting `nearai/ironclaw`. The test always
//! cleans up after itself: on completion it closes the PR and prunes
//! the branch.

Copilot AI Apr 22, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This module doc says the test "always cleans up" by closing PRs and pruning branches, but later notes the PR is intentionally left open and requires a manual cleanup script. Please make the doc and behavior consistent (ideally ensure teardown happens automatically, since this test mutates real GitHub state).

Copilot uses AI. Check for mistakes.
use crate::channels::web::auth::AuthenticatedUser;
use crate::channels::web::platform::state::GatewayState;
use crate::channels::web::types::*;
use crate::tools::builtin::memory::WorkspaceResolver;

Copilot AI Apr 22, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

use crate::tools::builtin::memory::WorkspaceResolver; appears unused in this module (no other references). This will trigger an unused-import warning/error under -D warnings; please remove it or use it explicitly.

Copilot uses AI. Check for mistakes.
Comment on lines +309 to +311
let maybe = crate::bridge::get_engine_project(&pid.0.to_string(), &ctx.user_id)
.await
.map_err(map_bridge_err)?;

Copilot AI Apr 22, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

get_engine_project(&pid.0.to_string(), …).await borrows a temporary String across an .await, which won’t compile (temporary doesn’t live long enough). Convert once into a local String (or change the bridge API to accept ProjectId) before awaiting.

Copilot uses AI. Check for mistakes.
Comment on lines +391 to +393
let maybe = crate::bridge::get_engine_project(&pid.0.to_string(), &ctx.user_id)
.await
.map_err(map_bridge_err)?;

Copilot AI Apr 22, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same temporary-borrow-across-.await issue here: get_engine_project(&pid.0.to_string(), …).await won’t compile. Store pid.0.to_string() in a local variable (or pass a typed ProjectId through the bridge) before awaiting.

Copilot uses AI. Check for mistakes.
Hardens the `apply_patch` tool against the two most common LLM failure
modes observed during the live fix-issue runs, and adds Playwright
coverage for the dev-project chrome pills so the widget surface has an
in-CI regression gate.

apply_patch

- **Closest-match hint on failure** — when `old_string` doesn't match
  (exact or any fuzzy form), the error now quotes the nearest matching
  region of the file with line numbers. The agent can compare its
  `old_string` against what the file actually contains instead of
  retrying blind. Port of claude-code's `meta: {actualOldString}`
  pattern (highest-impact single change per their own design).
- **Wrong-argument-shape guard** — when the model packs code or
  multi-line content into the `path` parameter (recurring regression:
  live run #8 hit this and wasted 3 LLM turns), the tool rejects at
  the parameter layer with a concrete diagnostic instead of surfacing
  the OS-level "Cannot access file" error. Heuristic: paths are one
  line, under 512 bytes; anything else is almost certainly mis-routed.
- **Description update** — added claude-code's "smallest unique
  old_string, 2-4 adjacent lines" guidance and the line-number-prefix
  stripping reminder. Both were bare-metal causes of the agent
  overmatching or copy-pasting `42 |` into its patch.
- New helper `file_edit_guard::find_nearest_context(haystack, needle)`:
  picks the longest-but-rare line of `needle`, finds it in `haystack`,
  widens to a few neighbouring lines, and line-numbers the result.

Tests: `tools::builtin::file::tests::test_apply_patch_not_found_includes_nearest_context`
and `test_apply_patch_rejects_content_in_path_arg`. All 58
`tools::builtin::file*` tests pass.

Playwright / e2e

- New scenarios in `test_coding_project_flow.py`:
  - `test_dev_project_pills_render` — seeds a dev project, drives
    `window.ProjectUI.refreshChromeFromThread({...})` with a full
    `ThreadProjectContext` payload (github_repo, branch, issue, pr),
    and asserts each of the four pills (repo, branch, #issue, PR#n)
    has the expected text, href, and hover title.
  - `test_dev_project_pills_hide_when_fields_absent` — the hide-on-null
    branch; ensures the chrome doesn't paint stale pills when the
    thread transitions out of a dev-workflow.
- `refreshChromeFromThread` promoted to `window.ProjectUI.*` so
  Playwright can drive the pill-render path without a full agent run.
- `conftest.py`: `ENGINE_V2=true` added to the main `ironclaw_server`
  fixture env. Without this the engine state stays uninitialized,
  `/api/engine/projects` 500s with "engine not initialized", and every
  coding-project scenario fails before the first assertion.

Quality gate
- `cargo fmt --all`
- `cargo clippy --all --benches --tests --examples --all-features -- -D warnings` — clean
- `cargo test --lib tools::builtin::file` — 58 passing
- `pytest scenarios/test_coding_project_flow.py -k pills -v` — 2 passing

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@serrrfirat

Copy link
Copy Markdown
Collaborator

Paranoid Architect Review — Request Changes

2 Critical, 5 High, 5 Medium findings. This is the highest-risk PR in the batch — 6K+ lines touching gateway, engine, TUI, and skills with two directly exploitable security issues.

Critical

  • XSS via attribute injection in project edit form (escapeHtml() doesn't escape quotes — 5 <input value="..."> constructions vulnerable)
  • TUI shell mode bypasses ToolDispatcher — no approval checks, no audit trail, no safety pipeline

High

  • Arbitrary directory traversal: workspace_path accepts any PathBuf with zero validation — user can set /etc and run shell commands there
  • LLM metadata key injection: thread_metadata_set allows LLM to overwrite project_id, redirecting shell commands to a different workspace
  • Unsanitized metadata in LLM prompt: Thread metadata injected as raw JSON into system prompt — prompt injection vector
  • Confused deputy: Shell dispatch uses thread-requesting user_id — potential cross-user action in multi-tenant
  • No input length limit on shell command content before dispatch

Medium

  • parse_shell_output reads stdout key but shell tool returns output — cache will never populate
  • Caller-supplied env vars can override safe PATH/LD_PRELOAD whitelist
  • from_trusted + transparent serde allows unvalidated GitHubRepo to persist
  • No concurrency guard on shell dispatch — concurrent commands interleave and garble history
  • IDOR: resolve_thread_project reads conversation metadata by UUID without user ownership check

Blocking before merge

  1. Fix the XSS: escapeHtml() must escape " and ' for attribute contexts
  2. Route TUI shell through ToolDispatcher::dispatch()
  3. Validate workspace_path (must be under ~/.ironclaw/ or project workspace root)
  4. Add allow-list for thread_metadata_set writable keys (block project_id, source_channel, etc.)
  5. Add content length cap on shell commands

@serrrfirat

Copy link
Copy Markdown
Collaborator

Paranoid Architect Review — Request Changes

2 Critical, 5 High, 5 Medium findings. This is the highest-risk PR in the batch — 6K+ lines touching gateway, engine, TUI, and skills with two directly exploitable security issues.

Critical

  • XSS via attribute injection in project edit form — escapeHtml() doesn't escape quotes. 5 <input value="...escapeHtml(x)..."> constructions allow attribute breakout. A project name like " onfocus="alert(document.cookie) injects arbitrary event handlers.
  • TUI shell mode bypasses ToolDispatcher — calls tool.execute() directly, skipping the approval pipeline, audit trail, safety layer, and rate limiter. Gateway path is correct.

High

  • Arbitrary directory traversal: workspace_path accepts any PathBuf with zero validation — user can set /etc and run shell commands there.
  • LLM metadata key injection: thread_metadata_set allows LLM to overwrite project_id, redirecting shell commands to a different workspace.
  • Unsanitized metadata in LLM prompt: Thread metadata injected as raw JSON into system prompt — prompt injection vector via skill-written metadata values.
  • Confused deputy: Shell dispatch uses thread-requesting user_id — potential cross-user action in multi-tenant.
  • No input length limit on shell command content before dispatch.

Medium

  • parse_shell_output reads stdout key but shell tool returns output — cache will never populate (bug).
  • Caller-supplied env vars can override safe PATH/LD_PRELOAD whitelist in FilesystemBackend::shell.
  • from_trusted + transparent serde allows unvalidated GitHubRepo to persist and potentially render unsafely.
  • No concurrency guard on shell dispatch — concurrent commands interleave and garble history replay.
  • IDOR: resolve_thread_project reads conversation metadata by UUID without user ownership check.

Blocking before merge

  1. Fix XSS: escapeHtml() must escape " and ' for attribute contexts, or build DOM elements programmatically
  2. Route TUI shell through ToolDispatcher::dispatch()
  3. Validate workspace_path (must be under allowed workspace root)
  4. Add allow-list for thread_metadata_set writable keys (block project_id, source_channel)
  5. Add content length cap on shell commands
  6. Fix parse_shell_output to read "output" instead of "stdout"

form.className = 'project-modal-form';
form.innerHTML = ''
+ '<h4>' + escapeHtml(t('project.modal.editTitle', 'Edit project')) + '</h4>'
+ '<label>Name<input type="text" id="pe-name" value="' + escapeHtml(nameValue) + '"'

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Critical: XSS via HTML attribute injection. escapeHtml() uses the textContent/innerHTML DOM technique which escapes <, >, & but does NOT escape double quotes. This <input value="...escapeHtml(x)..."> construction is vulnerable to attribute breakout.

A project name like " onfocus="alert(document.cookie) injects arbitrary event handlers into 5 input fields in the edit form.

Fix: Either use a proper attribute escaper that also handles " and ', or build DOM elements programmatically with input.value = rawString instead of string interpolation.

// Resolve the host path: explicit override or default ~/.ironclaw/... path.
let project_id = uuid::Uuid::parse_str(&info.id).ok()?;
let resolved_path = info
.workspace_path

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

High: Arbitrary directory traversal. workspace_path is accepted as any arbitrary PathBuf with zero validation — no check that it's under ~/.ironclaw/, no rejection of sensitive directories (/etc, /root). A user can create a project with workspace_path="/etc" and then run !cat shadow in shell mode.

The shell tool's blocklist catches cat /etc/shadow but NOT cat shadow when the workdir is already /etc.

Fix: Validate that workspace_path resolves to a path under the allowed workspace root (e.g., ~/.ironclaw/workspaces/), or at minimum reject paths outside $HOME.

}
}
for (k, v) in env {
cmd.env(k, v);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Medium: Caller env vars override safe whitelist. This applies caller-supplied env vars AFTER setting safe vars (PATH, HOME, etc.), meaning a caller can inject PATH=/evil/bin or LD_PRELOAD=/evil.so.

Fix: Apply safe vars last (after caller env), or filter out security-critical keys from the caller's env map.

Two pre-existing staging-merge regressions in the coding-flow e2e suite:

1. Assistant thread now intentionally returns `project: None` (#2751),
   so after `project_set_active` the chrome still showed "No project".
   `refreshCurrentThread` now mirrors the backend `resolve_thread_project`
   precedence — per-thread override → active pointer → none — and fetches
   the active-project pointer when the thread has no override. Makes the
   home/assistant thread surface the user's currently-selected coding
   project instead of appearing disconnected.

2. `currentThreadId` was module-scoped and not reachable from Playwright.
   Expose a read-only `window.currentThreadId` getter so scenarios can
   observe thread binding without postMessage wiring.

3. `test_shell_mode_rejects_when_no_project` asserted a 409 contract
   that was deliberately retired in 0323ead (shell mode now falls back
   to the per-user `default` project). Renamed to
   `test_shell_mode_falls_back_to_default_project` and flipped the
   assertion to 202 so the new contract is pinned; the security
   invariant (shell runs against a project cwd, not the gateway's)
   is still covered by `test_shell_mode_roundtrip_persists_across_reload`.

All 7 scenarios in `test_coding_project_flow.py` pass; `test_project_detail`
still passes alongside.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings April 23, 2026 17:43

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 74 out of 74 changed files in this pull request and generated 4 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/agent/agent_loop.rs
Comment on lines +502 to +503
let out = tool
.execute(serde_json::Value::Object(params), &ctx)

Copilot AI Apr 23, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The TUI shell-mode path executes the shell tool directly via Tool::execute, bypassing ToolDispatcher’s approval/audit pipeline. That means commands that are meant to be approval-gated (e.g. git reset --hard, git clean -f) can run without any approval checks. Route shell-mode through the dispatcher (if available here) or explicitly enforce requires_approval before executing.

Suggested change
let out = tool
.execute(serde_json::Value::Object(params), &ctx)
let params = serde_json::Value::Object(params);
if tool.requires_approval(&params) {
return Err("shell command requires approval and must be executed through the approval pipeline".to_string());
}
let out = tool
.execute(params, &ctx)

Copilot uses AI. Check for mistakes.
Comment on lines +277 to +289
fn parse_shell_output(value: &serde_json::Value) -> Option<ShellResult> {
// The shell tool returns a structured JSON object with stdout/stderr/
// exit_code; older builds may have returned a string. Accept either
// shape defensively — the cache should not panic on a shape drift.
if let Some(obj) = value.as_object() {
let stdout = obj
.get("stdout")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let exit_code = obj.get("exit_code").and_then(|v| v.as_i64()).unwrap_or(0) as i32;
return Some(ShellResult { stdout, exit_code });
}

Copilot AI Apr 23, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

parse_shell_output is looking for stdout, but the built-in shell tool returns { "output": ..., "exit_code": ... } (see src/tools/builtin/shell.rs). As a result, the project context cache will treat every command as having empty output and branch/dirty/PR probing won’t populate. Update this parser to read from output (and keep the string fallback if desired).

Copilot uses AI. Check for mistakes.
Comment on lines +112 to +146
fn spawn_stale_refreshes(
self: &Arc<Self>,
project_id: ProjectId,
workspace_path: &Path,
user_id: &str,
snapshot: &CachedContext,
) {
let now = Instant::now();
let workspace_path = workspace_path.to_path_buf();

if is_stale(snapshot.branch_fetched_at, BRANCH_TTL, now) {
let this = Arc::clone(self);
let user = user_id.to_string();
let wp = workspace_path.clone();
tokio::spawn(async move {
let _ = this.refresh_branch(project_id, &wp, &user).await;
});
}
if is_stale(snapshot.dirty_fetched_at, DIRTY_TTL, now) {
let this = Arc::clone(self);
let user = user_id.to_string();
let wp = workspace_path.clone();
tokio::spawn(async move {
let _ = this.refresh_dirty(project_id, &wp, &user).await;
});
}
if is_stale(snapshot.pr_fetched_at, PR_TTL, now) {
let this = Arc::clone(self);
let user = user_id.to_string();
let wp = workspace_path;
tokio::spawn(async move {
let _ = this.refresh_pr(project_id, &wp, &user).await;
});
}
}

Copilot AI Apr 23, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

get() can spawn duplicate refresh tasks for the same project/field when multiple requests arrive before the first refresh updates *_fetched_at (e.g. thread list + history in quick succession). This can lead to unbounded concurrent git/gh shell-outs and unnecessary load. Consider tracking per-project in-flight refreshes (or setting the fetched-at timestamp optimistically before spawning) so only one refresh per field runs at a time.

Copilot uses AI. Check for mistakes.
Comment thread src/channels/web/util.rs
Comment on lines +636 to +679
let (stdout, stderr, exit_code, truncated, completed_at) = if let Some(next) =
iter.peek()
&& next.role == "shell_output"
{
// safety: peeked `Some` two lines up; this next() cannot be None.
let Some(out) = iter.next() else {
continue;
};
let parsed: serde_json::Value =
serde_json::from_str(&out.content).unwrap_or(serde_json::Value::Null);
(
parsed
.get("stdout")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string(),
parsed
.get("stderr")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string(),
parsed
.get("exit_code")
.and_then(|v| v.as_i64())
.unwrap_or(0) as i32,
parsed
.get("truncated")
.and_then(|v| v.as_bool())
.unwrap_or(false),
Some(out.created_at.to_rfc3339()),
)
} else {
(String::new(), String::new(), 0, false, None)
};
turns.push(TurnInfo {
turn_number,
user_message_id: Some(msg.id),
user_input: msg.content.clone(),
response: None,
state: if exit_code == 0 {
"Completed".to_string()
} else {
"Failed".to_string()
},

Copilot AI Apr 23, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When a shell_command message has no following shell_output row, this builds a turn with exit_code = 0 and state Completed, which is misleading (it’s more likely “pending/unknown” or a failed/crashed dispatch). Consider using a sentinel exit code (e.g. -1) and/or a distinct state when the output row is missing or unparseable so the UI doesn’t show false success.

Copilot uses AI. Check for mistakes.
ilblackdragon and others added 3 commits April 24, 2026 03:51
# Conflicts:
#	crates/ironclaw_engine/src/executor/orchestrator.rs
#	crates/ironclaw_gateway/static/js/core/history.js
…e-clone

Three live-test-driven improvements observed from the 2026-04-23 run against
nearai/ironclaw-e2e-test:

1. apply_patch description now leads with a concrete JSON example
   ({"path": "...", "old_string": "...", "new_string": "..."}) instead of
   prose. LLMs follow shape-by-example better than imperatives.

2. fix-issue skill step 3 (worktree) is now mandatory and explicit:
   numbered 7-step sequence, required `git worktree list` verification,
   and an upfront "never cd outside /project/" guard. Step 2 gains a
   mandatory thread_metadata_set call with its own JSON example — the
   prior run made the http fetch but skipped the metadata update,
   leaving the UI pills blank.

3. live-test harness pre-clones the fixture repo into the project
   workspace tempdir. In production a dev project's workspace_path
   already contains the checkout; the empty tempdir on first run made
   the agent escape to /tmp instead of creating a worktree at the
   project root.

Also: fix engine v2 `thread_metadata` field on three missed
`ThreadExecutionContext` construction sites in executor/context.rs tests
(carried over from the pre-merge branch into staging's expanded test
surface).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ue skill

CodeAct (Tier 1) has a 30s Monty wall-clock cap that counts await time
against it — so `await shell(command="cargo check")` inside a ```repl```
block times out well before the underlying shell tool's 120s budget. In
the fix-issue live run against nearai/ironclaw-e2e-test, every cargo
invocation inside CodeAct hit this.

- `MONTY_CODEACT_TIMEOUT_SECS` env var now overrides the default, clamped
  to [1, 600]. Coding-agent workflows need minutes for `cargo check` /
  `cargo test`; safety-sensitive contexts can keep the 30s default.
- The e2e_live_fix_issue_flow harness sets it to 300s at test startup.

The fix-issue skill is rewritten to be CodeAct-first:
- Every edit is `read_file` → `str.replace()` → `write_file` in Python
  instead of `apply_patch` (which LLMs consistently mis-serialize).
- Long-running shell commands (`cargo test` etc.) are called as Tier-0
  tool calls on their own turn — the skill explicitly documents which
  ops go in ```repl``` vs a plain tool call, and why.
- The coding skill (which co-activates with fix-issue) was pushing
  "Prefer apply_patch" and had to be updated to match; it now recommends
  the CodeAct flow and bans `echo` narration.

Across 5 live runs, this gets the agent past:
  skill activation → issue fetch → thread_metadata_set → git worktree
  → cargo clippy (100s+, via Tier 0) → write_file of new tool.

Remaining gap: the agent still loses top-level bindings (`issue`,
`title`, `worktree`) across CodeAct turns, so multi-turn workflows
re-fetch the issue or re-derive the worktree path. That's LLM state
consistency, not an engine bug — next lever is either a more capable
model or a single-block skill template.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings April 24, 2026 04:56

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 77 out of 77 changed files in this pull request and generated 3 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +21 to 22
use crate::tools::builtin::memory::WorkspaceResolver;

Copilot AI Apr 24, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unused import: WorkspaceResolver is imported but not referenced anywhere in this module. With -D warnings/clippy this will fail CI; please remove it (or use it if something is missing).

Copilot uses AI. Check for mistakes.
Comment on lines +239 to +312
let res = self
.run_shell(
user_id,
workspace_path,
"gh pr view --json number,title,url,state",
)
.await?;

// Non-zero exit from `gh pr view` usually just means "no PR for the
// current branch" — treat as "no PR", not as an error. The chrome
// will simply omit the PR chip.
let pr = if res.exit_code == 0 {
parse_gh_pr(&res.stdout)
} else {
None
};

let mut guard = self.entries.write().await;
let entry = guard.entry(project_id).or_default();
entry.pr = pr;
entry.pr_fetched_at = Some(Instant::now());
Some(())
}

/// Clear a project's cache entry — used when `project_update` or
/// `project_delete` mutates a project so the next read does not serve
/// stale metadata for a moved `workspace_path`.
pub async fn invalidate(&self, project_id: ProjectId) {
self.entries.write().await.remove(&project_id);
}
}

#[derive(Debug, Clone)]
struct ShellResult {
stdout: String,
exit_code: i32,
}

fn parse_shell_output(value: &serde_json::Value) -> Option<ShellResult> {
// The shell tool returns a structured JSON object with stdout/stderr/
// exit_code; older builds may have returned a string. Accept either
// shape defensively — the cache should not panic on a shape drift.
if let Some(obj) = value.as_object() {
let stdout = obj
.get("stdout")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let exit_code = obj.get("exit_code").and_then(|v| v.as_i64()).unwrap_or(0) as i32;
return Some(ShellResult { stdout, exit_code });
}
if let Some(s) = value.as_str() {
return Some(ShellResult {
stdout: s.to_string(),
exit_code: 0,
});
}
None
}

fn parse_gh_pr(stdout: &str) -> Option<PrSummary> {
let raw = stdout.trim();
if raw.is_empty() {
return None;
}
#[derive(Deserialize)]
struct GhPrRow {
number: u32,
title: String,
url: String,
state: String,
#[serde(default)]
is_draft: bool,
}

Copilot AI Apr 24, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

refresh_pr runs gh pr view --json number,title,url,state, but parse_gh_pr expects a draft flag (is_draft). GitHub CLI uses isDraft and it is only present if requested via --json isDraft. As written, draft PRs will be reported as open in the chrome. Consider adding isDraft to the command and deserializing with #[serde(rename = "isDraft")].

Copilot uses AI. Check for mistakes.
Comment on lines +429 to +435
/// Resolve which project a thread should show chrome for.
///
/// Precedence:
/// 1. Per-thread override in `conversations.metadata.project_id`.
/// 2. User-level active pointer in `projects/_active.json`.
/// 3. `None` — the thread shows no project chrome.
///

Copilot AI Apr 24, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The doc comment for resolve_thread_project says precedence step 3 returns None (no project chrome), but the implementation always falls back to the user's default project. Please update the comment to match the actual behavior so callers and future maintainers don't rely on the wrong contract.

Copilot uses AI. Check for mistakes.
@henrypark133
henrypark133 changed the base branch from staging to main May 1, 2026 06:15
theredspoon pushed a commit to theredspoon/ironclaw that referenced this pull request Jun 21, 2026
* docs(rules): add review-driven guidance for Claude Code

Synthesizes recurring patterns from ~30 merged PRs, 147 bot review
comments (Copilot/Gemini), human reviews, and ~50 issues filed in the
past 2 weeks. Each rule cites the motivating PR/issue numbers.

New files:
- error-handling.md — silent-failure taxonomy (unwrap_or_default, .ok()?,
  poisoned caches), persist-then-reload atomicity, channel-edge error
  mapping. (nearai#2526, nearai#2633, nearai#2653, nearai#2673, nearai#2546, nearai#2407, nearai#2408)
- agent-evidence.md — side-effect claims must cite tool evidence,
  empty-fast outputs are errors, external-effect tools must read back,
  setup UI round-trip. (nearai#2544, nearai#2580, nearai#2582, nearai#2541, nearai#2545, nearai#2411, nearai#2543,
  nearai#2586)
- lifecycle.md — discovery vs. activation, terminal auth rejection,
  list_installed vs. list_active, deactivation unwinds, snapshot
  rehydrate must re-validate. (nearai#2556, nearai#2557, nearai#2558, nearai#2564, nearai#2419,
  PR nearai#2617, PR nearai#2631)

Extended:
- types.md — from_trusted boundary rule, validated-newtype template with
  shared validate(&str), serde(try_from) required for validated types,
  wire-stable enums (no Debug; serde alias for migrations), canonical
  wire-contract field naming. (PR nearai#2685, nearai#2681, nearai#2687, nearai#2678, nearai#2669,
  nearai#2665, nearai#2683, nearai#2702)
- safety-and-sandbox.md — every new ingress scans pre-transform/pre-
  injection, bounded resources (interners/streams/fan-out caps), cache
  keys must be complete. (nearai#2491, nearai#2676, nearai#2470, nearai#2633, nearai#2673, nearai#2710,
  PR nearai#2702)
- review-discipline.md — PR scope discipline, guardrail scripts are
  code (regression tests, grouped-import parsing, CI has_code inclusion),
  absolute-path ban in committed docs, stale comments after refactors.
  (PR nearai#2668, nearai#2628, nearai#2680, nearai#2687, nearai#2647, nearai#2689, nearai#2701)

All new files carry paths: frontmatter so they auto-load only on
matching files.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(rules): split agent-evidence into prompt + code rule

agent-evidence.md mixed two concerns: runtime agent instruction (what
the LLM should do when concluding a turn) and code-enforcement rules
(what the dispatcher, engine, and tools must implement). Rules under
.claude/rules/ only guide Claude Code when editing the repo — the
runtime agent never reads them.

Splits the two:

- crates/ironclaw_engine/prompts/codeact_postamble.md — new section
  "Evidence before claiming side effects". Sits next to the existing
  "FINAL() answer quality" guidance; loaded via include_str! in
  executor/prompt.rs (no Rust change needed).
- .claude/rules/tool-evidence.md — renamed from agent-evidence.md,
  keeps only the code invariants (engine v2 side-effect gate,
  empty-fast ToolError::EmptyResult, external-effect tools must read
  back, setup UI round-trip).

Prompt tests pass unchanged; the postamble addition is pure text.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* prompt: tighten evidence rule to FINAL() claims only, not tool use

Live-test validation of the "Evidence before claiming side effects"
section (added in the prior commit) showed it inhibited legitimate
tool use. With the original wording, `zizmor_scan_v2` live-recording
timed out at 302s with zero responses; reverting the postamble
restored healthy behavior (88s run, 8 shell calls including
`cargo install zizmor` and full workflow analysis).

The original phrasing conflated two things: what the agent should
claim and what tools it should call. The rule is only about the
claim. Re-tunes the section to:

- Open with an explicit "this does not restrict tool calls" scope.
- Drop the "<1ms = failure" heuristic (too broad — normal tools like
  `tool_info(schema)` are legitimately fast).
- Drop the full enumeration of forbidden side-effect verbs; keep the
  rule narrower and clearer.
- Shorten the code example (remove redundant early-return).

Re-tuned run: agent is active (shell calls, real reasoning), live
recording completes in ~9s. The remaining test failure is a
pre-existing assertion bug (exact `t == "shell"` match against tool
strings that now carry arguments like `"shell(cmd)"`) — reproduces
with the old postamble too.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test(live): fix tool-name assertions + re-record zizmor traces

The two `zizmor_scan*` live tests had four broken tool-name assertions
that silently failed to match: `tools.iter().any(|t| t == "shell")`
against a tool list that now contains `"shell(cmd)"` strings (tool
events carry args via `format_action_display_name` in
`src/bridge/router.rs`). Two of the four were negative assertions
checking for the absence of `tool_install` recovery loops — those
silently passed even when a recovery loop actually ran. `sandbox_live_e2e.rs:203`
already used the correct `t == "shell" || t.starts_with("shell(")`
pattern; applied it consistently to all four sites.

Verified live:

- `IRONCLAW_LIVE_TEST=1 cargo test --test e2e_live -- zizmor_scan --ignored --test-threads=1`
  → 2 passed, 0 failed, 51.78s. Agent installs and runs zizmor
  end-to-end, producing real findings (exit code 14, dangerous
  triggers, excessive permissions, etc.).

Traces re-recorded with the tuned postamble (commit 50d8517) and
scrubbed: replaced `/home/illia/.cargo/bin/zizmor` with
`/home/user/.cargo/bin/zizmor` per the developer-local-path ban in
`.claude/rules/review-discipline.md`. No credentials, PII, or
high-entropy secrets in either trace (only git SHAs from zizmor's
workflow analysis output).

Replay still passes: `cargo test --test e2e_live -- zizmor_scan --ignored`
→ 2/2 ok.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test(replay): update zizmor_scan_v2 insta snapshot

The engine v2 replay-snapshot gate (`engine_v2_tests::snapshot_zizmor_scan_v2`)
failed against the re-recorded trace from 1691efe because the old
snapshot encoded a broken run:

- final_state: Failed
- Missing Assistant message role
- 3 issues: thread_failure (error), no_response (warning), llm_error (error)
- 6 tool calls that never produced a final answer

The new trace completes cleanly:

- final_state: Done
- System / User / Assistant roles present
- 1 issue: mixed_mode (info)
- 3 shell tool calls + successful `FINAL()` with real findings

The snapshot was pinning a regression. Regenerated with
`INSTA_UPDATE=always cargo test --test e2e_engine_v2 -- snapshot_zizmor_scan_v2`;
passes on replay.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(rules): address PR nearai#2714 review feedback

- review-discipline: reword "Doc Absolute Paths" as a review convention
  (pre-commit only scans .rs; the rule misleadingly claimed enforcement).
- safety-and-sandbox: broaden `paths:` frontmatter to include the actual
  ingress owners (`bridge`, `channels`, `workspace`, `agent`, engine
  crate) so the rule auto-loads where it applies.
- tool-evidence: mark the side-effect gate, empty-fast rule, and
  `unverified` flag as target/aspirational invariants — neither
  `ToolError::EmptyResult`, an `unverified` field on `ToolOutput`, nor a
  byte-count field on `ActionRecord` exist today. Point at concrete
  interim conventions (`ToolError::ExecutionFailed`, `unverified: true`
  in the JSON result body).
- types: scope "Validated newtypes must gate Deserialize" to *new*
  types, document the `CredentialName`/`ExtensionName` exception (they
  intentionally use `#[serde(transparent)]` + derived `Deserialize`
  under the `serde_does_not_revalidate` test). Clarify the
  `from_trusted` trust boundary (trusted = typed upstream, untrusted =
  raw JSON field even if the field *name* is "registry entry").
  Switch `new` template to `impl Into<String>` to avoid an unnecessary
  clone when an owned `String` is passed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(rules): simplify types.md + split doc-hygiene; address review round 2

- types: collapse two templates into one canonical validated-newtype
  shape. New types use `#[serde(try_from = "String")]` with a shared
  `validate(&str)` helper — no more dual "transparent for some /
  try_from for others" guidance. `CredentialName`/`ExtensionName` are
  documented as the sole legacy exception (locked in by the
  `serde_does_not_revalidate` test); new code must not copy their
  `transparent` + `from_trusted` pattern. Removes the long "Using
  `from_trusted` safely" section and the separate "Validated newtypes
  must gate Deserialize" subsection that contradicted the Don'ts list.
- doc-hygiene: new tiny rule file scoped to `**/*.md`, `**/*.py`,
  `docs/**` that carries the "no developer-local absolute paths in
  committed docs" convention. Removed from review-discipline.md where
  its `src/**/*.rs` scope meant the rule never loaded on the files it
  governed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test(live): match hyphenated tool-install in attempted_relevant_tool

The engine records `action_name` as the raw string the LLM emitted
(`crates/ironclaw_engine/src/executor/structured.rs:381`), and the
registry's lookup canonicalization only affects dispatch — not the
name that reaches `StatusUpdate::ToolStarted`. The two other predicates
in this file (`bad_recovery` at :420, `phase_b_recovery` at :531)
already defend against both forms; this one should too, for
consistency. Addresses PR nearai#2714 review.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

contributor: core 20+ merged PRs risk: medium Business logic, config, or moderate-risk modules scope: agent Agent core (agent loop, router, scheduler) scope: channel/cli TUI / CLI channel scope: channel/wasm WASM channel runtime scope: channel/web Web gateway channel scope: channel Channel infrastructure scope: ci CI/CD workflows scope: db/postgres PostgreSQL backend scope: db Database trait / abstraction scope: docs Documentation scope: extensions Extension management scope: llm LLM integration scope: orchestrator Container orchestrator scope: pairing Pairing mode scope: tool/builtin Built-in tools scope: tool Tool infrastructure scope: worker Container worker size: XL 500+ changed lines

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants