Skip to content

feat: Switchyard-native goal mode (self-driven + claude/codex native passthrough + durable resume)#5

Merged
Yidhar merged 14 commits into
mainfrom
feat/goal-mode
Jul 2, 2026
Merged

feat: Switchyard-native goal mode (self-driven + claude/codex native passthrough + durable resume)#5
Yidhar merged 14 commits into
mainfrom
feat/goal-mode

Conversation

@Yidhar

@Yidhar Yidhar commented Jul 2, 2026

Copy link
Copy Markdown
Owner

What

Adds a goal mode to Switchyard: give a high-level objective and the active Core provider runs turn-after-turn until it's achieved or you stop it. Provider-agnostic, with native passthrough where a CLI has its own reachable goal engine.

Investigated all three CLIs' native /goal (source/binary verified) and mirrored the good parts:

  • Self-driven loop (kt / agy / fallback): Switchyard owns each turn; completion via the agent's self-signal marker <<<SWITCHYARD_GOAL_DONE>>> (codex/agy-style), no hard iteration/time cap (stuck-guard only). Optional Claude-Code-style evaluator turn is available but off by default.
  • Claude native /goal passthrough: sends /goal <cond> to the persistent live instance; Claude's own evaluator loop runs within one turn.
  • Codex native thread/goal/set (spike): drives codex's own goal engine over the app-server, persisting each autonomous continuation turn as a canonical Turn; stops on the thread/goal/updated terminal status. ⚠️ Needs live-codex validation.
  • Durable resume: a goal left running when the app closes auto-resumes on next workspace open (session-persisted state, no RuntimeDb host_job).

UI

  • /goal <cond> / /goal stop slash command with autocomplete.
  • Chat-header elapsed timer while a goal runs; ControlCenter Goal card + live plan checklist (self-driven providers maintain it via prompt injection; codex/claude keep their own).
  • Goal-mode prompt injection is kept out of the user's chat bubble (router display/model split); plan block + completion marker are stripped from bubbles.

Layers touched

session (goal + goal_plan fields) · provider-api (goal markers + plan parse + LiveInstance::drive_native_goal) · core (goal_runner: self-driven loop, native slash, native codex, router display split, runtime events) · cli/tui (event arms) · gui (start_goal/stop_goal/resume_active_goals + codex drive) · frontend (slash command, plan panel, timer, bubble strip).

Notes

  • Bundles 4 earlier feat/claude-backend-capture commits (its base, not yet on main).
  • Gates green: cargo fmt --check, clippy -D warnings, goal unit tests (7 core + 3 codex + sentinel), frontend tsc. Codex-native logic is a spike pending live validation.

🤖 Generated with Claude Code

Yidhar and others added 14 commits June 29, 2026 17:11
Switchyard's claude provider passed Claude Code's stream-json through raw, so
tool_use blocks (inside `assistant` messages) and tool_result blocks (inside
`user` messages) never became tool cards: the per-turn path emitted them as
unrecognized raw items and the persistent path dropped the `assistant` event
entirely. Both paths now curate the stream into normalized events the GUI
renders + monitors live:

- tool_use  -> a `tool_call` card (id = Claude tool_use.id, name, arguments)
- tool_result -> merged into that card by id (output + completed/failed)
- thinking / redacted_thinking -> a `reasoning` item
- text still streams token-by-token; the raw stdout protocol is no longer
  mirrored to the terminal channel (it was flooding the live card) — only
  genuine non-JSON output is.

Applies to both run_claude_turn (turn.rs) and the persistent ClaudeLiveInstance
(live.rs). Background Bash (run_in_background) and the Task subagent are just
tool_use calls, so they now surface as cards too.

Tests: new fake_claude fixture + claude_integration.rs (deterministic, CI)
asserting text streaming, tool_use->tool_call, tool_result merge-by-id, no
protocol terminal mirror, and failure propagation; unit tests for the
content-block helpers.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Lightweight cross-turn awareness for Claude Code background tasks (Step 2). A
Bash with `run_in_background` survives across turns on the persistent provider
instance (the core is warm-started as a live instance), but its launch
tool_result "completes" immediately, so the card read as a finished call.

- ToolCard badges such tools "后台运行中" (amber) and shows a running status
  instead of a green completed check, so a background task reads as long-lived;
  the badged card persists in history, making it visible across turns.
- The live-execution card summarizes "N 个后台任务运行中".

Detection is frontend-only off the tool_call arguments (run_in_background:true),
which Step 1's Claude tool_use normalization already surfaces. Reliable
cross-turn completion tracking (auto-clearing the count when a task is killed)
needs Bash<->BashOutput/KillShell shell-id correlation — deferred to the full
monitor panel.

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

Two kohaku-adapter fixes, both entangled in turn.rs:

- Thinking level was dropped: kohaku_runtime_args ignored thinking_level
  (`_thinking_level`). It now emits `kt run --reasoning <effort>`,
  normalized to KT's vocabulary (max→xhigh; auto/unknown→omit) and
  independent of --llm. Doc comments + the GUI capability hint updated.
- Direct-tool output was dropped: KT keys a DIRECT (foreground) tool's
  body under metadata.output (only sub-agents/background use `result`),
  but the adapter read only `result` — so every kt tool (read, edit,
  write, multi_edit, …) reached the GUI with empty output, hiding tool
  bodies and file diffs. Now reads `output` first, then `result`.

fake_kt uses the real `output` key + an edit tool whose output carries a
unified diff; kohaku_integration asserts the body (and the diff) survive.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The session loader caps the initial event pull (full load = oldest 1000
events). In a long session most turns' tool/diff events fall outside that
window, so renderTurnEvents finds no activity and their cards vanish —
even though the events are persisted.

New `get_turn_events(turn_id)` Tauri command (backed by the existing
EventLog::list_events + events(turn_id) index). When a historical turn
row scrolls into view (the list is virtualized) with activity but no
loaded events, the frontend fetches just that turn's events and merges
them in — deduped by event_id, at most once per turn, reset on session
switch.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Session-level goal mode state: goal text, goal_status lifecycle, goal_iteration counter, and a persisted goal_plan (Vec<GoalPlanItem>) tracked outside the chat. All #[serde(default)] so existing stored sessions deserialize without migration.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add GOAL_DONE_MARKER (self-signal completion) and GOAL_PLAN_BEGIN/END delimiters, plus parse_goal_plan() which reads the last plan block's markdown checkbox lines into (text, done) pairs. Kept decoupled from session types (plain tuples). Unit-tested.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
goal_runner: provider-agnostic goal loop (run_goal_loop) with no hard caps by default (unbounded until achieved/stopped; stuck-guard only), evaluator-gated completion (Claude-Code-style read-only judge turn) + self-signal fast-path + feedback re-injection, and native /goal passthrough (run_native_slash_goal) for Claude. Maintains + persists session.goal_plan each turn.

router: run_routed_turn_observable_with_display_and_prompt_injection adds an optional display_message so goal turns store a clean bubble while the verbose autonomous-mode instructions drive the model. runtime_events: GoalIterationStarted / GoalCompleted / GoalPlanUpdated.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add match arms for GoalIterationStarted / GoalCompleted / GoalPlanUpdated: CLI host-job mapper treats them as non-job events; TUI surfaces brief goal progress lines in the event log.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Tauri start_goal (blocking goal run on the persistent Core instance; native passthrough for Claude, self-driven loop otherwise, with a fresh isolated evaluator provider) and stop_goal (cooperative cancel). GoalRunState + ActiveTurnState mutual exclusion; ensure_persistent_core_instance helper shared with run_turn; goal RuntimeEvent arms in the runtime bridge.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Register /goal (activate/stop/status) as a slash command with autocomplete. ControlCenter Goal card: activate/stop + live plan checklist (persisted goal_plan). ChatArea header shows a live goal elapsed timer (no iteration counter). App.tsx wires start/stop + consumes goal runtime events (iteration/status/plan) live. RenderHelpers strips the goal plan block + completion marker from chat bubbles (surfaced only in the plan panel).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Completion now relies on the agent's own <<<SWITCHYARD_GOAL_DONE>>> marker (codex/agy-style) — one turn per iteration, no doubled cost. The Claude-Code-style evaluator stays available via run_goal_loop's judge arg but is opt-in; start_goal passes None.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Goals now survive an app restart. Extracted start_goal's body into drive_goal_run (fetches state from AppHandle so it can be spawned standalone). New resume_active_goals command scans the current workspace for sessions left goal_status=running and re-enters the goal loop for each (continuing from the persisted goal + goal_iteration + goal_plan); idempotent via GoalRunState. Frontend calls it after loadSessions. Uses session-persisted state rather than a RuntimeDb host_job since GUI goals run in-process with no separate owning worker.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
New LiveInstance::drive_native_goal (default: unsupported) implemented for codex: sends thread/goal/set (active), streams every autonomous continuation turn's events (re-minting the canonical turn_id per turn/started so the caller can persist one Switchyard Turn per codex turn), and stops on a terminal thread/goal/updated status (complete/blocked/budget_limited/usage_limited). Cancel interrupts the in-flight turn (turn/interrupt) then pauses the goal (thread/goal/set status:paused). Spike: server requests auto-acked (permissive); pure protocol helpers (status classification, param builders, goal/updated parsing) are unit-tested. Consumer-side per-turn persistence + start_goal wiring is the next increment.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
run_native_codex_goal checks out the persistent codex instance, calls drive_native_goal, and persists each autonomous continuation turn as a canonical Turn (map_provider_event for events, one Turn per re-minted turn_id), stopping on a terminal thread/goal/updated status (complete->achieved, blocked->failed, budget/usage_limited->stopped). start_goal routes codex backends here; claude stays native /goal passthrough, others self-driven. Spike: run-level diffs via the caller's file-watcher; codex keeps its own plan (panel empty). Needs live codex validation.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@Yidhar
Yidhar merged commit 047c6c2 into main Jul 2, 2026
1 check failed
@Yidhar
Yidhar deleted the feat/goal-mode branch July 2, 2026 11:16
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant