diff --git a/public/tools/omp.svg b/public/tools/omp.svg new file mode 100644 index 00000000..e1062125 --- /dev/null +++ b/public/tools/omp.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/scripts/gen-notice-copy.mjs b/scripts/gen-notice-copy.mjs new file mode 100644 index 00000000..b7911d20 --- /dev/null +++ b/scripts/gen-notice-copy.mjs @@ -0,0 +1,48 @@ +// Generate the backend-readable mirror of the notice copy that lives in the +// i18n catalogs. +// +// `src/i18n/{en,zh}.ts` stay authoritative (AGENTS.md: user-facing strings go +// only through those two). Some notices are posted by detached backend tasks +// that have no locale, so they emit a stable TOKEN and each consumer renders +// it; the webview reads its catalogs directly, while the IM bridge is Rust and +// cannot. This writes what Rust bakes in. +// +// Run after editing any listed key. `tests/frontend/noticeCopy.test.ts` fails +// if the generated file drifts from the catalogs, so it cannot go stale +// unnoticed. +import { writeFileSync } from "node:fs"; +import { en } from "../src/i18n/en.ts"; +import { zh } from "../src/i18n/zh.ts"; + +/** Backend token -> the catalog key holding its copy. */ +const TOKENS = { + "acp.force_reset_notice": "needs.acpForceResetNotice", +}; + +const OUT = new URL("../src-tauri/src/bus/notices.generated.json", import.meta.url); + +function lookup(catalog, key) { + return key.split(".").reduce((node, part) => node?.[part], catalog); +} + +export function buildNoticeCopy() { + const out = {}; + for (const [token, key] of Object.entries(TOKENS)) { + const enCopy = lookup(en, key); + const zhCopy = lookup(zh, key); + if (typeof enCopy !== "string" || typeof zhCopy !== "string") { + throw new Error(`${token}: ${key} is missing from en or zh`); + } + out[token] = { en: enCopy, zh: zhCopy }; + } + return out; +} + +export function serialize(copy) { + return `${JSON.stringify(copy, null, 2)}\n`; +} + +if (import.meta.url === `file://${process.argv[1]}`) { + writeFileSync(OUT, serialize(buildNoticeCopy())); + console.log(`wrote ${OUT.pathname}`); +} diff --git a/src-tauri/src/acp/backends/mod.rs b/src-tauri/src/acp/backends/mod.rs new file mode 100644 index 00000000..aa491043 --- /dev/null +++ b/src-tauri/src/acp/backends/mod.rs @@ -0,0 +1,103 @@ +//! Per-CLI ACP backend descriptors. +//! +//! The runtime is generic; only this layer knows binary names and capability +//! quirks. Adding agent N+1 = new file + register + product enum entries. + +use std::sync::{Arc, LazyLock}; + +use serde_json::Value; + +mod omp; + +pub use omp::OmpBackend; + +/// HTTP MCP server Weft injects into `session/new|resume`. +#[derive(Debug, Clone)] +pub struct McpServerSpec { + pub name: String, + pub url: String, +} + +/// Thin per-tool ACP identity. No process I/O. +pub trait AcpBackend: Send + Sync + 'static { + /// Stable tool identity (also the process-pool key), e.g. `"omp"`. + fn id(&self) -> &'static str; + + /// `(program, args)` to spawn the ACP server. `command` is the resolved + /// binary/alias from Settings (e.g. `"omp"` or a user override). + fn spawn_argv(&self, command: &str) -> (String, Vec); + + /// `clientCapabilities` object for `initialize`. + fn client_capabilities(&self) -> Value; + + /// Whether `session/fork` is advertised/usable for rewind helpers. + fn supports_fork(&self) -> bool { + true + } + + /// Paint Weft MCP specs into the wire `mcpServers` array shape this agent accepts. + fn paint_mcp_servers(&self, servers: Vec) -> Vec { + servers + .into_iter() + .map(|s| { + serde_json::json!({ + "type": "http", + "name": s.name, + "url": s.url, + }) + }) + .collect() + } +} + +static REGISTRY: LazyLock>> = + LazyLock::new(|| vec![Arc::new(OmpBackend) as Arc]); + +fn registry() -> &'static Vec> { + ®ISTRY +} + +/// Look up an ACP backend by tool identity. `None` → not an ACP tool. +pub fn backend_for(tool: &str) -> Option> { + registry().iter().find(|b| b.id() == tool).cloned() +} + +/// All registered ACP tool ids (for detect/tests). +pub fn registered_ids() -> Vec<&'static str> { + registry().iter().map(|b| b.id()).collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn omp_is_registered() { + let b = backend_for("omp").expect("omp backend"); + assert_eq!(b.id(), "omp"); + let (prog, args) = b.spawn_argv("omp"); + assert_eq!(prog, "omp"); + assert_eq!(args, vec!["acp"]); + assert!(b.supports_fork()); + let caps = b.client_capabilities(); + assert_eq!(caps["session"]["requestPermission"], true); + } + + #[test] + fn unknown_tool_is_none() { + assert!(backend_for("claude").is_none()); + assert!(backend_for("codex").is_none()); + } + + #[test] + fn paints_http_mcp() { + let b = backend_for("omp").unwrap(); + let painted = b.paint_mcp_servers(vec![McpServerSpec { + name: "weft_bus".into(), + url: "http://127.0.0.1:9/bus/1/1/mcp".into(), + }]); + assert_eq!(painted.len(), 1); + assert_eq!(painted[0]["type"], "http"); + assert_eq!(painted[0]["name"], "weft_bus"); + } +} diff --git a/src-tauri/src/acp/backends/omp.rs b/src-tauri/src/acp/backends/omp.rs new file mode 100644 index 00000000..6342a09d --- /dev/null +++ b/src-tauri/src/acp/backends/omp.rs @@ -0,0 +1,36 @@ +//! omp (Oh My Pi) ACP backend descriptor. + +use serde_json::{json, Value}; + +use super::AcpBackend; + +pub struct OmpBackend; + +impl AcpBackend for OmpBackend { + fn id(&self) -> &'static str { + "omp" + } + + fn spawn_argv(&self, command: &str) -> (String, Vec) { + (command.to_string(), vec!["acp".into()]) + } + + fn client_capabilities(&self) -> Value { + // fs/terminal off — agent runs tools locally in the worktree. + // requestPermission on so bash/edit gates surface to Weft Ask. + json!({ + "fs": { + "readTextFile": false, + "writeTextFile": false, + }, + "session": { + "requestPermission": true, + }, + }) + } + + fn supports_fork(&self) -> bool { + // Full-history fork only; cut-before rewind uses jsonl rewrite + load. + true + } +} diff --git a/src-tauri/src/acp/jsonrpc.rs b/src-tauri/src/acp/jsonrpc.rs new file mode 100644 index 00000000..43daec77 --- /dev/null +++ b/src-tauri/src/acp/jsonrpc.rs @@ -0,0 +1,249 @@ +//! Pure JSON-RPC 2.0 framing for the Agent Client Protocol. +//! +//! Real `"jsonrpc":"2.0"` envelopes (unlike Codex app-server's jsonrpc-like +//! dialect). This module has no process I/O and no backend-specific strings. + +use serde_json::{json, Map, Value}; + +/// A classified inbound line from an ACP agent. +#[derive(Debug, Clone, PartialEq)] +pub enum Incoming { + /// Reply to one of our requests — correlate by integer id. + Response { id: i64, result: Value }, + /// Error reply to one of our requests. + Error { id: i64, code: i64, message: String }, + /// Server → client notification (no id), e.g. `session/update`. + Notification { method: String, params: Value }, + /// Server → client request that must be answered, id echoed verbatim + /// (may be integer **or** string — omp has used `id: 0`). + ServerRequest { + id: Value, + method: String, + params: Value, + }, + /// Malformed / non-jsonrpc line. + Skip, +} + +/// Encode a client → agent request line (newline-terminated). +pub fn encode_request(id: i64, method: &str, params: Value) -> String { + format!( + "{}\n", + json!({ + "jsonrpc": "2.0", + "id": id, + "method": method, + "params": params, + }) + ) +} + +/// Encode a client → agent notification (no id), e.g. `session/cancel`. +pub fn encode_notification(method: &str, params: Option) -> String { + let mut obj = Map::new(); + obj.insert("jsonrpc".into(), Value::String("2.0".into())); + obj.insert("method".into(), Value::String(method.into())); + if let Some(p) = params { + obj.insert("params".into(), p); + } + format!("{}\n", Value::Object(obj)) +} + +/// Encode our reply to a server-initiated request — echo `id` verbatim. +pub fn encode_response(id: &Value, result: Value) -> String { + format!( + "{}\n", + json!({ + "jsonrpc": "2.0", + "id": id, + "result": result, + }) + ) +} + +/// Encode an error reply to a server-initiated request. +pub fn encode_error_response(id: &Value, code: i64, message: &str) -> String { + format!( + "{}\n", + json!({ + "jsonrpc": "2.0", + "id": id, + "error": { "code": code, "message": message }, + }) + ) +} + +/// Classify one stdout line. +pub fn classify(line: &str) -> Incoming { + let Ok(v) = serde_json::from_str::(line) else { + return Incoming::Skip; + }; + let Some(obj) = v.as_object() else { + return Incoming::Skip; + }; + if obj.get("jsonrpc").and_then(|j| j.as_str()) != Some("2.0") { + return Incoming::Skip; + } + + let id = obj.get("id"); + let method = obj.get("method").and_then(|m| m.as_str()); + let has_result = obj.contains_key("result"); + let has_error = obj.contains_key("error"); + + // Response / error to our request: id + result|error, no method. + if let Some(id_v) = id { + if method.is_none() { + if has_result { + let Some(rid) = id_as_i64(id_v) else { + return Incoming::Skip; + }; + return Incoming::Response { + id: rid, + result: obj.get("result").cloned().unwrap_or(Value::Null), + }; + } + if has_error { + let Some(rid) = id_as_i64(id_v) else { + return Incoming::Skip; + }; + let err = obj.get("error").cloned().unwrap_or(Value::Null); + let code = err.get("code").and_then(|c| c.as_i64()).unwrap_or(-1); + let message = err + .get("message") + .and_then(|m| m.as_str()) + .unwrap_or("") + .to_string(); + return Incoming::Error { + id: rid, + code, + message, + }; + } + } + // Server request: method + id, no result/error. + if let Some(m) = method { + if !has_result && !has_error { + return Incoming::ServerRequest { + id: id_v.clone(), + method: m.to_string(), + params: obj.get("params").cloned().unwrap_or(Value::Null), + }; + } + } + } + + // Notification: method, no id. + if let Some(m) = method { + if id.is_none() { + return Incoming::Notification { + method: m.to_string(), + params: obj.get("params").cloned().unwrap_or(Value::Null), + }; + } + } + + Incoming::Skip +} + +fn id_as_i64(id: &Value) -> Option { + if let Some(n) = id.as_i64() { + return Some(n); + } + if let Some(n) = id.as_u64() { + return i64::try_from(n).ok(); + } + if let Some(s) = id.as_str() { + return s.parse().ok(); + } + None +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn classifies_initialize_result_fixture_shape() { + let line = r#"{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1}}"#; + match classify(line) { + Incoming::Response { id, result } => { + assert_eq!(id, 1); + assert_eq!(result["protocolVersion"], 1); + } + o => panic!("{o:?}"), + } + } + + #[test] + fn classifies_permission_server_request_id_zero() { + let line = r#"{"jsonrpc":"2.0","id":0,"method":"session/request_permission","params":{}}"#; + match classify(line) { + Incoming::ServerRequest { id, method, .. } => { + assert_eq!(id, json!(0)); + assert_eq!(method, "session/request_permission"); + } + o => panic!("{o:?}"), + } + } + + #[test] + fn classifies_session_update_notification() { + let line = r#"{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"s","update":{}}}"#; + match classify(line) { + Incoming::Notification { method, params } => { + assert_eq!(method, "session/update"); + assert_eq!(params["sessionId"], "s"); + } + o => panic!("{o:?}"), + } + } + + #[test] + fn classifies_error_response() { + let line = r#"{"jsonrpc":"2.0","id":7,"error":{"code":-32602,"message":"Invalid params"}}"#; + match classify(line) { + Incoming::Error { id, code, message } => { + assert_eq!(id, 7); + assert_eq!(code, -32602); + assert_eq!(message, "Invalid params"); + } + o => panic!("{o:?}"), + } + } + + #[test] + fn skips_non_jsonrpc() { + assert_eq!(classify(r#"{"id":1,"result":{}}"#), Incoming::Skip); + assert_eq!(classify("not-json"), Incoming::Skip); + } + + #[test] + fn encode_request_is_newline_terminated_jsonrpc() { + let s = encode_request(3, "session/new", json!({"cwd": "/tmp"})); + assert!(s.ends_with('\n')); + let v: Value = serde_json::from_str(s.trim_end()).unwrap(); + assert_eq!(v["jsonrpc"], "2.0"); + assert_eq!(v["id"], 3); + assert_eq!(v["method"], "session/new"); + } + + #[test] + fn encode_response_echoes_id_verbatim() { + let s = encode_response(&json!(0), json!({"ok": true})); + let v: Value = serde_json::from_str(s.trim_end()).unwrap(); + assert_eq!(v["id"], 0); + assert_eq!(v["result"]["ok"], true); + } + + #[test] + fn encode_notification_session_cancel() { + let s = encode_notification( + "session/cancel", + Some(json!({"sessionId": "abc"})), + ); + let v: Value = serde_json::from_str(s.trim_end()).unwrap(); + assert!(v.get("id").is_none()); + assert_eq!(v["method"], "session/cancel"); + assert_eq!(v["params"]["sessionId"], "abc"); + } +} diff --git a/src-tauri/src/acp/map.rs b/src-tauri/src/acp/map.rs new file mode 100644 index 00000000..18d50a45 --- /dev/null +++ b/src-tauri/src/acp/map.rs @@ -0,0 +1,451 @@ +//! Map ACP `session/update` payloads onto Weft `ChatEvent` / side channels. +//! +//! Backend-agnostic: no CLI identity strings. Thinking chunks are dropped in v1. + +use serde_json::Value; + +use crate::lead_chat::proto::{ChatEvent, SlashCmd, ToolCall, ToolResultItem}; + +/// Outcome of mapping one `update` object (the inner `params.update`). +#[derive(Debug)] +pub enum UpdateOut { + Chat(ChatEvent), + Commands(Vec), + Usage { + context_tokens: u64, + window: Option, + }, + Meta { + model: Option, + thinking: Option, + }, + /// Streaming model reasoning (agent_thought_chunk). Not final answer text. + Thought { + text: String, + }, + /// Non-terminal tool progress — keep the watchdog fed. + ToolProgress { + summary: String, + }, + Ignore, +} + +/// Map a single ACP `update` value. +pub fn update_to_out(update: &Value) -> UpdateOut { + let Some(kind) = update.get("sessionUpdate").and_then(|k| k.as_str()) else { + return UpdateOut::Ignore; + }; + match kind { + "agent_message_chunk" => text_delta(update), + "agent_thought_chunk" => thought_chunk(update), + "user_message_chunk" => UpdateOut::Ignore, + "tool_call" => tool_call_start(update), + "tool_call_update" => tool_call_update(update), + "available_commands_update" => commands(update), + "usage_update" => usage(update), + "config_option_update" => config_meta(update), + "session_info_update" | "current_mode_update" | "plan" | "plan_update" | "plan_removed" => { + UpdateOut::Ignore + } + _ => UpdateOut::Ignore, + } +} + +fn text_delta(update: &Value) -> UpdateOut { + let text = update + .pointer("/content/text") + .and_then(|t| t.as_str()) + .unwrap_or(""); + if text.is_empty() { + return UpdateOut::Ignore; + } + UpdateOut::Chat(ChatEvent::TextDelta { + text: text.to_string(), + item: None, + agent_thread: None, + }) +} + +fn thought_chunk(update: &Value) -> UpdateOut { + let text = update + .pointer("/content/text") + .and_then(|t| t.as_str()) + .unwrap_or(""); + if text.is_empty() { + return UpdateOut::Ignore; + } + UpdateOut::Thought { + text: text.to_string(), + } +} + +fn tool_call_start(update: &Value) -> UpdateOut { + let id = update + .get("toolCallId") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + if id.is_empty() { + return UpdateOut::Ignore; + } + let title = update + .get("title") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + let kind = update + .get("kind") + .and_then(|v| v.as_str()) + .unwrap_or("tool"); + // Prefer a concrete tool name when rawInput implies one; else kind/title. + let name = tool_name_from_raw(update.get("rawInput"), kind); + let input = update + .get("rawInput") + .cloned() + .unwrap_or(Value::Object(Default::default())); + let summary = if title.is_empty() { + name.clone() + } else { + title + }; + UpdateOut::Chat(ChatEvent::Assistant { + texts: vec![], + tools: vec![ToolCall { + id, + name, + input, + summary, + output: None, + is_error: false, + collab_threads: Vec::new(), + }], + uuid: None, + agent_thread: None, + }) +} + +fn tool_name_from_raw(raw: Option<&Value>, kind: &str) -> String { + let Some(raw) = raw else { + return kind.to_string(); + }; + if raw.get("command").and_then(|c| c.as_str()).is_some() { + return "bash".into(); + } + if raw.get("path").is_some() || raw.get("file_path").is_some() { + // `write` belongs with the other mutations. Collapsing it to `read` + // labelled a file write as a read in the timeline — wrong name, wrong + // icon — while the permission layer classified the same kind as a + // write, so the two surfaces disagreed about the same tool call. + if matches!(kind, "edit" | "write" | "delete" | "move") { + return kind.to_string(); + } + return "read".into(); + } + kind.to_string() +} + +fn tool_call_update(update: &Value) -> UpdateOut { + let status = update.get("status").and_then(|s| s.as_str()).unwrap_or(""); + // Only terminal states become ToolResults; in_progress keeps the turn alive. + if status != "completed" && status != "failed" { + let title = update + .get("title") + .and_then(|t| t.as_str()) + .unwrap_or("tool"); + return UpdateOut::ToolProgress { + summary: title.to_string(), + }; + } + let id = update + .get("toolCallId") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + if id.is_empty() { + return UpdateOut::Ignore; + } + let output = crate::lead_chat::proto::cap_output(extract_tool_output(update)); + let is_error = status == "failed"; + UpdateOut::Chat(ChatEvent::ToolResults { + items: vec![ToolResultItem { + id, + output, + is_error, + collab_threads: Vec::new(), + }], + }) +} + +fn extract_tool_output(update: &Value) -> String { + // Prefer rawOutput.content text blocks; fall back to content[] text tails. + if let Some(arr) = update.pointer("/rawOutput/content").and_then(|c| c.as_array()) { + let mut parts = Vec::new(); + for item in arr { + if let Some(t) = item.get("text").and_then(|t| t.as_str()) { + parts.push(t.to_string()); + } + } + if !parts.is_empty() { + return parts.join(""); + } + } + if let Some(arr) = update.get("content").and_then(|c| c.as_array()) { + let mut parts = Vec::new(); + for item in arr { + if let Some(t) = item.pointer("/content/text").and_then(|t| t.as_str()) { + // Skip the leading "$ cmd" presentation block when a later block exists. + parts.push(t.to_string()); + } else if let Some(t) = item.get("text").and_then(|t| t.as_str()) { + parts.push(t.to_string()); + } + } + if parts.len() > 1 { + // Drop first if it looks like a command echo. + if parts[0].starts_with("$ ") { + return parts[1..].join(""); + } + } + if !parts.is_empty() { + return parts.join(""); + } + } + String::new() +} + +fn commands(update: &Value) -> UpdateOut { + let Some(arr) = update + .get("availableCommands") + .and_then(|a| a.as_array()) + else { + return UpdateOut::Ignore; + }; + // Empty array is authoritative: clear the engine's cached slash list. + if arr.is_empty() { + return UpdateOut::Commands(Vec::new()); + } + let mut out = Vec::new(); + for c in arr { + let Some(name) = c.get("name").and_then(|n| n.as_str()) else { + continue; + }; + if name.is_empty() { + continue; + } + let description = c + .get("description") + .and_then(|d| d.as_str()) + .map(str::to_string); + let arg_hint = c + .pointer("/input/hint") + .and_then(|h| h.as_str()) + .map(str::to_string); + out.push(SlashCmd { + name: name.to_string(), + description, + arg_hint, + }); + } + if out.is_empty() { + UpdateOut::Ignore + } else { + UpdateOut::Commands(out) + } +} + +fn usage(update: &Value) -> UpdateOut { + let used = update + .get("used") + .and_then(|u| u.as_u64()) + .or_else(|| update.get("used").and_then(|u| u.as_i64()).map(|i| i as u64)); + let Some(context_tokens) = used else { + return UpdateOut::Ignore; + }; + let window = update + .get("size") + .and_then(|s| s.as_u64()) + .or_else(|| update.get("size").and_then(|s| s.as_i64()).map(|i| i as u64)); + UpdateOut::Usage { + context_tokens, + window, + } +} + +fn config_meta(update: &Value) -> UpdateOut { + // config_option_update may carry currentValue for model/thinking ids. + let id = update.get("configId").and_then(|i| i.as_str()).unwrap_or(""); + let val = update + .get("currentValue") + .and_then(|v| v.as_str()) + .map(str::to_string); + if id == "model" { + return UpdateOut::Meta { + model: val, + thinking: None, + }; + } + if id == "thinking" { + return UpdateOut::Meta { + model: None, + thinking: val, + }; + } + UpdateOut::Ignore +} + +/// Map `session/prompt` stopReason → TurnEnd-ish flags. +pub fn stop_reason_is_error(stop: &str) -> bool { + matches!(stop, "refusal" | "max_tokens" | "max_turn_requests") +} + +pub fn stop_reason_is_cancelled(stop: &str) -> bool { + stop == "cancelled" +} + +#[cfg(test)] +mod tests { + /// A file write must stay a write in the timeline. Collapsing `write` into + /// the `read` fallback gave it a read's name and icon while the permission + /// layer classified the same call as a mutation — two surfaces disagreeing + /// about one tool call. + #[test] + fn a_write_tool_call_is_not_labelled_read() { + let raw = serde_json::json!({ "path": "src/main.rs" }); + for kind in ["edit", "write", "delete", "move"] { + assert_eq!( + super::tool_name_from_raw(Some(&raw), kind), + kind, + "{kind} is a mutation" + ); + } + assert_eq!(super::tool_name_from_raw(Some(&raw), "read"), "read"); + // A command still wins over any path-bearing input. + let cmd = serde_json::json!({ "command": "ls" }); + assert_eq!(super::tool_name_from_raw(Some(&cmd), "write"), "bash"); + } + + use super::*; + use serde_json::json; + + #[test] + fn message_chunk_to_text_delta() { + let u = json!({ + "sessionUpdate": "agent_message_chunk", + "content": { "type": "text", "text": "pong" } + }); + match update_to_out(&u) { + UpdateOut::Chat(ChatEvent::TextDelta { text, item: None, .. }) => { + assert_eq!(text, "pong"); + } + o => panic!("{o:?}"), + } + } + + #[test] + fn thought_chunk_maps() { + let u = json!({ + "sessionUpdate": "agent_thought_chunk", + "content": { "type": "text", "text": "hmm" } + }); + match update_to_out(&u) { + UpdateOut::Thought { text } => assert_eq!(text, "hmm"), + o => panic!("{o:?}"), + } + } + + #[test] + fn tool_call_and_completed_from_fixture_shapes() { + let start = json!({ + "sessionUpdate": "tool_call", + "toolCallId": "call-1", + "title": "$ echo TOOL_OK", + "kind": "execute", + "status": "pending", + "rawInput": { "command": "echo TOOL_OK" } + }); + match update_to_out(&start) { + UpdateOut::Chat(ChatEvent::Assistant { tools, .. }) => { + assert_eq!(tools.len(), 1); + assert_eq!(tools[0].id, "call-1"); + assert_eq!(tools[0].name, "bash"); + assert_eq!(tools[0].summary, "$ echo TOOL_OK"); + assert_eq!(tools[0].input["command"], "echo TOOL_OK"); + } + o => panic!("{o:?}"), + } + + let done = json!({ + "sessionUpdate": "tool_call_update", + "toolCallId": "call-1", + "status": "completed", + "rawOutput": { + "content": [{ "type": "text", "text": "TOOL_OK\n" }] + } + }); + match update_to_out(&done) { + UpdateOut::Chat(ChatEvent::ToolResults { items }) => { + assert_eq!(items.len(), 1); + assert_eq!(items[0].id, "call-1"); + assert!(items[0].output.contains("TOOL_OK")); + assert!(!items[0].is_error); + } + o => panic!("{o:?}"), + } + + let inflight = json!({ + "sessionUpdate": "tool_call_update", + "toolCallId": "call-1", + "status": "in_progress" + }); + assert!(matches!(update_to_out(&inflight), UpdateOut::ToolProgress { .. })); + } + + #[test] + fn usage_update_maps_size_used() { + let u = json!({ "sessionUpdate": "usage_update", "size": 500000, "used": 25615 }); + match update_to_out(&u) { + UpdateOut::Usage { + context_tokens, + window: Some(w), + } => { + assert_eq!(context_tokens, 25615); + assert_eq!(w, 500000); + } + o => panic!("{o:?}"), + } + } + + #[test] + fn empty_available_commands_clears() { + let u = json!({"sessionUpdate":"available_commands_update","availableCommands":[]}); + match update_to_out(&u) { + UpdateOut::Commands(c) => assert!(c.is_empty()), + other => panic!("expected empty Commands, got {other:?}"), + } + } + + #[test] + fn available_commands_update() { + let u = json!({ + "sessionUpdate": "available_commands_update", + "availableCommands": [ + { "name": "model", "description": "Show model" }, + { "name": "fast", "description": "Toggle fast", "input": { "hint": "[on|off]" } } + ] + }); + match update_to_out(&u) { + UpdateOut::Commands(cmds) => { + assert_eq!(cmds.len(), 2); + assert_eq!(cmds[0].name, "model"); + assert_eq!(cmds[1].arg_hint.as_deref(), Some("[on|off]")); + } + o => panic!("{o:?}"), + } + } + + #[test] + fn stop_reason_helpers() { + assert!(!stop_reason_is_error("end_turn")); + assert!(stop_reason_is_error("refusal")); + assert!(stop_reason_is_cancelled("cancelled")); + } +} diff --git a/src-tauri/src/acp/mod.rs b/src-tauri/src/acp/mod.rs new file mode 100644 index 00000000..8a987218 --- /dev/null +++ b/src-tauri/src/acp/mod.rs @@ -0,0 +1,30 @@ +//! Generic Agent Client Protocol (ACP) runtime. +//! +//! Protocol concerns (JSON-RPC framing, session/update mapping, permission +//! option selection, process demux) live here. Per-CLI identity belongs under +//! [`backends`]. Codex app-server is a **different** wire dialect and must not +//! use this module. + +#![allow(dead_code)] // Runtime/engine wire-in lands in later tasks; pure layers ship first. + +pub mod jsonrpc; +pub mod map; +pub mod permission; + +pub mod backends; +pub mod runtime; + +#[allow(unused_imports)] +pub use backends::{backend_for, registered_ids, AcpBackend, McpServerSpec}; + +#[allow(unused_imports)] +pub use jsonrpc::{ + classify, encode_error_response, encode_notification, encode_request, encode_response, Incoming, +}; +#[allow(unused_imports)] +pub use map::{stop_reason_is_cancelled, stop_reason_is_error, update_to_out, UpdateOut}; +#[allow(unused_imports)] +pub use permission::{ + intent_key, intent_key_from_params, pick_option_id, selected_outcome, summary_from_params, + AlwaysCache, Want, +}; diff --git a/src-tauri/src/acp/permission.rs b/src-tauri/src/acp/permission.rs new file mode 100644 index 00000000..c2b67fac --- /dev/null +++ b/src-tauri/src/acp/permission.rs @@ -0,0 +1,682 @@ +//! Map Weft allow/deny decisions onto ACP `session/request_permission` options. +//! Session-scoped always-cache only — no durable grants here. + +use std::collections::HashMap; + +use serde_json::Value; + +/// Which permission option class we want to select. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Want { + AllowOnce, + AllowAlways, + RejectOnce, + RejectAlways, +} + +impl Want { + fn kind_str(self) -> &'static str { + match self { + Want::AllowOnce => "allow_once", + Want::AllowAlways => "allow_always", + Want::RejectOnce => "reject_once", + Want::RejectAlways => "reject_always", + } + } + + pub fn is_allow(self) -> bool { + matches!(self, Want::AllowOnce | Want::AllowAlways) + } +} + +/// Pick an `optionId` from the agent's offered options for `want`. +/// Prefers matching `kind`, then `optionId` string equality with the kind name. +pub fn pick_option_id(options: &[Value], want: Want) -> Option { + let kind = want.kind_str(); + for opt in options { + if opt.get("kind").and_then(|k| k.as_str()) == Some(kind) { + if let Some(id) = opt.get("optionId").and_then(|i| i.as_str()) { + return Some(id.to_string()); + } + } + } + for opt in options { + if opt.get("optionId").and_then(|i| i.as_str()) == Some(kind) { + return Some(kind.to_string()); + } + } + // Soft fallback: first allow_* / reject_* by prefix. + let prefix = if want.is_allow() { "allow" } else { "reject" }; + for opt in options { + let id = opt.get("optionId").and_then(|i| i.as_str()).unwrap_or(""); + let k = opt.get("kind").and_then(|i| i.as_str()).unwrap_or(""); + if id.starts_with(prefix) || k.starts_with(prefix) { + if !id.is_empty() { + return Some(id.to_string()); + } + } + } + None +} + +/// Build the ACP permission result body for a selected optionId. +pub fn selected_outcome(option_id: &str) -> Value { + serde_json::json!({ + "outcome": { + "outcome": "selected", + "optionId": option_id, + } + }) +} + +/// Stable cache key for always-allow / always-deny within one ACP session. +pub fn intent_key(tool_kind: &str, raw_input: &Value) -> String { + if let Some(cmd) = raw_input.get("command").and_then(|c| c.as_str()) { + // Coarse: tool family only for always (matches omp cacheKey: toolName). + // Keep command out so "always allow bash" is session-wide for bash. + let _ = cmd; + return format!("{tool_kind}:bash"); + } + if tool_kind == "edit" || tool_kind == "delete" || tool_kind == "move" { + return format!("edit:{tool_kind}"); + } + if let Some(k) = raw_input.get("kind").and_then(|k| k.as_str()) { + return format!("{tool_kind}:{k}"); + } + tool_kind.to_string() +} + +/// What a permission request is actually asking to DO — one discriminated +/// value the risk classifier maps exhaustively. +/// +/// Derived from the `toolCall` itself and never re-parsed out of +/// [`intent_key`]: that key is a deliberately lossy grouping token for +/// always-grants (`execute:bash`, `edit:delete`, or a bare `read`), so +/// comparing it against bare verbs silently fails. A live omp shell request +/// arrives as kind `execute` + `rawInput.command` and keys to `execute:bash`, +/// which matches neither `"bash"` nor `"execute"` — every command, `rm -rf` +/// included, was scored as a generic tool call. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum PermissionIntent { + /// A shell command line — the request carries `rawInput.command`. + Command(String), + /// A filesystem read. EVERY named location, because the risk of the whole + /// request is the risk of its worst target — a read whose second path is + /// an SSH key is not a read-only request. Empty when none were named. + Read { paths: Vec }, + /// A filesystem mutation: edit, write, delete, or move. Same "every + /// location" rule as [`Self::Read`]. + Write { paths: Vec }, + /// Network access ACP itself declared as such (tool kind `fetch`) — the + /// same "the engine already identified it" case `RiskSignal::Network` + /// exists for, so it needs no further scanning. + Network, + /// Anything else; carries the ACP tool kind so the card can name it. + Other { kind: String }, +} + +/// Every path a file-shaped request names: ACP's own structured `locations` +/// first, then the raw-input key names agents actually use. +/// +/// ALL of them, not just the first. The first path decided the risk tier once, +/// which meant a multi-file read could be tiered `ReadOnly` off an ordinary +/// leading path while a later entry touched a credential — and a read-only +/// grant would then auto-approve it unseen. +/// +/// An empty result is not a downgrade: `classify_file` tiers on the tool verb, +/// so a write that named no location is still a write. +fn tool_paths(tc: &Value) -> Vec { + let mut out: Vec = Vec::new(); + let push = |out: &mut Vec, p: &str| { + if !p.is_empty() && !out.iter().any(|seen| seen == p) { + out.push(p.to_string()); + } + }; + for p in tc + .get("locations") + .and_then(|l| l.as_array()) + .into_iter() + .flatten() + .filter_map(|l| l.get("path").and_then(|p| p.as_str())) + { + push(&mut out, p); + } + // BOTH sources, not "structured first, raw as fallback". Treating them as + // alternatives let a request put an ordinary file in `locations` and a + // credential in `rawInput.path`: only the ordinary one was classified, the + // request came out `ReadOnly`, and a read-only grant approved the + // credential read with no card. Every raw key is collected for the same + // reason — they can name different files. + if let Some(raw) = tc.get("rawInput") { + for key in ["path", "file_path", "filePath", "abs_path", "absPath"] { + if let Some(p) = raw.get(key).and_then(|p| p.as_str()) { + push(&mut out, p); + } + } + } + out +} + +/// Classify a full permission request params object. +pub fn intent_from_params(params: &Value) -> PermissionIntent { + let tc = params.get("toolCall").unwrap_or(&Value::Null); + let kind = tc.get("kind").and_then(|k| k.as_str()).unwrap_or("tool"); + // A command line is a command whatever kind it is declared under — the + // same precedence `intent_key` gives it, and an agent that sends a shell + // line under kind `other` still runs a shell line. + if let Some(cmd) = tc + .get("rawInput") + .and_then(|r| r.get("command")) + .and_then(|c| c.as_str()) + { + return PermissionIntent::Command(cmd.to_string()); + } + match kind { + "read" | "search" => PermissionIntent::Read { + paths: tool_paths(tc), + }, + "edit" | "write" | "delete" | "move" => PermissionIntent::Write { + paths: tool_paths(tc), + }, + "fetch" => PermissionIntent::Network, + other => PermissionIntent::Other { + kind: other.to_string(), + }, + } +} + +/// The canonical, EXACT action identity for an Always grant (issue #89). +/// +/// The stringified `rawInput` alone is NOT it. ACP lets a request carry its +/// target only in `toolCall.locations` — which is exactly where +/// [`intent_from_params`] looks first — and two edits to different files then +/// stringify to an identical, sometimes empty, `rawInput`. An Always grant +/// keyed on that would let approving one file silently auto-approve another. +/// +/// EVERY named location joins the key, not just the first that `tool_path` +/// takes for display and risk tiering: otherwise a two-file request could be +/// replayed under the same grant with its second file swapped out. +/// +/// Never shown to a human — `summary`/`detail` are the display strings. +/// +/// Each location is LENGTH-PREFIXED rather than joined by a separator: a path +/// may contain any byte except `/` and NUL, so any delimiter could in +/// principle be forged to make two different location sets serialize +/// identically. Lengths make the encoding unambiguous outright. +pub fn grant_identity(params: &Value) -> String { + let tc = params.get("toolCall").unwrap_or(&Value::Null); + // Every field that can carry the action, not just the two that usually do. + // A sparse request may omit BOTH `rawInput` and `locations` and describe + // itself in `title`/`content` alone; keyed on the other two, two different + // `execute` requests collapsed to the same identity, and an Always granted + // to the first silently approved the second. + let mut out = String::new(); + for part in [ + tc.get("rawInput"), + tc.get("locations"), + tc.get("title"), + tc.get("content"), + tc.get("kind"), + ] { + // `to_string()` on a JSON value is stable for a given structure, and + // absent vs. present-but-null stay distinguishable (`""` vs `"null"`). + let encoded = part.map(|v| v.to_string()).unwrap_or_default(); + out.push_str(&format!("{}:{encoded}", encoded.len())); + } + out +} + +/// Intent key from a full permission request params object. +pub fn intent_key_from_params(params: &Value) -> String { + let tc = params.get("toolCall").unwrap_or(&Value::Null); + let kind = tc + .get("kind") + .and_then(|k| k.as_str()) + .unwrap_or("tool"); + let raw = tc.get("rawInput").cloned().unwrap_or(Value::Null); + intent_key(kind, &raw) +} + +/// Human summary line for the Needs-you card. +/// Readable text from a `toolCall.content` block list. +/// +/// Deliberately NOT `map::extract_tool_output`: that one reads tool RESULTS, +/// so it prefers `rawOutput` and drops a leading `$ cmd` echo. Here that echo +/// is the single most useful line — it is what the human is being asked to +/// approve. +fn content_text(tc: &Value) -> String { + let Some(arr) = tc.get("content").and_then(|c| c.as_array()) else { + return String::new(); + }; + let mut parts: Vec<&str> = Vec::new(); + for item in arr { + if let Some(t) = item.pointer("/content/text").and_then(|t| t.as_str()) { + parts.push(t); + } else if let Some(t) = item.get("text").and_then(|t| t.as_str()) { + parts.push(t); + } + } + parts.join("\n") +} + +pub fn summary_from_params(params: &Value) -> (String, String) { + let tc = params.get("toolCall").unwrap_or(&Value::Null); + // Absent title becomes EMPTY, not the literal "tool": the empty branch + // below is what emits the machine token the catalogs localize, and a + // non-empty placeholder made that branch unreachable — so a request + // without a title showed an untranslated `tool` in every locale. + let title = tc.get("title").and_then(|t| t.as_str()).unwrap_or_default(); + // `rawInput` alone is not enough to authorize by. An ACP request may name + // its targets ONLY in `toolCall.locations` — the shape the intent and + // grant-identity code reads first — leaving `rawInput` empty. With a + // generic or empty title, the card would then ask a human to approve a + // file operation without naming a single path. Append whatever locations + // were named so the detail always says what will be touched. + let raw = tc + .get("rawInput") + .map(|r| r.to_string()) + .unwrap_or_default(); + let mut parts: Vec = Vec::new(); + if !raw.is_empty() { + parts.push(raw); + } + parts.extend(tool_paths(tc)); + // `content` is a LAST RESORT, not an addition. For an ordinary request it + // would only restate `rawInput` in another form (`{"command":"ls"}` and + // `$ ls`), so it is used exactly when the action is described nowhere else + // — the sparse shape ACP permits and `grant_identity` already treats as + // action-bearing. Without it that card body is EMPTY and the human is + // asked to approve something unnamed, which leaves only blind-approve or + // refuse: both product failures. + let detail = if parts.is_empty() { + content_text(tc) + } else { + parts.join("\n") + }; + // Stable machine token — frontend i18n maps `acp.permission_required`. + let summary = if title.is_empty() { + "acp.permission_required".into() + } else { + title.to_string() + }; + (summary, detail) +} + +#[derive(Debug, Default, Clone)] +pub struct AlwaysCache { + map: HashMap, +} + +impl AlwaysCache { + pub fn new() -> Self { + Self { + map: HashMap::new(), + } + } + + pub fn get(&self, key: &str) -> Option { + self.map.get(key).copied() + } + + pub fn set(&mut self, key: String, want: Want) { + if matches!(want, Want::AllowAlways | Want::RejectAlways) { + self.map.insert(key, want); + } + } + + pub fn clear(&mut self) { + self.map.clear(); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn sample_options() -> Vec { + vec![ + json!({"optionId":"allow_once","name":"Allow once","kind":"allow_once"}), + json!({"optionId":"allow_always","name":"Always allow","kind":"allow_always"}), + json!({"optionId":"reject_once","name":"Reject","kind":"reject_once"}), + json!({"optionId":"reject_always","name":"Always reject","kind":"reject_always"}), + ] + } + + #[test] + fn picks_by_kind() { + let opts = sample_options(); + assert_eq!( + pick_option_id(&opts, Want::AllowOnce).as_deref(), + Some("allow_once") + ); + assert_eq!( + pick_option_id(&opts, Want::RejectAlways).as_deref(), + Some("reject_always") + ); + } + + #[test] + fn selected_outcome_shape() { + let v = selected_outcome("allow_once"); + assert_eq!(v["outcome"]["outcome"], "selected"); + assert_eq!(v["outcome"]["optionId"], "allow_once"); + } + + #[test] + fn intent_key_bash_is_coarse() { + let k = intent_key("execute", &json!({"command": "echo hi"})); + assert_eq!(k, "execute:bash"); + let k2 = intent_key("execute", &json!({"command": "rm -rf /"})); + assert_eq!(k, k2, "always-allow is session-wide for bash family"); + } + + #[test] + fn always_cache_only_stores_always_variants() { + let mut c = AlwaysCache::new(); + c.set("k".into(), Want::AllowOnce); + assert!(c.get("k").is_none()); + c.set("k".into(), Want::AllowAlways); + assert_eq!(c.get("k"), Some(Want::AllowAlways)); + } + + #[test] + fn summary_from_permission_fixture_shape() { + let params = json!({ + "sessionId": "s", + "toolCall": { + "toolCallId": "c1", + "title": "echo TOOL_OK", + "kind": "execute", + "rawInput": { "command": "echo TOOL_OK" } + } + }); + let (s, _) = summary_from_params(¶ms); + assert_eq!(s, "echo TOOL_OK"); + assert_eq!(intent_key_from_params(¶ms), "execute:bash"); + } + + /// Pinned to the live omp capture, not a hand-written shape: this is the + /// exact frame a real shell approval arrives as. `intent_key` folds it to + /// `execute:bash` for grant grouping — matching risk against THAT spelling + /// is what scored every command, `rm -rf` included, as a generic tool call. + #[test] + fn live_omp_shell_request_classifies_as_a_command() { + let raw = include_str!("../../tests/fixtures/acp/sample-permission-request.json"); + let frame: Value = serde_json::from_str(raw).expect("fixture parses"); + let params = frame.get("params").expect("fixture carries params"); + + assert_eq!( + intent_from_params(params), + PermissionIntent::Command("echo TOOL_OK".into()) + ); + // The lossy grant key is deliberately unchanged — different purpose. + assert_eq!(intent_key_from_params(params), "execute:bash"); + } + + #[test] + fn file_intents_split_read_from_write_and_prefer_acp_locations() { + let read = json!({ + "toolCall": { + "kind": "read", + "rawInput": { "path": "raw/input.rs" }, + "locations": [{ "path": "src/from_locations.rs" }] + } + }); + assert_eq!( + intent_from_params(&read), + PermissionIntent::Read { + paths: vec!["src/from_locations.rs".into(), "raw/input.rs".into()] + }, + "structured locations lead, but a raw-input path is still a target" + ); + + for kind in ["edit", "write", "delete", "move"] { + let params = json!({ + "toolCall": { "kind": kind, "rawInput": { "file_path": "src/main.rs" } } + }); + assert_eq!( + intent_from_params(¶ms), + PermissionIntent::Write { + paths: vec!["src/main.rs".into()] + }, + "{kind} mutates the filesystem" + ); + } + } + + /// Every location is carried, in order. Taking only the first meant a + /// multi-file read could be tiered off an ordinary leading path while a + /// later entry was a credential — and a read-only grant auto-approves a + /// `ReadOnly` ask without ever showing the human the card. + #[test] + fn every_named_location_reaches_the_classifier() { + let params = json!({ + "toolCall": { + "kind": "read", + "locations": [{"path":"src/main.rs"}, {"path":"/home/u/.ssh/id_rsa"}] + } + }); + assert_eq!( + intent_from_params(¶ms), + PermissionIntent::Read { + paths: vec!["src/main.rs".into(), "/home/u/.ssh/id_rsa".into()] + } + ); + } + + /// `locations` and `rawInput` are not alternatives. Classifying only the + /// structured one let a request hide a credential in the other and inherit + /// the ordinary file's `ReadOnly` tier, which a read-only grant releases + /// without a card. + #[test] + fn paths_come_from_locations_and_raw_input_together() { + let params = json!({ + "toolCall": { + "kind": "read", + "rawInput": { "path": "/home/u/.ssh/id_rsa" }, + "locations": [{ "path": "src/main.rs" }] + } + }); + assert_eq!( + intent_from_params(¶ms), + PermissionIntent::Read { + paths: vec!["src/main.rs".into(), "/home/u/.ssh/id_rsa".into()] + } + ); + } + + /// A path named in both places is one target, not two. + #[test] + fn a_path_named_twice_is_not_duplicated() { + let params = json!({ + "toolCall": { + "kind": "read", + "rawInput": { "path": "src/main.rs", "file_path": "src/main.rs" }, + "locations": [{ "path": "src/main.rs" }] + } + }); + assert_eq!( + intent_from_params(¶ms), + PermissionIntent::Read { + paths: vec!["src/main.rs".into()] + } + ); + } + + /// The card must name what it is authorizing. With the target only in + /// `locations`, `rawInput` is empty and the detail said nothing at all. + #[test] + fn detail_names_locations_even_when_raw_input_is_empty() { + let params = json!({ + "toolCall": { + "kind": "edit", + "locations": [{"path":"src/a.rs"}, {"path":"src/b.rs"}] + } + }); + let (_, detail) = summary_from_params(¶ms); + assert!(detail.contains("src/a.rs"), "detail: {detail:?}"); + assert!(detail.contains("src/b.rs"), "detail: {detail:?}"); + } + + /// A write with nowhere to point is still a write: `classify_file` tiers on + /// the verb, so an empty path must not demote it to a generic tool call. + #[test] + fn a_write_without_a_path_stays_a_write() { + let params = json!({ "toolCall": { "kind": "edit" } }); + assert_eq!( + intent_from_params(¶ms), + PermissionIntent::Write { paths: Vec::new() } + ); + } + + #[test] + fn fetch_is_network_and_unknown_kinds_carry_their_name() { + assert_eq!( + intent_from_params(&json!({ "toolCall": { "kind": "fetch" } })), + PermissionIntent::Network + ); + assert_eq!( + intent_from_params(&json!({ "toolCall": { "kind": "think" } })), + PermissionIntent::Other { + kind: "think".into() + } + ); + assert_eq!( + intent_from_params(&json!({})), + PermissionIntent::Other { + kind: "tool".into() + } + ); + } + + /// An Always grant is persisted and replayed, so its key must be the FULL + /// action. `detail` (the stringified `rawInput`) is not: ACP lets a request + /// name its target only in `locations` — the field `intent_from_params` + /// reads first — and two edits to different files then share an identical, + /// here empty, `rawInput`. Keyed on that, approving one file would silently + /// auto-approve the other. + #[test] + fn grant_identity_separates_requests_that_differ_only_in_locations() { + let a = json!({"toolCall":{"kind":"edit","locations":[{"path":"src/a.rs"}]}}); + let b = json!({"toolCall":{"kind":"edit","locations":[{"path":"src/b.rs"}]}}); + + // The bug in one line: `rawInput` — which WAS the whole key material — + // is identical for both. (`detail` now appends the locations too, so + // the card names them; the grant key must not depend on that.) + assert_eq!( + a.pointer("/toolCall/rawInput"), + b.pointer("/toolCall/rawInput") + ); + assert_ne!(grant_identity(&a), grant_identity(&b)); + // An identical request still shares its grant; that is what Always is. + assert_eq!(grant_identity(&a), grant_identity(&a)); + } + + /// Every location joins the key, not just the first one `tool_path` uses: + /// a two-file request must not be replayable with one file swapped. + #[test] + fn grant_identity_covers_every_location_not_just_the_first() { + let a = json!({"toolCall":{"locations":[{"path":"keep"},{"path":"b"}]}}); + let b = json!({"toolCall":{"locations":[{"path":"keep"},{"path":"c"}]}}); + assert_ne!(grant_identity(&a), grant_identity(&b)); + } + + /// The product failure this closes: a card whose body is empty leaves the + /// human only "approve something unnamed" or "refuse", and a request may + /// legitimately describe its action only in `content` — which this module + /// already treats as action-bearing in `grant_identity`. The two halves of + /// the authorization decision must agree on what the action IS. + #[test] + fn a_content_only_request_still_says_what_it_will_do() { + let params = json!({ + "toolCall": { + "kind": "execute", + "content": [ + {"type":"content","content":{"type":"text","text":"$ rm -rf build"}} + ] + } + }); + let (summary, detail) = summary_from_params(¶ms); + + assert!( + detail.contains("rm -rf build"), + "the card must name the action, got {detail:?}" + ); + // No title either: the summary must be the token the catalogs localize, + // not a literal that every locale shows in English. + assert_eq!(summary, "acp.permission_required"); + } + + /// `content` is a fallback, not an addition — an ordinary request must not + /// gain a second line restating `rawInput` in another form. + #[test] + fn content_does_not_duplicate_an_ordinary_requests_detail() { + let params = json!({ + "toolCall": { + "kind": "execute", + "title": "echo TOOL_OK", + "rawInput": { "command": "echo TOOL_OK" }, + "content": [ + {"type":"content","content":{"type":"text","text":"$ echo TOOL_OK"}} + ] + } + }); + let (summary, detail) = summary_from_params(¶ms); + + assert_eq!(summary, "echo TOOL_OK"); + assert!(!detail.contains("$ echo"), "content restated rawInput: {detail:?}"); + assert!(detail.contains("echo TOOL_OK")); + } + + /// A sparse request — no `rawInput`, no `locations` — must still be + /// distinguishable. Keyed on only those two, every such request collapsed + /// to one identity, so an Always granted to the first `execute` silently + /// approved a different one. + #[test] + fn sparse_requests_do_not_collapse_to_one_identity() { + let a = json!({"toolCall": {"kind": "execute", "title": "deploy staging"}}); + let b = json!({"toolCall": {"kind": "execute", "title": "deploy production"}}); + assert_ne!(grant_identity(&a), grant_identity(&b)); + + // Content-only differences count too. + let c = json!({"toolCall": {"kind": "execute", "content": [{"text": "one"}]}}); + let d = json!({"toolCall": {"kind": "execute", "content": [{"text": "two"}]}}); + assert_ne!(grant_identity(&c), grant_identity(&d)); + + // Absent stays distinguishable from present-but-null. + let absent = json!({"toolCall": {"kind": "execute"}}); + let null_title = json!({"toolCall": {"kind": "execute", "title": null}}); + assert_ne!(grant_identity(&absent), grant_identity(&null_title)); + + // And identical requests still share their grant. + assert_eq!(grant_identity(&a), grant_identity(&a)); + } + + /// Length prefixes, not delimiters: a path can contain any byte but `/` and + /// NUL, so a separator-joined encoding could be forged into a collision. + #[test] + fn grant_identity_boundaries_cannot_be_forged_from_path_bytes() { + for probe in ["a\u{1f}b", "a\u{1e}b", "a:b", "2:ab"] { + let one = json!({"toolCall":{"locations":[{"path":probe}]}}); + let two = json!({"toolCall":{"locations":[{"path":"a"},{"path":"b"}]}}); + assert_ne!( + grant_identity(&one), + grant_identity(&two), + "path {probe:?} must not collide with a two-location request" + ); + } + } + + /// Whatever kind an agent declares, a command line runs a shell. + #[test] + fn a_command_under_any_kind_is_still_a_command() { + let params = json!({ + "toolCall": { "kind": "other", "rawInput": { "command": "rm -rf /tmp/x" } } + }); + assert_eq!( + intent_from_params(¶ms), + PermissionIntent::Command("rm -rf /tmp/x".into()) + ); + } +} diff --git a/src-tauri/src/acp/runtime.rs b/src-tauri/src/acp/runtime.rs new file mode 100644 index 00000000..f3ac9ab1 --- /dev/null +++ b/src-tauri/src/acp/runtime.rs @@ -0,0 +1,1404 @@ +//! Global multiplexed ACP client pool — one child process per backend id. +//! +//! Protocol-only: no CLI name strings except via [`super::backends::AcpBackend`]. +//! Codex app-server must NOT use this module (different wire dialect). + +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::process::Stdio; +use std::sync::{Arc, LazyLock}; +use std::time::Duration; + +use serde_json::{json, Value}; +use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; +use tokio::process::Command; +use tokio::sync::{mpsc, oneshot, Mutex}; + +use super::backends::{backend_for, AcpBackend, McpServerSpec}; +use super::jsonrpc::{ + classify, encode_error_response, encode_notification, encode_request, encode_response, Incoming, +}; +use super::map::{stop_reason_is_cancelled, stop_reason_is_error, update_to_out, UpdateOut}; +use super::permission::{ + intent_key_from_params, pick_option_id, selected_outcome, summary_from_params, AlwaysCache, Want, +}; +use crate::lead_chat::proto::{ChatEvent, SlashCmd}; + +/// Events demuxed to a subscribed Weft session. +#[derive(Debug)] +pub enum SessionEvent { + Chat(ChatEvent), + Commands(Vec), + Usage { + context_tokens: u64, + window: Option, + }, + Meta { + model: Option, + thinking: Option, + }, + /// Streaming reasoning text (agent_thought_chunk) for the busy indicator. + Thought { + text: String, + }, + ToolProgress { + summary: String, + }, + /// Prompt-task → consumer ordering: consumer replies when this is dequeued. + DrainBarrier(tokio::sync::oneshot::Sender<()>), + /// Permission needed — engine/runtime resolves via Ask and replies on the wire. + /// Carries enough for the default handler; custom handlers may be installed later. + Permission { + request_id: Value, + summary: String, + detail: String, + intent_key: String, + /// What the request wants to do, classified from the `toolCall` while + /// its structure is still here. `intent_key` is the always-grant + /// cache token and is far too lossy to re-derive this from. + intent: super::permission::PermissionIntent, + /// Canonical full action identity for the persisted Always grant. + /// Carries the structured `locations` that `detail` (stringified + /// `rawInput`) can omit entirely — see `permission::grant_identity`. + grant_id: String, + options: Vec, + }, +} + +#[derive(Debug, Clone)] +pub struct UsageBits { + pub input_tokens: Option, + pub output_tokens: Option, + pub total_tokens: Option, + pub cached_read_tokens: Option, +} + +#[derive(Debug, Clone)] +pub struct PromptOutcome { + pub stop_reason: String, + pub usage: Option, + pub is_error: bool, + pub cancelled: bool, +} + +type PendingMap = HashMap>>; +type SessionMap = HashMap; + +struct SessionRoute { + events: mpsc::UnboundedSender, + always: AlwaysCache, + /// Optional auto-reply for tests / dangerous mode short-circuit at route layer. + auto_want: Option, +} + +/// Whether an outstanding JSON-RPC reply still protects a pooled client from +/// being retired. "No routes, none opening, none expected" is required either +/// way — this only decides what a non-empty `pending` means in that state. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum PendingPolicy { + /// Ordinary route teardown: an outstanding reply means the peer is still + /// working on something, so keep the child alive for it. + Protects, + /// After the agent ignored `session/cancel`: the reply is never coming and + /// its route is already gone, so it must not pin the child. + Abandoned, +} + +/// A pooled client's liveness counters at one instant. +#[derive(Debug, Clone, Copy)] +struct RouteState { + sessions: usize, + opening: usize, + expecting: u32, + pending: usize, + /// Whether a route was EVER installed on this connection. + ever_had_session: bool, +} + +/// Whether a pooled client in this state may be retired. Split out from the +/// locking so the policy itself is directly testable. +/// +/// `ever_had_session` separates "done with its routes" from "has not opened one +/// yet". A freshly reconnected client sits in the second state between +/// `initialize` and the caller's `session/new|resume`: every counter reads zero +/// and its `pending` map drains the moment the handshake resolves, so without +/// this it looked idle and was retired out from under the caller, whose next +/// `session/*` then failed with `ACP not connected`. +fn may_retire(state: RouteState, policy: PendingPolicy) -> bool { + if !state.ever_had_session { + return false; + } + let unused = state.sessions == 0 && state.opening == 0 && state.expecting == 0; + let replies_settled = match policy { + PendingPolicy::Protects => state.pending == 0, + PendingPolicy::Abandoned => true, + }; + unused && replies_settled +} + +struct Inner { + write_tx: mpsc::UnboundedSender<(Vec, Option>)>, + next_id: i64, + pending: PendingMap, + sessions: SessionMap, + /// Permission request ids still awaiting a reply, mapped to a monotonic + /// generation. Safety-net timers capture the generation at arm time so a + /// stale 1h timer cannot reject a later request that reused omp's id:0. + pending_permission_ids: std::collections::HashMap, + permission_gen: u64, + /// session/update events that arrived before subscribe() installed a route + /// (OMP often emits available_commands_update right after session/new). + pending_updates: std::collections::HashMap>, + /// Sessions currently between session/new|resume and subscribe — only these + /// may buffer pre-subscribe updates. Explicit unsubscribe drops the key. + opening_sessions: std::collections::HashSet, + /// Whether any session route has EVER existed on this connection. + /// + /// Distinguishes "finished with its routes" from "has not opened one yet". + /// A freshly (re)connected client sits in the second state between + /// `initialize` and the caller's `session/new|resume`: its `pending` map + /// drains when the handshake resolves, and treating that as "idle" retired + /// the client the caller was about to use, which then failed with + /// `ACP not connected`. + ever_had_session: bool, + /// In-flight session/new|resume|load count — buffer updates while > 0. + /// Counter (not bool) so concurrent opens don't clear early. + expecting_session: u32, + /// Generation of this child connection; read_loop only clears inner when + /// its generation still owns the slot (writer-fail reconnect race). + connection_gen: u64, + _child: tokio::process::Child, + _reg: crate::proc_registry::Registration, +} +/// Result of `session/new` / `session/resume`, including config metadata +/// OMP returns under `configOptions` (model / thinking). +#[derive(Debug, Clone)] +pub struct SessionOpen { + pub session_id: String, + pub model: Option, + pub thinking: Option, +} + +/// Read `currentValue` for a named entry in `result.configOptions`. +fn config_option_current(result: &Value, id: &str) -> Option { + let opts = result.get("configOptions")?.as_array()?; + for opt in opts { + let oid = opt.get("id").or_else(|| opt.get("configId")).and_then(|v| v.as_str()); + if oid != Some(id) { + continue; + } + if let Some(v) = opt.get("currentValue").and_then(|v| v.as_str()) { + return Some(v.to_string()); + } + if let Some(v) = opt.get("value").and_then(|v| v.as_str()) { + return Some(v.to_string()); + } + } + None +} + +#[derive(Clone)] +pub struct ClientHandle { + backend_id: &'static str, + inner: Arc>>, + /// Effective program last successfully connected; used to detect pin changes. + program: Arc>>, + /// Held during spawn+initialize so concurrent ensure_connected waiters + /// don't race a half-ready child. + connect_lock: Arc>, +} + +fn pool_key(backend_id: &str, program: &str) -> String { + // Isolate clients by effective binary so two OMP sessions with different + // command pins never reap each other's active turns. + format!("{backend_id}\0{program}") +} + +struct Pool { + clients: Mutex>, +} + +static POOL: LazyLock = LazyLock::new(|| Pool { + clients: Mutex::new(HashMap::new()), +}); + +/// Serializes first-time ACP child creation so two concurrent first turns cannot +/// each spawn a child and race the pool insert. +/// One creation lock per pool key, so `client()` and `reap_if_unused` stay +/// atomic against each other for the SAME key without touching other keys. +/// +/// Per KEY, not process-wide: this is held across `ensure_connected`, whose +/// handshake waits up to 60s, and a single slow or broken command pin must not +/// stall the first send of every other `(backend, program)`. +static KEY_LOCKS: LazyLock>>>> = + LazyLock::new(|| Mutex::new(HashMap::new())); + +async fn key_lock(key: &str) -> Arc> { + let mut g = KEY_LOCKS.lock().await; + g.entry(key.to_string()) + .or_insert_with(|| Arc::new(Mutex::new(()))) + .clone() +} + +/// Get or create the client for (`backend_id`, `program`). +/// Different program pins get isolated children — never recycle a live peer. +pub async fn client(backend_id: &str, program: &str) -> anyhow::Result { + let backend = backend_for(backend_id) + .ok_or_else(|| anyhow::anyhow!("no ACP backend registered for {backend_id}"))?; + let id = backend.id(); + let key = pool_key(id, program); + // Held across lookup+ensure so `reap_if_unused` cannot remove a handle + // another caller just acquired (unsubscribe/reap race). + let create_lock = key_lock(&key).await; + let _create = create_lock.lock().await; + let existing = { + let g = POOL.clients.lock().await; + g.get(&key).cloned() + }; + if let Some(c) = existing { + c.ensure_connected(backend, program).await?; + // The shutdown check belongs on THIS path too. `shutdown_all` can drain + // this very handle and clear its `inner` between the lookup above and + // `ensure_connected`, which then spawns a REPLACEMENT child — and this + // early return would hand it back without re-inserting, leaving a child + // nothing tracks and the exit path already past. Re-checked under the + // pool lock, the same mutual exclusion the fresh-handle path uses. + let mut pool = POOL.clients.lock().await; + if SHUTTING_DOWN.load(std::sync::atomic::Ordering::SeqCst) { + drop(pool); + c.shutdown_and_reap().await; + anyhow::bail!("ACP pool is shutting down"); + } + // Re-insert if the drain removed us: the handle is live again. + pool.entry(key).or_insert_with(|| c.clone()); + return Ok(c); + } + let handle = ClientHandle { + backend_id: id, + inner: Arc::new(Mutex::new(None)), + program: Arc::new(tokio::sync::Mutex::new(None)), + connect_lock: Arc::new(tokio::sync::Mutex::new(())), + }; + handle.ensure_connected(backend, program).await?; + // The check and the insert happen under the POOL LOCK, which `shutdown_all` + // also takes to set the flag and drain. That makes them mutually exclusive + // without either side waiting on the other's work: a creation that loses + // the race sees the flag and reaps its own child rather than inserting into + // a pool nobody will drain again, and the exit path never waits out a 60s + // handshake to find out. + { + let mut pool = POOL.clients.lock().await; + if SHUTTING_DOWN.load(std::sync::atomic::Ordering::SeqCst) { + drop(pool); + handle.shutdown_and_reap().await; + anyhow::bail!("ACP pool is shutting down"); + } + pool.insert(key, handle.clone()); + } + Ok(handle) +} + +/// Tear down one (`backend_id`, `program`) pooled client only. +pub async fn shutdown_program(backend_id: &str, program: &str) { + let key = pool_key(backend_id, program); + let c = { + let mut g = POOL.clients.lock().await; + g.remove(&key) + }; + if let Some(c) = c { + c.shutdown_and_reap().await; + } +} + +/// Tear down every pooled connection for `backend_id` (any program pin). +/// Prefer [`shutdown_program`] when only one pin should die. +pub async fn shutdown(backend_id: &str) { + let victims: Vec = { + let mut g = POOL.clients.lock().await; + let keys: Vec = g + .keys() + .filter(|k| k.split('\0').next() == Some(backend_id)) + .cloned() + .collect(); + keys.into_iter().filter_map(|k| g.remove(&k)).collect() + }; + for c in victims { + c.shutdown_and_reap().await; + } +} + +/// Set once the exit path has drained the pool. `client()` checks it while +/// holding `CREATE_LOCK`, so a child spawned by an in-flight creation is reaped +/// by its own creator instead of being inserted into a pool nobody will drain +/// again. +static SHUTTING_DOWN: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false); + +pub async fn shutdown_all() { + // Flag and drain under ONE pool-lock acquisition. `client()` checks the flag + // and inserts under that same lock, so a creation racing this either + // inserts before the drain (and is drained) or sees the flag and reaps its + // own child — no untracked survivor either way. + // + // Deliberately NOT waiting for in-flight creations to finish: this runs + // synchronously on `ExitRequested`, and an unresponsive agent's handshake + // can take 60s, which would read as a hung desktop app. The flag makes + // waiting unnecessary. + let all: Vec = { + let mut g = POOL.clients.lock().await; + SHUTTING_DOWN.store(true, std::sync::atomic::Ordering::SeqCst); + g.drain().map(|(_, c)| c).collect() + }; + for c in all { + c.shutdown_and_reap().await; + } +} + +impl ClientHandle { + pub fn backend_id(&self) -> &'static str { + self.backend_id + } + + async fn ensure_connected( + &self, + backend: Arc, + program: &str, + ) -> anyhow::Result<()> { + // Serialize reconnect/init so a second waiter can't send session/* + // before initialize finishes. + let _guard = self.connect_lock.lock().await; + if self.inner.lock().await.is_some() { + // Live child for this (backend, program) key — keep it. + return Ok(()); + } + self.spawn(backend, program).await?; + *self.program.lock().await = Some(program.to_string()); + Ok(()) + } + + async fn spawn(&self, backend: Arc, program: &str) -> anyhow::Result<()> { + let mut g = self.inner.lock().await; + if g.is_some() { + return Ok(()); + } + let (prog, args) = backend.spawn_argv(program); + let mut command = Command::new(&prog); + command.args(&args); + command.env("PATH", crate::detect::tool_path()); + command + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .kill_on_drop(true); + let owner = crate::proc_registry::Owner::global_app_server(); + let configured = crate::proc_registry::configure(&mut command, owner); + let mut child = command + .spawn() + .map_err(|e| anyhow::anyhow!("spawn {} ACP: {e}", backend.id()))?; + let reg = configured.register(&child); + let stdin = child + .stdin + .take() + .ok_or_else(|| anyhow::anyhow!("no stdin"))?; + let stdout = child + .stdout + .take() + .ok_or_else(|| anyhow::anyhow!("no stdout"))?; + + let (write_tx, mut write_rx) = + mpsc::unbounded_channel::<(Vec, Option>)>(); + let me_w = self.clone(); + tauri::async_runtime::spawn(async move { + let mut stdin = stdin; + while let Some((bytes, ack)) = write_rx.recv().await { + if stdin.write_all(&bytes).await.is_err() || stdin.flush().await.is_err() { + me_w.fail_pending_and_reap("ACP stdin writer failed").await; + break; + } + if let Some(a) = ack { + let _ = a.send(()); + } + } + }); + + let connection_gen = { + static GEN: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1); + GEN.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + }; + *g = Some(Inner { + write_tx, + next_id: 1, + pending: HashMap::new(), + sessions: HashMap::new(), + pending_permission_ids: std::collections::HashMap::new(), + permission_gen: 0, + pending_updates: std::collections::HashMap::new(), + opening_sessions: std::collections::HashSet::new(), + ever_had_session: false, + expecting_session: 0, + connection_gen, + _child: child, + _reg: reg, + }); + drop(g); // request() needs this mutex — never hold across initialize. + + let me = self.clone(); + tauri::async_runtime::spawn(async move { me.read_loop(stdout, connection_gen).await }); + + // initialize + let init_params = json!({ + "protocolVersion": 1, + "clientInfo": { + "name": "weft", + "version": env!("CARGO_PKG_VERSION"), + }, + "clientCapabilities": backend.client_capabilities(), + }); + if let Err(e) = self.request("initialize", init_params).await { + self.shutdown_and_reap().await; + return Err(e); + } + Ok(()) + } + + async fn fail_pending_and_reap(&self, message: &str) { + if let Some(mut inner) = self.inner.lock().await.take() { + for (_, tx) in inner.pending.drain() { + let _ = tx.send(Err(message.to_string())); + } + // Tree-aware: OMP may have shell/tool descendants in their own groups. + crate::proc_registry::reap(&mut inner._child, &inner._reg).await; + } + } + + pub async fn shutdown_and_reap(&self) { + if let Some(mut inner) = self.inner.lock().await.take() { + crate::proc_registry::reap(&mut inner._child, &inner._reg).await; + } + } + + async fn read_loop(&self, stdout: tokio::process::ChildStdout, connection_gen: u64) { + let mut lines = BufReader::new(stdout).lines(); + while let Ok(Some(line)) = lines.next_line().await { + match classify(&line) { + Incoming::Response { id, result } => self.resolve(id, Ok(result)).await, + Incoming::Error { id, message, .. } => self.resolve(id, Err(message)).await, + Incoming::Notification { method, params } => { + if method == "session/update" { + self.on_session_update(params).await; + } + } + Incoming::ServerRequest { id, method, params } => { + if method == "session/request_permission" { + self.on_permission(id, params).await; + } else { + let _ = self + .write_raw(encode_error_response( + &id, + -32601, + &format!("method not supported: {method}"), + )) + .await; + } + } + Incoming::Skip => {} + } + } + // Stdout closed: tree-reap if we still own the slot. A writer-fail may + // have already taken inner and spawned a newer generation. + let mut g = self.inner.lock().await; + if g.as_ref().is_some_and(|i| i.connection_gen == connection_gen) { + if let Some(mut inner) = g.take() { + drop(g); + crate::proc_registry::reap(&mut inner._child, &inner._reg).await; + } + } + } + + async fn resolve(&self, id: i64, res: Result) { + let settled_last = { + let mut g = self.inner.lock().await; + let Some(inner) = g.as_mut() else { + return; + }; + // Race: session/update can arrive before the open RPC future resumes + // and calls mark_opening. If the response body already names a + // sessionId, buffer for THAT sid immediately (not globally). + if let Ok(val) = &res { + if let Some(sid) = val.get("sessionId").and_then(|s| s.as_str()) { + if !sid.is_empty() { + inner.opening_sessions.insert(sid.to_string()); + inner.ever_had_session = true; + } + } + } + let waiter = inner.pending.remove(&id); + // A connection that has not opened a route yet is BETWEEN connect + // and `session/new|resume`, not done with its work. Its `pending` + // map drains the moment `initialize` resolves, and retiring there + // shut down the very client the caller was about to use — the next + // `session/*` then failed with `ACP not connected`. + let drained = inner.pending.is_empty() && inner.ever_had_session; + if let Some(tx) = waiter { + let _ = tx.send(res); + } + drained + }; + // Retirement is attempted on route teardown, but an in-flight reply + // protects the client at that moment (`PendingPolicy::Protects`). When + // the last session is stopped or switched mid-prompt, that teardown + // therefore declines — and nothing tried again once the reply landed, + // so the child survived routeless until process exit and every command + // pin change left one more orphan behind. This is that retry; the + // route checks inside still apply, so a client that is merely idle + // between turns is untouched. + if settled_last { + self.maybe_reap_if_idle().await; + } + } + + async fn on_session_update(&self, params: Value) { + let sid = params + .get("sessionId") + .and_then(|s| s.as_str()) + .unwrap_or(""); + let update = params.get("update").cloned().unwrap_or(Value::Null); + let out = update_to_out(&update); + let ev = match out { + UpdateOut::Chat(c) => SessionEvent::Chat(c), + UpdateOut::Commands(c) => SessionEvent::Commands(c), + UpdateOut::Usage { + context_tokens, + window, + } => SessionEvent::Usage { + context_tokens, + window, + }, + UpdateOut::Meta { model, thinking } => SessionEvent::Meta { model, thinking }, + UpdateOut::Thought { text } => SessionEvent::Thought { text }, + UpdateOut::ToolProgress { summary } => SessionEvent::ToolProgress { summary }, + UpdateOut::Ignore => return, + }; + if let Some(inner) = self.inner.lock().await.as_mut() { + if let Some(route) = inner.sessions.get(sid) { + let _ = route.events.send(ev); + } else if !sid.is_empty() && inner.opening_sessions.contains(sid) { + // Buffer only for the session currently between open and + // subscribe. Never use a process-global "expecting" flag — that + // would capture late updates from an unrelated unsubscribed sid + // while another session opens. Response handler marks opening + // as soon as sessionId is known (before caller resumes). + const MAX_BUFFERED: usize = 64; + let buf = inner.pending_updates.entry(sid.to_string()).or_default(); + if buf.len() < MAX_BUFFERED { + buf.push(ev); + } + } + } + } + + async fn on_permission(&self, id: Value, params: Value) { + let sid = params + .get("sessionId") + .and_then(|s| s.as_str()) + .unwrap_or("") + .to_string(); + let options = params + .get("options") + .and_then(|o| o.as_array()) + .cloned() + .unwrap_or_default(); + let (summary, detail) = summary_from_params(¶ms); + let intent = intent_key_from_params(¶ms); + let what = super::permission::intent_from_params(¶ms); + let grant = super::permission::grant_identity(¶ms); + let req_key = Self::permission_id_key(&id); + + let (auto, gen) = { + let mut g = self.inner.lock().await; + let Some(inner) = g.as_mut() else { + drop(g); + let _ = self + .write_raw(encode_error_response(&id, -32000, "not connected")) + .await; + return; + }; + // pending map keys by JSON-RPC id (omp reuses 0) → generation + inner.permission_gen = inner.permission_gen.wrapping_add(1); + let gen = inner.permission_gen; + inner.pending_permission_ids.insert(req_key.clone(), gen); + let Some(route) = inner.sessions.get_mut(&sid) else { + drop(g); + self.reply_permission(&id, &options, Want::RejectOnce).await; + return; + }; + // always-cache keys by intent, NOT request id + let auto = if let Some(w) = route.always.get(&intent) { + Some(w) + } else { + route.auto_want + }; + (auto, gen) + }; + if let Some(want) = auto { + self.reply_permission(&id, &options, want).await; + return; + } + + let delivered = { + let g = self.inner.lock().await; + g.as_ref() + .and_then(|inner| inner.sessions.get(&sid)) + .map(|route| { + route + .events + .send(SessionEvent::Permission { + request_id: id.clone(), + summary: summary.clone(), + detail: detail.clone(), + intent_key: intent.clone(), + intent: what.clone(), + grant_id: grant.clone(), + options: options.clone(), + }) + .is_ok() + }) + .unwrap_or(false) + }; + if !delivered { + self.reply_permission(&id, &options, Want::RejectOnce).await; + return; + } + let me = self.clone(); + let id_t = id.clone(); + let opts_t = options.clone(); + tauri::async_runtime::spawn(async move { + tokio::time::sleep(Duration::from_secs(3600)).await; + me.reply_permission_if_pending_gen(&id_t, &opts_t, Want::RejectOnce, gen) + .await; + }); + } + + fn permission_id_key(id: &Value) -> String { + id.to_string() + } + + /// Answer a permission request (called by engine after Ask, or auto paths). + /// Idempotent while the id is pending: once removed, further calls no-op so + /// a reused JSON-RPC id (omp has used `0`) can be accepted on the next ask. + pub async fn reply_permission(&self, id: &Value, options: &[Value], want: Want) { + { + let mut g = self.inner.lock().await; + let Some(inner) = g.as_mut() else { + return; + }; + let key = Self::permission_id_key(id); + if inner.pending_permission_ids.remove(&key).is_none() { + return; // already answered or unknown + } + } + let option_id = pick_option_id(options, want) + .or_else(|| pick_option_id(options, Want::RejectOnce)) + .unwrap_or_else(|| want.kind_str_fallback().to_string()); + let body = selected_outcome(&option_id); + let _ = self.write_raw(encode_response(id, body)).await; + } + + async fn reply_permission_if_pending(&self, id: &Value, options: &[Value], want: Want) { + self.reply_permission(id, options, want).await; + } + + /// Timeout path: only reject if the pending generation still matches. + async fn reply_permission_if_pending_gen( + &self, + id: &Value, + options: &[Value], + want: Want, + gen: u64, + ) { + { + let mut g = self.inner.lock().await; + let Some(inner) = g.as_mut() else { + return; + }; + let key = Self::permission_id_key(id); + match inner.pending_permission_ids.get(&key) { + Some(g) if *g == gen => {} + _ => return, // answered, or a newer request reused this id + } + } + self.reply_permission(id, options, want).await; + } + + /// Record always decision on a session after user picks Always. + pub async fn remember_always(&self, session_id: &str, intent_key: &str, want: Want) { + if let Some(inner) = self.inner.lock().await.as_mut() { + if let Some(route) = inner.sessions.get_mut(session_id) { + route.always.set(intent_key.to_string(), want); + } + } + } + + /// For tests: auto-allow every permission on this session. + pub async fn set_auto_want(&self, session_id: &str, want: Option) { + if let Some(inner) = self.inner.lock().await.as_mut() { + if let Some(route) = inner.sessions.get_mut(session_id) { + route.auto_want = want; + } + } + } + + async fn write_raw(&self, line: String) -> anyhow::Result<()> { + let g = self.inner.lock().await; + let inner = g + .as_ref() + .ok_or_else(|| anyhow::anyhow!("ACP not connected"))?; + inner + .write_tx + .send((line.into_bytes(), None)) + .map_err(|_| anyhow::anyhow!("ACP writer closed"))?; + Ok(()) + } + + /// Short-timeout request (handshake, session lifecycle). Not for prompt. + pub async fn request(&self, method: &str, params: Value) -> anyhow::Result { + self.request_timeout(method, params, Duration::from_secs(60)) + .await + } + + /// Long-lived request for `session/prompt` — cancel is the abort path. + pub async fn request_long(&self, method: &str, params: Value) -> anyhow::Result { + // 24h ceiling so a totally wedged agent still frees the oneshot eventually. + self.request_timeout(method, params, Duration::from_secs(86_400)) + .await + } + + async fn request_timeout( + &self, + method: &str, + params: Value, + reply_budget: Duration, + ) -> anyhow::Result { + let (id, rx, flushed) = { + let mut g = self.inner.lock().await; + let inner = g + .as_mut() + .ok_or_else(|| anyhow::anyhow!("ACP not connected"))?; + let id = inner.next_id; + inner.next_id += 1; + let (tx, rx) = oneshot::channel(); + inner.pending.insert(id, tx); + let (flush_tx, flush_rx) = oneshot::channel(); + let line = encode_request(id, method, params); + inner + .write_tx + .send((line.into_bytes(), Some(flush_tx))) + .map_err(|_| anyhow::anyhow!("ACP writer closed"))?; + (id, rx, flush_rx) + }; + match tokio::time::timeout(Duration::from_secs(60), flushed).await { + Ok(Ok(())) => {} + Ok(Err(_)) => anyhow::bail!("ACP {method}: writer closed before flush"), + Err(_) => { + self.fail_pending_and_reap("ACP stdin flush stalled").await; + anyhow::bail!("ACP {method}: stdin flush stalled"); + } + } + match tokio::time::timeout(reply_budget, rx).await { + Ok(Ok(Ok(v))) => Ok(v), + Ok(Ok(Err(e))) => anyhow::bail!("ACP {method}: {e}"), + Ok(Err(_)) => anyhow::bail!("ACP {method}: reply dropped"), + Err(_) => { + if let Some(inner) = self.inner.lock().await.as_mut() { + inner.pending.remove(&id); + } + anyhow::bail!("ACP {method}: timed out") + } + } + } + + pub async fn notify(&self, method: &str, params: Option) -> anyhow::Result<()> { + let line = encode_notification(method, params); + self.write_raw(line).await + } + + fn paint_mcp(backend_id: &str, mcp: Vec) -> Vec { + match backend_for(backend_id) { + Some(b) => b.paint_mcp_servers(mcp), + None => mcp + .into_iter() + .map(|s| { + json!({ + "type": "http", + "name": s.name, + "url": s.url, + }) + }) + .collect(), + } + } + + pub async fn is_alive(&self) -> bool { + self.inner.lock().await.is_some() + } + + pub async fn is_subscribed(&self, session_id: &str) -> bool { + self.inner + .lock() + .await + .as_ref() + .is_some_and(|i| i.sessions.contains_key(session_id)) + } + + pub async fn subscribe( + &self, + session_id: &str, + tx: mpsc::UnboundedSender, + ) -> anyhow::Result<()> { + let mut g = self.inner.lock().await; + let inner = g + .as_mut() + .ok_or_else(|| anyhow::anyhow!("ACP not connected"))?; + inner.opening_sessions.remove(session_id); + let buffered = inner + .pending_updates + .remove(session_id) + .unwrap_or_default(); + inner.ever_had_session = true; + inner.sessions.insert( + session_id.to_string(), + SessionRoute { + events: tx, + always: AlwaysCache::new(), + auto_want: None, + }, + ); + if let Some(route) = inner.sessions.get(session_id) { + for ev in buffered { + let _ = route.events.send(ev); + } + } + Ok(()) + } + + pub async fn unsubscribe(&self, session_id: &str) { + let empty = { + if let Some(inner) = self.inner.lock().await.as_mut() { + inner.sessions.remove(session_id); + inner.opening_sessions.remove(session_id); + inner.pending_updates.remove(session_id); + inner.sessions.is_empty() + && inner.opening_sessions.is_empty() + && inner.expecting_session == 0 + } else { + false + } + }; + if empty { + self.maybe_reap_if_idle().await; + } + } + + /// Drop this program-keyed pool entry when no routes remain so command-pin + /// changes do not accumulate orphan ACP children for the app lifetime. + async fn maybe_reap_if_idle(&self) { + self.reap_if_unused(PendingPolicy::Protects).await + } + + /// Retire the client even though replies are still outstanding — the + /// force-reset path, taken only after the agent ignored `session/cancel`. + /// + /// That ignored cancel is exactly why the ordinary check cannot be used: + /// the wedged `session/prompt` stays in `pending` (its own ceiling is 24h), + /// so [`maybe_reap_if_idle`] refuses forever and the child stays pooled. + /// The next send then calls `client()`, gets this same handle back, finds a + /// live child via `ensure_connected`, and prompts the very agent that is + /// still running the abandoned turn — which either races it or is rejected + /// as busy. Retiring is safe here precisely because the route checks still + /// apply: with no sessions, none opening and none expected, whatever is + /// left in `pending` belongs to a route that has already been torn down. + /// Dropping `Inner` releases those senders, so their callers fail fast + /// instead of waiting out the ceiling. + pub async fn retire_after_ignored_cancel(&self) { + self.reap_if_unused(PendingPolicy::Abandoned).await + } + + async fn reap_if_unused(&self, pending_policy: PendingPolicy) { + // Resolve the key BEFORE locking: the guard is per-key now, so there is + // nothing to take until we know which key this is. + let prog = self.program.lock().await.clone(); + let Some(program) = prog else { + return; + }; + let key = pool_key(self.backend_id, &program); + // Same key lock as `client()` — empty-check + pool remove stay atomic + // against a concurrent get-or-create for this key. + let create_lock = key_lock(&key).await; + let _create = create_lock.lock().await; + let empty = { + if let Some(inner) = self.inner.lock().await.as_ref() { + may_retire( + RouteState { + sessions: inner.sessions.len(), + opening: inner.opening_sessions.len(), + expecting: inner.expecting_session, + pending: inner.pending.len(), + ever_had_session: inner.ever_had_session, + }, + pending_policy, + ) + } else { + true + } + }; + if !empty { + return; + } + let removed = { + let mut g = POOL.clients.lock().await; + match g.get(&key) { + Some(c) if std::sync::Arc::ptr_eq(&c.inner, &self.inner) => g.remove(&key), + _ => None, + } + }; + // Release before awaiting the reap (shutdown takes other locks). + drop(_create); + if let Some(c) = removed { + c.shutdown_and_reap().await; + } + } + + async fn mark_opening(&self, session_id: &str) { + if let Some(inner) = self.inner.lock().await.as_mut() { + inner.opening_sessions.insert(session_id.to_string()); + inner.ever_had_session = true; + } + } + + async fn set_expecting_session(&self, on: bool) { + let cleared_last = { + match self.inner.lock().await.as_mut() { + Some(inner) if on => { + inner.expecting_session = inner.expecting_session.saturating_add(1); + inner.ever_had_session = true; + false + } + Some(inner) => { + inner.expecting_session = inner.expecting_session.saturating_sub(1); + inner.expecting_session == 0 + } + None => false, + } + }; + // A FAILED open leaves no route behind, and `resolve`'s own retry + // already declined while this expectation was standing — so nothing + // else would ever retire the client, and it sat in the static pool + // unreachable by any later stop/switch (the engine never published it + // into `acp_client`). One orphan per command pin whose open failed. + // + // A SUCCESSFUL open calls `mark_opening` before clearing its + // expectation, so the route checks inside decline and it is untouched. + if cleared_last { + self.maybe_reap_if_idle().await; + } + } + + /// Enqueue a session-scoped event (used for DrainBarrier). Returns false if + /// the route is gone. + pub async fn send_session_event(&self, session_id: &str, ev: SessionEvent) -> bool { + let g = self.inner.lock().await; + g.as_ref() + .and_then(|inner| inner.sessions.get(session_id)) + .map(|route| route.events.send(ev).is_ok()) + .unwrap_or(false) + } + + pub async fn new_session( + &self, + cwd: &Path, + mcp: Vec, + ) -> anyhow::Result { + let mcp_v = Self::paint_mcp(self.backend_id, mcp); + self.set_expecting_session(true).await; + let result = self + .request( + "session/new", + json!({ + "cwd": path_str(cwd), + "mcpServers": mcp_v, + }), + ) + .await; + let result = match result { + Ok(r) => r, + Err(e) => { + self.set_expecting_session(false).await; + return Err(e); + } + }; + let session_id = match result + .get("sessionId") + .and_then(|s| s.as_str()) + .map(str::to_string) + { + Some(id) => id, + None => { + self.set_expecting_session(false).await; + return Err(anyhow::anyhow!("session/new missing sessionId")); + } + }; + self.mark_opening(&session_id).await; + self.set_expecting_session(false).await; + Ok(SessionOpen { + session_id, + model: config_option_current(&result, "model"), + thinking: config_option_current(&result, "thinking") + .or_else(|| config_option_current(&result, "reasoning")), + }) + } + + pub async fn resume_session( + &self, + session_id: &str, + cwd: &Path, + mcp: Vec, + ) -> anyhow::Result { + let mcp_v = Self::paint_mcp(self.backend_id, mcp); + self.set_expecting_session(true).await; + // Pre-mark the known id so updates for this sid buffer immediately. + self.mark_opening(session_id).await; + let result = self + .request( + "session/resume", + json!({ + "sessionId": session_id, + "cwd": path_str(cwd), + "mcpServers": mcp_v, + }), + ) + .await; + let result = match result { + Ok(r) => r, + Err(e) => { + self.set_expecting_session(false).await; + return Err(e); + } + }; + let sid = result + .get("sessionId") + .and_then(|s| s.as_str()) + .unwrap_or(session_id) + .to_string(); + if sid != session_id { + self.mark_opening(&sid).await; + } + self.set_expecting_session(false).await; + Ok(SessionOpen { + session_id: sid, + model: config_option_current(&result, "model"), + thinking: config_option_current(&result, "thinking") + .or_else(|| config_option_current(&result, "reasoning")), + }) + } + + pub async fn load_session( + &self, + session_id: &str, + cwd: &Path, + mcp: Vec, + ) -> anyhow::Result { + let mcp_v = Self::paint_mcp(self.backend_id, mcp); + // Known id up front — load responses may omit sessionId, and command/ + // config updates can race before the RPC future resumes. + self.mark_opening(session_id).await; + self.set_expecting_session(true).await; + let result = self + .request( + "session/load", + json!({ + "sessionId": session_id, + "cwd": path_str(cwd), + "mcpServers": mcp_v, + }), + ) + .await; + let result = match result { + Ok(r) => r, + Err(e) => { + self.set_expecting_session(false).await; + // Leave opening_sessions: caller may retry/subscribe; explicit + // unsubscribe/clear_opening happens on failure paths that drop. + // Clear so a failed load does not buffer forever under this sid. + if let Some(inner) = self.inner.lock().await.as_mut() { + inner.opening_sessions.remove(session_id); + inner.pending_updates.remove(session_id); + } + return Err(e); + } + }; + let sid = result + .get("sessionId") + .and_then(|s| s.as_str()) + .unwrap_or(session_id) + .to_string(); + if sid != session_id { + self.mark_opening(&sid).await; + if let Some(inner) = self.inner.lock().await.as_mut() { + inner.opening_sessions.remove(session_id); + } + } + self.set_expecting_session(false).await; + Ok(sid) + } + + pub async fn fork_session( + &self, + session_id: &str, + cwd: &Path, + mcp: Vec, + ) -> anyhow::Result { + let mcp_v = Self::paint_mcp(self.backend_id, mcp); + let result = self + .request( + "session/fork", + json!({ + "sessionId": session_id, + "cwd": path_str(cwd), + "mcpServers": mcp_v, + }), + ) + .await?; + result + .get("sessionId") + .and_then(|s| s.as_str()) + .map(str::to_string) + .ok_or_else(|| anyhow::anyhow!("session/fork missing sessionId")) + } + + pub async fn close_session(&self, session_id: &str) -> anyhow::Result<()> { + let _ = self + .request("session/close", json!({ "sessionId": session_id })) + .await; + self.unsubscribe(session_id).await; + Ok(()) + } + + pub async fn cancel(&self, session_id: &str) -> anyhow::Result<()> { + self.notify("session/cancel", Some(json!({ "sessionId": session_id }))) + .await + } + + pub async fn prompt( + &self, + session_id: &str, + text: &str, + images: &[(String, String)], + ) -> anyhow::Result { + // ACP ContentBlock: text + image (base64). Keep text first so models that + // only read the leading block still see the user message. + let mut prompt_blocks = vec![json!({ "type": "text", "text": text })]; + for (media_type, data) in images { + if data.is_empty() { + continue; + } + prompt_blocks.push(json!({ + "type": "image", + "mimeType": media_type, + "data": data, + })); + } + let result = self + .request_long( + "session/prompt", + json!({ + "sessionId": session_id, + "prompt": prompt_blocks, + }), + ) + .await?; + let stop = result + .get("stopReason") + .and_then(|s| s.as_str()) + .unwrap_or("end_turn") + .to_string(); + let usage = result.get("usage").map(|u| UsageBits { + input_tokens: u.get("inputTokens").and_then(|v| v.as_u64()), + output_tokens: u.get("outputTokens").and_then(|v| v.as_u64()), + total_tokens: u.get("totalTokens").and_then(|v| v.as_u64()), + cached_read_tokens: u.get("cachedReadTokens").and_then(|v| v.as_u64()), + }); + Ok(PromptOutcome { + is_error: stop_reason_is_error(&stop), + cancelled: stop_reason_is_cancelled(&stop), + stop_reason: stop, + usage, + }) + } +} + +fn path_str(p: &Path) -> String { + p.to_string_lossy().into_owned() +} + +impl Want { + fn kind_str_fallback(self) -> &'static str { + match self { + Want::AllowOnce => "allow_once", + Want::AllowAlways => "allow_always", + Want::RejectOnce => "reject_once", + Want::RejectAlways => "reject_always", + } + } +} + +// Silence unused PathBuf import if only Path is used via refs in signatures. +#[allow(dead_code)] +fn _pathbuf_ty(_: PathBuf) {} + +#[cfg(test)] +mod tests { + use super::{may_retire, PendingPolicy, RouteState}; + + fn state(sessions: usize, opening: usize, expecting: u32, pending: usize) -> RouteState { + RouteState { + sessions, + opening, + expecting, + pending, + ever_had_session: true, + } + } + + /// The force-reset case. An agent that ignored `session/cancel` leaves its + /// `session/prompt` in `pending` for up to 24h; while that pins the client, + /// the next send gets the same pooled handle and prompts the very agent + /// still running the abandoned turn. + #[test] + fn an_abandoned_reply_does_not_pin_a_client_whose_routes_are_gone() { + assert!( + !may_retire(state(0, 0, 0, 1), PendingPolicy::Protects), + "the ordinary path keeps the child while a reply may still arrive" + ); + assert!( + may_retire(state(0, 0, 0, 1), PendingPolicy::Abandoned), + "after an ignored cancel the outstanding reply must not pin the child" + ); + } + + /// `Abandoned` relaxes ONLY the reply check. A client still serving a route + /// — or one mid-`session/new` — must survive either way, or a force reset + /// on one session would kill another session's live agent. + #[test] + fn live_routes_block_retirement_under_every_policy() { + for policy in [PendingPolicy::Protects, PendingPolicy::Abandoned] { + assert!( + !may_retire(state(1, 0, 0, 0), policy), + "{policy:?}: a live session still needs its child" + ); + assert!( + !may_retire(state(0, 1, 0, 0), policy), + "{policy:?}: a session being opened still needs its child" + ); + assert!( + !may_retire(state(0, 0, 1, 0), policy), + "{policy:?}: an expected session still needs its child" + ); + assert!( + may_retire(state(0, 0, 0, 0), policy), + "{policy:?}: fully unused clients are retired" + ); + } + } + + /// A reconnected client between `initialize` and `session/new` reads as + /// fully idle — zero routes, and `pending` empties when the handshake + /// resolves. Retiring it there shut down the client the caller was about + /// to use; the next `session/*` answered `ACP not connected`. + #[test] + fn a_connection_that_never_opened_a_route_is_never_retired() { + for policy in [PendingPolicy::Protects, PendingPolicy::Abandoned] { + let fresh = RouteState { + sessions: 0, + opening: 0, + expecting: 0, + pending: 0, + ever_had_session: false, + }; + assert!( + !may_retire(fresh, policy), + "{policy:?}: a client mid-handshake is not idle, it is not started" + ); + } + // Once a route has existed, the ordinary rules resume. + assert!(may_retire(state(0, 0, 0, 0), PendingPolicy::Protects)); + } + + /// Different pool keys must not serialize against each other. The whole + /// point of keying by `(backend, program)` is that a slow or broken command + /// pin is isolated; a process-wide creation mutex held across a handshake + /// (up to 60s) put every other key behind it. + #[tokio::test] + async fn creation_locks_are_per_key_not_process_wide() { + let a = super::key_lock("omp\0slow-binary").await; + let b = super::key_lock("omp\0healthy-binary").await; + + let held = a.lock().await; + // A different key must be free while the first is held mid-handshake. + assert!( + b.try_lock().is_ok(), + "a different pool key must not wait on this one" + ); + // The SAME key still serializes — that is what keeps create/retire atomic. + assert!( + super::key_lock("omp\0slow-binary").await.try_lock().is_err(), + "the same key must still serialize" + ); + drop(held); + } + + #[test] + fn config_options_from_session_new_fixture() { + let result = serde_json::json!({ + "sessionId": "s-1", + "configOptions": [ + {"id": "model", "currentValue": "gpt-5"}, + {"id": "thinking", "currentValue": "high"} + ] + }); + assert_eq!(config_option_current(&result, "model").as_deref(), Some("gpt-5")); + assert_eq!(config_option_current(&result, "thinking").as_deref(), Some("high")); + assert_eq!(config_option_current(&result, "missing"), None); + } + + use super::*; + + #[tokio::test] + #[ignore = "requires omp on PATH"] + async fn omp_acp_pong_live() { + let c = client("omp", "omp").await.expect("client"); + let cwd = std::env::temp_dir(); + let (tx, mut rx) = mpsc::unbounded_channel(); + let open = c.new_session(&cwd, vec![]).await.expect("new"); + let sid = open.session_id; + // OMP returns model via configOptions on session/new. + let _ = open.model; + c.subscribe(&sid, tx).await.unwrap(); + c.set_auto_want(&sid, Some(Want::AllowOnce)).await; + let outcome = c + .prompt(&sid, "Reply with exactly: pong. Do not use tools.", &[]) + .await + .expect("prompt"); + assert_eq!(outcome.stop_reason, "end_turn"); + // Drain some events + let mut saw_text = false; + while let Ok(ev) = rx.try_recv() { + if let SessionEvent::Chat(ChatEvent::TextDelta { text, .. }) = ev { + if text.contains("pong") { + saw_text = true; + } + } + } + let _ = saw_text; + let _ = c.cancel(&sid).await; + shutdown("omp").await; + } +} diff --git a/src-tauri/src/adapters/mod.rs b/src-tauri/src/adapters/mod.rs index 1a45a4dd..9752ada9 100644 --- a/src-tauri/src/adapters/mod.rs +++ b/src-tauri/src/adapters/mod.rs @@ -230,6 +230,49 @@ impl AgentAdapter for CodexAppServerAdapter { } } +// ───────────────────────────── ACP (generic) ───────────────────────────── + +/// Connection adapter for any tool registered with `acp::backend_for`. +/// Spawn/parse go through `acp::runtime` (later task), not argv. +pub struct AcpAdapter { + id: &'static str, +} + +impl AcpAdapter { + pub fn new(id: &'static str) -> Self { + Self { id } + } +} + +impl AgentAdapter for AcpAdapter { + fn tool(&self) -> &'static str { + self.id + } + fn per_turn(&self) -> bool { + false + } + fn is_connection(&self) -> bool { + true + } + + fn build_argv(&self, _ctx: &AdapterContext) -> anyhow::Result<(String, Vec)> { + anyhow::bail!( + "{} is an ACP connection adapter — drive it via acp::runtime", + self.id + ) + } + + fn parse_line(&self, _line: &str) -> ChatEvent { + ChatEvent::Other + } + fn extract_native_id(&self, _line: &str) -> Option { + None + } + fn interrupt(&self) -> Interrupt { + Interrupt::Connection + } +} + // ───────────────────────────── opencode ───────────────────────────── pub struct OpenCodeAdapter; @@ -309,7 +352,8 @@ pub fn adapter_for(tool: &str) -> Option> { "claude" => Some(Arc::new(ClaudeAdapter)), "codex" => Some(Arc::new(CodexExecAdapter)), "opencode" => Some(Arc::new(OpenCodeAdapter)), - _ => None, + t => crate::acp::backend_for(t) + .map(|b| Arc::new(AcpAdapter::new(b.id())) as Arc), } } @@ -408,6 +452,10 @@ mod tests { let codex = adapter_for("codex").unwrap(); assert_eq!(codex.tool(), "codex"); assert!(codex.per_turn() && !codex.is_connection()); + let omp = adapter_for("omp").unwrap(); + assert_eq!(omp.tool(), "omp"); + assert!(!omp.per_turn() && omp.is_connection()); + assert_eq!(omp.interrupt(), Interrupt::Connection); assert!(adapter_for("mystery").is_none()); } diff --git a/src-tauri/src/ask.rs b/src-tauri/src/ask.rs index 8f30b361..d4f9c52f 100644 --- a/src-tauri/src/ask.rs +++ b/src-tauri/src/ask.rs @@ -1242,6 +1242,41 @@ pub fn classify_risk(signal: RiskSignal) -> RiskLevel { } } +/// Severity order for folding several verdicts into one. +/// +/// `Unknown` outranks `ReadOnly` deliberately: it means "not established as +/// safe", the same reason `classify_risk` never guesses low. It sits below the +/// tiers that were positively identified as dangerous, which are the ones a +/// human most needs to see. This is NOT the enum's declaration order, so it +/// lives here rather than as a derived `Ord`. +fn severity(level: RiskLevel) -> u8 { + match level { + RiskLevel::ReadOnly => 0, + RiskLevel::Unknown => 1, + RiskLevel::Write => 2, + RiskLevel::NetworkOrCredential => 3, + } +} + +/// The most severe of several verdicts — for one request naming several +/// targets. +/// +/// A multi-file read whose FIRST path is ordinary but whose second is `.env` +/// or an SSH key must not inherit the first path's `ReadOnly` tier: +/// [`AskRegistry::auto_decision`] releases `ReadOnly` asks under a read-only +/// session or issue grant, so the credential access would be auto-approved +/// without a human ever seeing it. +/// +/// An EMPTY set yields `Unknown`, never `ReadOnly` — "nothing to judge" is not +/// evidence of safety, and callers with a genuinely path-less action classify +/// it explicitly instead of passing nothing. +pub fn most_severe(levels: impl IntoIterator) -> RiskLevel { + levels + .into_iter() + .max_by_key(|level| severity(*level)) + .unwrap_or(RiskLevel::Unknown) +} + /// A pending permission request, awaiting the human's decision. #[derive(Clone, Debug, Serialize, PartialEq, Eq)] pub struct Ask { @@ -2131,6 +2166,31 @@ impl AskRegistry { mod tests { use super::*; + /// The fold's whole job: one dangerous target lifts the whole request, so + /// a read-only grant cannot release it. `Unknown` outranks `ReadOnly` + /// because it means "not established as safe", but stays below the tiers + /// that were positively identified — this is NOT the declaration order, + /// which is why `severity` exists rather than a derived `Ord`. + #[test] + fn most_severe_takes_the_worst_verdict_not_the_first() { + use RiskLevel::*; + assert_eq!(most_severe([ReadOnly, NetworkOrCredential]), NetworkOrCredential); + assert_eq!(most_severe([NetworkOrCredential, ReadOnly]), NetworkOrCredential); + assert_eq!(most_severe([ReadOnly, Write]), Write); + assert_eq!(most_severe([ReadOnly, Unknown]), Unknown); + assert_eq!(most_severe([Unknown, Write]), Write); + assert_eq!(most_severe([ReadOnly, ReadOnly]), ReadOnly); + assert_eq!(most_severe([ReadOnly]), ReadOnly); + } + + /// "Nothing to judge" is not evidence of safety. Returning `ReadOnly` for + /// an empty set would let a read-only session grant auto-approve a request + /// whose targets we failed to extract at all. + #[test] + fn most_severe_of_nothing_is_unknown_never_read_only() { + assert_eq!(most_severe([]), RiskLevel::Unknown); + } + #[test] fn answer_as_str_round_trips_with_parse() { for a in [Answer::Allow, Answer::Deny, Answer::Always, Answer::Full] { diff --git a/src-tauri/src/bus/inject.rs b/src-tauri/src/bus/inject.rs index e582aee2..e844fbf9 100644 --- a/src-tauri/src/bus/inject.rs +++ b/src-tauri/src/bus/inject.rs @@ -31,6 +31,47 @@ fn ask_url(base: &str, thread: i32, dir: &str, tool: &str) -> String { format!("{base}/ask/{thread}/{dir}?tool={tool}") } +/// HTTP MCP servers Weft should pass on ACP `session/new|resume` for this +/// engine role. Workers get `weft_bus`; lead also gets planner when `dir` is +/// the lead lane; concierge/global callers pass `include_global`. +pub fn acp_mcp_servers( + base: &str, + thread: i32, + dir: &str, + include_bus: bool, + include_planner: bool, + include_global: bool, + include_curator: bool, +) -> Vec { + let mut out = Vec::new(); + // Concierge is global-only (no per-thread bus) — same as inject_global path. + if include_bus { + out.push(crate::acp::McpServerSpec { + name: "weft_bus".into(), + url: mcp_url(base, thread, dir), + }); + } + if include_planner { + out.push(crate::acp::McpServerSpec { + name: "weft_planner".into(), + url: planner_url(base, thread), + }); + } + if include_curator { + out.push(crate::acp::McpServerSpec { + name: "weft_curator".into(), + url: curator_url(base, thread), + }); + } + if include_global { + out.push(crate::acp::McpServerSpec { + name: "weft_global".into(), + url: global_url(base), + }); + } + out +} + /// The shared, FAIL-CLOSED tail of both bash ask-hook scripts — claude's /// per-worktree `.weft-ask-hook.sh` (below) and codex's global helper /// (`codex.rs::ensure_codex_hook_in`, which splices it in at @@ -99,8 +140,12 @@ exit 0"#; /// Install the Ask Bridge for a session. Claude gets a worktree-local /// PreToolUse settings file; Codex writes only a worktree route file consumed /// by Weft's stable global hook in `~/.codex/config.toml`; OpenCode bridges via -/// its server `/event` plugin. Best-effort: empty args if files can't be written. +/// its server `/event` plugin. ACP tools (omp) use `session/request_permission` +/// instead — no worktree files. Best-effort: empty args if files can't be written. pub fn inject_ask_hook(base: &str, thread: i32, dir: &str, tool: &str, cwd: &Path) -> Injection { + if crate::acp::backend_for(tool).is_some() { + return Injection { args: vec![] }; + } if tool == "opencode" { return inject_opencode_ask_plugin(base, thread, dir, cwd); } @@ -238,6 +283,10 @@ pub fn inject_global(base: &str, tool: &str, cwd: &Path) -> Injection { /// overriding the sub-repo's own config. `stem` names the claude temp config /// file (`.weft-.mcp.json`). fn inject_mcp(server: &str, stem: &str, url: &str, tool: &str, cwd: &Path) -> Injection { + if crate::acp::backend_for(tool).is_some() { + // MCP is supplied on session/new|resume, not via launch flags/files. + return Injection { args: vec![] }; + } match tool { "claude" => { // ephemeral --mcp-config file inside the cwd. It's an injected, diff --git a/src-tauri/src/bus/mod.rs b/src-tauri/src/bus/mod.rs index 636841bb..bfdf4e26 100644 --- a/src-tauri/src/bus/mod.rs +++ b/src-tauri/src/bus/mod.rs @@ -1,3 +1,4 @@ +pub mod notice_text; pub mod builtin_allow; pub mod global; pub mod inject; diff --git a/src-tauri/src/bus/notice_text.rs b/src-tauri/src/bus/notice_text.rs new file mode 100644 index 00000000..caf8bf36 --- /dev/null +++ b/src-tauri/src/bus/notice_text.rs @@ -0,0 +1,97 @@ +//! Human copy for the stable notice tokens background tasks post to the bus. +//! +//! Some notices are raised from detached tasks — a watchdog sweep, a +//! force-reset timer — that have no locale to render with: `lang` reaches the +//! backend per command invocation rather than being persisted. Those paths post +//! a TOKEN, and each consumer renders it with the locale it actually has. +//! +//! The webview renders tokens through its own catalogs. This module exists for +//! consumers that cannot reach them — today the IM bridge, which sends on its +//! own fixed locale. +//! +//! The copy is AUTHORED in `src/i18n/{en,zh}.ts` like every other user-facing +//! string (AGENTS.md), and `scripts/gen-notice-copy.mjs` mirrors the tokens +//! listed there into `notices.generated.json`, which this module bakes in at +//! compile time. Rust never authors the sentences — an earlier version did, and +//! that meant a catalog edit silently gave remote and in-app users different +//! text. `tests/frontend/noticeCopy.test.ts` fails if the mirror drifts from +//! the catalogs, so the generated file cannot go stale unnoticed. + +use std::collections::HashMap; +use std::sync::LazyLock; + +/// `token -> lang -> copy`, compiled in from the generated catalog mirror. +/// +/// A malformed file yields an EMPTY table rather than a panic (production paths +/// must not panic), which would surface as raw tokens. The test below fails the +/// build's test run in that case, so it cannot ship silently. +static NOTICES: LazyLock>> = LazyLock::new(|| { + serde_json::from_str(include_str!("notices.generated.json")).unwrap_or_default() +}); + +/// The copy for `token` in `lang`, or `None` when the text is ordinary agent +/// prose that should pass through untouched. +/// +/// Falls back to English for a locale the catalog does not carry — a sentence +/// in the wrong language still beats a raw `acp.force_reset_notice`. +pub fn resolve(token: &str, lang: &str) -> Option<&'static str> { + let by_lang = NOTICES.get(token)?; + by_lang + .get(lang) + .or_else(|| by_lang.get("en")) + .map(String::as_str) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The table is `include_str!`-ed, so a malformed or moved file degrades to + /// empty at runtime. This is the check that keeps that from shipping. + #[test] + fn generated_notice_copy_parses_and_carries_both_languages() { + assert!( + !NOTICES.is_empty(), + "notices.generated.json failed to parse — every token would render raw" + ); + for (token, by_lang) in NOTICES.iter() { + for lang in ["en", "zh"] { + let copy = by_lang + .get(lang) + .unwrap_or_else(|| panic!("{token} is missing {lang}")); + assert!(!copy.trim().is_empty(), "{token}/{lang} is blank"); + assert!( + !copy.contains(token), + "{token}/{lang} contains the raw token" + ); + } + } + } + + /// The one token the force-reset path posts must be renderable, or a remote + /// human receives `acp.force_reset_notice` verbatim. + #[test] + fn the_force_reset_token_resolves_in_both_languages() { + for lang in ["zh", "en"] { + let text = resolve(crate::lead_chat::engine::ACP_FORCE_RESET_NOTICE, lang) + .expect("force-reset notice must have copy"); + assert!(!text.is_empty()); + } + } + + /// An unknown locale still gets a sentence rather than the token. + #[test] + fn an_unknown_locale_falls_back_to_english() { + let text = resolve(crate::lead_chat::engine::ACP_FORCE_RESET_NOTICE, "fr") + .expect("fallback copy"); + let en = resolve(crate::lead_chat::engine::ACP_FORCE_RESET_NOTICE, "en").expect("en copy"); + assert_eq!(text, en); + } + + /// Ordinary agent prose is not a token and must pass through untouched. + #[test] + fn prose_is_not_a_token() { + assert!(resolve("Should I bump the major version?", "en").is_none()); + assert!(resolve("", "zh").is_none()); + } +} diff --git a/src-tauri/src/bus/notices.generated.json b/src-tauri/src/bus/notices.generated.json new file mode 100644 index 00000000..ddd193dd --- /dev/null +++ b/src-tauri/src/bus/notices.generated.json @@ -0,0 +1,6 @@ +{ + "acp.force_reset_notice": { + "en": "⏹️ The agent did not answer the cancellation after Stop, so the turn was force-interrupted and continues on a brand-new session. The conversation stays in the timeline, but the new session carries no native context — if later replies seem to have \"forgotten\" earlier details, re-state the key points.", + "zh": "⏹️ 停止后 agent 未响应取消请求,已强制中断并重置为全新会话继续。历史对话仍保留在时间线里,但新会话不带原生上下文;如果后续回复像「忘记」了之前的内容,请重新提示一下关键信息。" + } +} diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index aaeccda4..f8e28305 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -1714,6 +1714,13 @@ pub async fn set_tool_command( match tool.as_str() { "opencode" => crate::opencode::shutdown().await, "codex" => crate::codex_app_server::shutdown_global().await, + t if crate::acp::backend_for(t).is_some() => { + // Shared ACP child serves all sessions for this backend. Command + // changes already set pending_command_refresh on engines so the + // bounce happens on the next idle send — never reap here or an + // in-flight prompt dies mid-turn. + let _ = t; + } _ => {} } } @@ -2191,8 +2198,31 @@ pub async fn session_meta( } None => None, }; - let snap = + let mut snap = crate::session_meta::gather(&dir.tool, &wt.path, native.as_deref(), &command).await; + // ACP workers: overlay live engine cache (MCP inject + reasoning/model). + if crate::lead_chat::engine::is_acp_tool(&dir.tool) { + if let Some(sid) = sid { + if let Some(states) = app.try_state::() { + if let Some(eng) = states.get(sid as i64) { + let g = eng.lock().await; + snap.mcp_servers = Some(g.last_mcp_servers.clone()); + if snap.model.is_none() { + snap.model = g.last_model.clone(); + } + if snap.reasoning_effort.is_none() { + snap.reasoning_effort = g.last_reasoning.clone(); + } + if snap.context_tokens.is_none() { + snap.context_tokens = g.last_context_tokens; + } + if snap.window.is_none() { + snap.window = g.last_window; + } + } + } + } + } // Probe results feed the engine cache + persisted snapshot: codex/opencode // model/window/MCP only exist here, never in engine events. if let Some(sid) = sid { diff --git a/src-tauri/src/curator.rs b/src-tauri/src/curator.rs index f049204a..50f65b15 100644 --- a/src-tauri/src/curator.rs +++ b/src-tauri/src/curator.rs @@ -961,6 +961,7 @@ async fn run_streaming_agent( prompt: &str, on_event: &mut F, ) -> Result { + let tool = curator_exec_tool(tool)?; if tool == "codex" && crate::adapters::codex_prefers_appserver() { match run_codex_appserver(cwd, prompt, on_event).await { Ok(text) => return Ok(text), @@ -972,6 +973,28 @@ async fn run_streaming_agent( run_exec(tool, cwd, prompt, on_event).await } +/// Pick a tool the curator runner can actually drive. +/// +/// The curator drives agents through exec / app-server argv, and an ACP engine +/// (omp) has no argv dialect. This used to substitute the next installed +/// non-ACP CLI so analysis "still ran" — but the caller has already committed +/// the routing decision via `record_decision`, so that would send the +/// repository to a provider the user did not choose while the audit trail +/// names the one they did. It refuses instead. +/// +/// Unreachable today, and deliberately so rather than merely absent: +/// `EngineId` has no ACP variant, so a hand-pinned `omp` blocks the route as +/// `invalid_manual_tool` and `route.selected()` can never yield an ACP tool. +/// `curator_refuses_an_acp_engine_rather_than_substituting_one` pins BOTH +/// halves, so the day an ACP engine becomes routable this fails closed and the +/// test says what to build instead. +fn curator_exec_tool(preferred: &str) -> Result<&str> { + if crate::lead_chat::engine::is_acp_tool(preferred) { + anyhow::bail!("curator_unsupported_engine:{preferred}"); + } + Ok(preferred) +} + /// codex app-server transport: spawn a per-run app-server in `cwd` with read-only /// config overrides, start one thread + turn, and stream the agent's text deltas /// to `on_event` while accumulating the final reply. Auto-declines any approval @@ -2608,6 +2631,45 @@ mod tests { use crate::profile::AgentRelation; use crate::store::{repo, Db}; + /// The curator commits its routing decision (`record_decision`) BEFORE the + /// runner starts, so substituting a different engine inside the runner + /// would send the repository to a provider the user did not choose while + /// the audit trail names the one they did. Two independent things prevent + /// that, and both are asserted here because either one alone is a silent + /// dependency: routing cannot yield an ACP engine, and the runner refuses + /// one if it ever sees it. + #[test] + fn curator_refuses_an_acp_engine_rather_than_substituting_one() { + assert!( + curator_exec_tool("omp").is_err(), + "an ACP engine must fail loudly, not become a different provider" + ); + assert_eq!(curator_exec_tool("codex").ok(), Some("codex")); + assert_eq!(curator_exec_tool("claude").ok(), Some("claude")); + + // This refusal is now a LIVE defence, not a latent one. omp became a + // routable identity so manual pins could select it (the pickers already + // offered it), which means `route.selected()` really can hand an ACP + // engine to a runner that cannot drive it. Any registered ACP backend + // that routing also accepts must therefore be refused here — failing + // visibly beats running the repository through a provider the user did + // not choose while `record_decision` names the one they did. + let routable_acp: Vec<&str> = crate::acp::registered_ids() + .into_iter() + .filter(|id| crate::engine_routing::EngineId::parse(id).is_some()) + .collect(); + assert!( + routable_acp.contains(&"omp"), + "omp should be routable; if that changed, this guard needs rethinking" + ); + for id in routable_acp { + assert!( + curator_exec_tool(id).is_err(), + "{id} is routable AND ACP, so the curator must refuse it" + ); + } + } + async fn mem() -> Db { Db::connect("sqlite::memory:").await.unwrap() } diff --git a/src-tauri/src/detect.rs b/src-tauri/src/detect.rs index cc517bbf..c0630814 100644 --- a/src-tauri/src/detect.rs +++ b/src-tauri/src/detect.rs @@ -256,6 +256,7 @@ pub(crate) fn min_version(tool: &str) -> Option<(u32, u32, u32)> { "claude" => Some((1, 0, 0)), "codex" => Some((0, 20, 0)), "opencode" => Some((0, 1, 0)), + "omp" => Some((17, 1, 0)), _ => None, } } @@ -349,7 +350,7 @@ impl ToolDiagnostic { } /// Preference order when the user hasn't chosen a tool explicitly. -pub(crate) const TOOL_PRIORITY: [&str; 3] = ["codex", "claude", "opencode"]; +pub(crate) const TOOL_PRIORITY: [&str; 4] = ["codex", "claude", "opencode", "omp"]; /// Pure default-tool decision: an explicit user choice wins when that CLI is /// installed; otherwise the first installed tool by priority; otherwise codex @@ -612,6 +613,12 @@ mod tests { assert_eq!(pick_default_tool(None, only_oc), "opencode"); } + #[test] + fn default_tool_omp_when_only_omp_installed() { + let only_omp = |t: &str| t == "omp"; + assert_eq!(pick_default_tool(None, only_omp), "omp"); + } + #[test] fn default_tool_codex_when_nothing_installed() { assert_eq!(pick_default_tool(None, |_| false), "codex"); diff --git a/src-tauri/src/engine_routing.rs b/src-tauri/src/engine_routing.rs index c785a3f9..f58a5693 100644 --- a/src-tauri/src/engine_routing.rs +++ b/src-tauri/src/engine_routing.rs @@ -20,6 +20,10 @@ pub enum EngineId { Claude, Codex, Opencode, + /// The first ACP-transport engine. Routable so a manual pin can select it + /// — the pickers already offer it — but deliberately NOT one of the + /// automatic preferences below, which stay Codex/Claude. + Omp, } impl EngineId { @@ -28,6 +32,7 @@ impl EngineId { Self::Claude => "claude", Self::Codex => "codex", Self::Opencode => "opencode", + Self::Omp => "omp", } } @@ -36,6 +41,7 @@ impl EngineId { "claude" => Some(Self::Claude), "codex" => Some(Self::Codex), "opencode" => Some(Self::Opencode), + "omp" => Some(Self::Omp), _ => None, } } @@ -333,10 +339,15 @@ pub fn resolve_with_manual_intent( return blocked(RouteReason::BothAutomaticCandidatesExceeded, request.hint); } - // OpenCode remains a manual/legacy engine, never an automatic fallback. + // OpenCode and OMP remain manual-only engines, never an automatic fallback. // A configured Codex/Claude legacy identity can still preserve the old // default behavior when the automatic pool has no usable reading. - if request.legacy_tool != EngineId::Opencode { + // + // OMP joined `candidate_list` so a MANUAL pin could resolve — the pickers + // already offered it — and being in that list made it reachable here too, + // which contradicts the closed Codex/Claude automatic pool. It would also + // route curator work to an engine the curator refuses outright. + if !matches!(request.legacy_tool, EngineId::Opencode | EngineId::Omp) { if let Some(legacy) = legacy { if legacy.installed && legacy.quota != Some(QuotaStatus::Exceeded) { return selected( @@ -411,7 +422,11 @@ pub fn resolve_failover( let expected = match current { EngineId::Claude => EngineId::Codex, EngineId::Codex => EngineId::Claude, - EngineId::Opencode => return FailoverDecision::Skip(FailoverSkipReason::NoFallback), + // No quota-failover partner: both are single-vendor identities, so + // there is no equivalent engine to hand the turn to. + EngineId::Opencode | EngineId::Omp => { + return FailoverDecision::Skip(FailoverSkipReason::NoFallback) + } }; let Some(fallback) = fallback else { return FailoverDecision::Skip(FailoverSkipReason::NoFallback); @@ -467,7 +482,12 @@ fn candidate_for(tool: EngineId, snapshots: &[QuotaSnapshot]) -> RouteCandidate fn candidate_list() -> Vec { let snapshots = snapshots_by_tool(); - [EngineId::Codex, EngineId::Claude, EngineId::Opencode] + [ + EngineId::Codex, + EngineId::Claude, + EngineId::Opencode, + EngineId::Omp, + ] .into_iter() .map(|tool| candidate_for(tool, &snapshots)) .collect() @@ -592,7 +612,7 @@ pub async fn try_quota_failover_for_db( let fallback_tool = match current { EngineId::Claude => Some(EngineId::Codex), EngineId::Codex => Some(EngineId::Claude), - EngineId::Opencode => None, + EngineId::Opencode | EngineId::Omp => None, }; let fallback = fallback_tool.and_then(|tool| candidate(&candidates, tool)); Ok(resolve_failover( @@ -1532,6 +1552,42 @@ mod tests { assert_eq!(out.reason, RouteReason::PreferredUnavailable); } + /// OMP's contract: selectable by a MANUAL pin, never by automatic routing. + /// It sits in `candidate_list` only so a manual pin can find its + /// installation/quota reading, and the legacy-fallback arm must not turn + /// that presence into an automatic choice. + #[test] + fn omp_is_manual_only_and_never_an_automatic_fallback() { + // Automatic on, both automatic candidates missing, legacy = omp. + let out = resolve(&request( + true, + None, + EngineId::Omp, + RoutingHint::Normal, + vec![ + candidate(EngineId::Codex, false, None), + candidate(EngineId::Claude, false, None), + candidate(EngineId::Omp, true, Some(QuotaStatus::Ok)), + ], + )); + assert!(out.blocked, "omp must not be picked as a legacy fallback"); + assert_ne!(out.selected(), Some(EngineId::Omp)); + + // An explicit manual pin still resolves. + let manual = resolve(&request( + true, + Some(EngineId::Omp), + EngineId::Codex, + RoutingHint::Normal, + vec![ + candidate(EngineId::Codex, true, Some(QuotaStatus::Ok)), + candidate(EngineId::Omp, true, Some(QuotaStatus::Ok)), + ], + )); + assert_eq!(manual.selected(), Some(EngineId::Omp)); + assert_eq!(manual.reason, RouteReason::ManualPin); + } + #[test] fn alternate_ok_after_preferred_unknown_quota_reports_quota_unknown() { let out = resolve(&request( diff --git a/src-tauri/src/hook_test_support.rs b/src-tauri/src/hook_test_support.rs index ee776d4e..f0765805 100644 --- a/src-tauri/src/hook_test_support.rs +++ b/src-tauri/src/hook_test_support.rs @@ -48,7 +48,13 @@ pub(crate) async fn run_hook_script( // into `wait_with_output`, which consumes it. let pgid = child.id().map(|pid| pid as i32); let mut stdin = child.stdin.take().unwrap(); - stdin.write_all(payload.as_bytes()).await.unwrap(); + // A broken pipe here is the script BEHAVING CORRECTLY, not a test failure: + // several of these hooks exit before reading stdin (the non-loopback route + // returns without posting at all), so the write races a legitimate early + // exit. Unwrapping made that race a flaky `Broken pipe` panic on loaded CI + // runners while passing everywhere fast. What the tests assert is the + // script's stdout and exit code, both captured below. + let _ = stdin.write_all(payload.as_bytes()).await; drop(stdin); // EOF — curl's `--data-binary @-` reads until it match tokio::time::timeout(limit, child.wait_with_output()).await { Ok(out) => { diff --git a/src-tauri/src/im/mod.rs b/src-tauri/src/im/mod.rs index 740bb408..e5a0c36f 100644 --- a/src-tauri/src/im/mod.rs +++ b/src-tauri/src/im/mod.rs @@ -1237,7 +1237,12 @@ async fn consume_human_event( // nothing to patch (take_human → None), which is exactly right for // a notice that never carries an answer. if !ask.kind.is_answerable() { - let notice = format!("{title} · {from}\n{}", ask.text); + // Background tasks post a stable token rather than prose (they + // have no locale); render it here, or a remote human receives + // the raw `acp.force_reset_notice`. + let body = crate::bus::notice_text::resolve(&ask.text, IM_LANG) + .unwrap_or(ask.text.as_str()); + let notice = format!("{title} · {from}\n{body}"); if let Err(e) = ch.send_text(&owner, ¬ice).await { eprintln!("[weft][im] send stall notice: {e}"); } @@ -1246,7 +1251,13 @@ async fn consume_human_event( match ch .send_card( &owner, - outbound::human_card(&title, &from, &ask.text, IM_LANG), + outbound::human_card( + &title, + &from, + crate::bus::notice_text::resolve(&ask.text, IM_LANG) + .unwrap_or(ask.text.as_str()), + IM_LANG, + ), ) .await { diff --git a/src-tauri/src/lead_chat/commands.rs b/src-tauri/src/lead_chat/commands.rs index 2b0eb7b0..88a03bdc 100644 --- a/src-tauri/src/lead_chat/commands.rs +++ b/src-tauri/src/lead_chat/commands.rs @@ -321,6 +321,7 @@ pub async fn lead_engine( pending_command_refresh: false, last_context_tokens: None, last_model: None, + last_reasoning: None, last_window: None, last_mcp_servers: vec![], last_tools: vec![], @@ -330,10 +331,13 @@ pub async fn lead_engine( tool_rows: std::collections::HashMap::new(), stopped, codex_client: None, + acp_client: None, + acp_pending_asks: Vec::new(), turn_user_row: None, last_assistant_uuid: None, rewinding: false, quota_failover_committing: false, + tearing_down: false, worktree_id: None, }; // Restore the last persisted meta snapshot so the Session panel is populated @@ -441,6 +445,7 @@ pub struct LeadStateInfo { pub context_tokens: Option, pub window: Option, pub model: Option, + pub reasoning_effort: Option, pub mcp_servers: Vec, pub tools: Vec, } @@ -466,8 +471,8 @@ fn lead_state_label(alive: bool, busy: bool, stopped: bool) -> &'static str { /// resident child; a codex app-server lead has NO per-turn child when idle but /// stays alive while its client handle is present (a send reconnects if needed) — /// without this a remount's `loadLeadChat` mislabels the idle lead as "stopped". -fn lead_alive(child_alive: bool, has_codex_client: bool) -> bool { - child_alive || has_codex_client +fn lead_alive(child_alive: bool, has_connection_client: bool) -> bool { + child_alive || has_connection_client } #[cfg(test)] @@ -702,6 +707,7 @@ mod tests { context_tokens: None, window: None, model: None, + reasoning_effort: None, mcp_servers: vec![], tools: vec![], }; @@ -755,6 +761,7 @@ pub async fn lead_state( context_tokens: snap.context_tokens, window: snap.window, model: snap.model, + reasoning_effort: snap.reasoning_effort.clone(), mcp_servers: snap.mcp_servers, tools: snap.tools, }) @@ -767,7 +774,7 @@ pub async fn lead_state( .map(|c| c.try_wait().ok().flatten().is_none()) .unwrap_or(false); // codex app-server leads are childless when idle but alive via the client. - let alive = lead_alive(child_alive, i.codex_client.is_some()); + let alive = lead_alive(child_alive, i.codex_client.is_some() || i.acp_client.is_some()); let command = crate::tool_command::effective(i.command.as_deref(), &i.tool); Ok(LeadStateInfo { state: lead_state_label(alive, i.turn.busy, i.stopped).into(), @@ -779,6 +786,7 @@ pub async fn lead_state( context_tokens: i.last_context_tokens, window: i.last_window, model: i.last_model.clone(), + reasoning_effort: i.last_reasoning.clone(), mcp_servers: i.last_mcp_servers.clone(), tools: i.last_tools.clone(), }) @@ -807,13 +815,38 @@ pub async fn lead_session_meta( // Ticket BEFORE gathering: a slow probe overlapping a fresher one must not // roll usage back when it finally lands (see absorb_probe_meta). let ticket = engine::take_probe_ticket(&app, thread_id, None).await; - let snap = crate::session_meta::gather( + let mut snap = crate::session_meta::gather( &t.lead_tool, &cwd.to_string_lossy(), native.as_deref(), &command, ) .await; + // ACP tools: MCP is what Weft injected on session/new — read engine cache. + // Also backfill model/reasoning from the live engine (session/new configOptions). + // Use LeadChatState::get (same as lead_state). NEVER write Some([]) when the + // engine is missing — absorb_probe_meta would treat that as freshest empty + // and wipe last_mcp_servers on a live engine race / ticket ordering quirk. + if crate::lead_chat::engine::is_acp_tool(&t.lead_tool) { + if let Some(eng) = app.state::().get(lead_key(thread_id)) { + let g = eng.lock().await; + // Some(list) even when only Weft-internal servers (frontend filters + // those) → mcpAuthoritative so the panel shows "none" not "pending". + snap.mcp_servers = Some(g.last_mcp_servers.clone()); + if snap.model.is_none() { + snap.model = g.last_model.clone(); + } + if snap.reasoning_effort.is_none() { + snap.reasoning_effort = g.last_reasoning.clone(); + } + if snap.context_tokens.is_none() { + snap.context_tokens = g.last_context_tokens; + } + if snap.window.is_none() { + snap.window = g.last_window; + } + } + } // Probe results feed the engine cache + persisted snapshot: codex/opencode // model/window/MCP only exist here, never in engine events. engine::absorb_probe_meta(&app, &db, thread_id, None, ticket, &snap).await; @@ -869,6 +902,17 @@ pub async fn discover_slash( "codex" => { crate::codex_slash::discover_commands_for_cwd(std::path::Path::new(&sess.cwd)).await } + t if crate::acp::backend_for(t).is_some() => { + // ACP tools populate slash_commands via available_commands_update. + let eng = match state.get(sid as i64) { + Some(eng) => eng, + None => worker_engine(&app, &db, sid) + .await + .map_err(|e| e.to_string())?, + }; + let cmds = eng.lock().await.slash_commands.clone(); + cmds + } _ => vec![], }); } @@ -908,6 +952,7 @@ pub async fn discover_slash( cmds } } + t if crate::acp::backend_for(t).is_some() => live, _ => live, }; if !discovered.is_empty() { @@ -1451,6 +1496,7 @@ pub(crate) async fn chat_open_worker_impl( pending_command_refresh: false, last_context_tokens: None, last_model: None, + last_reasoning: None, last_window: None, last_mcp_servers: vec![], last_tools: vec![], @@ -1460,10 +1506,13 @@ pub(crate) async fn chat_open_worker_impl( tool_rows: std::collections::HashMap::new(), stopped: sess.status == "stopped", codex_client: None, + acp_client: None, + acp_pending_asks: Vec::new(), turn_user_row: None, last_assistant_uuid: None, rewinding: false, quota_failover_committing: false, + tearing_down: false, worktree_id: Some(wt.id), }; // Restore the last persisted meta snapshot so the Session panel is @@ -1584,6 +1633,7 @@ async fn worker_engine(app: &AppHandle, db: &Db, session_id: i32) -> anyhow::Res pending_command_refresh: false, last_context_tokens: None, last_model: None, + last_reasoning: None, last_window: None, last_mcp_servers: vec![], last_tools: vec![], @@ -1593,10 +1643,13 @@ async fn worker_engine(app: &AppHandle, db: &Db, session_id: i32) -> anyhow::Res tool_rows: std::collections::HashMap::new(), stopped: sess.status == "stopped", codex_client: None, + acp_client: None, + acp_pending_asks: Vec::new(), turn_user_row: None, last_assistant_uuid: None, rewinding: false, quota_failover_committing: false, + tearing_down: false, // One cheap lookup at engine build so send's admission can honor a // worktree-level restore reservation without a per-send DB query. worktree_id: repo::worktree_for(db, sess.direction_id, sess.repo_id) @@ -1720,10 +1773,15 @@ pub async fn lead_rewind( .map_err(|e| e.to_string()) } -/// The three coding-agent identities weft actually drives. `switch_lead_tool`/ +/// The coding-agent identities weft actually drives. `switch_lead_tool`/ /// `switch_worker_tool` reject anything else up front — a typo'd tool name /// must fail loudly here, not deep inside a spawn as a raw "No such file". -const KNOWN_TOOLS: &[&str] = &["claude", "codex", "opencode"]; +/// +/// Aliased from `crate::tools::TOOLS` rather than re-listed: this used to be +/// its own hard-coded triple, so every engine added to the picker (omp, via +/// the ACP backend registry) stayed un-switchable until someone remembered to +/// edit this line too. See that constant for the full reasoning. +const KNOWN_TOOLS: &[&str] = &crate::tools::TOOLS; /// Blank → None (a cleared/never-set override), trimmed otherwise. Mirrors how /// `set_tool_command` treats an empty alias as "no override" rather than diff --git a/src-tauri/src/lead_chat/engine.rs b/src-tauri/src/lead_chat/engine.rs index 2fa446ad..6afbe0e2 100644 --- a/src-tauri/src/lead_chat/engine.rs +++ b/src-tauri/src/lead_chat/engine.rs @@ -148,6 +148,8 @@ pub enum Push { tools: Vec, model: Option, window: Option, + #[serde(default)] + mcp_known: bool, }, /// The tool call currently executing — transient: rendered while it runs, /// replaced by the next one, cleared by the Turn event. Never persisted. @@ -432,7 +434,10 @@ fn hidden_delivery(tool: &str, busy: bool, has_stdin: bool, stopped: bool) -> Hi HiddenDelivery::Noop } else if busy { HiddenDelivery::Queue - } else if per_turn(tool) { + } else if per_turn(tool) || is_acp_tool(tool) { + // Per-turn tools and ACP connection tools have no resident stdin when + // idle — both need a turn spawn (ACP → spawn_acp_turn; per-turn → + // spawn_turn / codex appserver redirect in send_hidden_inner). HiddenDelivery::SpawnTurn } else if has_stdin { HiddenDelivery::WriteResident @@ -698,6 +703,21 @@ fn emit_turn_state( ); } +/// Whether a HIDDEN turn (a bus wake, a plumbing delivery) may start now. +/// +/// The visible path asks `send_reservation_valid`; hidden delivery never passes +/// through it, so the teardown reservation has to be checked here too. Without +/// this, a teardown holding `tearing_down` — which has already cleared +/// `turn.busy` and `interrupting` and bumped the epoch — is invisible to a bus +/// wake, which then starts a fresh native session mid-cleanup and has its +/// native id cleared and an idle state emitted over it by the older reset. +/// +/// Split out rather than inlined at both call sites so the two entry points +/// cannot drift, and so the rule is testable without an `AppHandle`. +fn hidden_turn_admissible(inner: &EngineInner) -> bool { + !inner.stopped && !inner.tearing_down +} + async fn begin_hidden_turn(app: &AppHandle, db: &Db, inner: &mut EngineInner) -> i32 { let turn_id = mark_hidden_turn_started(inner); crate::power::on_turn_began(app); @@ -728,6 +748,46 @@ fn queue_hidden_delivery(app: &AppHandle, inner: &mut EngineInner, out: Outgoing /// `status` distinguishes WHY the turn died: "error" for a genuine failure, /// "interrupted" when a stop/interrupt canceled it (a guard-canceled spawn must /// not be reported as an agent failure). +/// Drain the consumer for `session_id`, then finalize every row this turn left +/// open, as `status`. +/// +/// The queued-prompt error path skipped both. `reset_failed_hidden_turn` clears +/// `inner.current` outright, so a follow-up prompt that had already streamed +/// text before its JSON-RPC request failed left that row `streaming` in the DB +/// with nothing to ever close it — and updates still sitting in the consumer +/// could arrive after the turn was reset and attach to an idle session. The +/// first-prompt path gets this via `acp_drain_then_end`; this is the same +/// sequence, in the same order, for the failure branch. +async fn acp_drain_and_finalize_open_rows( + app: &AppHandle, + db: &Db, + eng: &EngineRef, + client: &crate::acp::runtime::ClientHandle, + session_id: &str, + status: &str, +) { + let (tx, rx) = tokio::sync::oneshot::channel(); + if client + .send_session_event( + session_id, + crate::acp::runtime::SessionEvent::DrainBarrier(tx), + ) + .await + { + // Bounded, same as the success path: a gone consumer must not hang this. + let _ = tokio::time::timeout(std::time::Duration::from_secs(5), rx).await; + } + let mut inner = eng.lock().await; + let thread_id = inner.thread_id; + let orphans: Vec<(i32, serde_json::Value)> = + inner.tool_rows.drain().map(|(_, v)| v).collect(); + finalize_orphan_tool_rows(app, db, thread_id, orphans, status).await; + finalize_open_texts(app, db, &mut inner, status).await; + if inner.current.is_some() { + finalize_current_text(app, db, &mut inner, status).await; + } +} + async fn rollback_failed_turn( app: &AppHandle, db: &Db, @@ -1640,6 +1700,8 @@ pub struct EngineInner { /// 解析出 mcp/model/window,turn 结束更新 context_tokens)。 pub last_context_tokens: Option, pub last_model: Option, + /// Reasoning effort / thinking level from ACP configOptions or updates. + pub last_reasoning: Option, pub last_window: Option, pub last_mcp_servers: Vec, pub last_tools: Vec, @@ -1664,6 +1726,13 @@ pub struct EngineInner { /// Per-session `codex app-server` connection (app-server transport only), /// spawned lazily on the first turn with this session's `-c mcp_servers` args. pub codex_client: Option, + /// Per-session ACP runtime handle (omp and future ACP backends). The + /// underlying child is process-global per backend; this Option means "this + /// engine has subscribed at least one session on that client". + pub acp_client: Option, + /// In-flight AskRegistry ids for ACP `session/request_permission` cards. + /// Cancelled on hard stop so a late Always/Full cannot grant after takeover. + pub acp_pending_asks: Vec, /// Rewind anchor bookkeeping for the in-flight turn: the user row that /// opened it. Written with the turn's native anchor at a clean TurnEnd /// (claude: `last_assistant_uuid`; codex app-server: the turn id). @@ -1682,6 +1751,9 @@ pub struct EngineInner { /// set, new sends fail visibly rather than starting a healthy turn that /// the imminent switch would interrupt. pub quota_failover_committing: bool, + /// Set only for the window in which [`stop_quiet`] has released the engine + /// lock to await ACP cancel/unsubscribe. See `send_reservation_valid`. + pub tearing_down: bool, /// The worktree this worker runs in (None for the lead console): lets /// send's admission honor a worktree-level restore reservation without a /// DB lookup. Sibling sessions of one worktree share the same id. @@ -1700,6 +1772,8 @@ pub struct PersistedMeta { pub window: Option, pub model: Option, #[serde(default)] + pub reasoning_effort: Option, + #[serde(default)] pub mcp_servers: Vec, #[serde(default)] pub tools: Vec, @@ -1795,6 +1869,7 @@ async fn persist_engine_meta(db: &Db, inner: &EngineInner) { context_tokens: inner.last_context_tokens, window: inner.last_window, model: inner.last_model.clone(), + reasoning_effort: inner.last_reasoning.clone(), mcp_servers: inner.last_mcp_servers.clone(), tools: inner.last_tools.clone(), }; @@ -1862,6 +1937,7 @@ pub async fn absorb_probe_meta( context_tokens: inner.last_context_tokens, window: inner.last_window, model: inner.last_model.clone(), + reasoning_effort: inner.last_reasoning.clone(), mcp_servers: inner.last_mcp_servers.clone(), tools: inner.last_tools.clone(), }; @@ -1869,6 +1945,9 @@ pub async fn absorb_probe_meta( inner.last_context_tokens = m.context_tokens; inner.last_window = m.window; inner.last_model = m.model.clone(); + if m.reasoning_effort.is_some() { + inner.last_reasoning = m.reasoning_effort.clone(); + } inner.last_mcp_servers = m.mcp_servers.clone(); persist_engine_meta(db, &inner).await; } @@ -1922,6 +2001,7 @@ pub fn apply_persisted_meta(inner: &mut EngineInner, json: &str) { inner.last_context_tokens = m.context_tokens; inner.last_window = m.window; inner.last_model = m.model; + inner.last_reasoning = m.reasoning_effort; inner.last_mcp_servers = m.mcp_servers; inner.last_tools = m.tools; } @@ -2065,7 +2145,9 @@ async fn ensure_running_locked( if inner.stopped { return Ok(None); } - if per_turn(&inner.tool) { + if per_turn(&inner.tool) || is_acp_tool(&inner.tool) { + // Per-turn and ACP connection tools have no resident child to keep + // alive here — turns are driven by spawn_turn / spawn_acp_turn. return Ok(None); } if inner.tool != "claude" { @@ -2341,6 +2423,13 @@ fn send_reservation_valid(inner: &EngineInner, ctx: &SendContext) -> bool { if inner.stopped { return false; } + // A teardown that has released the lock for ACP I/O is going to overwrite + // `turn` when it reacquires it. Admitting work into that window loses it + // silently — the epoch check below cannot catch this one, because a send + // arriving mid-teardown captures the ALREADY-bumped epoch and matches. + if inner.tearing_down { + return false; + } // A stop/reset since Phase 1 — even one immediately followed by a restart that // cleared `stopped` and set `busy` again — bumps reset_epoch (stop_quiet). That // invalidates this send so it can't be delivered onto a turn the user canceled. @@ -2441,7 +2530,22 @@ pub async fn send( ) }; if skill_pending || cmd_now { - let (tid, _sid, _texts, orphans, _was_busy) = stop_quiet(eng).await; + let tool_for_shutdown = eng.lock().await.tool.clone(); + let StopQuietOutcome { + thread_id: tid, + orphans, + acp_asks, + .. + } = stop_quiet(eng).await; + if let Some(asks) = app.try_state::() { + for id in acp_asks { + asks.inner().cancel(id); + } + } + // Do NOT backend-wide reap the ACP pool — other sessions may still be + // mid-turn on another pin. stop_quiet already cancelled+unsubscribed + // THIS engine; next send uses client(backend, new_program). + let _ = (cmd_now, tool_for_shutdown); { let mut g = eng.lock().await; g.pending_skill_refresh = false; @@ -2715,7 +2819,9 @@ pub async fn send( // snapshot (which only decided the row's optimistic status). The lock drops // before any turn-spawning awaits. let is_codex_appserver = ctx.tool == "codex" && codex_appserver_enabled(); - let spawn_now = ctx.direct && per_turn(&ctx.tool) && !is_codex_appserver; + let is_acp = is_acp_tool(&ctx.tool); + let is_connection = is_codex_appserver || is_acp; + let spawn_now = ctx.direct && per_turn(&ctx.tool) && !is_connection; // Set when a queued send found the engine idle and claimed a fresh turn. let mut promoted: Option = None; { @@ -2738,7 +2844,7 @@ pub async fn send( "send could not be delivered: the turn ended or the engine stopped while it was persisting" )); } - if ctx.direct && !spawn_now && !is_codex_appserver { + if ctx.direct && !spawn_now && !is_connection { if let Err(e) = write_user(&mut inner, &out).await { drop(inner); rollback_failed_visible_turn(app, db, eng, ctx.turn, row_id, &content, "error").await; @@ -2780,7 +2886,7 @@ pub async fn send( // Under the lock for the same ordering reason as Phase 1's direct // write: a concurrent stop's "stopped" write must not be overtaken. persist_activity(db, inner.session_id, inner.thread_id, "running").await; - if !per_turn(&ctx.tool) && !is_codex_appserver { + if !per_turn(&ctx.tool) && !is_connection { // Resident tool: deliver through the live stdin under this // lock, exactly like a direct resident send. if let Err(e) = write_user(&mut inner, &out).await { @@ -2855,6 +2961,20 @@ pub async fn send( rollback_failed_visible_turn(app, db, eng, ctx.turn, row_id, &content, status).await; return Err(e); } + } else if ctx.direct && is_acp { + if let Err(e) = spawn_acp_turn( + app.clone(), + db.clone(), + eng.clone(), + out, + Some(ctx.reset_epoch), + ) + .await + { + let status = spawn_failure_status(eng, &ctx).await; + rollback_failed_visible_turn(app, db, eng, ctx.turn, row_id, &content, status).await; + return Err(e); + } } else if let Some(pturn) = promoted { // The promoted send owns a fresh turn now. Flip its row to delivered // (complete + delivery seq + finalize emit, same as a drained queue item), @@ -2872,7 +2992,7 @@ pub async fn send( turn: pturn, ..ctx.clone() }; - if per_turn(&ctx.tool) && !is_codex_appserver { + if per_turn(&ctx.tool) && !is_connection { if let Err(e) = spawn_turn( app.clone(), db.clone(), @@ -2900,6 +3020,20 @@ pub async fn send( rollback_failed_visible_turn(app, db, eng, pturn, row_id, &content, status).await; return Err(e); } + } else if is_acp { + if let Err(e) = spawn_acp_turn( + app.clone(), + db.clone(), + eng.clone(), + dispatched, + Some(ctx.reset_epoch), + ) + .await + { + let status = spawn_failure_status(eng, &promoted_ctx).await; + rollback_failed_visible_turn(app, db, eng, pturn, row_id, &content, status).await; + return Err(e); + } } } // The digest peeked above (if any) is now genuinely embedded in text that @@ -3138,6 +3272,1145 @@ fn codex_first_turn_text(system_prompt: &str, message: &str, had_native: bool) - } } + +pub(crate) fn is_acp_tool(tool: &str) -> bool { + crate::acp::backend_for(tool).is_some() +} + +/// Drive a turn over the generic ACP runtime (omp today). Mirrors the +/// connection-shaped codex app-server path: ensure session, subscribe a +/// long-lived consumer once, then `session/prompt`. + +/// Clear a never-prompted first ACP native id (engine + DB) so the next send +/// re-opens and still prepends the system prompt. +async fn clear_acp_native_never_prompted( + _app: &AppHandle, + db: &Db, + eng: &EngineRef, + session_id: Option, + thread_id: i32, +) { + { + let mut g = eng.lock().await; + g.native_id = None; + g.acp_client = None; + } + if let Some(sid) = session_id { + let _ = repo::set_session_native_id_opt(db, sid, None).await; + } else { + let _ = repo::set_lead_native_id_opt(db, thread_id, None).await; + } +} + +async fn spawn_acp_turn( + app: AppHandle, + db: Db, + eng: EngineRef, + out: Outgoing, + expected_epoch: Option, +) -> anyhow::Result<()> { + let (native, cwd, sid, thread_id_i, system_prompt, tool, command, ask_dir) = { + let i = eng.lock().await; + // `tearing_down` included as defence in depth: the hidden path already + // refuses, but this is the one gate every ACP turn passes through. + if i.stopped + || i.interrupting + || i.tearing_down + || expected_epoch.is_some_and(|e| e != i.reset_epoch) + { + return Err(anyhow::anyhow!("engine stopped; not starting an ACP turn")); + } + ( + i.native_id.clone(), + i.cwd.clone(), + i.session_id, + i.thread_id, + i.system_prompt.clone(), + i.tool.clone(), + i.command.clone(), + i.ask_dir.clone(), + ) + }; + let backend = crate::acp::backend_for(&tool) + .ok_or_else(|| anyhow::anyhow!("not an ACP tool: {tool}"))?; + let program = crate::tool_command::effective(command.as_deref(), &tool); + let client = crate::acp::runtime::client(backend.id(), &program).await?; + + // MCP list: mirror lead_engine inject branches (thread.kind), not ask_dir alone + // — lead/concierge/curator all store ask_dir="lead". + let base = app + .try_state::() + .map(|b| b.0.clone()) + .unwrap_or_default(); + let mcp = if base.is_empty() { + vec![] + } else if sid.is_none() { + // Lead-kind engine: choose MCP from thread kind. + let kind = repo::get_thread(&db, thread_id_i) + .await + .ok() + .flatten() + .map(|th| th.kind) + .unwrap_or_default(); + match kind.as_str() { + // Concierge: weft_global only (never bus). + "concierge" => crate::bus::inject::acp_mcp_servers( + &base, thread_id_i, "lead", false, false, true, false, + ), + // Curator: curator MCP + bus under LEAD identity. + "curator" => crate::bus::inject::acp_mcp_servers( + &base, thread_id_i, crate::bus::LEAD, true, false, false, true, + ), + // Issue lead: planner + bus. + _ => crate::bus::inject::acp_mcp_servers( + &base, thread_id_i, crate::bus::LEAD, true, true, false, false, + ), + } + } else { + // Worker: bus only under direction id. + crate::bus::inject::acp_mcp_servers( + &base, thread_id_i, &ask_dir, true, false, false, false, + ) + }; + + let had_native = native.is_some(); + let prior_native = native.clone(); + // Keep mcp specs for Session Info seeding (moved into open calls via clone). + let mcp_for_meta = mcp.clone(); + let (session_id, open_model, open_thinking) = match native { + Some(id) => { + // Prefer resume; fall back to load (hand-cut rewind files). + // Do NOT keep a stale id when both fail — next send would target an + // unopened session forever. + match client.resume_session(&id, &cwd, mcp.clone()).await { + Ok(open) => (open.session_id, open.model, open.thinking), + Err(resume_err) => match client.load_session(&id, &cwd, mcp.clone()).await { + Ok(sid) => (sid, None, None), + Err(load_err) => { + eprintln!( + "[weft][acp] resume failed ({resume_err}); load also failed ({load_err})" + ); + // Actually do what the comment above says. Returning + // while the id survives in the engine AND the DB is + // what made a deleted or unloadable session file + // permanent: every later send re-entered this same + // branch and answered `acp_session_open_failed` + // forever. Cleared, the next send takes the `None` + // arm and opens a fresh session. + // + // Tear the RUNTIME route down too, the same way every + // other abandonment path does. Clearing only the ids + // leaves the old `SessionRoute` and its consumer + // registered: they keep delivering late text, tool and + // permission events into this engine after the retry + // opened a different session, and the surviving route + // also pins the pooled client so it can never retire. + let _ = client.cancel(&id).await; + client.unsubscribe(&id).await; + clear_acp_native_never_prompted(&app, &db, &eng, sid, thread_id_i).await; + return Err(anyhow::anyhow!("acp_session_open_failed")); + } + }, + } + } + None => { + let open = match client.new_session(&cwd, mcp).await { + Ok(o) => o, + Err(e) => { + eprintln!("[weft][acp] session/new failed: {e}"); + return Err(anyhow::anyhow!("acp_session_open_failed")); + } + }; + (open.session_id, open.model, open.thinking) + } + }; + { + let mut g = eng.lock().await; + if let Some(model) = open_model { + g.last_model = Some(model); + } + if let Some(thinking) = open_thinking { + g.last_reasoning = Some(thinking); + } + // Seed MCP list from what we injected so Session Info is not empty after + // the first turn (OMP has no separate mcp discovery event). + if !mcp_for_meta.is_empty() { + // Refresh names from this turn's inject set (idempotent). + g.last_mcp_servers = mcp_for_meta + .into_iter() + .map(|s| super::proto::McpServer { + name: s.name, + status: "connected".into(), + }) + .collect(); + } + // Always push Init after ACP open so Session Info hydrates MCP/model + // immediately (probe gather used to return empty defaults for omp). + let _ = app.emit( + EVENT, + Push::Init { + thread_id: g.thread_id, + session_id: g.session_id, + native_id: session_id.clone(), + slash_commands: g.slash_commands.clone(), + mcp_servers: g.last_mcp_servers.clone(), + tools: g.last_tools.clone(), + model: g.last_model.clone(), + window: g.last_window, + mcp_known: true, + }, + ); + } + + // If resume/load minted a replacement id, drop the prior route so late + // notifications cannot mutate this engine under the old sessionId. + if let Some(prev) = prior_native.as_deref() { + if prev != session_id.as_str() { + let _ = client.cancel(prev).await; + client.unsubscribe(prev).await; + } + } + + // Always resubscribe when the runtime lost the route (child restart / + // shutdown clears sessions) even if the engine still holds acp_client. + let need_sub = !client.is_subscribed(&session_id).await; + if need_sub { + let (tx, rx) = tokio::sync::mpsc::unbounded_channel(); + client.subscribe(&session_id, tx).await?; + // Capture the generation HERE, not inside the spawned task. Read after + // the spawn, the consumer records whatever epoch is current when it + // first takes the lock — so a Stop, rewind, or engine switch landing in + // that gap makes it adopt the NEW epoch and treat already-buffered + // events from the abandoned session as live, appending or finalizing + // stale text onto a session that was stopped or switched. + let route_epoch = eng.lock().await.reset_epoch; + let (a, d, e, c, s) = ( + app.clone(), + db.clone(), + eng.clone(), + client.clone(), + session_id.clone(), + ); + tauri::async_runtime::spawn( + async move { acp_consumer(a, d, e, c, s, rx, route_epoch).await }, + ); + } + + let stop_won = { + let mut g = eng.lock().await; + // Ordinary composer Stop sets `interrupting` without bumping epoch until + // the delayed force reset — must not publish acp_client or arm prompt. + let won = g.stopped + || g.interrupting + || expected_epoch.is_some_and(|e| e != g.reset_epoch); + if !won { + g.acp_client = Some(client.clone()); + if g.native_id.as_deref() != Some(session_id.as_str()) { + g.native_id = Some(session_id.clone()); + } + } + won + }; + if stop_won { + // Subscribe may already have installed a route before acp_client was + // published; stop_quiet couldn't see it. Tear the route down or the + // next send reuses a stale-epoch consumer that drops every update. + let _ = client.cancel(&session_id).await; + client.unsubscribe(&session_id).await; + // First open never got session/prompt — drop native id so the next + // send re-opens and still prepends the system prompt. + if prior_native.is_none() { + clear_acp_native_never_prompted(&app, &db, &eng, sid, thread_id_i).await; + } + return Err(anyhow::anyhow!("engine stopped during ACP connect")); + } + + // Persist whenever open id differs from the id we started with — resume/ + // load can mint a replacement even when had_native was true. + if prior_native.as_deref() != Some(session_id.as_str()) { + if let Some(sid) = sid { + let _ = repo::set_session_native_id(&db, sid, &session_id).await; + } else { + let _ = repo::set_lead_native_id(&db, thread_id_i, &session_id).await; + } + } + + let text = if !had_native && !system_prompt.is_empty() { + format!("{system_prompt}\n\n{}", out.text) + } else { + out.text.clone() + }; + + // Non-blocking: codex-style. Launch prompt on a background task so send() + // returns while the consumer streams; finalize when prompt resolves. + // Final stop check immediately before arming the prompt task — a takeover + // between stop_won and here must not start session/prompt after cancel. + let prompt_epoch = { + let g = eng.lock().await; + if g.stopped + || g.interrupting + || expected_epoch.is_some_and(|e| e != g.reset_epoch) + { + drop(g); + let _ = client.cancel(&session_id).await; + client.unsubscribe(&session_id).await; + if prior_native.is_none() { + clear_acp_native_never_prompted(&app, &db, &eng, sid, thread_id_i).await; + } + return Err(anyhow::anyhow!("engine stopped before ACP prompt")); + } + g.reset_epoch + }; + let first_open = prior_native.is_none(); + let (a, d, e, c, s, txt, imgs, first_open, sid_opt, tid) = ( + app.clone(), + db.clone(), + eng.clone(), + client.clone(), + session_id.clone(), + text, + out.images.clone(), + first_open, + sid, + thread_id_i, + ); + tauri::async_runtime::spawn(async move { + // Re-validate under the lock once more right before the RPC — stop may + // have landed while this task was scheduled. + { + let g = e.lock().await; + if g.stopped || g.interrupting || g.reset_epoch != prompt_epoch { + drop(g); + let _ = c.cancel(&s).await; + c.unsubscribe(&s).await; + if first_open { + clear_acp_native_never_prompted(&a, &d, &e, sid_opt, tid).await; + } + return; + } + } + // Soft thinking chip for the pre-token gap. Many models (incl. current + // omp/grok) do not stream agent_thought_chunk; without this the UI only + // shows a generic "working" pulse until the first answer token. + { + let (thread_id, session_id) = { + let g = e.lock().await; + (g.thread_id, g.session_id) + }; + let _ = a.emit( + EVENT, + Push::Activity { + thread_id, + session_id, + name: "thinking".into(), + summary: String::new(), + }, + ); + } + match c.prompt(&s, &txt, &imgs).await { + Ok(outcome) => { + let (cancelled, stopped) = { + let g = e.lock().await; + ( + outcome.cancelled || g.interrupting || g.stopped, + g.stopped || g.reset_epoch != prompt_epoch, + ) + }; + if stopped { + // Hard stop already owns terminal state — do not emit idle. + return; + } + acp_drain_then_end( + a.clone(), + d.clone(), + e.clone(), + c.clone(), + s.clone(), + outcome.is_error, + cancelled, + outcome.usage.clone(), + prompt_epoch, + ) + .await; + } + Err(err) => { + eprintln!("[weft][acp] prompt failed: {err}"); + let (interrupting, stopped) = { + let g = e.lock().await; + ( + g.interrupting, + g.stopped || g.reset_epoch != prompt_epoch, + ) + }; + if stopped { + return; + } + // The prompt is gone; its permission cards must go with it. + cancel_open_acp_asks(&a, &e).await; + acp_drain_then_end( + a.clone(), + d.clone(), + e.clone(), + c.clone(), + s.clone(), + true, + interrupting, + None, + prompt_epoch, + ) + .await; + } + } + }); + // Stop pressed while we were connecting? Best-effort cancel. + if eng.lock().await.interrupting { + let _ = client.cancel(&session_id).await; + } + Ok(()) +} + +/// Cancel every permission card this turn left open, and stop tracking them. +/// +/// A TRANSPORT failure — the child died, the connection dropped — ends the +/// prompt while the consumer is still blocked on the `AskRegistry` receiver, so +/// nothing else ever retires the card. It stays actionable for the full hour +/// timeout, and answering it with Always or Full persists a standing grant for +/// a request whose native session no longer exists. Cancelling also releases +/// the consumer, which then replies `RejectOnce` into a dead connection — +/// harmless, and the honest wire answer. +async fn cancel_open_acp_asks(app: &AppHandle, eng: &EngineRef) { + let asks = std::mem::take(&mut eng.lock().await.acp_pending_asks); + if asks.is_empty() { + return; + } + if let Some(reg) = app.try_state::() { + for id in asks { + reg.inner().cancel(id); + } + } +} + +/// Wait until the session consumer has drained events enqueued before the +/// prompt result, then finalize. Prevents turn-end from racing late text/tool +/// rows still in the mpsc buffer. +async fn acp_drain_then_end( + app: AppHandle, + db: Db, + eng: EngineRef, + client: crate::acp::runtime::ClientHandle, + session_id: String, + is_error: bool, + cancelled: bool, + usage: Option, + prompt_epoch: u64, +) { + let (tx, rx) = tokio::sync::oneshot::channel(); + let sent = client + .send_session_event(&session_id, crate::acp::runtime::SessionEvent::DrainBarrier(tx)) + .await; + if sent { + // Bounded wait: if the consumer is gone, don't hang finalize forever. + let _ = tokio::time::timeout(std::time::Duration::from_secs(5), rx).await; + } + acp_emit_turn_end(app, db, eng, is_error, cancelled, usage, prompt_epoch).await; +} + +/// Whether a DEQUEUED prompt may still be dispatched. +/// +/// The epoch alone is not enough. `interrupt()` sets `interrupting` WITHOUT +/// bumping the epoch — deliberately, so a queued message survives interrupting +/// the current turn — and it sends `session/cancel` against whatever prompt is +/// live. A Stop landing after `on_turn_end` promoted a queued item but before +/// it is dispatched therefore cancels NOTHING (the old prompt has ended, the +/// new one has not started), leaves the epoch untouched, and the dispatch went +/// ahead: the user pressed Stop and a fresh turn ran anyway, executing tools +/// until it finished on its own or the 8s forced reset fired. +/// +/// `stopped` and `tearing_down` are included for the same reason they gate +/// every other admission point — this is one of them. +fn queued_dispatch_admissible(inner: &EngineInner, dequeue_epoch: u64) -> bool { + !inner.stopped + && !inner.interrupting + && !inner.tearing_down + && inner.reset_epoch == dequeue_epoch +} + +async fn acp_emit_turn_end( + app: AppHandle, + db: Db, + eng: EngineRef, + is_error: bool, + cancelled: bool, + usage: Option, + prompt_epoch: u64, +) { + let mut pending_usage = usage; + let mut pending_error = is_error; + let mut pending_cancel = cancelled; + // Optional follow-up prompt after finalize (queue drain) — loop, not recurse. + let mut follow_up: Option<(crate::acp::runtime::ClientHandle, String, Outgoing, u64, i32)> = + None; + loop { + if let Some((client, sid, msg, dequeue_epoch, turn_id)) = follow_up.take() { + let admissible = { + let g = eng.lock().await; + queued_dispatch_admissible(&g, dequeue_epoch) + }; + if !admissible { + rollback_failed_turn(&app, &db, &eng, turn_id, "interrupted").await; + finalize_dequeued_row(&app, &db, eng.lock().await.thread_id, &msg, "interrupted") + .await; + break; + } + let thread_id = eng.lock().await.thread_id; + let session_id = eng.lock().await.session_id; + mark_queued_delivered(&app, &db, thread_id, session_id, &msg).await; + match client.prompt(&sid, &msg.text, &msg.images).await { + Ok(outcome) => { + pending_usage = outcome.usage.clone(); + pending_error = outcome.is_error; + pending_cancel = outcome.cancelled || eng.lock().await.interrupting; + // Drain consumer before finalizing this queued turn (same as + // first-prompt path via acp_drain_then_end). + let (tx, rx) = tokio::sync::oneshot::channel(); + if client + .send_session_event( + &sid, + crate::acp::runtime::SessionEvent::DrainBarrier(tx), + ) + .await + { + let _ = tokio::time::timeout(std::time::Duration::from_secs(5), rx).await; + } + } + Err(err) => { + eprintln!("[weft][acp] flush prompt failed: {err}"); + let status = drain_failure_status(&eng, dequeue_epoch).await; + // Same for a queued prompt that died mid-flight. + cancel_open_acp_asks(&app, &eng).await; + // Close what this prompt already streamed BEFORE resetting + // the turn — the reset drops `inner.current` without + // finalizing it, and un-drained consumer updates would + // otherwise land on an already-idle session. + acp_drain_and_finalize_open_rows(&app, &db, &eng, &client, &sid, status).await; + rollback_failed_turn(&app, &db, &eng, turn_id, status).await; + finalize_dequeued_row(&app, &db, thread_id, &msg, status).await; + break; + } + } + } + + let mut inner = eng.lock().await; + // Hard stop / takeover already wrote STATUS_STOPPED and bumped + // reset_epoch. A late cancelled prompt must not overwrite that with idle. + if inner.stopped || inner.reset_epoch != prompt_epoch { + drop(inner); + break; + } + let thread_id = inner.thread_id; + let session_id = inner.session_id; + // Prompt-result usage is billing counters (totalTokens/inputTokens), + // not the live context window. Context comes only from usage_update + // notifications (SessionEvent::Usage → last_context_tokens). Do not + // overwrite that here — OMP's totalTokens is ~2× used. + let _ = pending_usage; + persist_engine_meta(&db, &inner).await; + let status = if inner.interrupting || pending_cancel { + "interrupted" + } else if pending_error { + "error" + } else { + "complete" + }; + inner.interrupting = false; + let orphans: Vec<(i32, serde_json::Value)> = + inner.tool_rows.drain().map(|(_, v)| v).collect(); + finalize_orphan_tool_rows(&app, &db, thread_id, orphans, status).await; + let had_item_rows = !inner.open_texts.is_empty(); + finalize_open_texts(&app, &db, &mut inner, status).await; + if inner.current.is_some() { + finalize_current_text(&app, &db, &mut inner, status).await; + } else if !had_item_rows && !inner.turn_saw_text { + if let Ok(Some(m)) = insert_terminal_assistant_if_missing( + &db, + thread_id, + inner.session_id, + inner.turn_id, + status, + ) + .await + { + let _ = app.emit( + EVENT, + Push::Message { + thread_id, + message: m, + }, + ); + } + } + let next = inner.turn.on_turn_end(); + inner.turn_user_row = next.as_ref().and_then(|n| n.queue_id); + inner.last_assistant_uuid = None; + inner.turn_saw_text = false; + inner.current_origin_tag = next.as_ref().and_then(|n| n.origin_tag.clone()); + let next_turn_id = if next.is_some() { + inner.turn_id += 1; + Some(inner.turn_id) + } else { + None + }; + let dequeue_epoch = inner.reset_epoch; + let still_busy = inner.turn.busy; + let client = inner.acp_client.clone(); + let native = inner.native_id.clone(); + persist_activity( + &db, + inner.session_id, + thread_id, + if still_busy { "running" } else { "idle" }, + ) + .await; + inner.clock.on_turn_end(still_busy); + let _ = app.emit( + EVENT, + Push::Turn { + thread_id, + session_id: inner.session_id, + state: if still_busy { "busy" } else { "idle" }.into(), + recovered: false, + queue: queue_items(&inner.turn), + }, + ); + drop(inner); + + let flush_stop_won = { + let g = eng.lock().await; + g.stopped || g.reset_epoch != dequeue_epoch + }; + if flush_stop_won { + if let Some(turn_id) = next_turn_id { + rollback_failed_turn(&app, &db, &eng, turn_id, "interrupted").await; + } + if let Some(n) = next.as_ref() { + finalize_dequeued_row(&app, &db, thread_id, n, "interrupted").await; + } + break; + } + match (next, next_turn_id, client, native) { + (Some(n), Some(turn_id), Some(client), Some(sid)) => { + if let Some(qid) = n.queue_id { + snapshot_turn_checkpoint(&app, &db, session_id, turn_id, qid).await; + } + follow_up = Some((client, sid, n, dequeue_epoch, turn_id)); + pending_usage = None; + pending_error = false; + pending_cancel = false; + continue; + } + _ => break, + } + } +} + +/// Worst-case tier across every path a file request named, or `pathless` when +/// it named none. +/// +/// The pathless verdict is per-verb rather than "ask `classify_file` with an +/// empty path", because those two disagree in the direction that matters: a +/// write with no target is still a write, but a READ with no target has +/// established nothing at all and must not inherit `ReadOnly`. +fn file_risk( + tool_name: &'static str, + paths: &[String], + pathless: crate::ask::RiskLevel, +) -> crate::ask::RiskLevel { + if paths.is_empty() { + return pathless; + } + crate::ask::most_severe(paths.iter().map(|path| { + crate::ask::classify_risk(crate::ask::RiskSignal::File { tool_name, path }) + })) +} + +/// Reject an ACP permission request on the wire without a human verdict — +/// used when teardown won the race and the card must not stand. +async fn return_permission( + client: &crate::acp::runtime::ClientHandle, + request_id: &serde_json::Value, + options: &[serde_json::Value], +) { + client + .reply_permission(request_id, options, crate::acp::Want::RejectOnce) + .await; +} + +/// Risk tier for one ACP permission request (issue #101). +/// +/// A file request is scored across EVERY path it names, worst tier wins. The +/// first path used to decide alone, so a multi-file read whose leading entry +/// was ordinary came out `ReadOnly` even when a later entry was a credential — +/// and `auto_decision` releases `ReadOnly` asks under a read-only session or +/// issue grant (issue #103), so that access would be approved without a human +/// ever seeing the card. +/// +/// A file request that named NO path is still classified by its verb: it goes +/// through `classify_file` once with an empty path, rather than through +/// `most_severe` of nothing, so a write stays a write. +fn acp_permission_risk( + intent: &crate::acp::permission::PermissionIntent, + detail: &str, +) -> crate::ask::RiskLevel { + use crate::acp::permission::PermissionIntent; + match intent { + PermissionIntent::Command(cmd) => { + crate::ask::classify_risk(crate::ask::RiskSignal::Command(cmd)) + } + // A read that named nothing has established NOTHING. `classify_file` + // would answer `ReadOnly` from the verb alone, and a read-only session + // or issue grant releases `ReadOnly` without a card — so a sparse + // request whose target rides in `title`/`content` could read `.env` or + // an SSH key unseen. Same rule as `most_severe` of an empty set: + // "nothing to judge" is not evidence of safety. + PermissionIntent::Read { paths } => { + file_risk("Read", paths, crate::ask::RiskLevel::Unknown) + } + // A write that named nothing IS still a write: the verb alone + // establishes mutation, so the verb-derived tier is the honest floor. + PermissionIntent::Write { paths } => { + file_risk("Edit", paths, crate::ask::RiskLevel::Write) + } + PermissionIntent::Network => crate::ask::classify_risk(crate::ask::RiskSignal::Network), + PermissionIntent::Other { kind } => { + crate::ask::classify_risk(crate::ask::RiskSignal::Other { + tool_name: kind, + args_text: detail, + }) + } + } +} + +/// How much of the reasoning stream the busy-line chip shows. +const THOUGHT_TAIL_CHARS: usize = 160; + +/// A bounded tail of one turn's reasoning text, for the busy-line chip. +/// +/// Kept bounded rather than accumulated: a turn's whole `agent_thought_chunk` +/// stream can be arbitrarily long, and re-collecting it into a `Vec` on +/// every chunk just to slice off the last [`THOUGHT_TAIL_CHARS`] made the work +/// quadratic in the reasoning length — on the same single task that forwards +/// tool progress and answer tokens, so a long reasoning turn could starve its +/// own liveness signal and read as stalled. Trimming on push keeps both the +/// buffer and the per-chunk work proportional to the display window. +#[derive(Default)] +struct ThoughtTail { + buf: String, + /// Whether anything was dropped off the front — the chip's leading + /// ellipsis. Not recoverable from `buf` once it has been trimmed to size. + elided: bool, +} + +impl ThoughtTail { + fn push(&mut self, text: &str) { + self.buf.push_str(text); + // Bounded by the window plus THIS chunk, never by the turn so far. + let len = self.buf.chars().count(); + if len <= THOUGHT_TAIL_CHARS { + return; + } + let excess = len - THOUGHT_TAIL_CHARS; + let cut = self + .buf + .char_indices() + .nth(excess) + .map(|(i, _)| i) + .unwrap_or(self.buf.len()); + self.buf.drain(..cut); + self.elided = true; + } + + fn summary(&self) -> String { + if self.elided { + return format!("…{}", self.buf); + } + self.buf.clone() + } + + fn clear(&mut self) { + self.buf.clear(); + self.elided = false; + } +} + +async fn acp_consumer( + app: AppHandle, + db: Db, + eng: EngineRef, + client: crate::acp::runtime::ClientHandle, + _session_id: String, + mut rx: tokio::sync::mpsc::UnboundedReceiver, + // The engine generation this route was subscribed under — captured by the + // caller BEFORE this task was spawned, so it names the session that created + // the route rather than whatever won a race afterwards. + start_epoch: u64, +) { + use crate::acp::runtime::SessionEvent; + use super::proto::ChatEvent; + // Accumulated thought text for the busy-line chip, bounded to the display + // window. Cleared at every prompt boundary — see the DrainBarrier arm. + let mut thought_buf = ThoughtTail::default(); + while let Some(msg) = rx.recv().await { + match msg { + // Barrier: prompt-task waits until prior events are drained. + SessionEvent::DrainBarrier(tx) => { + // This barrier IS the end of a prompt, and it is the only end + // every prompt reaches. Clearing only on answer text or a tool + // call leaks reasoning across turns whenever a prompt produced + // thought chunks and nothing else — a clean cancellation, an + // error, a refusal — because this consumer outlives the prompt. + // The next turn's first chunk would then render appended to the + // previous turn's reasoning as if it were current activity. + // + // Placed BEFORE the stop/epoch guard below on purpose: a turn + // torn down mid-reasoning is exactly a turn whose tail must not + // survive, and that guard would otherwise skip this arm. + thought_buf.clear(); + let _ = tx.send(()); + } + _ if { + let g = eng.lock().await; + g.stopped || g.reset_epoch != start_epoch + } => { + // Drop late events after stop/unsubscribe/teardown. + } + SessionEvent::ToolProgress { summary } => { + let mut inner = eng.lock().await; + note_turn_activity(&app, &db, &eng, &mut inner); + let (thread_id, session_id) = (inner.thread_id, inner.session_id); + drop(inner); + let _ = app.emit( + EVENT, + Push::Activity { + thread_id, + session_id, + name: "tool".into(), + summary, + }, + ); + } + SessionEvent::Thought { text } => { + { + let mut inner = eng.lock().await; + note_turn_activity(&app, &db, &eng, &mut inner); + } + thought_buf.push(&text); + // Live reasoning on the busy line so the turn doesn't look stuck + // before the first answer token. Show a tail window of the buffer. + let summary = thought_buf.summary(); + let thread_id = eng.lock().await.thread_id; + let session_id = eng.lock().await.session_id; + let _ = app.emit( + EVENT, + Push::Activity { + thread_id, + session_id, + name: "thinking".into(), + summary, + }, + ); + } + + SessionEvent::Chat(ChatEvent::TextDelta { text, item: _, agent_thread: _ }) => { + // Answer tokens started — drop soft/real thinking chip always. + thought_buf.clear(); + { + let (thread_id, session_id) = { + let i = eng.lock().await; + (i.thread_id, i.session_id) + }; + let _ = app.emit( + EVENT, + Push::Activity { + thread_id, + session_id, + name: String::new(), + summary: String::new(), + }, + ); + } + let mut inner = eng.lock().await; + note_turn_activity(&app, &db, &eng, &mut inner); + let thread_id = inner.thread_id; + let (sid, turn) = (inner.session_id, inner.turn_id); + if inner.current.is_none() { + let Ok(m) = repo::insert_lead_message( + &db, + thread_id, + sid, + turn, + "assistant", + "text", + r#"{"text":""}"#, + "streaming", + ) + .await + else { + continue; + }; + inner.current = Some((m.id, String::new(), std::time::Instant::now())); + let _ = app.emit(EVENT, Push::Message { thread_id, message: m }); + } + let origin_tag = inner.current_origin_tag.clone(); + let Some(c) = inner.current.as_mut() else { continue }; + c.1.push_str(&text); + let row = c.0; + if c.2.elapsed().as_millis() >= STREAM_THROTTLE_MS { + c.2 = std::time::Instant::now(); + let content = serde_json::json!({ "text": c.1 }).to_string(); + let _ = repo::update_lead_message(&db, row, &content, "streaming").await; + emit_lead_delta(&app, thread_id, row, &c.1, false, origin_tag); + } + let _ = app.emit( + EVENT, + Push::Delta { + thread_id, + message_id: row, + text, + }, + ); + } + SessionEvent::Chat(ChatEvent::Assistant { tools, .. }) => { + // Tool calls end thinking (including soft pre-token chip). + thought_buf.clear(); + { + let (thread_id, session_id) = { + let i = eng.lock().await; + (i.thread_id, i.session_id) + }; + let _ = app.emit( + EVENT, + Push::Activity { + thread_id, + session_id, + name: String::new(), + summary: String::new(), + }, + ); + } + let mut inner = eng.lock().await; + note_turn_activity(&app, &db, &eng, &mut inner); + // Close the open text row so post-tool text starts a new bubble. + if inner.current.is_some() { + finalize_current_text(&app, &db, &mut inner, "complete").await; + } + persist_tool_calls(&app, &db, &mut inner, tools, None).await; + } + SessionEvent::Chat(ChatEvent::ToolResults { items }) => { + let mut inner = eng.lock().await; + merge_tool_results(&app, &db, &mut inner, items).await; + } + SessionEvent::Chat(ChatEvent::Commands { commands }) => { + let mut inner = eng.lock().await; + // Empty is authoritative (session cleared its slash palette). + inner.slash_commands = commands.clone(); + let (thread_id, session_id) = (inner.thread_id, inner.session_id); + let _ = app.emit( + EVENT, + Push::Init { + thread_id, + session_id, + native_id: inner.native_id.clone().unwrap_or_default(), + slash_commands: commands, + mcp_servers: inner.last_mcp_servers.clone(), + tools: inner.last_tools.clone(), + model: inner.last_model.clone(), + window: inner.last_window, + mcp_known: false, + }, + ); + } + SessionEvent::Commands(commands) => { + let mut inner = eng.lock().await; + // Empty is authoritative (session cleared its slash palette). + inner.slash_commands = commands.clone(); + let (thread_id, session_id) = (inner.thread_id, inner.session_id); + let _ = app.emit( + EVENT, + Push::Init { + thread_id, + session_id, + native_id: inner.native_id.clone().unwrap_or_default(), + slash_commands: commands, + mcp_servers: inner.last_mcp_servers.clone(), + tools: inner.last_tools.clone(), + model: inner.last_model.clone(), + window: inner.last_window, + mcp_known: false, + }, + ); + } + SessionEvent::Usage { + context_tokens, + window, + } => { + let mut inner = eng.lock().await; + inner.last_context_tokens = Some(context_tokens); + if window.is_some() { + inner.last_window = window; + } + let (thread_id, session_id) = (inner.thread_id, inner.session_id); + let _ = app.emit( + EVENT, + Push::Usage { + thread_id, + session_id, + context_tokens, + window: inner.last_window, + model: inner.last_model.clone(), + }, + ); + } + SessionEvent::Meta { model, thinking } => { + let mut inner = eng.lock().await; + if model.is_some() { + inner.last_model = model; + } + if thinking.is_some() { + inner.last_reasoning = thinking; + } + } + SessionEvent::Permission { + request_id, + summary, + detail, + intent_key, + intent, + grant_id, + options, + } => { + let (thread_id, tool, dir, reject_now) = { + let i = eng.lock().await; + ( + i.thread_id, + i.tool.clone(), + i.ask_dir.clone(), + i.stopped || i.interrupting, + ) + }; + if reject_now { + client + .reply_permission(&request_id, &options, crate::acp::Want::RejectOnce) + .await; + continue; + } + // Precise Always key (issue #89): ACP family + session intent + + // the canonical action identity, so two different actions never + // share a grant. NOT `detail`: that is the stringified + // `rawInput`, which is identical (often empty) for two edits + // whose only difference lives in `toolCall.locations` — the + // very field the risk classifier reads first. `grant_id` + // folds every named location in; see `permission::grant_identity`. + let action_key = crate::ask::action_key(&["Acp", &intent_key, &grant_id]); + // Clone the registry BEFORE any await — State guards are !Send. + let asks = app + .try_state::() + .map(|s| s.inner().clone()); + // Risk tier for the Needs-you card (issue #101), from the + // classified `toolCall` rather than the lossy always-grant key. + // Computed BEFORE `auto_decision` because the read-only batch + // grants (issue #103) key on the tier: deriving it only in the + // `None` arm would make every ACP ask miss those grants. + let risk = acp_permission_risk(&intent, &detail); + let want = if let Some(asks) = asks { + match asks.auto_decision(thread_id, &dir, risk, &action_key) { + Some(crate::ask::Decision::Allow) => crate::acp::Want::AllowOnce, + Some(crate::ask::Decision::Deny) => crate::acp::Want::RejectOnce, + None => { + let (id, rx) = asks.request( + thread_id, + &dir, + &tool, + &summary, + &detail, + risk, + &action_key, + ); + // Register in the SAME lock acquisition that + // re-checks teardown, and give up the card if + // teardown already won. With registration as a + // separate acquisition, a Stop or engine switch + // landing right after `asks.request` took an empty + // `acp_pending_asks` and completed teardown while + // the card was already on screen. The card then + // outlived the turn, and answering it with Always + // or Full persisted a standing grant — the + // post-await check below only turns the WIRE reply + // into a rejection, it cannot un-grant that. + let registered = { + let mut g = eng.lock().await; + let lost = g.stopped + || g.interrupting + || g.reset_epoch != start_epoch; + if !lost { + g.acp_pending_asks.push(id); + } + !lost + }; + if !registered { + asks.cancel(id); + return_permission(&client, &request_id, &options).await; + continue; + } + let decided = match tokio::time::timeout( + std::time::Duration::from_secs(3600), + rx, + ) + .await + { + Ok(Ok(crate::ask::Decision::Allow)) => crate::acp::Want::AllowOnce, + Ok(Ok(crate::ask::Decision::Deny)) => crate::acp::Want::RejectOnce, + _ => { + asks.cancel(id); + crate::acp::Want::RejectOnce + } + }; + // Drop tracking whether answered or cancelled. + { + let mut g = eng.lock().await; + g.acp_pending_asks.retain(|x| *x != id); + } + decided + } + } + } else { + crate::acp::Want::RejectOnce + }; + // ONE final gate, on the path every branch leaves through, so + // "never allow after teardown" is an invariant of the reply + // rather than a check each branch has to remember. + // + // The waited-for-a-human branch used to carry its own copy and + // the AUTO-GRANT branch carried none: with an Always/Full hit + // there is no ask to cancel, so a Stop landing between the + // `reject_now` sample and the `auto_decision` verdict reached + // the wire as an allow — queued ahead of `session/cancel`, + // starting a tool after the user had stopped the turn. + let want = { + let g = eng.lock().await; + if g.stopped || g.interrupting || g.reset_epoch != start_epoch { + crate::acp::Want::RejectOnce + } else { + want + } + }; + client.reply_permission(&request_id, &options, want).await; + } + SessionEvent::Chat(_) => {} + } + } + let _ = client; // keep handle for permission replies while loop runs +} + /// issue #97: whether a `text` delta arriving on codex_consumer's anonymous /// slot (`item: None`) is an exact repeat of what's already at the TAIL of the /// accumulated buffer. codex app-server's only `item:None` deltas are error @@ -4070,6 +5343,160 @@ const INTERRUPT_WRITE_TIMEOUT: std::time::Duration = std::time::Duration::from_s /// Interrupt the current turn: protocol control_request first (verified live: /// control_response + result{terminal_reason:aborted_streaming}); kill after 3s /// as the hard fallback. Either way `--resume` recovers the session next send. + +async fn force_acp_finalize_drain( + app: &AppHandle, + thread_id: i32, + session_id: Option, + turn_id: i32, + drain: FrozenTurnDrain, +) { + let Some(db) = app.try_state::() else { + return; + }; + persist_activity(&db, session_id, thread_id, "idle").await; + let had_orphan_texts = !drain.orphan_texts.is_empty() || drain.turn_saw_text; + if let Ok(Some(row)) = persist_disconnected_turn_row( + &db, + thread_id, + session_id, + turn_id, + "interrupted", + !had_orphan_texts, + drain.current, + ) + .await + { + match row { + DisconnectedTurnRow::Finalized { message_id } => { + emit_finalize(app, thread_id, message_id, "interrupted"); + } + DisconnectedTurnRow::Inserted(message) => { + let _ = app.emit(EVENT, Push::Message { thread_id, message }); + } + } + } + for (id, text, agent_thread) in drain.orphan_texts { + let _ = repo::update_lead_message( + &db, + id, + &text_row_content(&text, agent_thread.as_deref()), + "interrupted", + ) + .await; + emit_finalize(app, thread_id, id, "interrupted"); + } + finalize_orphan_tool_rows(app, &db, thread_id, drain.orphan_tools, "interrupted").await; + if !drain.drained_queue.is_empty() { + if let Ok(rows) = + repo::set_queued_status_by_ids(&db, &drain.drained_queue, "interrupted").await + { + for m in rows { + emit_finalize(app, thread_id, m.id, "interrupted"); + } + } + } +} + +/// Force-reset a wedged ACP turn after cancel is ignored. +async fn force_acp_turn_reset( + app: &AppHandle, + eng: &EngineRef, + turn_id: i32, + epoch_at_arm: u64, +) { + let snapshot = { + let mut inner = eng.lock().await; + if !inner.turn.busy || inner.turn_id != turn_id || inner.reset_epoch != epoch_at_arm { + return; + } + if !is_acp_tool(&inner.tool) { + return; + } + let client = inner.acp_client.take(); + // TAKE, not clone. Retiring the pooled client only helps when this + // engine was its last route; a client kept alive by ANOTHER session + // survives, and holding on to this session id would make the next send + // resume the very session whose prompt is still running — racing it or + // being rejected as busy. Dropping the id is what abandons the wedged + // session safely in both cases; the next send opens a fresh one. + let sid = inner.native_id.take(); + let ask_dir = inner.ask_dir.clone(); + let Some(drain) = reset_frozen_appserver_turn(&mut inner, turn_id) else { + inner.acp_client = client; + inner.native_id = sid; + return; + }; + inner.reset_epoch = inner.reset_epoch.saturating_add(1); + // Held across the cancel/unsubscribe/retire, the DB native-id clear and + // the row finalization below — the THIRD teardown path needing this, + // after `stop_quiet` and freeze recovery, and for the identical reason: + // `reset_frozen_appserver_turn` clears `turn.busy` AND `interrupting`, + // so a send arriving during those awaits captures the already-bumped + // epoch, is admitted onto a fresh session, and then has its native id + // cleared and an idle state emitted over it by this older reset. + inner.tearing_down = true; + ( + inner.thread_id, + inner.session_id, + client, + sid, + drain, + ask_dir, + ) + }; + let (thread_id, session_id, client, sid, drain, ask_dir) = snapshot; + if let (Some(c), Some(sid)) = (client.as_ref(), sid.as_deref()) { + let _ = c.cancel(sid).await; + c.unsubscribe(sid).await; + // `unsubscribe` reaps only when nothing is outstanding — and the wedged + // prompt IS outstanding, which is the whole reason this fallback ran. + // Left pooled, the next send gets this same handle back with a live + // child and prompts the agent still running the abandoned turn. + c.retire_after_ignored_cancel().await; + } + // Mirror the in-memory clear above into the DB, or a restart would restore + // the abandoned session id and resume onto it. + if let Some(db) = app.try_state::() { + if let Err(err) = clear_native_id(&db, session_id, thread_id).await { + eprintln!( + "[weft] acp force reset: failed to clear native id for thread {thread_id}: {err}" + ); + } + } + force_acp_finalize_drain(app, thread_id, session_id, turn_id, drain).await; + // Cleanup is complete; the engine can accept work again. Released before + // the idle push so the state the user sees and the state the engine will + // accept agree. + eng.lock().await.tearing_down = false; + emit_turn_push(app, thread_id, session_id, "idle", false, Vec::new()); + // The native context is gone — say so. A NOTICE, not a question: there is + // nothing to answer, and `ask_human` would render an answer box whose reply + // injects a stray bus message into the session that was just reset. And + // action-required rather than plain `notify_human`, because no background + // process retracts this one: the context stays gone until the human + // re-states what matters. + if let Some(bus) = app.try_state::() { + bus.notify_human_action_required(thread_id, &ask_dir, ACP_FORCE_RESET_NOTICE); + } +} + +/// The ACP sibling of [`freeze_recovery_text`]. The user pressed Stop, the +/// agent ignored `session/cancel`, and the turn was cut loose after the grace +/// window — same consequence (the native session is abandoned), so the same +/// persistent notice rather than a self-clearing hint. +/// +/// A STABLE MACHINE TOKEN, not prose. This path has no locale to read — it runs +/// on a detached watchdog task 8s after the Stop, and `lang` is passed per +/// command invocation (see `lang_directive`'s callers) rather than persisted — +/// so the copy belongs in the catalogs the webview already owns. `summary_from +/// _params` established exactly this shape for `acp.permission_required`; +/// `ConfirmationCard` maps the token to `t(...)`. An earlier round of this PR +/// shipped the text bilingually inline instead; that left the string +/// untranslatable and inconsistent with the rest of the UI, which is the wrong +/// trade when a token costs one line on each side. +pub(crate) const ACP_FORCE_RESET_NOTICE: &str = "acp.force_reset_notice"; + pub async fn interrupt(app: &AppHandle, eng: &EngineRef) -> anyhow::Result<()> { let mut inner = eng.lock().await; if !inner.turn.busy { @@ -4100,6 +5527,32 @@ pub async fn interrupt(app: &AppHandle, eng: &EngineRef) -> anyhow::Result<()> { } return Ok(()); } + if is_acp_tool(&inner.tool) { + let sid = inner.native_id.clone(); + let client = inner.acp_client.clone(); + let turn_id = inner.turn_id; + let epoch = inner.reset_epoch; + // Drop open Needs-you cards so Always/Full cannot land after Stop. + let asks = std::mem::take(&mut inner.acp_pending_asks); + drop(inner); + if let Some(reg) = app.try_state::() { + for id in asks { + reg.inner().cancel(id); + } + } + if let (Some(sid), Some(client)) = (sid, client) { + let _ = client.cancel(&sid).await; + } + // OMP may ignore session/cancel while request_long sits up to 24h. + // Bound recovery: if still the same busy turn after grace, force idle. + let eng2 = eng.clone(); + let app2 = app.clone(); + tauri::async_runtime::spawn(async move { + tokio::time::sleep(std::time::Duration::from_secs(8)).await; + force_acp_turn_reset(&app2, &eng2, turn_id, epoch).await; + }); + return Ok(()); + } // Process-tool interrupt by transport (via the adapter): per-turn dialects // (codex exec / opencode) kill the per-turn child; the claude resident gets a // protocol interrupt payload + the delayed kill below. @@ -4195,6 +5648,17 @@ async fn send_hidden_inner( return Err(err); } let mut inner = eng.lock().await; + // Same reservation the visible path honours via `send_reservation_valid`, + // which hidden delivery does not go through. A bus wake skipped here is not + // lost — the coordinator re-delivers it on the next sweep — whereas one + // admitted mid-teardown has its turn reset out from under it. + if !hidden_turn_admissible(&inner) { + drop(inner); + if bus_read { + return Ok(()); + } + return Err(anyhow::anyhow!("engine is tearing down")); + } if ensure { // Spawn the resident process under THIS lock, never releasing it before // the slot is reserved below. The reader task blocks on this lock and @@ -4254,8 +5718,9 @@ async fn send_hidden_inner( HiddenDelivery::SpawnTurn => { // codex on app-server must stay on app-server even for hidden turns // (bus wakes), else an exec turn and the app-server connection diverge - // on the same thread. + // on the same thread. ACP tools similarly stay on the ACP runtime. let codex_appserver = inner.tool == "codex" && codex_appserver_enabled(); + let acp = is_acp_tool(&inner.tool); let turn_id = begin_hidden_turn(app, db, &mut inner).await; // Captured under the lock: a stop-then-restart before the spawn task // runs clears `stopped` but bumps the epoch — a canceled hidden turn @@ -4272,6 +5737,15 @@ async fn send_hidden_inner( Some(hidden_epoch), ) .await + } else if acp { + spawn_acp_turn( + app.clone(), + db.clone(), + eng.clone(), + out, + Some(hidden_epoch), + ) + .await } else { spawn_turn(app.clone(), db.clone(), eng.clone(), out, Some(hidden_epoch)).await }; @@ -4666,7 +6140,24 @@ pub fn build_switch_digest(old_tool: &str, new_tool: &str, messages: &[lead_mess /// interruption that did not happen is the same class of lie as the "nothing /// changed" claims earlier rounds removed, pointing the other way. pub async fn teardown_for_switch(app: &AppHandle, eng: &EngineRef) -> bool { - let (thread_id, session_id, texts, orphans, was_busy) = stop_quiet(eng).await; + let StopQuietOutcome { + thread_id, + session_id, + texts, + orphans, + acp_asks, + was_busy, + } = stop_quiet(eng).await; + // Same reason `stop` does it: a switch replaces this engine outright, so an + // ACP permission card still on screen belongs to a turn that no longer + // exists. Answering it — especially with Always/Full — would persist a + // grant against a torn-down session and, on the ACP side, reply to a + // request whose client was already cancelled and unsubscribed. + if let Some(asks) = app.try_state::() { + for id in acp_asks { + asks.inner().cancel(id); + } + } let had_open_rows = !texts.is_empty() || !orphans.is_empty(); let mut drained_queue = 0usize; if let Some(db) = app.try_state::() { @@ -4812,6 +6303,12 @@ enum FreezeClaim { /// function) — resetting it again here would race that cleanup, so this /// deliberately leaves it alone. Owned(Option<(crate::codex_app_server::Client, FrozenTurnDrain)>), + /// ACP freeze recovery: session handle taken + turn reset. + OwnedAcp { + client: Option, + session_id: Option, + drain: FrozenTurnDrain, + }, } /// Re-confirm turn ownership AND (app-server dialect) take the live client, @@ -4853,19 +6350,33 @@ async fn take_frozen_turn(eng: &EngineRef, turn_id: i32) -> FreezeClaim { if inner.turn_id != turn_id || !inner.turn.busy { return FreezeClaim::Stale; } + if inner.acp_client.is_some() && inner.codex_client.is_none() { + let acp = inner.acp_client.take(); + // TAKE, not clone — the same rule `force_acp_turn_reset` follows. The + // caller is about to abandon this native session; leaving the id in + // place lets a send admitted during cleanup resume the very session + // being torn down. + let sid = inner.native_id.take(); + let Some(drain) = reset_frozen_appserver_turn(&mut inner, turn_id) else { + inner.acp_client = acp; + inner.native_id = sid; + return FreezeClaim::Stale; + }; + inner.reset_epoch = inner.reset_epoch.saturating_add(1); + // Held until the caller finishes cancel/unsubscribe/finalize/marker/ + // native-id clearing. The epoch bump alone does not cover this window: + // a send arriving DURING it captures the already-bumped value, passes + // every check, and can prompt the abandoned session — after which the + // recovery clears state underneath the newer turn. Same reservation + // `stop_quiet` takes for the same reason. + inner.tearing_down = true; + return FreezeClaim::OwnedAcp { + client: acp, + session_id: sid, + drain, + }; + } let client = inner.codex_client.take(); - // `reset_frozen_appserver_turn` re-checks the SAME turn_id+busy guard - // just checked above — redundant in practice (this function holds ONE - // continuous `&mut` borrow with no `.await` in between the two checks, - // so nothing could have changed `inner`) but deliberately NOT asserted - // away with `.expect()`/`.unwrap()` (this crate's production paths ban - // both — see CLAUDE.md): reusing the guarded function as-is and letting - // `and_then`/`map` degrade a theoretically-unreachable mismatch to - // "nothing taken" keeps this provably panic-free, not just - // panic-free-by-inspection. `and_then` also means a `None` client - // (every non-app-server dialect) short-circuits without ever calling - // the reset — see [`FreezeClaim::Owned`] for why that dialect must NOT - // have its state reset here. let taken = client .and_then(|c| reset_frozen_appserver_turn(&mut inner, turn_id).map(|drain| (c, drain))); FreezeClaim::Owned(taken) @@ -5020,6 +6531,7 @@ pub(super) fn test_inner(tool: &str) -> EngineInner { pending_command_refresh: false, last_context_tokens: None, last_model: None, + last_reasoning: None, last_window: None, last_mcp_servers: vec![], last_tools: vec![], @@ -5029,10 +6541,13 @@ pub(super) fn test_inner(tool: &str) -> EngineInner { tool_rows: std::collections::HashMap::new(), stopped: false, codex_client: None, + acp_client: None, + acp_pending_asks: Vec::new(), turn_user_row: None, last_assistant_uuid: None, rewinding: false, quota_failover_committing: false, + tearing_down: false, worktree_id: None, } } @@ -5128,6 +6643,27 @@ async fn recover_from_freeze(app: &AppHandle, eng: &EngineRef, freeze_secs: u64) // connection drop, no native-id clear, no marker, no notice. let taken = match take_frozen_turn(eng, turn_id).await { FreezeClaim::Stale => return false, + FreezeClaim::OwnedAcp { + client, + session_id: acp_sid, + drain, + } => { + if let (Some(c), Some(sid)) = (client.as_ref(), acp_sid.as_deref()) { + let _ = c.cancel(sid).await; + c.unsubscribe(sid).await; + // A FROZEN prompt is the case most likely to ignore this + // cancel, which is exactly when `unsubscribe`'s own reap + // declines: the outstanding `session/prompt` still sits in + // `pending` and the ordinary policy treats it as live work. + // Without this the next send gets the same pooled process back, + // still running the abandoned prompt. The route checks inside + // still apply, so a client shared with another session lives. + c.retire_after_ignored_cancel().await; + } + force_acp_finalize_drain(app, thread_id, session_id, turn_id, drain).await; + emit_turn_push(app, thread_id, session_id, "idle", false, Vec::new()); + None + } FreezeClaim::Owned(taken) => taken, }; // Publish the grace marker BEFORE anything below exposes a recoverable @@ -5262,6 +6798,11 @@ async fn recover_from_freeze(app: &AppHandle, eng: &EngineRef, freeze_secs: u64) ) .await; } + // Every ACP cleanup step above is done; the engine can accept work again. + // Released here rather than at any earlier return: this is the one point + // all recovery paths converge on, so the reservation cannot outlive the + // cleanup it was protecting. + eng.lock().await.tearing_down = false; if let Some(bus) = app.try_state::() { bus.ask_human(thread_id, &dir, &freeze_recovery_text(freeze_secs)); } @@ -5513,24 +7054,73 @@ pub fn spawn_watchdog(app: AppHandle) { }); } +/// What a [`stop_quiet`] teardown tore down and left for its caller to finish. +/// +/// Named fields rather than a tuple on purpose. This return grew twice in +/// parallel — `was_busy` on main (PR #140) and `acp_asks` on this branch — and +/// each growth silently invalidated every positional destructuring elsewhere. +/// One such call site was missed while merging and only surfaced as a CI +/// compile error on two platforms; with named fields a caller that ignores a +/// new field keeps compiling and a caller that needs it says so by name. +pub struct StopQuietOutcome { + pub thread_id: i32, + pub session_id: Option, + /// Open text rows to finalize: `(row id, accumulated text, agent thread tag)`. + pub texts: Vec<(i32, String, Option)>, + /// Open tool rows to finalize: `(row id, args json)`. + pub orphans: Vec<(i32, serde_json::Value)>, + /// Open ACP permission requests to cancel in the `AskRegistry`, so an + /// Always/Full answer cannot land against a torn-down turn. + pub acp_asks: Vec, + /// Whether a turn was BUSY at the moment this reset it, captured inside the + /// same critical section (PR #140 review round 14). Read through a separate + /// `eng.lock()` beforehand it describes a different state from the one + /// actually reset — a send admitted in the gap gets interrupted while the + /// flag says `false`, a turn that finished cleanly reports `true` — and + /// `teardown_for_switch` turns that flag into a sentence shown to the user. + pub was_busy: bool, +} + +/// What the ACP half of a teardown needs once the engine lock is released. +struct AcpTeardown { + client: Option, + session_id: Option, + asks: Vec, +} + +/// Take the ACP handles for teardown AND invalidate the turn, as ONE step. +/// +/// These belong together. Cancelling and unsubscribing an ACP session must +/// happen without the engine lock (they take the runtime mutex), and `stopped` +/// is not set until `stop_quiet` returns — so between releasing the lock and +/// re-taking it the engine still looks like it belongs to the running turn. An +/// in-flight prompt task that grabs the lock in that gap would still match its +/// own `prompt_epoch`, pass `acp_emit_turn_end`'s ownership check, and dequeue +/// and dispatch the NEXT queued prompt into an engine a terminal takeover is +/// tearing down. Advancing `reset_epoch` before the lock is released makes +/// every such task self-invalidate instead. +/// +/// The bump also does its original job: invalidating any send that reserved +/// against the turn just cleared — one whose Phase 1 ran before this stop but +/// whose Phase 3 runs after, and a stop-then-restart that resets +/// `stopped`/`busy` and would otherwise slip past those flags. +/// `send_reservation_valid` compares the captured `reset_epoch`. +fn take_acp_teardown_and_invalidate(inner: &mut EngineInner) -> AcpTeardown { + let client = inner.acp_client.take(); + let session_id = inner.native_id.clone(); + let asks = std::mem::take(&mut inner.acp_pending_asks); + inner.reset_epoch += 1; + AcpTeardown { + client, + session_id, + asks, + } +} + /// Kill the live child + reset turn state WITHOUT emitting a "stopped" event — /// the UI keeps its last (idle) state. Used by the skill-refresh restart so the /// bounce is invisible; `stop` wraps this and then emits "stopped". -/// The trailing `bool` is whether a turn was BUSY at the moment this reset it, -/// captured inside the same critical section (PR #140 review round 14). Read -/// through a separate `eng.lock()` beforehand it describes a different state -/// from the one actually reset — a send admitted in the gap gets interrupted -/// while the flag says `false`, a turn that finished cleanly reports `true` — -/// and `teardown_for_switch` turns that flag into a sentence shown to the user. -pub async fn stop_quiet( - eng: &EngineRef, -) -> ( - i32, - Option, - Vec<(i32, String, Option)>, - Vec<(i32, serde_json::Value)>, - bool, -) { +pub async fn stop_quiet(eng: &EngineRef) -> StopQuietOutcome { let mut inner = eng.lock().await; let target = (inner.thread_id, inner.session_id); let was_busy = inner.turn.busy; @@ -5562,7 +7152,33 @@ pub async fn stop_quiet( if let Some(c) = inner.codex_client.take() { c.shutdown().await; } + // Cancel any in-flight ACP prompt and drop the session route so a late + // acp_emit_turn_end cannot overwrite stopped → idle after takeover. + let AcpTeardown { + client: acp, + session_id: acp_sid, + asks: acp_asks, + } = take_acp_teardown_and_invalidate(&mut inner); + // Closed the other direction of the same race. Bumping the epoch stops work + // that reserved BEFORE this teardown from landing after it; this stops work + // admitted DURING it. The ACP cancel/unsubscribe below need the lock + // released, and `stopped` is not set until `stop` returns, so a human send + // or bus wake in that window reserves against the NEW epoch, passes every + // check, and is then silently discarded when the reset below replaces + // `turn` with `TurnState::default()`. + inner.tearing_down = true; + // Drop the engine lock before awaiting ACP cancel/unsubscribe (they take + // the runtime mutex). Re-lock afterwards for the remaining field clears. + drop(inner); + if let Some(c) = acp { + if let Some(sid) = acp_sid { + let _ = c.cancel(&sid).await; + c.unsubscribe(&sid).await; + } + } + let mut inner = eng.lock().await; inner.child = None; + inner.child_reg = None; // Hand the session_gate slot back on the explicit stop, not on the next // spawn. "Stop" is a high-frequency button and a stopped session may never // send again — holding its slot until a respawn that never comes is a leak @@ -5577,12 +7193,17 @@ pub async fn stop_quiet( // turn inherits a stale true and a pre-output failure there would wrongly // suppress its error_before_output row. inner.turn_saw_text = false; - // Invalidate any send that reserved against the turn we just cleared — even one - // whose Phase 1 ran before this stop but whose Phase 3 runs after, and even a - // stop-then-restart (which resets `stopped`/`busy` and would otherwise slip - // past those flags). send_reservation_valid compares the captured reset_epoch. - inner.reset_epoch += 1; - (target.0, target.1, texts, orphan_tools, was_busy) + // The window is over: state is fully reset, so the engine can accept work + // again (a skill bounce reuses this same engine right after). + inner.tearing_down = false; + StopQuietOutcome { + thread_id: target.0, + session_id: target.1, + texts, + orphans: orphan_tools, + acp_asks, + was_busy, + } } /// Stop the engine outright (e.g. before a terminal takeover or by the runaway @@ -5592,10 +7213,23 @@ pub async fn stop_quiet( /// (which skips "stopped"). Distinct from "idle" so a cleanly-idle session can /// still be driven by a bus post. pub async fn stop(app: &AppHandle, eng: &EngineRef) { - let (thread_id, session_id, texts, orphans, _was_busy) = stop_quiet(eng).await; + let StopQuietOutcome { + thread_id, + session_id, + texts, + orphans, + acp_asks, + .. + } = stop_quiet(eng).await; let mut inner = eng.lock().await; inner.stopped = true; drop(inner); + // Drop open ACP permission cards so Always/Full cannot land after takeover. + if let Some(asks) = app.try_state::() { + for id in acp_asks { + asks.inner().cancel(id); + } + } if let Some(db) = app.try_state::() { persist_activity(&db, session_id, thread_id, STATUS_STOPPED).await; // Stop is now visible to the engine, so finalizing here can't race a @@ -5665,6 +7299,24 @@ pub struct RewindOutcome { pub code_restored: bool, } +/// The 1-based cut ordinal for `target` under the addressing rule of `tool`'s +/// dialect. +/// +/// The dialects disagree about ONE case: a message with no text. ACP always +/// writes a text block (`{"type":"text","text":""}` for an image-only prompt, +/// since images ride as sibling blocks rather than the spilled-path appendix +/// per-turn tools append), so an empty prompt is a real, matchable entry. In a +/// claude transcript the same message has no text block at all and +/// `rewind::user_text` skips the line, so an empty target is unaddressable and +/// a zero — which surfaces as "this session has no rewind anchor" — is the +/// honest answer rather than a cut at the wrong line. +fn rewind_ordinal(tool: &str, texts: &[String], target: &str) -> usize { + if crate::acp::backend_for(tool).is_some() { + return super::rewind::ordinal_of_prompt(texts, target); + } + super::rewind::ordinal_of(texts, target) +} + /// Rewind a worker session's OR the lead console's conversation to just /// BEFORE `message_id`: fork the native session at the cut point (claude /// transcript surgery / codex `thread/fork` / opencode `session/fork`), drop @@ -5727,7 +7379,9 @@ async fn rewind_reserved( cwd: inner.cwd.clone(), native_id: inner.native_id.clone(), ask_dir: inner.ask_dir.clone(), + system_prompt: inner.system_prompt.clone(), codex_client: inner.codex_client.clone(), + acp_client: inner.acp_client.clone(), } }; // Lead engines (session_id None) can only rewind the conversation — they @@ -5783,7 +7437,7 @@ async fn rewind_reserved( .filter(|m| super::rewind::native_delivered(&m.role, &m.status)) .map(|m| super::rewind::dispatched_text(per_turn_tool, m.id, &m.content)) .collect(); - let ordinal = super::rewind::ordinal_of(&user_texts, &match_text); + let ordinal = rewind_ordinal(&snap.tool, &user_texts, &match_text); // Resolve the session's worktree ONCE: mandatory for the code half, // best-effort for a conversation-only rewind (it only drives the @@ -5872,6 +7526,22 @@ async fn rewind_reserved( ) } }, + t if crate::acp::backend_for(t).is_some() => match &old_native { + None => None, + Some(_) if prev_user.is_none() => None, + Some(old) => { + if ordinal == 0 { + return Err(anyhow::anyhow!("该会话历史缺少回退锚点(旧会话)")); + } + super::rewind::fork_omp_at( + &snap.cwd, + old, + &match_text, + ordinal, + &snap.system_prompt, + )? + } + }, _ => return Err(anyhow::anyhow!("该工具暂不支持回退")), }, }; @@ -6295,7 +7965,13 @@ struct RewindSnap { cwd: std::path::PathBuf, native_id: Option, ask_dir: String, + /// The prepend the FIRST ACP user turn carries (`{system}\n\n{user}`). + /// Needed to strip it exactly during a rewind match — guessing the split + /// by blank line mis-handles multi-paragraph prompts. + system_prompt: String, codex_client: Option, + #[allow(dead_code)] + acp_client: Option, } /// `thread/fork` at `last_turn_id`: ride the session's live app-server @@ -6371,6 +8047,7 @@ fn spawn_reader( tools: inner.last_tools.clone(), model: inner.last_model.clone(), window: inner.last_window, + mcp_known: false, }, ); } @@ -6446,6 +8123,7 @@ fn spawn_reader( tools, model, window, + mcp_known: false, }, ); } @@ -6463,6 +8141,7 @@ fn spawn_reader( tools: inner.last_tools.clone(), model: inner.last_model.clone(), window: inner.last_window, + mcp_known: false, }, ); } @@ -7071,6 +8750,281 @@ fn emit_lead_delta( mod tests { use super::*; + /// Stop must actually stop. `interrupt()` sets `interrupting` without + /// bumping the epoch, so a Stop landing between `on_turn_end` promoting a + /// queued item and its dispatch cancels nothing and leaves the epoch + /// matching — and the queued prompt ran anyway, executing tools, after the + /// user had pressed Stop. + #[test] + fn a_queued_prompt_is_not_dispatched_after_stop() { + let mut inner = test_inner("omp"); + let epoch = inner.reset_epoch; + assert!( + queued_dispatch_admissible(&inner, epoch), + "baseline: an idle engine dispatches its queue" + ); + + inner.interrupting = true; + assert!( + !queued_dispatch_admissible(&inner, epoch), + "Stop must block the dispatch even though it leaves the epoch alone" + ); + + inner.interrupting = false; + inner.stopped = true; + assert!(!queued_dispatch_admissible(&inner, epoch)); + + inner.stopped = false; + inner.tearing_down = true; + assert!(!queued_dispatch_admissible(&inner, epoch)); + + inner.tearing_down = false; + assert!(!queued_dispatch_admissible(&inner, epoch + 1), "a reset still invalidates"); + assert!(queued_dispatch_admissible(&inner, epoch), "and recovers otherwise"); + } + + /// Hidden delivery bypasses `send_reservation_valid` entirely, so the + /// teardown reservation has to be enforced on its own path — otherwise a + /// bus wake starts a native session during cleanup and the older reset + /// clears its id and emits idle over it. + #[test] + fn a_hidden_turn_is_refused_while_a_teardown_is_reserved() { + let mut inner = test_inner("omp"); + assert!(hidden_turn_admissible(&inner), "baseline: idle engine accepts"); + + inner.tearing_down = true; + assert!(!hidden_turn_admissible(&inner), "a reserved teardown refuses"); + + inner.tearing_down = false; + inner.stopped = true; + assert!(!hidden_turn_admissible(&inner), "a stopped engine refuses"); + + inner.stopped = false; + assert!(hidden_turn_admissible(&inner), "and accepts again afterwards"); + } + + /// A stale claim owns nothing, so it must leave every field it inspected + /// exactly as it found them — including the reservation and the native id. + /// + /// The ACP branch itself (which sets the reservation and takes the id) has + /// no unit test: it keys on a live `acp_client`, and constructing one means + /// spawning a real child. This covers the decline path, where getting it + /// wrong would strand the engine permanently unsendable. + #[tokio::test] + async fn a_stale_freeze_claim_reserves_nothing() { + let mut inner = test_inner("omp"); + inner.turn_id = 7; + inner.turn.busy = false; // already ended → Stale + inner.native_id = Some("sess-frozen".into()); + let eng: EngineRef = Arc::new(tokio::sync::Mutex::new(inner)); + + assert!(matches!(take_frozen_turn(&eng, 7).await, FreezeClaim::Stale)); + + let g = eng.lock().await; + assert!(!g.tearing_down, "a declined claim must not reserve"); + assert_eq!( + g.native_id.as_deref(), + Some("sess-frozen"), + "a declined claim must not take the native id" + ); + } + + /// The teardown window's gate. Bumping the epoch stops work reserved + /// BEFORE a teardown; this stops work admitted DURING one — a send arriving + /// mid-teardown captures the already-bumped epoch, so the epoch check + /// cannot see it, and its queued work would be dropped when the reset + /// replaces `turn` with the default. + #[test] + fn a_send_is_refused_while_a_teardown_holds_the_engine_open() { + let mut inner = test_inner("omp"); + inner.turn_id = 5; + inner.turn.busy = true; + let ctx = SendContext { + thread_id: 1, + session_id: None, + turn: 5, + direct: true, + is_command: false, + tool: "omp".into(), + origin_tag: None, + reset_epoch: inner.reset_epoch, + }; + assert!( + send_reservation_valid(&inner, &ctx), + "baseline: this send is otherwise admissible" + ); + + inner.tearing_down = true; + assert!( + !send_reservation_valid(&inner, &ctx), + "a teardown that released the lock for ACP I/O must not admit work" + ); + + inner.tearing_down = false; + assert!( + send_reservation_valid(&inner, &ctx), + "and the engine is usable again once the window closes" + ); + } + + /// The notice is a machine token, and the FRONTEND owns its copy + /// (`NeedsRows.tsx`'s `NOTICE_TOKENS` → `needs.acpForceResetNotice`). + /// Changing this string without changing it there renders the raw token as + /// the notice body, so pin the exact value on this side too. + #[test] + fn the_force_reset_notice_is_the_token_the_frontend_maps() { + assert_eq!(ACP_FORCE_RESET_NOTICE, "acp.force_reset_notice"); + } + + /// The hole this closes, end to end: a read naming an ordinary file FIRST + /// and a credential second was tiered `ReadOnly` off the first path alone, + /// and `auto_decision` releases a `ReadOnly` ask under a read-only session + /// or issue grant (issue #103) — so the SSH key would have been read with + /// no card ever shown. The tier now comes from the worst target. + #[test] + fn a_multi_file_read_is_tiered_by_its_worst_target() { + use crate::acp::permission::PermissionIntent; + + let ordinary = PermissionIntent::Read { + paths: vec!["src/main.rs".into()], + }; + assert_eq!( + acp_permission_risk(&ordinary, ""), + crate::ask::RiskLevel::ReadOnly, + "an ordinary read is still releasable by a read-only grant" + ); + + let with_secret = PermissionIntent::Read { + paths: vec!["src/main.rs".into(), "/home/u/.ssh/id_rsa".into()], + }; + assert_eq!( + acp_permission_risk(&with_secret, ""), + crate::ask::RiskLevel::NetworkOrCredential, + "a credential anywhere in the set must lift the whole request" + ); + } + + /// The two pathless cases differ, and the difference is the whole point. + /// A write with no named target is still a write. A READ with no named + /// target established nothing — `classify_file("Read", "")` says + /// `ReadOnly`, which a read-only grant releases with no card, so a sparse + /// request hiding its target in `title`/`content` could read a credential + /// unseen. + #[test] + fn a_pathless_read_is_unknown_while_a_pathless_write_stays_write() { + use crate::acp::permission::PermissionIntent; + + assert_eq!( + acp_permission_risk(&PermissionIntent::Write { paths: Vec::new() }, ""), + crate::ask::RiskLevel::Write + ); + assert_eq!( + acp_permission_risk(&PermissionIntent::Read { paths: Vec::new() }, ""), + crate::ask::RiskLevel::Unknown, + "an unauditable read must not be auto-released as read-only" + ); + } + + /// An image-only message is addressable in ACP and not in claude, and the + /// rewind path has exactly one place that decides which rule applies. + #[test] + fn the_rewind_ordinal_rule_follows_the_dialect() { + let texts = vec!["hello".to_string(), String::new()]; + + assert_eq!( + rewind_ordinal("omp", &texts, ""), + 1, + "ACP writes an empty text block, so the image-only prompt is addressable" + ); + assert_eq!( + rewind_ordinal("claude", &texts, ""), + 0, + "claude's transcript has no line to match, so refuse rather than mis-cut" + ); + + // Ordinary text targets are unaffected by the dialect. + for tool in ["omp", "claude", "opencode", "codex"] { + assert_eq!(rewind_ordinal(tool, &texts, "hello"), 1, "{tool}"); + } + } + + /// The whole point of the helper: a teardown that hands out the ACP client + /// has ALREADY invalidated the turn. Splitting these leaves a window in + /// which the engine lock is free, `stopped` is not yet set and the epoch + /// still matches, so an in-flight prompt task passes its ownership check + /// and dispatches the next queued prompt into an engine being torn down. + #[test] + fn acp_teardown_invalidates_the_turn_in_the_step_that_takes_the_client() { + let mut inner = test_inner("omp"); + inner.native_id = Some("sess-1".into()); + inner.acp_pending_asks = vec![7, 9]; + let before = inner.reset_epoch; + + let taken = take_acp_teardown_and_invalidate(&mut inner); + + assert_eq!( + inner.reset_epoch, + before + 1, + "the epoch must already be advanced when the lock is released for the ACP awaits" + ); + assert_eq!(taken.session_id.as_deref(), Some("sess-1")); + assert_eq!(taken.asks, vec![7, 9]); + assert!( + inner.acp_pending_asks.is_empty(), + "asks move to the caller so they are cancelled exactly once" + ); + } + + /// The chip shows a tail, so the buffer holds a tail — a turn that reasons + /// for a megabyte must not park a megabyte on the consumer task. + #[test] + fn thought_tail_stays_bounded_across_a_long_reasoning_stream() { + let mut tail = ThoughtTail::default(); + for _ in 0..1000 { + tail.push(&"x".repeat(500)); + } + + assert_eq!( + tail.buf.chars().count(), + THOUGHT_TAIL_CHARS, + "buffer must hold the display window, not the whole trace" + ); + let summary = tail.summary(); + assert!(summary.starts_with('…'), "elided tail keeps its ellipsis"); + assert_eq!(summary.chars().count(), THOUGHT_TAIL_CHARS + 1); + } + + /// Trimming happens on char boundaries: a tail cut mid-codepoint would + /// panic on `drain`, and the reasoning stream is routinely non-ASCII. + #[test] + fn thought_tail_trims_on_character_boundaries() { + let mut tail = ThoughtTail::default(); + tail.push(&"思".repeat(THOUGHT_TAIL_CHARS + 40)); + + assert_eq!(tail.buf.chars().count(), THOUGHT_TAIL_CHARS); + assert!(tail.buf.chars().all(|c| c == '思')); + } + + /// Short reasoning is shown whole — no ellipsis implying dropped text that + /// was never dropped — and a cleared tail starts clean again. + #[test] + fn thought_tail_under_the_window_is_verbatim_and_clears() { + let mut tail = ThoughtTail::default(); + tail.push("planning the edit"); + assert_eq!(tail.summary(), "planning the edit"); + + tail.push(&"y".repeat(THOUGHT_TAIL_CHARS)); + assert!(tail.summary().starts_with('…')); + + tail.clear(); + tail.push("second turn"); + assert_eq!( + tail.summary(), + "second turn", + "clear must drop the elision flag with the text" + ); + } + #[tokio::test] async fn worker_liveness_requires_an_active_turn() { let state = LeadChatState::default(); @@ -7218,6 +9172,7 @@ mod tests { context_tokens: Some(57_000), window: Some(200_000), model: Some("claude-sonnet-4-5".into()), + reasoning_effort: None, mcp_servers: vec![super::super::proto::McpServer { name: "context7".into(), status: "connected".into(), @@ -7295,6 +9250,7 @@ mod tests { context_tokens: Some(57_000), window: Some(100_000), model: Some("kept".into()), + reasoning_effort: None, mcp_servers: vec![], tools: vec![], }; @@ -8668,6 +10624,11 @@ mod tests { hidden_delivery("opencode", false, false, false), HiddenDelivery::SpawnTurn ); + // ACP connection tools also have no resident stdin when idle. + assert_eq!( + hidden_delivery("omp", false, false, false), + HiddenDelivery::SpawnTurn + ); } #[test] @@ -8684,6 +10645,10 @@ mod tests { hidden_delivery("codex", true, false, false), HiddenDelivery::Queue ); + assert_eq!( + hidden_delivery("omp", true, false, false), + HiddenDelivery::Queue + ); } #[test] @@ -8692,6 +10657,10 @@ mod tests { hidden_delivery("codex", false, false, true), HiddenDelivery::Noop ); + assert_eq!( + hidden_delivery("omp", false, false, true), + HiddenDelivery::Noop + ); } #[test] @@ -8913,6 +10882,7 @@ mod tests { pending_command_refresh: false, last_context_tokens: None, last_model: None, + last_reasoning: None, last_window: None, last_mcp_servers: vec![], last_tools: vec![], @@ -8922,10 +10892,13 @@ mod tests { tool_rows: std::collections::HashMap::new(), stopped: false, codex_client: None, + acp_client: None, + acp_pending_asks: Vec::new(), turn_user_row: None, last_assistant_uuid: None, rewinding: false, quota_failover_committing: false, + tearing_down: false, worktree_id: None, }; let fresh = build_args(&inner); diff --git a/src-tauri/src/lead_chat/proto.rs b/src-tauri/src/lead_chat/proto.rs index e49bbd5e..1bd1872f 100644 --- a/src-tauri/src/lead_chat/proto.rs +++ b/src-tauri/src/lead_chat/proto.rs @@ -534,7 +534,7 @@ fn opencode_output(state: &Value) -> String { /// Cap tool output so a pathological stdout / large file read can't bloat the /// persisted row, its push payload, or the React store. The collapsed row shows /// only a summary anyway, and the expanded view already paginates. -fn cap_output(s: String) -> String { +pub(crate) fn cap_output(s: String) -> String { const MAX: usize = 16_000; if s.chars().count() <= MAX { return s; diff --git a/src-tauri/src/lead_chat/rewind.rs b/src-tauri/src/lead_chat/rewind.rs index c2f075c8..aff876de 100644 --- a/src-tauri/src/lead_chat/rewind.rs +++ b/src-tauri/src/lead_chat/rewind.rs @@ -8,7 +8,7 @@ //! holding every message strictly before the matched user message. //! Spike-verified against opencode 1.17.9. -use std::path::Path; +use std::path::{Path, PathBuf}; use std::process::Stdio; use std::time::Duration; @@ -44,7 +44,7 @@ pub(crate) fn dispatched_text(per_turn_tool: bool, row_id: i32, content_json: &s return d.to_string(); } let mut out = v["text"].as_str().unwrap_or_default().to_string(); - let mut list = |key: &str| -> Vec { + let list = |key: &str| -> Vec { v[key] .as_array() .map(|a| a.iter().filter_map(|x| x.as_str().map(String::from)).collect()) @@ -255,11 +255,32 @@ pub(crate) fn normalize_ws(s: &str) -> String { /// 1-based position of `target` among `texts` under [`normalize_ws`] identity. /// The engine computes a fallback cut ordinal from DB rows with this, so it /// matches the transcript-side normalized matching exactly. 0 = no match. +/// +/// An EMPTY target is "no match" here because that is claude's rule, not a +/// universal one: [`user_text`] skips a transcript user line carrying no text +/// blocks, so an empty target is genuinely unaddressable in that dialect and +/// counting it would only trade a clear "no anchor" error for a confusing +/// "text not found" one. Dialects that DO record an empty prompt verbatim use +/// [`ordinal_of_prompt`]. pub(crate) fn ordinal_of(texts: &[String], target: &str) -> usize { - let want = normalize_ws(target); - if want.is_empty() { + if normalize_ws(target).is_empty() { return 0; } + ordinal_of_prompt(texts, target) +} + +/// [`ordinal_of`] for dialects where an empty prompt is still an addressable +/// message — ACP in particular. +/// +/// `session/prompt` always sends a text block, `{"type":"text","text":""}` +/// even for an image-only message (images ride as sibling image blocks rather +/// than the spilled-path appendix per-turn tools get). So "" is a real entry +/// in the transcript, and `fork_omp_at`'s matcher already treats it as one — +/// but the engine's `ordinal == 0` guard rejected it upstream, so rewinding a +/// non-first image-only message failed as "no rewind anchor" and the matcher +/// never ran. +pub(crate) fn ordinal_of_prompt(texts: &[String], target: &str) -> usize { + let want = normalize_ws(target); texts.iter().filter(|t| normalize_ws(t) == want).count() } @@ -515,6 +536,387 @@ fn strip_outer_quotes(s: &str) -> &str { } } + + + +/// Whether an omp jsonl user-message body matches the rewind target. +/// +/// Exact whitespace-normalized equality always matches. The FIRST user message +/// may also match after stripping the `{system}\n\n{user}` prepend Weft adds on +/// session start — `system` is passed in, not guessed. +/// +/// It used to be guessed, as "everything before the LAST blank line". A +/// multi-paragraph first prompt breaks that in both directions: `SYS\n\nHello\n\ +/// \nWorld` isolates only `World`, so a later message whose text is `World` +/// falsely matches the first turn and steals its ordinal (rewind then cuts the +/// whole session away), while the real target `Hello\n\nWorld` fails to match +/// its own first occurrence. An attachment appendix — which `dispatched_text` +/// appends as its own blank-line block — puts every attachment-bearing first +/// prompt in that same shape. +/// +/// A mismatched `system` (the prompt changed since the session opened) simply +/// declines the relaxed match; the caller then reports no anchor rather than +/// cutting at the wrong message. +fn omp_user_body_matches( + body: &str, + want_norm: &str, + first_user_prepend: Option<&str>, +) -> bool { + if normalize_ws(body) == want_norm { + return true; + } + // Only the first user turn carries the prepend. + let Some(system) = first_user_prepend else { + return false; + }; + if system.is_empty() { + return false; + } + let Some(rest) = body + .strip_prefix(system) + .and_then(|rest| rest.strip_prefix("\n\n")) + else { + return false; + }; + normalize_ws(rest) == want_norm +} + +/// Line index of the `ordinal`-th user prompt whose text blocks match `text`, +/// i.e. where a cut-before rewind should truncate. `None` = no such prompt. +/// +/// Split from [`fork_omp_at`]'s file IO so the matching rule — including the +/// empty-text (image-only) prompt that [`ordinal_of_prompt`] can now address — +/// is testable end to end without a session file on disk. +fn omp_cut_index( + lines: &[&str], + text: &str, + ordinal: usize, + system_prompt: &str, +) -> Option { + let want = normalize_ws(text); + let mut user_hits = 0usize; + let mut seen_users = 0usize; + for (i, line) in lines.iter().enumerate() { + let Ok(v) = serde_json::from_str::(line) else { + continue; + }; + if v.get("type").and_then(|t| t.as_str()) != Some("message") { + continue; + } + let role = v + .pointer("/message/role") + .and_then(|r| r.as_str()) + .unwrap_or(""); + if role != "user" { + continue; + } + let content = v.pointer("/message/content").and_then(|c| c.as_array()); + // Tool-result rows are also role=user but have no direct text block. + // Attachment-only prompts DO have a text block (text may be empty). + let Some(arr) = content else { continue }; + let has_text_block = arr.iter().any(|b| { + b.get("type").and_then(|t| t.as_str()) == Some("text") + || b.get("text").and_then(|t| t.as_str()).is_some() + }); + if !has_text_block { + continue; + } + let body = arr + .iter() + .filter_map(|b| b.get("text").and_then(|t| t.as_str())) + .collect::>() + .join("\n"); + let first_user_prepend = (seen_users == 0).then_some(system_prompt); + seen_users += 1; + if !omp_user_body_matches(&body, &want, first_user_prepend) { + continue; + } + user_hits += 1; + if user_hits == ordinal { + return Some(i); + } + } + None +} + +/// Cut-before rewind for omp ACP sessions. +/// +/// ACP `session/fork` only does full-history copy. We rewrite the on-disk +/// `~/.omp/agent/sessions//*_.jsonl` to keep entries strictly +/// before the Nth matching user message, mint a new session id, and return it +/// so the engine can `session/load` next turn. Spike-verified: hand-cut files +/// load and only see the kept prefix (omp 17.1.1). +pub fn fork_omp_at( + cwd: &Path, + session_id: &str, + text: &str, + ordinal: usize, + system_prompt: &str, +) -> Result> { + if ordinal == 0 { + return Err(anyhow!("ordinal is 1-based")); + } + let Some(src) = find_omp_session_file(cwd, session_id)? else { + return Err(anyhow!("omp_session_not_found")); + }; + let raw = std::fs::read_to_string(&src) + .with_context(|| format!("read omp session {}", src.display()))?; + let lines: Vec<&str> = raw.lines().filter(|l| !l.trim().is_empty()).collect(); + let Some(cut) = omp_cut_index(&lines, text, ordinal, system_prompt) else { + return Err(anyhow!("omp_user_not_found")); + }; + if cut == 0 { + // Nothing to keep before first line — fresh session. + return Ok(None); + } + let kept = &lines[..cut]; + if kept.iter().all(|l| { + serde_json::from_str::(l) + .ok() + .and_then(|v| v.get("type").and_then(|t| t.as_str()).map(|s| s.to_string())) + .map(|t| t != "message") + .unwrap_or(true) + }) { + // Only headers — equivalent to before-first-message. + return Ok(None); + } + // Random, not clock-derived: two rewinds on the same tick produced the SAME + // id, and both write `weft-rewind_.jsonl` with a truncating `fs::write`, + // so one fork silently overwrote the other and two sessions resumed the same + // (or a corrupted) history. `new_uuid_v4` is the same RFC 4122 v4 helper the + // claude rewind already uses. + let new_id = new_uuid_v4(); + let mut out = String::new(); + for line in kept { + let Ok(mut v) = serde_json::from_str::(line) else { + out.push_str(line); + out.push('\n'); + continue; + }; + if v.get("type").and_then(|t| t.as_str()) == Some("session") { + if let Some(obj) = v.as_object_mut() { + obj.insert("id".into(), Value::String(new_id.clone())); + obj.remove("parentSession"); + } + } + out.push_str(&v.to_string()); + out.push('\n'); + } + let parent = src.parent().unwrap_or(Path::new(".")); + let dst = parent.join(format!("weft-rewind_{new_id}.jsonl")); + std::fs::write(&dst, out).with_context(|| format!("write omp fork {}", dst.display()))?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&dst, std::fs::Permissions::from_mode(0o600)) + .with_context(|| format!("chmod omp fork {}", dst.display()))?; + } + Ok(Some(new_id)) +} + +fn find_omp_session_file(cwd: &Path, session_id: &str) -> Result> { + let home = dirs::home_dir().ok_or_else(|| anyhow!("no home dir"))?; + let root = home.join(".omp/agent/sessions"); + // The ROOT itself must not be a symlink. `is_dir()` follows one, and the + // bucket/entry checks below only reject a symlink at the FINAL component — + // so a redirected root put the whole scan outside the intended tree, and + // rewind then writes its fork beside that external file. + let root_ok = std::fs::symlink_metadata(&root) + .map(|m| m.is_dir() && !m.file_type().is_symlink()) + .unwrap_or(false); + if !root_ok { + return Ok(None); + } + // Containment of REAL locations, checked once at the end. Rejecting a + // symlink component-by-component can only cover the components this code + // happens to look at; canonicalizing the winner and requiring it under the + // canonical root covers every intermediate component and any `..` escape. + // Same rule `bus::builtin_allow` uses for ask-bridge path containment. + let canon_root = std::fs::canonicalize(&root)?; + // Prefer encoded-cwd bucket; fall back to any match. + let encoded = omp_encode_cwd(cwd); + let mut candidates = Vec::new(); + // Whether a scan stopped at its cap. "Nothing found" and "stopped looking" + // are different answers, and reporting the second as the first told the + // user their session was gone when it was merely past the cap. + let mut truncated = false; + let preferred = root.join(&encoded); + // Do not follow a symlink bucket (or entries): rewind must stay under + // ~/.omp/agent/sessions. + let preferred_ok = std::fs::symlink_metadata(&preferred) + .map(|m| m.is_dir() && !m.file_type().is_symlink()) + .unwrap_or(false); + if preferred_ok { + const MAX_PREFERRED_SCAN: usize = 4_096; + const MAX_PREFERRED_HITS: usize = 32; + let mut scanned = 0usize; + for e in std::fs::read_dir(&preferred)? { + if scanned >= MAX_PREFERRED_SCAN { + truncated = true; + break; + } + let e = e?; + scanned += 1; + let p = e.path(); + let Ok(meta) = std::fs::symlink_metadata(&p) else { continue }; + if meta.file_type().is_symlink() { + continue; + } + if !meta.is_file() { + continue; + } + let name = e.file_name().to_string_lossy().into_owned(); + if name.contains(session_id) && name.ends_with(".jsonl") { + candidates.push(p); + if candidates.len() >= MAX_PREFERRED_HITS { + break; + } + } + } + } + if candidates.is_empty() { + let (hits, walk_truncated) = walkdir_jsonl(&root, session_id)?; + truncated = truncated || walk_truncated; + candidates.extend(hits); + } + candidates.retain(|p| is_inside_canonical(p, &canon_root)); + // Distinguish the two failure answers. `Ok(None)` means "scanned everything + // reachable, it is not there"; a truncated scan cannot claim that, and + // `fork_omp_at` would otherwise report `omp_session_not_found` for a + // session that exists just past the cap. + if candidates.is_empty() && truncated { + return Err(anyhow!("omp_session_scan_truncated")); + } + // Newest mtime wins if several. + candidates.sort_by_key(|p| { + std::fs::metadata(p) + .and_then(|m| m.modified()) + .ok() + .map(|t| t.duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0)) + .unwrap_or(0) + }); + Ok(candidates.pop()) +} + +/// Whether `candidate`'s REAL location is under `canon_root` (already +/// canonical). +/// +/// This is the actual boundary. The component-by-component symlink rejections +/// above and in [`walkdir_jsonl`] are shape matching — they can only cover the +/// components that particular code happens to look at — whereas resolving the +/// winner and requiring containment holds for every intermediate component and +/// for `..` escapes at once. +/// +/// It is unreachable through today's callers precisely BECAUSE those scans also +/// refuse symlinks, so no integration test can drive it without first weakening +/// them; the predicate is tested directly instead. It stays as the structural +/// guarantee those local checks are only approximating. Note it does NOT catch +/// hard links — `canonicalize` does not resolve them — so it is containment of +/// paths, not of inodes. +/// +/// A path that cannot be canonicalized is NOT contained: when containment can't +/// be established, the honest answer is "not a candidate". +fn is_inside_canonical(candidate: &Path, canon_root: &Path) -> bool { + std::fs::canonicalize(candidate) + .map(|real| real.starts_with(canon_root)) + .unwrap_or(false) +} + +/// Returns the hits plus whether any cap stopped the walk early — the caller +/// must not report a truncated walk as "not found". +fn walkdir_jsonl(root: &Path, session_id: &str) -> Result<(Vec, bool)> { + use std::collections::HashSet; + /// Hard caps so a huge/unexpected `~/.omp/agent/sessions` tree cannot pin + /// the async command thread or blow memory. Prefer the encoded-cwd bucket + /// (caller); this walk is only the fallback. + const MAX_DIRS: usize = 256; + const MAX_FILES_SCANNED: usize = 4_096; + const MAX_DEPTH: usize = 6; + const MAX_HITS: usize = 32; + const SKIP_DIR_NAMES: &[&str] = &[ + "node_modules", "target", ".git", "dist", "build", "cache", ".cache", + ]; + let mut out = Vec::new(); + let mut stack: Vec<(PathBuf, usize)> = vec![(root.to_path_buf(), 0)]; + let mut seen = HashSet::new(); + let mut dirs_visited = 0usize; + let mut files_scanned = 0usize; + // A cap stopped the walk with directories still queued: the answer is + // "stopped looking", not "not there". + let mut truncated = false; + while let Some((dir, depth)) = stack.pop() { + if dirs_visited >= MAX_DIRS || files_scanned >= MAX_FILES_SCANNED { + truncated = true; + break; + } + if depth > MAX_DEPTH { + continue; + } + let Ok(canon) = dir.canonicalize() else { + continue; + }; + if !seen.insert(canon) { + continue; // cycle / revisit + } + dirs_visited += 1; + let rd = match std::fs::read_dir(&dir) { + Ok(r) => r, + Err(_) => continue, + }; + for e in rd.flatten() { + if files_scanned >= MAX_FILES_SCANNED { + truncated = true; + break; + } + // Hitting the HIT cap is not truncation of the search space — the + // session was found, several times over. + if out.len() >= MAX_HITS { + break; + } + let p = e.path(); + let Ok(meta) = std::fs::symlink_metadata(&p) else { + continue; + }; + if meta.file_type().is_symlink() { + continue; // never follow symlink dirs/files into the walk + } + if meta.is_dir() { + let name = p.file_name().and_then(|n| n.to_str()).unwrap_or(""); + if SKIP_DIR_NAMES.iter().any(|s| name.eq_ignore_ascii_case(s)) { + continue; + } + // Cap enqueues, not only visits — a wide fan-out can otherwise + // grow `stack` without bound before dirs_visited hits MAX_DIRS. + if dirs_visited + stack.len() >= MAX_DIRS { + truncated = true; + continue; + } + stack.push((p, depth + 1)); + } else { + files_scanned += 1; + let name = p.file_name().and_then(|n| n.to_str()).unwrap_or(""); + if name.contains(session_id) && name.ends_with(".jsonl") { + out.push(p); + } + } + } + } + Ok((out, truncated)) +} + +/// omp session bucket: non-alnum path chars → `-` (observed +/// `~/.omp/agent/sessions/-workspace-weft-...`). +fn omp_encode_cwd(cwd: &Path) -> String { + let s = cwd + .canonicalize() + .unwrap_or_else(|_| cwd.to_path_buf()) + .to_string_lossy() + .into_owned(); + s.chars() + .map(|c| if c.is_ascii_alphanumeric() { c } else { '-' }) + .collect() +} + #[cfg(test)] mod tests { use super::*; @@ -957,4 +1359,519 @@ mod tests { "look\n\nAttached images (read them as needed):\n- /tmp/weft-attachments/msg7-0.png\n" ); } + + #[test] + fn omp_first_user_is_row_order_not_match_count() { + // Unrelated first user row must consume the "first" slot so a later + // `notes\n\nrun tests` cannot match as system-prepend. + let want = normalize_ws("run tests"); + assert!(!omp_user_body_matches("hello world", &want, Some("sys"))); + // After the first row is visited, a blank-paragraph body is not first. + assert!(!omp_user_body_matches("notes\n\nrun tests", &want, None)); + // Exact later still matches. + assert!(omp_user_body_matches("run tests", &want, None)); + } + + #[test] + fn omp_multi_paragraph_system_prepend_matches() { + let want = normalize_ws("run tests"); + let system = "You are the lead.\n\nOperate with judgment.\n\nThird para."; + let body = format!("{system}\n\nrun tests"); + assert!(omp_user_body_matches(&body, &want, Some(system))); + assert!(!omp_user_body_matches(&body, &want, None)); + } + + /// The prepend is STRIPPED, never guessed. Splitting on the last blank line + /// broke both ways once a prompt had more than one paragraph. + #[test] + fn a_multi_paragraph_first_prompt_matches_whole_and_not_by_tail() { + let system = "You are the lead."; + let body = format!("{system}\n\nHello\n\nWorld"); + + // The real target is the WHOLE user message. + assert!(omp_user_body_matches( + &body, + &normalize_ws("Hello\n\nWorld"), + Some(system) + )); + // A later message equal to the first prompt's final paragraph must not + // steal the first turn's ordinal — that cut the whole session away. + assert!(!omp_user_body_matches( + &body, + &normalize_ws("World"), + Some(system) + )); + } + + /// A system prompt that changed since the session opened declines the + /// relaxed match, so the caller reports "no anchor" instead of cutting at + /// the wrong message. + #[test] + fn a_stale_system_prompt_declines_rather_than_mismatching() { + let want = normalize_ws("run tests"); + let body = "You are helpful.\n\nrun tests"; + assert!(!omp_user_body_matches(body, &want, Some("Different prompt."))); + assert!(!omp_user_body_matches(body, &want, Some(""))); + assert!(omp_user_body_matches(body, &want, Some("You are helpful."))); + } + + #[test] + fn omp_user_body_matches_system_prepend_only_on_first() { + let want = normalize_ws("run tests"); + assert!(omp_user_body_matches( + "You are helpful.\n\nrun tests", + &want, + Some("You are helpful.") + )); + assert!(omp_user_body_matches("run tests", &want, Some("sys"))); + // Later user with a blank paragraph must NOT match as a prepend. + assert!(!omp_user_body_matches("notes\n\nrun tests", &want, None)); + assert!(omp_user_body_matches("run tests", &want, None)); + // Leading blank lines collapse under normalize_ws → exact match path. + assert!(omp_user_body_matches("\n\nrun tests", &want, Some("sys"))); + // A non-empty prefix that is NOT the system prompt no longer matches + // just because it shares a blank-line separator. + assert!(!omp_user_body_matches("sys\n\nrun tests", &want, Some("other"))); + assert!(omp_user_body_matches("sys\n\nrun tests", &want, Some("sys"))); + } + + #[test] + fn walkdir_jsonl_skips_symlinks_and_caps() { + let dir = tempfile::tempdir().expect("tmp"); + let root = dir.path().join("root"); + std::fs::create_dir_all(&root).unwrap(); + // real hit + std::fs::write(root.join("sess-abc.jsonl"), "{}\n").unwrap(); + // symlink file must be skipped + #[cfg(unix)] + { + let _ = std::os::unix::fs::symlink(root.join("sess-abc.jsonl"), root.join("link-sess-abc.jsonl")); + // outside is a SIBLING of root, not a child — only reachable via symlink + let outside = dir.path().join("outside"); + std::fs::create_dir_all(&outside).unwrap(); + std::fs::write(outside.join("sess-abc-out.jsonl"), "{}\n").unwrap(); + let _ = std::os::unix::fs::symlink(&outside, root.join("symdir")); + } + // artifact dir skipped + let nm = root.join("node_modules"); + std::fs::create_dir_all(&nm).unwrap(); + std::fs::write(nm.join("sess-abc-nm.jsonl"), "{}\n").unwrap(); + let (hits, _truncated) = walkdir_jsonl(&root, "sess-abc").expect("walk"); + let names: Vec<_> = hits.iter().filter_map(|p| p.file_name().map(|n| n.to_string_lossy().into_owned())).collect(); + assert!(names.iter().any(|n| n == "sess-abc.jsonl"), "real hit: {names:?}"); + assert!(names.iter().all(|n| n != "link-sess-abc.jsonl"), "symlink file skipped: {names:?}"); + assert!(names.iter().all(|n| !n.contains("out")), "symlink dir not followed: {names:?}"); + assert!(names.iter().all(|n| n != "sess-abc-nm.jsonl"), "node_modules skipped: {names:?}"); + } + + #[test] + fn preferred_omp_bucket_rejects_symlink_dir() { + let dir = tempfile::tempdir().expect("tmp"); + // Build a fake home-relative structure is hard; unit-test the metadata gate. + let bucket = dir.path().join("bucket"); + std::fs::create_dir_all(&bucket).unwrap(); + std::fs::write(bucket.join("x-sid.jsonl"), "a\n").unwrap(); + let meta = std::fs::symlink_metadata(&bucket).unwrap(); + assert!(meta.is_dir() && !meta.file_type().is_symlink()); + #[cfg(unix)] + { + let link = dir.path().join("linkbucket"); + let _ = std::os::unix::fs::symlink(&bucket, &link); + let lm = std::fs::symlink_metadata(&link).unwrap(); + assert!(lm.file_type().is_symlink()); + // Gate used by find_omp_session_file: + let ok = std::fs::symlink_metadata(&link) + .map(|m| m.is_dir() && !m.file_type().is_symlink()) + .unwrap_or(false); + assert!(!ok, "symlink bucket must be rejected"); + } + } + + #[test] + fn walkdir_jsonl_respects_max_hits_cap() { + let dir = tempfile::tempdir().expect("tmp"); + let root = dir.path(); + // Create more matching files than MAX_HITS (32). + for i in 0..80 { + std::fs::write(root.join(format!("sess-cap-{i}.jsonl")), b"{}\n").unwrap(); + } + let (hits, truncated) = walkdir_jsonl(root, "sess-cap").expect("walk"); + assert!(hits.len() <= 32, "capped at MAX_HITS, got {}", hits.len()); + assert!(!hits.is_empty()); + assert!( + !truncated, + "stopping at the HIT cap is not a truncated search — the session was found" + ); + } + + #[test] + fn walkdir_jsonl_caps_wide_directory_fanout() { + use std::fs; + let dir = tempfile::tempdir().expect("tmp"); + let root = dir.path(); + // One level with many child dirs (few files) — must not grow stack unbounded. + for i in 0..2_000 { + let d = root.join(format!("d{i}")); + fs::create_dir(&d).expect("mkdir"); + // one non-matching file per dir so we exercise file branch too + fs::write(d.join("x.txt"), b"x").expect("file"); + } + // A single hit buried late should still be findable within caps, but + // the walk must terminate quickly and never enqueue > MAX_DIRS pending. + fs::write(root.join("sess-wide.jsonl"), b"{}\n").expect("hit"); + let (hits, _truncated) = walkdir_jsonl(root, "sess-wide").expect("walk"); + // Cap may stop before discovering the hit if fan-out exhausts MAX_DIRS; + // the invariant under test is termination + no panic/OOM, and hits ≤ MAX_HITS. + assert!(hits.len() <= 32, "hits capped"); + } + + /// CLAUDE.md requires symlink-containment coverage for recursive + /// filesystem work. `is_dir()` FOLLOWS a symlink, and the per-bucket and + /// per-entry checks only reject one at the final component — so a + /// redirected root put the whole scan outside `~/.omp/agent/sessions`, and + /// a rewind would then write its fork beside that external file. + #[cfg(unix)] + #[test] + fn a_symlinked_session_root_is_refused() { + use std::fs; + let _serialized = home_test_lock().lock().unwrap_or_else(|e| e.into_inner()); + let dir = tempfile::tempdir().expect("tmp"); + let home = dir.path(); + let cwd = home.join("proj"); + fs::create_dir_all(&cwd).unwrap(); + + // The real session tree lives OUTSIDE the home we will point at. + let outside = dir.path().join("elsewhere"); + let bucket = outside.join(omp_encode_cwd(&cwd)); + fs::create_dir_all(&bucket).unwrap(); + fs::write(bucket.join("zzz_sess-outside.jsonl"), b"{}\n").unwrap(); + + // ~/.omp/agent/sessions -> the outside tree. + fs::create_dir_all(home.join(".omp/agent")).unwrap(); + std::os::unix::fs::symlink(&outside, home.join(".omp/agent/sessions")).unwrap(); + + let old = std::env::var_os("HOME"); + unsafe { std::env::set_var("HOME", home) }; + let found = find_omp_session_file(&cwd, "sess-outside"); + match old { + Some(v) => unsafe { std::env::set_var("HOME", v) }, + None => unsafe { std::env::remove_var("HOME") }, + } + + assert!( + matches!(found, Ok(None)), + "a symlinked session root must select nothing, got {found:?}" + ); + } + + /// The containment check is on REAL locations, so an entry reachable only + /// through a symlinked BUCKET (an intermediate component, which the + /// per-entry check never looks at) is refused too. + #[cfg(unix)] + #[test] + fn a_session_reached_through_a_symlinked_bucket_is_refused() { + use std::fs; + let _serialized = home_test_lock().lock().unwrap_or_else(|e| e.into_inner()); + let dir = tempfile::tempdir().expect("tmp"); + let home = dir.path(); + let cwd = home.join("proj"); + fs::create_dir_all(&cwd).unwrap(); + + let outside = dir.path().join("outside-bucket"); + fs::create_dir_all(&outside).unwrap(); + fs::write(outside.join("zzz_sess-linked.jsonl"), b"{}\n").unwrap(); + + let sessions = home.join(".omp/agent/sessions"); + fs::create_dir_all(&sessions).unwrap(); + std::os::unix::fs::symlink(&outside, sessions.join(omp_encode_cwd(&cwd))).unwrap(); + + let old = std::env::var_os("HOME"); + unsafe { std::env::set_var("HOME", home) }; + let found = find_omp_session_file(&cwd, "sess-linked"); + match old { + Some(v) => unsafe { std::env::set_var("HOME", v) }, + None => unsafe { std::env::remove_var("HOME") }, + } + + assert!( + matches!(found, Ok(None)), + "a symlinked bucket must not yield a candidate, got {found:?}" + ); + } + + /// The boundary predicate itself. Unreachable via `find_omp_session_file` + /// today (its scans refuse symlinks first), so it is exercised directly — + /// a guard with no test is indistinguishable from one that always returns + /// the same answer. + #[cfg(unix)] + #[test] + fn containment_is_of_real_locations_not_path_strings() { + use std::fs; + let dir = tempfile::tempdir().expect("tmp"); + let root = dir.path().join("root"); + let outside = dir.path().join("outside"); + fs::create_dir_all(&root).unwrap(); + fs::create_dir_all(&outside).unwrap(); + fs::write(outside.join("escaped.jsonl"), b"{}\n").unwrap(); + fs::write(root.join("real.jsonl"), b"{}\n").unwrap(); + let canon_root = fs::canonicalize(&root).unwrap(); + + assert!(is_inside_canonical(&root.join("real.jsonl"), &canon_root)); + + // Reachable through a path that LOOKS inside, but resolves outside. + std::os::unix::fs::symlink(outside.join("escaped.jsonl"), root.join("looks-inside.jsonl")) + .unwrap(); + assert!( + !is_inside_canonical(&root.join("looks-inside.jsonl"), &canon_root), + "a symlink pointing out of the tree is not contained" + ); + + // `..` traversal that stringly-starts-with the root. + assert!(!is_inside_canonical( + &root.join("../outside/escaped.jsonl"), + &canon_root + )); + + // Cannot be canonicalized → not contained. + assert!(!is_inside_canonical(&root.join("missing.jsonl"), &canon_root)); + } + + /// "Stopped looking" must not be reported as "not there". The previous + /// large-directory test only asserted `is_ok()`, which accepts exactly that + /// false negative: a bucket past the scan cap made `fork_omp_at` answer + /// `omp_session_not_found` for a session that exists. + #[test] + fn a_truncated_scan_is_a_distinct_error_not_a_missing_session() { + use std::fs; + let _serialized = home_test_lock().lock().unwrap_or_else(|e| e.into_inner()); + let dir = tempfile::tempdir().expect("tmp"); + let home = dir.path(); + let cwd = home.join("proj"); + fs::create_dir_all(&cwd).unwrap(); + let bucket = home.join(".omp/agent/sessions").join(omp_encode_cwd(&cwd)); + fs::create_dir_all(&bucket).unwrap(); + // Past MAX_PREFERRED_SCAN (4096) with NO matching file anywhere, so the + // fallback walk is capped too and the session is genuinely unreachable + // within the caps. + for i in 0..5_000 { + fs::write(bucket.join(format!("noise-{i}.txt")), b"x").unwrap(); + } + + let old = std::env::var_os("HOME"); + unsafe { std::env::set_var("HOME", home) }; + let found = find_omp_session_file(&cwd, "sess-past-the-cap"); + match old { + Some(v) => unsafe { std::env::set_var("HOME", v) }, + None => unsafe { std::env::remove_var("HOME") }, + } + + let err = found.expect_err("a truncated scan must not answer Ok(None)"); + assert!( + err.to_string().contains("omp_session_scan_truncated"), + "got {err}" + ); + } + + /// The guards must not reject the ordinary case. + #[test] + fn an_ordinary_session_file_is_still_found() { + use std::fs; + let _serialized = home_test_lock().lock().unwrap_or_else(|e| e.into_inner()); + let dir = tempfile::tempdir().expect("tmp"); + let home = dir.path(); + let cwd = home.join("proj"); + fs::create_dir_all(&cwd).unwrap(); + let bucket = home.join(".omp/agent/sessions").join(omp_encode_cwd(&cwd)); + fs::create_dir_all(&bucket).unwrap(); + fs::write(bucket.join("zzz_sess-ok.jsonl"), b"{}\n").unwrap(); + + let old = std::env::var_os("HOME"); + unsafe { std::env::set_var("HOME", home) }; + let found = find_omp_session_file(&cwd, "sess-ok"); + match old { + Some(v) => unsafe { std::env::set_var("HOME", v) }, + None => unsafe { std::env::remove_var("HOME") }, + } + + let path = found.expect("lookup ok").expect("a candidate"); + assert!(path.ends_with("zzz_sess-ok.jsonl"), "got {path:?}"); + } + + #[test] + fn preferred_bucket_caps_large_dir() { + use std::fs; + let _serialized = home_test_lock().lock().unwrap_or_else(|e| e.into_inner()); + let dir = tempfile::tempdir().expect("tmp"); + let home = dir.path(); + let sessions = home.join(".omp/agent/sessions"); + let cwd = home.join("proj"); + fs::create_dir_all(&cwd).unwrap(); + let encoded = omp_encode_cwd(&cwd); + let bucket = sessions.join(&encoded); + fs::create_dir_all(&bucket).unwrap(); + for i in 0..5_000 { + fs::write(bucket.join(format!("noise-{i}.txt")), b"x").unwrap(); + } + fs::write(bucket.join("zzz_sess-cap-me.jsonl"), b"{}\n").unwrap(); + let old = std::env::var_os("HOME"); + unsafe { std::env::set_var("HOME", home) }; + let found = find_omp_session_file(&cwd, "sess-cap-me"); + match old { + Some(v) => unsafe { std::env::set_var("HOME", v) }, + None => unsafe { std::env::remove_var("HOME") }, + } + assert!(found.is_ok()); + } + + /// The system prepend the first ACP user turn carries in these fixtures. + const SYS: &str = "SYSTEM PREAMBLE"; + + /// Serializes the tests that override `HOME`. `find_omp_session_file` + /// resolves it through `dirs::home_dir()` and cargo runs tests on threads, + /// so without this two HOME-swapping tests interleave and each sees the + /// other's root — a flake that only shows up under load. + fn home_test_lock() -> &'static std::sync::Mutex<()> { + static LOCK: std::sync::OnceLock> = std::sync::OnceLock::new(); + LOCK.get_or_init(|| std::sync::Mutex::new(())) + } + + fn omp_user_line(blocks: serde_json::Value) -> String { + serde_json::json!({ + "type": "message", + "message": { "role": "user", "content": blocks } + }) + .to_string() + } + + fn omp_assistant_line(text: &str) -> String { + serde_json::json!({ + "type": "message", + "message": { "role": "assistant", "content": [{"type":"text","text":text}] } + }) + .to_string() + } + + /// What omp actually records for an image-only message: `prompt()` always + /// emits a text block, so the text is EMPTY rather than absent. + fn image_only_line() -> String { + omp_user_line(serde_json::json!([ + {"type":"text","text":""}, + {"type":"image","mimeType":"image/png","data":"AAAA"} + ])) + } + + /// Tool results are role=user too, but carry no text block — they must + /// never be mistaken for an (empty-bodied) user prompt. + #[test] + fn omp_cut_skips_tool_results_which_have_no_text_block() { + let tool_result = omp_user_line(serde_json::json!([ + {"type":"tool_result","tool_use_id":"t1","content":"ok"} + ])); + let lines: Vec<&str> = vec![&tool_result]; + + assert_eq!( + omp_cut_index(&lines, "", 1, SYS), + None, + "a tool_result row is not an empty user prompt" + ); + } + + /// The whole upstream path for a NON-FIRST image-only rewind: the DB-side + /// ordinal has to survive into the transcript cut. A matcher-level check + /// cannot catch this — `ordinal_of` zeroed the empty target and the engine + /// rejected the rewind as "no anchor" before the matcher ever ran. + #[test] + fn image_only_omp_prompt_rewinds_end_to_end() { + let first = omp_user_line(serde_json::json!([{"type":"text","text":"hello"}])); + let reply = omp_assistant_line("hi"); + let tool_result = omp_user_line(serde_json::json!([ + {"type":"tool_result","tool_use_id":"t1","content":"ok"} + ])); + let image_only = image_only_line(); + let after = omp_assistant_line("nice picture"); + let lines: Vec<&str> = vec![&first, &reply, &tool_result, &image_only, &after]; + + // The engine reconstructs these dispatched texts from the DB rows; the + // image-only row's is empty because ACP keeps images out of the text. + let dispatched = vec!["hello".to_string(), String::new()]; + assert_eq!( + ordinal_of(&dispatched, ""), + 0, + "claude's transcript cannot address an empty prompt" + ); + let ordinal = ordinal_of_prompt(&dispatched, ""); + assert_eq!(ordinal, 1, "ACP can: it is the first empty-bodied prompt"); + + assert_eq!( + omp_cut_index(&lines, "", ordinal, SYS), + Some(3), + "cut before the image-only prompt itself" + ); + } + + /// Two image-only messages share the empty identity, so the ordinal is the + /// only thing telling them apart — it must select the right one. + #[test] + fn the_ordinal_picks_between_two_image_only_prompts() { + let first = omp_user_line(serde_json::json!([{"type":"text","text":"hello"}])); + let a = image_only_line(); + let reply = omp_assistant_line("one"); + let b = image_only_line(); + let lines: Vec<&str> = vec![&first, &a, &reply, &b]; + + let dispatched = vec!["hello".to_string(), String::new(), String::new()]; + assert_eq!(ordinal_of_prompt(&dispatched, ""), 2, "counts up to the target"); + + assert_eq!(omp_cut_index(&lines, "", 1, SYS), Some(1)); + assert_eq!(omp_cut_index(&lines, "", 2, SYS), Some(3)); + assert_eq!(omp_cut_index(&lines, "", 3, SYS), None, "only two exist"); + } + + /// A rewind of the first post-ENGINE-SWITCH message does not match, and + /// that is pinned deliberately. + /// + /// The dispatched text is `system + context_digest + user`, while the + /// persisted row holds only `user` — the digest is kept out of the DB on + /// purpose (PR #139: it can carry paths and pasted secrets). Stripping the + /// system prompt still leaves `digest + user`, so nothing matches. + /// + /// No match is the SAFE outcome: `fork_omp_at` runs before any stop or + /// truncate, so the rewind fails with the session fully intact. The tempting + /// "fix" — accepting any trailing segment after a blank line — is the + /// mis-cut this matcher was tightened to prevent one round earlier. A real + /// fix has to anchor the OMP-native history boundary (the jsonl starts at + /// the switch, while the DB ordinal counts the whole session), which is + /// cross-engine rewind semantics, not a matcher tweak. + #[test] + fn a_post_switch_first_prompt_does_not_match_rather_than_guessing() { + let dispatched = format!("{SYS}\n\nCONTEXT DIGEST: 12 prior turns…\n\nrun the tests"); + let first = omp_user_line(serde_json::json!([{"type":"text","text":dispatched}])); + let lines: Vec<&str> = vec![&first]; + + assert_eq!( + omp_cut_index(&lines, "run the tests", 1, SYS), + None, + "refusing to guess the digest boundary is correct; a match here would \ + mean accepting any trailing segment, which mis-cuts multi-paragraph prompts" + ); + } + + /// The first prompt carries the system prepend, whose relaxed match must + /// not swallow an empty target — that would cut the whole session away. + #[test] + fn a_system_prepended_first_prompt_never_matches_an_empty_target() { + let first = omp_user_line(serde_json::json!([ + {"type":"text","text":format!("{SYS}\n\nhello")} + ])); + let image_only = image_only_line(); + let lines: Vec<&str> = vec![&first, &image_only]; + + assert_eq!( + omp_cut_index(&lines, "", 1, SYS), + Some(1), + "the empty target is the image-only prompt, not the prepended first turn" + ); + // And the prepended turn IS reachable by its own text. + assert_eq!(omp_cut_index(&lines, "hello", 1, SYS), Some(0)); + } } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index a38a7079..1edc4669 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -6,6 +6,7 @@ deny(clippy::unwrap_used, clippy::expect_used, clippy::panic) )] +mod acp; mod adapters; pub mod ask; mod auth_persist; @@ -392,8 +393,25 @@ pub fn run() { commands_backup::backup_export_recovery_key, commands_backup::backup_restore, ]) - .run(tauri::generate_context!()) - .unwrap_or_else(|e| fatal("running tauri application", e)); + .build(tauri::generate_context!()) + .unwrap_or_else(|e| fatal("building tauri application", e)) + .run(|_app, event| { + if let tauri::RunEvent::ExitRequested { .. } = event { + // ACP children are spawned into their own process group with no + // parent-death signal, and the pool that owns them is a + // process-static `LazyLock` — normal static teardown never drops + // its `Child` values, so the agent process and every tool it + // spawned would outlive the UI that was supervising them. + // + // Awaited rather than detached (unlike the backup flush on + // CloseRequested, which is only IO): this is the last moment the + // reap can still run, and a detached task dies with the process + // that was about to leak the children. `proc_registry::reap` + // SIGKILLs each process group in the descendant closure and then + // waits on an already-killed child, so this cannot hang. + tauri::async_runtime::block_on(acp::runtime::shutdown_all()); + } + }); } #[cfg(test)] diff --git a/src-tauri/src/planner.rs b/src-tauri/src/planner.rs index 3c80653e..e3f1e138 100644 --- a/src-tauri/src/planner.rs +++ b/src-tauri/src/planner.rs @@ -3728,6 +3728,13 @@ mod tests { "opencode".to_string(), root.join("missing-opencode").to_string_lossy().into_owned(), ), + // omp is a routing candidate too; without an override the test + // fixture's `installed || command == tool` fallback would call it + // available and this "nothing is installed" setup would not hold. + ( + "omp".to_string(), + root.join("missing-omp").to_string_lossy().into_owned(), + ), ])); let preview = get_resolved(&db, confirm_thread).await.unwrap().unwrap(); @@ -3861,6 +3868,13 @@ mod tests { "opencode".to_string(), root.join("missing-opencode").to_string_lossy().into_owned(), ), + // omp is a routing candidate too; without an override the test + // fixture's `installed || command == tool` fallback would call it + // available and this "nothing is installed" setup would not hold. + ( + "omp".to_string(), + root.join("missing-omp").to_string_lossy().into_owned(), + ), ])); let confirm_checks = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); @@ -4012,6 +4026,13 @@ mod tests { "opencode".to_string(), root.join("missing-opencode").to_string_lossy().into_owned(), ), + // omp is a routing candidate too; without an override the test + // fixture's `installed || command == tool` fallback would call it + // available and this "nothing is installed" setup would not hold. + ( + "omp".to_string(), + root.join("missing-omp").to_string_lossy().into_owned(), + ), ])); let result = approve_direction_with_pin(&db, thread.id, 0, None).await; crate::tool_command::set_overrides(std::collections::HashMap::new()); diff --git a/src-tauri/src/session_meta.rs b/src-tauri/src/session_meta.rs index 1001a76d..7356e4ce 100644 --- a/src-tauri/src/session_meta.rs +++ b/src-tauri/src/session_meta.rs @@ -308,6 +308,28 @@ async fn gather_claude(cwd: &str) -> SessionMetaSnapshot { /// 按 tool 分派。claude 只在带外补 skill(其余 meta 走事件流);其他未知 tool 返回空。 /// `command` is the effective binary for this session (per-session/lead pin, else /// the global alias) so MCP/skill probes hit the binary actually driving it. +/// OMP/ACP: skills from the lead/worker cwd (Weft materializes into `.agents` +/// and `.claude`). MCP is injected by Weft over ACP session/new — the engine +/// cache is the authority and is overlaid in `lead_session_meta` / worker meta. +async fn gather_omp(cwd: &str) -> SessionMetaSnapshot { + // Prefer .agents (ACP/omp ecosystem); also scan .claude since inject writes both. + let skills = crate::skills::cwd_skills(std::path::Path::new(cwd), &[".agents", ".claude"]) + .into_iter() + .map(|s| SkillInfo { + name: s.name, + description: s.description, + }) + .collect::>(); + SessionMetaSnapshot { + context_tokens: None, + window: None, + model: None, + mcp_servers: None, // engine overlay supplies injected MCP + skills: Some(skills), + reasoning_effort: None, + } +} + pub async fn gather( tool: &str, cwd: &str, @@ -318,6 +340,7 @@ pub async fn gather( "codex" => gather_codex(cwd, command).await, "opencode" => gather_opencode(cwd, native_id, command).await, "claude" => gather_claude(cwd).await, + "omp" => gather_omp(cwd).await, _ => SessionMetaSnapshot::default(), } } diff --git a/src-tauri/src/tools.rs b/src-tauri/src/tools.rs index 9a8ac90b..5a217750 100644 --- a/src-tauri/src/tools.rs +++ b/src-tauri/src/tools.rs @@ -21,9 +21,16 @@ pub struct ToolStatus { pub diagnostics: Vec, } -// Display order for Settings (default-tool picker + diagnostics): mirrors the -// default-tool priority (codex > claude > opencode). -const TOOLS: [&str; 3] = ["codex", "claude", "opencode"]; +/// Every coding-agent identity weft drives, in Settings display order (which +/// mirrors the default-tool priority: codex > claude > opencode > omp). +/// +/// The SINGLE source for that set: `lead_chat::commands` validates +/// engine-switch requests against this same list rather than its own copy. +/// While they were separate constants, registering omp here made it appear in +/// the picker and in `EngineSwitchDialog` while `switch_lead_tool` / +/// `switch_worker_tool` still rejected it as `unknown tool "omp"` — offered +/// everywhere, selectable nowhere. +pub const TOOLS: [&str; 4] = ["codex", "claude", "opencode", "omp"]; /// The executable used for the version probe, plus whether that exact path is /// reachable by a bare session spawn. Prefer the spawnable PATH match over a @@ -148,6 +155,22 @@ pub async fn default_tool(db: &crate::store::Db) -> String { mod tests { use super::*; + /// Registering an ACP backend is what puts a tool in front of the user — + /// detect probes it, Settings lists it, `EngineSwitchDialog` offers it. If + /// it is missing from `TOOLS`, `switch_lead_tool`/`switch_worker_tool` + /// answer `unknown tool "…"` and the engine is unreachable by every path + /// that offered it. This asserts the registry can never get ahead of the + /// switch allowlist again, for the NEXT backend as much as for omp. + #[test] + fn every_registered_acp_backend_is_a_switchable_tool() { + for id in crate::acp::registered_ids() { + assert!( + TOOLS.contains(&id), + "ACP backend {id:?} is registered but not in TOOLS, so switching to it fails" + ); + } + } + #[test] fn probe_target_prefers_a_spawnable_path_over_diagnostic_only_match() { let diagnostic = std::path::PathBuf::from("/first/codex"); diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 40d1b9b7..7883aeae 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -20,7 +20,8 @@ ], "security": { "csp": null - } + }, + "withGlobalTauri": true }, "plugins": { "updater": { diff --git a/src-tauri/tests/fixtures/acp/initialize-result.json b/src-tauri/tests/fixtures/acp/initialize-result.json new file mode 100644 index 00000000..58258a76 --- /dev/null +++ b/src-tauri/tests/fixtures/acp/initialize-result.json @@ -0,0 +1,36 @@ +{ + "jsonrpc": "2.0", + "id": 1, + "result": { + "protocolVersion": 1, + "agentInfo": { + "name": "oh-my-pi", + "title": "Oh My Pi", + "version": "17.1.1" + }, + "authMethods": [ + { + "id": "agent", + "name": "Use existing local credentials", + "description": "Authenticate via the provider keys/OAuth state already configured under ~/.omp." + } + ], + "agentCapabilities": { + "loadSession": true, + "mcpCapabilities": { + "http": true, + "sse": true + }, + "promptCapabilities": { + "embeddedContext": true, + "image": true + }, + "sessionCapabilities": { + "list": {}, + "fork": {}, + "resume": {}, + "close": {} + } + } + } +} \ No newline at end of file diff --git a/src-tauri/tests/fixtures/acp/omp-spike-transcript.json b/src-tauri/tests/fixtures/acp/omp-spike-transcript.json new file mode 100644 index 00000000..2be4fdaf --- /dev/null +++ b/src-tauri/tests/fixtures/acp/omp-spike-transcript.json @@ -0,0 +1,6877 @@ +[ + { + "dir": "out", + "raw": { + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": { + "protocolVersion": 1, + "clientInfo": { + "name": "weft-spike", + "version": "0.0.0" + }, + "clientCapabilities": { + "fs": { + "readTextFile": false, + "writeTextFile": false + }, + "session": { + "requestPermission": true + } + } + } + } + }, + { + "dir": "in", + "raw": { + "jsonrpc": "2.0", + "id": 1, + "result": { + "protocolVersion": 1, + "agentInfo": { + "name": "oh-my-pi", + "title": "Oh My Pi", + "version": "17.1.1" + }, + "authMethods": [ + { + "id": "agent", + "name": "Use existing local credentials", + "description": "Authenticate via the provider keys/OAuth state already configured under ~/.omp." + } + ], + "agentCapabilities": { + "loadSession": true, + "mcpCapabilities": { + "http": true, + "sse": true + }, + "promptCapabilities": { + "embeddedContext": true, + "image": true + }, + "sessionCapabilities": { + "list": {}, + "fork": {}, + "resume": {}, + "close": {} + } + } + } + } + }, + { + "dir": "out", + "raw": { + "jsonrpc": "2.0", + "id": 2, + "method": "session/new", + "params": { + "cwd": "/Users/solojiang/workspace/weft/.worktrees/feat/omp-acp-runtime", + "mcpServers": [] + } + } + }, + { + "dir": "in", + "raw": { + "jsonrpc": "2.0", + "id": 2, + "result": { + "sessionId": "019f9475-63bb-7000-bff7-58073a1d26e9", + "configOptions": [ + { + "id": "mode", + "name": "Mode", + "category": "mode", + "type": "select", + "currentValue": "default", + "options": [ + { + "value": "default", + "name": "Default", + "description": "Standard ACP headless mode" + }, + { + "value": "plan", + "name": "Plan", + "description": "Read-only planning mode that drafts a plan to a markdown file before any code changes" + } + ] + }, + { + "id": "model", + "name": "Model", + "category": "model", + "type": "select", + "currentValue": "xai-oauth/grok-4.5", + "options": [ + { + "value": "github-copilot/claude-fable-5", + "name": "Claude Fable 5", + "description": "github-copilot/claude-fable-5" + }, + { + "value": "github-copilot/claude-haiku-4.5", + "name": "Claude Haiku 4.5", + "description": "github-copilot/claude-haiku-4.5" + }, + { + "value": "github-copilot/claude-opus-4.5", + "name": "Claude Opus 4.5", + "description": "github-copilot/claude-opus-4.5" + }, + { + "value": "github-copilot/claude-opus-4.6", + "name": "Claude Opus 4.6", + "description": "github-copilot/claude-opus-4.6" + }, + { + "value": "github-copilot/claude-opus-4.7", + "name": "Claude Opus 4.7", + "description": "github-copilot/claude-opus-4.7" + }, + { + "value": "github-copilot/claude-opus-4.8", + "name": "Claude Opus 4.8", + "description": "github-copilot/claude-opus-4.8" + }, + { + "value": "github-copilot/claude-sonnet-4", + "name": "Claude Sonnet 4", + "description": "github-copilot/claude-sonnet-4" + }, + { + "value": "github-copilot/claude-sonnet-4.5", + "name": "Claude Sonnet 4.5", + "description": "github-copilot/claude-sonnet-4.5" + }, + { + "value": "github-copilot/claude-sonnet-4.6", + "name": "Claude Sonnet 4.6", + "description": "github-copilot/claude-sonnet-4.6" + }, + { + "value": "github-copilot/claude-sonnet-5", + "name": "Claude Sonnet 5", + "description": "github-copilot/claude-sonnet-5" + }, + { + "value": "github-copilot/gemini-2.5-pro", + "name": "Gemini 2.5 Pro", + "description": "github-copilot/gemini-2.5-pro" + }, + { + "value": "github-copilot/gemini-3-flash-preview", + "name": "Gemini 3 Flash (Preview)", + "description": "github-copilot/gemini-3-flash-preview" + }, + { + "value": "github-copilot/gemini-3-pro-preview", + "name": "Gemini 3 Pro Preview", + "description": "github-copilot/gemini-3-pro-preview" + }, + { + "value": "github-copilot/gemini-3.1-pro-preview", + "name": "Gemini 3.1 Pro", + "description": "github-copilot/gemini-3.1-pro-preview" + }, + { + "value": "github-copilot/gemini-3.5-flash", + "name": "Gemini 3.5 Flash", + "description": "github-copilot/gemini-3.5-flash" + }, + { + "value": "github-copilot/gpt-4.1", + "name": "GPT-4.1", + "description": "github-copilot/gpt-4.1" + }, + { + "value": "github-copilot/gpt-4o", + "name": "GPT-4o", + "description": "github-copilot/gpt-4o" + }, + { + "value": "github-copilot/gpt-5", + "name": "GPT-5", + "description": "github-copilot/gpt-5" + }, + { + "value": "github-copilot/gpt-5-mini", + "name": "GPT-5 mini", + "description": "github-copilot/gpt-5-mini" + }, + { + "value": "github-copilot/gpt-5.1", + "name": "GPT-5.1", + "description": "github-copilot/gpt-5.1" + }, + { + "value": "github-copilot/gpt-5.1-codex", + "name": "GPT-5.1 Codex", + "description": "github-copilot/gpt-5.1-codex" + }, + { + "value": "github-copilot/gpt-5.1-codex-max", + "name": "GPT-5.1 Codex Max", + "description": "github-copilot/gpt-5.1-codex-max" + }, + { + "value": "github-copilot/gpt-5.1-codex-mini", + "name": "GPT-5.1 Codex mini", + "description": "github-copilot/gpt-5.1-codex-mini" + }, + { + "value": "github-copilot/gpt-5.2", + "name": "GPT-5.2", + "description": "github-copilot/gpt-5.2" + }, + { + "value": "github-copilot/gpt-5.2-codex", + "name": "GPT-5.2 Codex", + "description": "github-copilot/gpt-5.2-codex" + }, + { + "value": "github-copilot/gpt-5.3-codex", + "name": "GPT-5.3-Codex", + "description": "github-copilot/gpt-5.3-codex" + }, + { + "value": "github-copilot/gpt-5.4", + "name": "GPT-5.4", + "description": "github-copilot/gpt-5.4" + }, + { + "value": "github-copilot/gpt-5.4-mini", + "name": "GPT-5.4 mini", + "description": "github-copilot/gpt-5.4-mini" + }, + { + "value": "github-copilot/gpt-5.4-nano", + "name": "GPT-5.4 nano", + "description": "github-copilot/gpt-5.4-nano" + }, + { + "value": "github-copilot/gpt-5.5", + "name": "GPT-5.5", + "description": "github-copilot/gpt-5.5" + }, + { + "value": "github-copilot/gpt-5.6-luna", + "name": "GPT-5.6 Luna", + "description": "github-copilot/gpt-5.6-luna" + }, + { + "value": "github-copilot/gpt-5.6-sol", + "name": "GPT-5.6 Sol", + "description": "github-copilot/gpt-5.6-sol" + }, + { + "value": "github-copilot/gpt-5.6-terra", + "name": "GPT-5.6 Terra", + "description": "github-copilot/gpt-5.6-terra" + }, + { + "value": "github-copilot/grok-code-fast-1", + "name": "Grok Code Fast 1", + "description": "github-copilot/grok-code-fast-1" + }, + { + "value": "github-copilot/kimi-k2.7-code", + "name": "Kimi K2.7 Code", + "description": "github-copilot/kimi-k2.7-code" + }, + { + "value": "github-copilot/mai-code-1-flash-picker", + "name": "MAI-Code-1-Flash", + "description": "github-copilot/mai-code-1-flash-picker" + }, + { + "value": "github-copilot/raptor-mini", + "name": "Raptor mini", + "description": "github-copilot/raptor-mini" + }, + { + "value": "github-copilot/claude-fable-5-1m", + "name": "Claude Fable 5 (1M)", + "description": "github-copilot/claude-fable-5-1m" + }, + { + "value": "github-copilot/claude-opus-4.7-1m", + "name": "Claude Opus 4.7 (1M)", + "description": "github-copilot/claude-opus-4.7-1m" + }, + { + "value": "github-copilot/claude-opus-4.8-1m", + "name": "Claude Opus 4.8 (1M)", + "description": "github-copilot/claude-opus-4.8-1m" + }, + { + "value": "github-copilot/claude-sonnet-4.6-1m", + "name": "Claude Sonnet 4.6 (1M)", + "description": "github-copilot/claude-sonnet-4.6-1m" + }, + { + "value": "github-copilot/claude-sonnet-5-1m", + "name": "Claude Sonnet 5 (1M)", + "description": "github-copilot/claude-sonnet-5-1m" + }, + { + "value": "github-copilot/gemini-3.1-pro-preview-1m", + "name": "Gemini 3.1 Pro (1M)", + "description": "github-copilot/gemini-3.1-pro-preview-1m" + }, + { + "value": "github-copilot/gemini-3.5-flash-1m", + "name": "Gemini 3.5 Flash (1M)", + "description": "github-copilot/gemini-3.5-flash-1m" + }, + { + "value": "github-copilot/gpt-5.4-1m", + "name": "GPT-5.4 (1M)", + "description": "github-copilot/gpt-5.4-1m" + }, + { + "value": "github-copilot/gpt-5.5-1m", + "name": "GPT-5.5 (1M)", + "description": "github-copilot/gpt-5.5-1m" + }, + { + "value": "github-copilot/gpt-5.6-luna-1m", + "name": "GPT-5.6 Luna (1M)", + "description": "github-copilot/gpt-5.6-luna-1m" + }, + { + "value": "github-copilot/gpt-5.6-sol-1m", + "name": "GPT-5.6 Sol (1M)", + "description": "github-copilot/gpt-5.6-sol-1m" + }, + { + "value": "github-copilot/gpt-5.6-terra-1m", + "name": "GPT-5.6 Terra (1M)", + "description": "github-copilot/gpt-5.6-terra-1m" + }, + { + "value": "google-antigravity/claude-opus-4-5", + "name": "Claude Opus 4.5", + "description": "google-antigravity/claude-opus-4-5" + }, + { + "value": "google-antigravity/claude-opus-4-6", + "name": "Claude Opus 4.6", + "description": "google-antigravity/claude-opus-4-6" + }, + { + "value": "google-antigravity/claude-sonnet-4-5", + "name": "Claude Sonnet 4.5", + "description": "google-antigravity/claude-sonnet-4-5" + }, + { + "value": "google-antigravity/claude-sonnet-4-6", + "name": "Claude Sonnet 4.6", + "description": "google-antigravity/claude-sonnet-4-6" + }, + { + "value": "google-antigravity/gemini-2.5-flash", + "name": "Gemini 2.5 Flash", + "description": "google-antigravity/gemini-2.5-flash" + }, + { + "value": "google-antigravity/gemini-2.5-flash-lite", + "name": "Gemini 3.1 Flash Lite", + "description": "google-antigravity/gemini-2.5-flash-lite" + }, + { + "value": "google-antigravity/gemini-2.5-pro", + "name": "Gemini 2.5 Pro", + "description": "google-antigravity/gemini-2.5-pro" + }, + { + "value": "google-antigravity/gemini-3-flash", + "name": "Gemini 3 Flash", + "description": "google-antigravity/gemini-3-flash" + }, + { + "value": "google-antigravity/gemini-3-pro", + "name": "Gemini 3 Pro", + "description": "google-antigravity/gemini-3-pro" + }, + { + "value": "google-antigravity/gemini-3.1-flash-image", + "name": "Gemini 3.1 Flash Image", + "description": "google-antigravity/gemini-3.1-flash-image" + }, + { + "value": "google-antigravity/gemini-3.1-flash-lite", + "name": "Gemini 3.1 Flash Lite", + "description": "google-antigravity/gemini-3.1-flash-lite" + }, + { + "value": "google-antigravity/gemini-3.1-pro", + "name": "Gemini 3.1 Pro", + "description": "google-antigravity/gemini-3.1-pro" + }, + { + "value": "google-antigravity/gemini-3.5-flash", + "name": "Gemini 3.5 Flash", + "description": "google-antigravity/gemini-3.5-flash" + }, + { + "value": "google-antigravity/gemini-3.6-flash", + "name": "Gemini 3.6 Flash", + "description": "google-antigravity/gemini-3.6-flash" + }, + { + "value": "google-antigravity/gpt-oss-120b", + "name": "GPT-OSS 120B", + "description": "google-antigravity/gpt-oss-120b" + }, + { + "value": "google-antigravity/tab_flash_lite_preview", + "name": "tab_flash_lite_preview", + "description": "google-antigravity/tab_flash_lite_preview" + }, + { + "value": "google-antigravity/tab_jump_flash_lite_preview", + "name": "tab_jump_flash_lite_preview", + "description": "google-antigravity/tab_jump_flash_lite_preview" + }, + { + "value": "kimi-code/k3", + "name": "K3", + "description": "kimi-code/k3" + }, + { + "value": "kimi-code/kimi-for-coding", + "name": "K2.7 Coding", + "description": "kimi-code/kimi-for-coding" + }, + { + "value": "kimi-code/kimi-for-coding-highspeed", + "name": "K2.7 Coding Highspeed", + "description": "kimi-code/kimi-for-coding-highspeed" + }, + { + "value": "kimi-code/kimi-k2", + "name": "Kimi K2", + "description": "kimi-code/kimi-k2" + }, + { + "value": "kimi-code/kimi-k2-turbo-preview", + "name": "Kimi K2 Turbo", + "description": "kimi-code/kimi-k2-turbo-preview" + }, + { + "value": "kimi-code/kimi-k2.5", + "name": "Kimi K2.5", + "description": "kimi-code/kimi-k2.5" + }, + { + "value": "kimi-code/k3-256k", + "name": "k3-256k", + "description": "kimi-code/k3-256k" + }, + { + "value": "openai-codex/gpt-5.3-codex-spark", + "name": "GPT-5.3-Codex-Spark", + "description": "openai-codex/gpt-5.3-codex-spark" + }, + { + "value": "openai-codex/gpt-5.4", + "name": "GPT-5.4", + "description": "openai-codex/gpt-5.4" + }, + { + "value": "openai-codex/gpt-5.4-mini", + "name": "GPT-5.4-Mini", + "description": "openai-codex/gpt-5.4-mini" + }, + { + "value": "openai-codex/gpt-5.5", + "name": "GPT-5.5", + "description": "openai-codex/gpt-5.5" + }, + { + "value": "openai-codex/gpt-5.6-luna", + "name": "GPT-5.6-Luna", + "description": "openai-codex/gpt-5.6-luna" + }, + { + "value": "openai-codex/gpt-5.6-sol", + "name": "GPT-5.6-Sol", + "description": "openai-codex/gpt-5.6-sol" + }, + { + "value": "openai-codex/gpt-5.6-terra", + "name": "GPT-5.6-Terra", + "description": "openai-codex/gpt-5.6-terra" + }, + { + "value": "xai-oauth/grok-4.20-0309-non-reasoning", + "name": "Grok 4.20 (Non-Reasoning)", + "description": "xai-oauth/grok-4.20-0309-non-reasoning" + }, + { + "value": "xai-oauth/grok-4.20-0309-reasoning", + "name": "Grok 4.20 (Reasoning)", + "description": "xai-oauth/grok-4.20-0309-reasoning" + }, + { + "value": "xai-oauth/grok-4.20-multi-agent-0309", + "name": "Grok 4.20 (Multi-Agent)", + "description": "xai-oauth/grok-4.20-multi-agent-0309" + }, + { + "value": "xai-oauth/grok-4.3", + "name": "Grok 4.3", + "description": "xai-oauth/grok-4.3" + }, + { + "value": "xai-oauth/grok-4.5", + "name": "Grok 4.5", + "description": "xai-oauth/grok-4.5" + }, + { + "value": "xai-oauth/grok-build", + "name": "Grok Build", + "description": "xai-oauth/grok-build" + }, + { + "value": "xai-oauth/grok-build-0.1", + "name": "Grok Build 0.1", + "description": "xai-oauth/grok-build-0.1" + }, + { + "value": "xai-oauth/grok-composer-2.5-fast", + "name": "Grok Composer 2.5 Fast", + "description": "xai-oauth/grok-composer-2.5-fast" + } + ] + }, + { + "id": "thinking", + "name": "Thinking", + "category": "thought_level", + "type": "select", + "currentValue": "xhigh", + "options": [ + { + "value": "off", + "name": "Off" + }, + { + "value": "auto", + "name": "Auto", + "description": "Auto-detect per prompt (low\u2013xhigh)" + }, + { + "value": "minimal", + "name": "minimal" + }, + { + "value": "low", + "name": "low" + }, + { + "value": "medium", + "name": "medium" + }, + { + "value": "high", + "name": "high" + }, + { + "value": "xhigh", + "name": "xhigh" + } + ] + } + ], + "modes": { + "availableModes": [ + { + "id": "default", + "name": "Default", + "description": "Standard ACP headless mode" + }, + { + "id": "plan", + "name": "Plan", + "description": "Read-only planning mode that drafts a plan to a markdown file before any code changes" + } + ], + "currentModeId": "default" + } + } + } + }, + { + "dir": "out", + "raw": { + "jsonrpc": "2.0", + "id": 3, + "method": "session/new", + "params": { + "cwd": "/Users/solojiang/workspace/weft/.worktrees/feat/omp-acp-runtime", + "mcpServers": [ + { + "type": "http", + "name": "weft_bus", + "url": "http://127.0.0.1:9/bus/1/1/mcp" + } + ] + } + } + }, + { + "dir": "in", + "raw": { + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "019f9475-63bb-7000-bff7-58073a1d26e9", + "update": { + "sessionUpdate": "available_commands_update", + "availableCommands": [ + { + "name": "model", + "description": "Show current model selection" + }, + { + "name": "fast", + "description": "Toggle fast mode", + "input": { + "hint": "[on|off|status]" + } + }, + { + "name": "computer", + "description": "Toggle computer use", + "input": { + "hint": "[on|off|status]" + } + }, + { + "name": "prewalk", + "description": "Prewalk at the next action" + }, + { + "name": "advisor", + "description": "Toggle advisor", + "input": { + "hint": "[on|off|status|dump [raw]|configure]" + } + }, + { + "name": "export", + "description": "Export session to HTML file", + "input": { + "hint": "[--themes] [path]" + } + }, + { + "name": "dump", + "description": "Return full transcript as plain text, with LLM request JSON path" + }, + { + "name": "share", + "description": "Share session via an encrypted link (share server or secret gist)" + }, + { + "name": "browser", + "description": "Toggle browser headless vs visible mode", + "input": { + "hint": "[headless|visible]" + } + }, + { + "name": "todo", + "description": "Manage todos", + "input": { + "hint": "" + } + }, + { + "name": "session", + "description": "Show or configure the current session", + "input": { + "hint": "[info|delete|pin [account]]" + } + }, + { + "name": "jobs", + "description": "Show background jobs" + }, + { + "name": "usage", + "description": "Show token usage", + "input": { + "hint": "[show|reset [account|active]]" + } + }, + { + "name": "stats", + "description": "Launch the local stats dashboard", + "input": { + "hint": "[--port ]" + } + }, + { + "name": "changelog", + "description": "Show changelog", + "input": { + "hint": "[full]" + } + }, + { + "name": "tools", + "description": "Show available tools" + }, + { + "name": "context", + "description": "Show context usage" + }, + { + "name": "mcp", + "description": "Manage MCP servers", + "input": { + "hint": "" + } + }, + { + "name": "ssh", + "description": "Manage SSH connections", + "input": { + "hint": "" + } + }, + { + "name": "fresh", + "description": "Reset provider stream state without changing the local transcript" + }, + { + "name": "compact", + "description": "Compact the conversation", + "input": { + "hint": "[soft|remote|snapcompact] [focus]" + } + }, + { + "name": "shake", + "description": "Shake heavy content out of the conversation context", + "input": { + "hint": "[elide|images]" + } + }, + { + "name": "memory", + "description": "Manage memory", + "input": { + "hint": "" + } + }, + { + "name": "rename", + "description": "Rename the current session", + "input": { + "hint": "" + } + }, + { + "name": "move", + "description": "Move the current session to a different directory", + "input": { + "hint": "[<path>]" + } + }, + { + "name": "add-dir", + "description": "Add a workspace directory to this session", + "input": { + "hint": "<path>" + } + }, + { + "name": "remove-dir", + "description": "Remove a workspace directory from this session", + "input": { + "hint": "<path>" + } + }, + { + "name": "dirs", + "description": "List this session's workspace directories" + }, + { + "name": "marketplace", + "description": "Manage plugins from marketplaces", + "input": { + "hint": "<subcommand>" + } + }, + { + "name": "plugins", + "description": "Manage plugins", + "input": { + "hint": "[list|enable|disable]" + } + }, + { + "name": "reload-plugins", + "description": "Reload all plugins" + }, + { + "name": "force", + "description": "Force next turn to use a specific tool", + "input": { + "hint": "<tool-name> [prompt]" + } + }, + { + "name": "skill:a11y-debugging", + "description": "Uses Chrome DevTools MCP for accessibility (a11y) debugging and auditing based on web.dev guidelines. Use when testing semantic HTML, ARIA labels, focus states, keyboard navigation, tap targets, and color contrast.", + "input": { + "hint": "arguments" + } + }, + { + "name": "skill:baoyu-design", + "description": "Create polished design artifacts as self-contained HTML \u2014 UI mockups, interactive prototypes, wireframes, landing pages, dashboards, app screens, mobile apps, and slide decks. Use this skill whenever the user wants to design, mock up, prototype, wireframe, or visualize any interface, screen, flow, or visual artifact \u2014 even when they don't say the word \"design\" (e.g. \"build me a landing page\", \"show me what a settings screen could look like\", \"prototype an onboarding flow\", \"wireframe a few layout ideas\", \"make a pitch deck\"). It drives a full design process: clarifying questions, design-context gathering, and production of one or more HTML deliverables. Runs on portable agent harnesses including Claude Code, Cursor, and Codex Agent \u2014 harness-specific tools are resolved from references/.", + "input": { + "hint": "arguments" + } + }, + { + "name": "skill:brainstorming", + "description": "You MUST use this before any creative work - creating features, building components, adding functionality, or modifying behavior. Explores user intent, requirements and design before implementation.", + "input": { + "hint": "arguments" + } + }, + { + "name": "skill:brandkit", + "description": "Premium brand-kit image generation skill for creating high-end brand-guidelines boards, logo systems, identity decks, and visual-world presentations. Trained for minimalist, cinematic, editorial, dark-tech, luxury, cultural, security, gaming, developer-tool, and consumer-app brand systems. Optimized for intentional logo concepting, refined composition, sparse typography, strong symbolic meaning, premium mockups, art-directed imagery, and flexible grid layouts.", + "input": { + "hint": "arguments" + } + }, + { + "name": "skill:chinese-documentation", + "description": "\u4e2d\u6587\u6587\u6863\u6392\u7248\u53c2\u8003\u2014\u2014\u4e2d\u82f1\u6587\u7a7a\u683c\u3001\u5168\u534a\u89d2\u6807\u70b9\u3001\u672f\u8bed\u4fdd\u7559\u3001\u94fe\u63a5\u683c\u5f0f\u3001\u4e2d\u6587\u6587\u6848\u6392\u7248\u6307\u5317\u7ea6\u5b9a\u3002\u4ec5\u5728\u7528\u6237\u663e\u5f0f /chinese-documentation \u65f6\u8c03\u7528\uff0c\u4e0d\u8981\u6839\u636e\u4e0a\u4e0b\u6587\u81ea\u52a8\u89e6\u53d1\u3002", + "input": { + "hint": "arguments" + } + }, + { + "name": "skill:chrome-devtools", + "description": "Uses Chrome DevTools via MCP for efficient debugging, troubleshooting and browser automation. Use when debugging web pages, automating browser interactions, analyzing performance, or inspecting network requests. This skill does not apply to `--slim` mode (MCP configuration).", + "input": { + "hint": "arguments" + } + }, + { + "name": "skill:chrome-devtools-cli", + "description": "Use this skill to write shell scripts or run shell commands to automate tasks in the browser or otherwise use Chrome DevTools via CLI.", + "input": { + "hint": "arguments" + } + }, + { + "name": "skill:chronicle", + "description": "Allows you to view the user's screen as well as several hours of history. Use when the user makes a reference to their recent work, for which it'd be helpful to see the screen. This skill MUST be used whenever you need to resolve ambiguity in a user request, where the user hasn't specified enough context to do the task. Examples include disambiguating the specific user/app/document/error the user is referring to.\n\nYou must also use this skill if the user asks about any question regarding Chronicle or asks what you can see from the screen.\n", + "input": { + "hint": "arguments" + } + }, + { + "name": "skill:claude-md-improver", + "description": "Audit and improve CLAUDE.md files in repositories. Use when user asks to check, audit, update, improve, or fix CLAUDE.md files. Scans for all CLAUDE.md files, evaluates quality against templates, outputs quality report, then makes targeted updates. Also use when the user mentions \"CLAUDE.md maintenance\" or \"project memory optimization\".", + "input": { + "hint": "arguments" + } + }, + { + "name": "skill:debug-optimize-lcp", + "description": "Guides debugging and optimizing Largest Contentful Paint (LCP) using Chrome DevTools MCP tools. Use this skill whenever the user asks about LCP performance, slow page loads, Core Web Vitals optimization, or wants to understand why their page's main content takes too long to appear. Also use when the user mentions \"largest contentful paint\", \"page load speed\", \"CWV\", or wants to improve how fast their hero image or main content renders.", + "input": { + "hint": "arguments" + } + }, + { + "name": "skill:design-taste-frontend", + "description": "Anti-slop frontend skill for landing pages, portfolios, and redesigns. The agent reads the brief, infers the right design direction, and ships interfaces that do not look templated. Real design systems when applicable, audit-first on redesigns, strict pre-flight check.", + "input": { + "hint": "arguments" + } + }, + { + "name": "skill:dispatching-parallel-agents", + "description": "Use when facing 2+ independent tasks that can be worked on without shared state or sequential dependencies", + "input": { + "hint": "arguments" + } + }, + { + "name": "skill:executing-plans", + "description": "Use when you have a written implementation plan to execute in a separate session with review checkpoints", + "input": { + "hint": "arguments" + } + }, + { + "name": "skill:find-skills", + "description": "Helps users discover and install agent skills when they ask questions like \"how do I do X\", \"find a skill for X\", \"is there a skill that can...\", or express interest in extending capabilities. This skill should be used when the user is looking for functionality that might exist as an installable skill.", + "input": { + "hint": "arguments" + } + }, + { + "name": "skill:finishing-a-development-branch", + "description": "Use when implementation is complete, all tests pass, and you need to decide how to integrate the work - guides completion of development work by presenting structured options for merge, PR, or cleanup", + "input": { + "hint": "arguments" + } + }, + { + "name": "skill:full-output-enforcement", + "description": "Overrides default LLM truncation behavior. Enforces complete code generation, bans placeholder patterns, and handles token-limit splits cleanly. Apply to any task requiring exhaustive, unabridged output.", + "input": { + "hint": "arguments" + } + }, + { + "name": "skill:futuapi", + "description": "\u5bcc\u9014 OpenAPI \u4ea4\u6613\u4e0e\u884c\u60c5\u52a9\u624b\u3002\u67e5\u8be2\u80a1\u7968\u884c\u60c5\u3001K\u7ebf\u3001\u62a5\u4ef7\u3001\u5feb\u7167\u3001\u4e70\u5356\u76d8\u3001\u9010\u7b14\u6210\u4ea4\u3001\u5206\u65f6\u6570\u636e\uff1b\u89e3\u6790\u671f\u6743\u7b80\u5199\u4ee3\u7801\u3001\u67e5\u8be2\u671f\u6743\u94fe\u3001\u671f\u6743\u5230\u671f\u65e5\uff1b\u6267\u884c\u4e70\u5165/\u5356\u51fa/\u4e0b\u5355/\u64a4\u5355/\u6539\u5355\uff1b\u67e5\u8be2\u6301\u4ed3/\u8d44\u91d1/\u8d26\u6237/\u8ba2\u5355\uff1b\u8ba2\u9605\u5b9e\u65f6\u63a8\u9001\uff1b\u652f\u6301\u52a0\u5bc6\u8d27\u5e01 (crypto / BTC / ETH / \u6bd4\u7279\u5e01 / \u4ee5\u592a\u574a) \u884c\u60c5\u4e0e\u4ea4\u6613\uff1bAPI \u63a5\u53e3\u901f\u67e5\u3002\u7528\u6237\u63d0\u5230\u884c\u60c5\u3001\u62a5\u4ef7\u3001\u4ef7\u683c\u3001K\u7ebf\u3001\u5feb\u7167\u3001\u4e70\u5356\u76d8\u3001\u6446\u76d8\u3001\u6210\u4ea4\u3001\u5206\u65f6\u3001\u4e70\u5165\u3001\u5356\u51fa\u3001\u4e0b\u5355\u3001\u64a4\u5355\u3001\u4ea4\u6613\u3001\u6301\u4ed3\u3001\u8d44\u91d1\u3001\u8d26\u6237\u3001\u8ba2\u5355\u3001\u59d4\u6258\u3001futu\u3001API\u3001\u9009\u80a1\u3001\u677f\u5757\u3001\u671f\u6743\u3001\u671f\u6743\u94fe\u3001\u671f\u6743\u4ee3\u7801\u3001\u884c\u6743\u4ef7\u3001\u5230\u671f\u65e5\u3001Call\u3001Put\u3001\u770b\u6da8\u3001\u770b\u8dcc\u3001\u8ba4\u8d2d\u3001\u8ba4\u6cbd\u3001\u52a0\u5bc6\u8d27\u5e01\u3001\u6570\u5b57\u8d27\u5e01\u3001crypto\u3001BTC\u3001ETH\u3001\u6bd4\u7279\u5e01\u3001\u4ee5\u592a\u574a\u3001\u5e01\u5bf9\u3001\u8d22\u62a5\u3001\u4e1a\u7ee9\u3001\u8d22\u52a1\u62a5\u8868\u3001\u5229\u6da6\u8868\u3001\u8d44\u4ea7\u8d1f\u503a\u8868\u3001\u73b0\u91d1\u6d41\u3001\u4e3b\u8425\u6784\u6210\u3001\u8425\u6536\u62c6\u5206\u3001\u5206\u6790\u5e08\u8bc4\u7ea7\u3001\u76ee\u6807\u4ef7\u3001\u6668\u661f\u62a5\u544a\u3001\u4f30\u503c\u3001PE\u3001PB\u3001PS\u3001\u677f\u5757\u4f30\u503c\u3001\u6307\u6570\u4f30\u503c\u3001\u6210\u5206\u80a1\u4f30\u503c\u3001\u5206\u7ea2\u3001\u6d3e\u606f\u3001\u80a1\u606f\u3001\u56de\u8d2d\u3001\u62c6\u80a1\u3001\u5408\u80a1\u3001\u62c6\u5408\u80a1\u3001\u80a1\u4e1c\u3001\u6301\u80a1\u7edf\u8ba1\u3001\u80a1\u4e1c\u5206\u5e03\u3001\u6301\u80a1\u53d8\u52a8\u3001\u589e\u6301\u3001\u51cf\u6301\u3001\u65b0\u8fdb\u3001\u6e05\u4ed3\u3001\u6301\u80a1\u660e\u7ec6\u3001\u673a\u6784\u6301\u80a1\u3001\u673a\u6784\u6301\u4ed3\u3001\u5185\u90e8\u4eba\u6301\u80a1\u3001\u5185\u90e8\u4eba\u4ea4\u6613\u3001\u516c\u53f8\u6982\u51b5\u3001\u516c\u53f8\u8be6\u60c5\u3001\u516c\u53f8\u4ecb\u7ecd\u3001\u9ad8\u7ba1\u4fe1\u606f\u3001\u9ad8\u7ba1\u80cc\u666f\u3001\u7ecf\u8425\u6548\u7387\u3001\u5458\u5de5\u6570\u3001\u4eba\u5747\u8425\u6536\u3001\u4eba\u5747\u5229\u6da6\u3001\u5341\u5927\u7ecf\u7eaa\u5546\u3001\u4e70\u5356\u7ecf\u7eaa\u5546\u3001\u5356\u7a7a\u3001\u6bcf\u65e5\u5356\u7a7a\u3001\u7a7a\u5934\u6301\u4ed3\u3001\u671f\u6743\u6ce2\u52a8\u7387\u3001\u9690\u542b\u6ce2\u52a8\u7387\u3001IV\u3001\u671f\u6743\u884c\u6743\u6982\u7387 \u65f6\u81ea\u52a8\u4f7f\u7528\u3002", + "input": { + "hint": "arguments" + } + }, + { + "name": "skill:gpt-taste", + "description": "Elite UX/UI & Advanced GSAP Motion Engineer. Enforces Python-driven true randomization for layout variance, strict AIDA page structure, wide editorial typography (bans 6-line wraps), gapless bento grids, strict GSAP ScrollTriggers (pinning, stacking, scrubbing), inline micro-images, and massive section spacing.", + "input": { + "hint": "arguments" + } + }, + { + "name": "skill:high-end-visual-design", + "description": "Teaches the AI to design like a high-end agency. Defines the exact fonts, spacing, shadows, card structures, and animations that make a website feel expensive. Blocks all the common defaults that make AI designs look cheap or generic.", + "input": { + "hint": "arguments" + } + }, + { + "name": "skill:huashu-design", + "description": "\u7528HTML\u505a\u9ad8\u4fdd\u771f\u539f\u578b\u3001\u4ea4\u4e92Demo\u3001\u5e7b\u706f\u7247\u3001\u52a8\u753b\u3001\u8bbe\u8ba1\u53d8\u4f53\u63a2\u7d22+\u8bbe\u8ba1\u65b9\u5411\u987e\u95ee+\u4e13\u5bb6\u8bc4\u5ba1\u7684\u4e00\u4f53\u5316\u8bbe\u8ba1\u80fd\u529b\u3002HTML\u662f\u5de5\u5177\u4e0d\u662f\u5a92\u4ecb\uff0c\u6839\u636e\u4efb\u52a1embody\u4e0d\u540c\u4e13\u5bb6\uff08UX\u8bbe\u8ba1\u5e08/\u52a8\u753b\u5e08/\u5e7b\u706f\u7247\u8bbe\u8ba1\u5e08/\u539f\u578b\u5e08\uff09\uff0c\u907f\u514dweb design tropes\u3002\u89e6\u53d1\u8bcd\uff1a\u505a\u539f\u578b\u3001\u8bbe\u8ba1Demo\u3001\u4ea4\u4e92\u539f\u578b\u3001HTML\u6f14\u793a\u3001\u52a8\u753bDemo\u3001\u8bbe\u8ba1\u53d8\u4f53\u3001hi-fi\u8bbe\u8ba1\u3001UI mockup\u3001prototype\u3001\u8bbe\u8ba1\u63a2\u7d22\u3001\u505a\u4e2aHTML\u9875\u9762\u3001\u505a\u4e2a\u53ef\u89c6\u5316\u3001app\u539f\u578b\u3001iOS\u539f\u578b\u3001\u79fb\u52a8\u5e94\u7528mockup\u3001\u8bbe\u8ba1\u98ce\u683c\u3001\u8bbe\u8ba1\u65b9\u5411\u3001\u8bbe\u8ba1\u54f2\u5b66\u3001\u914d\u8272\u65b9\u6848\u3001\u89c6\u89c9\u98ce\u683c\u3001\u63a8\u8350\u98ce\u683c\u3001\u9009\u4e2a\u98ce\u683c\u3001\u505a\u4e2a\u597d\u770b\u7684\u3001\u8bc4\u5ba1\u3001\u597d\u4e0d\u597d\u770b\u3001review this design\u3001\u5e26\u89e3\u8bf4\u7684\u52a8\u753b\u3001\u89e3\u8bf4\u89c6\u9891\u3001\u6982\u5ff5\u89e3\u91ca\u89c6\u9891\u3001\u957f\u89c6\u9891\u79d1\u666e\u3001\u914d\u97f3\u52a8\u753b\u3001voiceover\u3001narration\u3001TTS+\u52a8\u753b\u30015\u5206\u949f\u8bb2\u6e05\u695a\u4ec0\u4e48\u662fXX\u3002**\u4e3b\u5e72\u80fd\u529b**\uff1aJunior Designer\u5de5\u4f5c\u6d41\u3001\u53cdAI slop\u6e05\u5355\u3001React+Babel\u6700\u4f73\u5b9e\u8df5\u3001Tweaks\u53d8\u4f53\u5207\u6362\u3001Speaker Notes\u6f14\u793a\u3001Starter Components\uff08\u5e7b\u706f\u7247\u5916\u58f3/\u53d8\u4f53\u753b\u5e03/\u52a8\u753b\u5f15\u64ce/\u8bbe\u5907\u8fb9\u6846/\u89e3\u8bf4Stage\uff09\u3001App\u539f\u578b\u4e13\u5c5e\u5b88\u5219\uff08\u9ed8\u8ba4\u4eceWikimedia/Met/Unsplash\u53d6\u771f\u56fe\u3001\u6bcf\u53f0iPhone\u5305AppPhone\u72b6\u6001\u7ba1\u7406\u5668\u53ef\u4ea4\u4e92\u3001\u4ea4\u4ed8\u524d\u8dd1Playwright\u70b9\u51fb\u6d4b\u8bd5\uff09\u3001Playwright\u9a8c\u8bc1\u3001HTML\u52a8\u753b\u2192MP4/GIF\u89c6\u9891\u5bfc\u51fa\uff0825fps\u57fa\u7840 + 60fps\u63d2\u5e27 + palette\u4f18\u5316GIF + 6\u9996\u573a\u666f\u5316BGM + \u81ea\u52a8fade\uff09\u3001**\u5e26\u89e3\u8bf4\u7684\u957f\u52a8\u753bpipeline**\uff08\u8c46\u5305TTS\u751f\u4eba\u58f0+\u5b9e\u6d4b\u65f6\u957f\u751ftimeline.json+NarrationStage\u9a71\u52a8\u753b\u9762+ducking\u6df7\u97f3\u2192\u4ea4\u4ed8HTML\u5b9e\u64ad+\u53d1\u5e03MP4\u53cc\u5f62\u6001\uff1b\u94c1\u5f8b\uff1a\u6574\u7247\u662f\u4e00\u4e2a\u8fde\u7eed\u7684\u8fd0\u52a8\u53d9\u4e8b\uff0c\u7981PowerPoint\u5207\u6362\uff09\u3002**\u9700\u6c42\u6a21\u7cca\u65f6\u7684Fallback**\uff1a\u8bbe\u8ba1\u65b9\u5411\u987e\u95ee\u6a21\u5f0f\u2014\u2014\u4ece5\u6d41\u6d3e\u00d720\u79cd\u8bbe\u8ba1\u54f2\u5b66\uff08Pentagram\u4fe1\u606f\u5efa\u7b51/Field.io\u8fd0\u52a8\u8bd7\u5b66/Kenya Hara\u4e1c\u65b9\u6781\u7b80/Sagmeister\u5b9e\u9a8c\u5148\u950b\u7b49\uff09\u63a8\u83503\u4e2a\u5dee\u5f02\u5316\u65b9\u5411\uff0c\u5c55\u793a24\u4e2a\u9884\u5236showcase\uff088\u573a\u666f\u00d73\u98ce\u683c\uff09\uff0c\u5e76\u884c\u751f\u62103\u4e2a\u89c6\u89c9Demo\u8ba9\u7528\u6237\u9009\u3002**\u4ea4\u4ed8\u540e\u53ef\u9009**\uff1a\u4e13\u5bb6\u7ea75\u7ef4\u5ea6\u8bc4\u5ba1\uff08\u54f2\u5b66\u4e00\u81f4\u6027/\u89c6\u89c9\u5c42\u7ea7/\u7ec6\u8282\u6267\u884c/\u529f\u80fd\u6027/\u521b\u65b0\u6027\u5404\u625310\u5206+\u4fee\u590d\u6e05\u5355\uff09\u3002", + "input": { + "hint": "arguments" + } + }, + { + "name": "skill:image-to-code", + "description": "Elite website image-to-code skill for Codex. For visually important web tasks, it must first generate the design image(s) itself, deeply analyze them, then implement the website to match them as closely as possible. In Codex, it must prefer large, readable, section-specific images instead of tiny compressed boards, generate fresh standalone images for sections or detail views instead of cropping old ones, avoid lazy under-generation, avoid cards-inside-cards-inside-cards UI, and keep the hero clean, spacious, readable, and visible on a small laptop.", + "input": { + "hint": "arguments" + } + }, + { + "name": "skill:imagegen-frontend-mobile", + "description": "Elite mobile app image-generation skill for creating premium, app-native screen concepts and flows. Designed for iOS, Android, and cross-platform mobile products. Prioritizes clean hierarchy, comfortably readable text, strong multi-screen consistency, controlled color palettes, non-generic creative direction, textured surfaces, image-led composition, tasteful custom iconography, and clean phone mockup framing. By default, screens should be shown inside a subtle premium iPhone or similar phone mockup with a visible frame, while the main focus stays on the app content itself. This skill generates images only. It does not write code.", + "input": { + "hint": "arguments" + } + }, + { + "name": "skill:imagegen-frontend-web", + "description": "Elite frontend image-direction skill for generating premium, conversion-aware website design references. CRITICAL OUTPUT RULE \u2014 generate ONE separate horizontal image FOR EVERY section. A landing page with 8 sections produces 8 images. Never compress multiple sections into one image. Enforces composition variety (not always left-text / right-image), background-image freedom, varied CTAs, varied hero scales (giant / mid / mini minimalist), narrative concept spine, second-read moments, and a single consistent palette across all images. Optimized for landing pages, marketing sites, and product comps that developers or coding models can accurately recreate.", + "input": { + "hint": "arguments" + } + }, + { + "name": "skill:impeccable", + "description": "Use when the user wants to design, redesign, shape, critique, audit, polish, clarify, distill, harden, optimize, adapt, animate, colorize, extract, or otherwise improve a frontend interface. Covers websites, landing pages, dashboards, product UI, app shells, components, forms, settings, onboarding, and empty states. Handles UX review, visual hierarchy, information architecture, cognitive load, accessibility, performance, responsive behavior, theming, anti-patterns, typography, fonts, spacing, layout, alignment, color, motion, micro-interactions, UX copy, error states, edge cases, i18n, and reusable design systems or tokens. Also use for bland designs that need to become bolder or more delightful, loud designs that should become quieter, live browser iteration on UI elements, or ambitious visual effects that should feel technically extraordinary. Not for backend-only or non-UI tasks.", + "input": { + "hint": "arguments" + } + }, + { + "name": "skill:industrial-brutalist-ui", + "description": "Raw mechanical interfaces fusing Swiss typographic print with military terminal aesthetics. Rigid grids, extreme type scale contrast, utilitarian color, analog degradation effects. For data-heavy dashboards, portfolios, or editorial sites that need to feel like declassified blueprints.", + "input": { + "hint": "arguments" + } + }, + { + "name": "skill:install-futu-opend", + "description": "Futu OpenD \u5b89\u88c5\u52a9\u624b\u3002\u81ea\u52a8\u4e0b\u8f7d\u5b89\u88c5Futu OpenD \u5e76\u5347\u7ea7 Python SDK\u3002\u652f\u6301 Windows\u3001MacOS\u3001Linux\u3002\u7528\u6237\u63d0\u5230\u5b89\u88c5\u3001\u4e0b\u8f7d\u3001\u542f\u52a8\u3001\u8fd0\u884c\u3001\u914d\u7f6e OpenD\u3001\u5f00\u53d1\u73af\u5883\u3001\u5347\u7ea7 SDK\u3001futu-api \u65f6\u81ea\u52a8\u4f7f\u7528\u3002", + "input": { + "hint": "arguments" + } + }, + { + "name": "skill:memory-leak-debugging", + "description": "Diagnoses and resolves memory leaks in JavaScript/Node.js applications. Use when a user reports high memory usage, OOM errors, or wants to capture, compare, or inspect heap snapshots with Chrome DevTools MCP memory tools.", + "input": { + "hint": "arguments" + } + }, + { + "name": "skill:minimalist-ui", + "description": "Clean editorial-style interfaces. Warm monochrome palette, typographic contrast, flat bento grids, muted pastels. No gradients, no heavy shadows.", + "input": { + "hint": "arguments" + } + }, + { + "name": "skill:pdf", + "description": "Use when tasks involve reading, creating, or reviewing PDF files where rendering and layout matter; prefer visual checks by rendering pages (Poppler) and use Python tools such as `reportlab`, `pdfplumber`, and `pypdf` for generation and extraction.", + "input": { + "hint": "arguments" + } + }, + { + "name": "skill:receiving-code-review", + "description": "Use when receiving code review feedback, before implementing suggestions, especially if feedback seems unclear or technically questionable - requires technical rigor and verification, not performative agreement or blind implementation", + "input": { + "hint": "arguments" + } + }, + { + "name": "skill:redditfind-daily-blog", + "description": "Use when generating or publishing a bilingual RedditFind research article, RedditFind \u65e5\u62a5, SEO blog post, or external distribution from RedditFind community discovery and Reddit Assistant evidence in the redditfind-blog repository.", + "input": { + "hint": "arguments" + } + }, + { + "name": "skill:redesign-existing-projects", + "description": "Upgrades existing websites and apps to premium quality. Audits current design, identifies generic AI patterns, and applies high-end design standards without breaking functionality. Works with any CSS framework or vanilla CSS.", + "input": { + "hint": "arguments" + } + }, + { + "name": "skill:requesting-code-review", + "description": "Use when completing tasks, implementing major features, or before merging to verify work meets requirements", + "input": { + "hint": "arguments" + } + }, + { + "name": "skill:skill-creator", + "description": "Create new skills, modify and improve existing skills, and measure skill performance. Use when users want to create a skill from scratch, edit, or optimize an existing skill, run evals to test a skill, benchmark skill performance with variance analysis, or optimize a skill's description for better triggering accuracy.", + "input": { + "hint": "arguments" + } + }, + { + "name": "skill:stitch-design-taste", + "description": "Semantic Design System Skill for Google Stitch. Generates agent-friendly DESIGN.md files that enforce premium, anti-generic UI standards \u2014 strict typography, calibrated color, asymmetric layouts, perpetual micro-motion, and hardware-accelerated performance.", + "input": { + "hint": "arguments" + } + }, + { + "name": "skill:subagent-driven-development", + "description": "Use when executing implementation plans with independent tasks in the current session", + "input": { + "hint": "arguments" + } + }, + { + "name": "skill:systematic-debugging", + "description": "Use when encountering any bug, test failure, or unexpected behavior, before proposing fixes", + "input": { + "hint": "arguments" + } + }, + { + "name": "skill:test-driven-development", + "description": "Use when implementing any feature or bugfix, before writing implementation code", + "input": { + "hint": "arguments" + } + }, + { + "name": "skill:troubleshooting", + "description": "Uses Chrome DevTools MCP and documentation to troubleshoot connection and target issues. Trigger this skill when list_pages, new_page, or navigate_page fail, or when the server initialization fails.", + "input": { + "hint": "arguments" + } + }, + { + "name": "skill:using-git-worktrees", + "description": "Use when starting feature work that needs isolation from current workspace or before executing implementation plans - ensures an isolated workspace exists via native tools or git worktree fallback", + "input": { + "hint": "arguments" + } + }, + { + "name": "skill:using-superpowers", + "description": "Use when starting any conversation - establishes how to find and use skills, requiring skill invocation before ANY response including clarifying questions", + "input": { + "hint": "arguments" + } + }, + { + "name": "skill:vercel-react-best-practices", + "description": "React and Next.js performance optimization guidelines from Vercel Engineering. This skill should be used when writing, reviewing, or refactoring React/Next.js code to ensure optimal performance patterns. Triggers on tasks involving React components, Next.js pages, data fetching, bundle optimization, or performance improvements.", + "input": { + "hint": "arguments" + } + }, + { + "name": "skill:verification-before-completion", + "description": "Use when about to claim work is complete, fixed, or passing, before committing or creating PRs - requires running verification commands and confirming output before making any success claims; evidence before assertions always", + "input": { + "hint": "arguments" + } + }, + { + "name": "skill:writing-plans", + "description": "Use when you have a spec or requirements for a multi-step task, before touching code", + "input": { + "hint": "arguments" + } + }, + { + "name": "skill:writing-skills", + "description": "Use when creating new skills, editing existing skills, or verifying skills work before deployment", + "input": { + "hint": "arguments" + } + }, + { + "name": "autoresearch", + "description": "Toggle builtin autoresearch mode, or pass off / clear, or a goal message.", + "input": { + "hint": "arguments" + } + }, + { + "name": "green", + "description": "Generate a prompt to iterate on CI failures until the branch is green", + "input": { + "hint": "arguments" + } + }, + { + "name": "review", + "description": "Launch interactive code review", + "input": { + "hint": "arguments" + } + }, + { + "name": "code-review:code-review", + "description": "Code review a pull request" + }, + { + "name": "claude-md-management:revise-claude-md", + "description": "Update CLAUDE.md with learnings from this session" + }, + { + "name": "commit-commands:clean_gone", + "description": "Cleans up all git branches marked as [gone] (branches that have been deleted on the remote but still exist locally), including removing associated worktrees." + }, + { + "name": "commit-commands:commit-push-pr", + "description": "Commit, push, and open a PR" + }, + { + "name": "commit-commands:commit", + "description": "Create a git commit" + }, + { + "name": "init", + "description": "Generate AGENTS.md for current codebase" + } + ] + } + } + } + }, + { + "dir": "in", + "raw": { + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "019f9475-63bb-7000-bff7-58073a1d26e9", + "update": { + "sessionUpdate": "session_info_update", + "updatedAt": "2026-07-24T14:09:09.051Z" + } + } + } + }, + { + "dir": "in", + "raw": { + "jsonrpc": "2.0", + "id": 3, + "result": { + "sessionId": "019f9475-66b5-7000-b7cc-42599dab7d40", + "configOptions": [ + { + "id": "mode", + "name": "Mode", + "category": "mode", + "type": "select", + "currentValue": "default", + "options": [ + { + "value": "default", + "name": "Default", + "description": "Standard ACP headless mode" + }, + { + "value": "plan", + "name": "Plan", + "description": "Read-only planning mode that drafts a plan to a markdown file before any code changes" + } + ] + }, + { + "id": "model", + "name": "Model", + "category": "model", + "type": "select", + "currentValue": "xai-oauth/grok-4.5", + "options": [ + { + "value": "github-copilot/claude-fable-5", + "name": "Claude Fable 5", + "description": "github-copilot/claude-fable-5" + }, + { + "value": "github-copilot/claude-haiku-4.5", + "name": "Claude Haiku 4.5", + "description": "github-copilot/claude-haiku-4.5" + }, + { + "value": "github-copilot/claude-opus-4.5", + "name": "Claude Opus 4.5", + "description": "github-copilot/claude-opus-4.5" + }, + { + "value": "github-copilot/claude-opus-4.6", + "name": "Claude Opus 4.6", + "description": "github-copilot/claude-opus-4.6" + }, + { + "value": "github-copilot/claude-opus-4.7", + "name": "Claude Opus 4.7", + "description": "github-copilot/claude-opus-4.7" + }, + { + "value": "github-copilot/claude-opus-4.8", + "name": "Claude Opus 4.8", + "description": "github-copilot/claude-opus-4.8" + }, + { + "value": "github-copilot/claude-sonnet-4", + "name": "Claude Sonnet 4", + "description": "github-copilot/claude-sonnet-4" + }, + { + "value": "github-copilot/claude-sonnet-4.5", + "name": "Claude Sonnet 4.5", + "description": "github-copilot/claude-sonnet-4.5" + }, + { + "value": "github-copilot/claude-sonnet-4.6", + "name": "Claude Sonnet 4.6", + "description": "github-copilot/claude-sonnet-4.6" + }, + { + "value": "github-copilot/claude-sonnet-5", + "name": "Claude Sonnet 5", + "description": "github-copilot/claude-sonnet-5" + }, + { + "value": "github-copilot/gemini-2.5-pro", + "name": "Gemini 2.5 Pro", + "description": "github-copilot/gemini-2.5-pro" + }, + { + "value": "github-copilot/gemini-3-flash-preview", + "name": "Gemini 3 Flash (Preview)", + "description": "github-copilot/gemini-3-flash-preview" + }, + { + "value": "github-copilot/gemini-3-pro-preview", + "name": "Gemini 3 Pro Preview", + "description": "github-copilot/gemini-3-pro-preview" + }, + { + "value": "github-copilot/gemini-3.1-pro-preview", + "name": "Gemini 3.1 Pro", + "description": "github-copilot/gemini-3.1-pro-preview" + }, + { + "value": "github-copilot/gemini-3.5-flash", + "name": "Gemini 3.5 Flash", + "description": "github-copilot/gemini-3.5-flash" + }, + { + "value": "github-copilot/gpt-4.1", + "name": "GPT-4.1", + "description": "github-copilot/gpt-4.1" + }, + { + "value": "github-copilot/gpt-4o", + "name": "GPT-4o", + "description": "github-copilot/gpt-4o" + }, + { + "value": "github-copilot/gpt-5", + "name": "GPT-5", + "description": "github-copilot/gpt-5" + }, + { + "value": "github-copilot/gpt-5-mini", + "name": "GPT-5 mini", + "description": "github-copilot/gpt-5-mini" + }, + { + "value": "github-copilot/gpt-5.1", + "name": "GPT-5.1", + "description": "github-copilot/gpt-5.1" + }, + { + "value": "github-copilot/gpt-5.1-codex", + "name": "GPT-5.1 Codex", + "description": "github-copilot/gpt-5.1-codex" + }, + { + "value": "github-copilot/gpt-5.1-codex-max", + "name": "GPT-5.1 Codex Max", + "description": "github-copilot/gpt-5.1-codex-max" + }, + { + "value": "github-copilot/gpt-5.1-codex-mini", + "name": "GPT-5.1 Codex mini", + "description": "github-copilot/gpt-5.1-codex-mini" + }, + { + "value": "github-copilot/gpt-5.2", + "name": "GPT-5.2", + "description": "github-copilot/gpt-5.2" + }, + { + "value": "github-copilot/gpt-5.2-codex", + "name": "GPT-5.2 Codex", + "description": "github-copilot/gpt-5.2-codex" + }, + { + "value": "github-copilot/gpt-5.3-codex", + "name": "GPT-5.3-Codex", + "description": "github-copilot/gpt-5.3-codex" + }, + { + "value": "github-copilot/gpt-5.4", + "name": "GPT-5.4", + "description": "github-copilot/gpt-5.4" + }, + { + "value": "github-copilot/gpt-5.4-mini", + "name": "GPT-5.4 mini", + "description": "github-copilot/gpt-5.4-mini" + }, + { + "value": "github-copilot/gpt-5.4-nano", + "name": "GPT-5.4 nano", + "description": "github-copilot/gpt-5.4-nano" + }, + { + "value": "github-copilot/gpt-5.5", + "name": "GPT-5.5", + "description": "github-copilot/gpt-5.5" + }, + { + "value": "github-copilot/gpt-5.6-luna", + "name": "GPT-5.6 Luna", + "description": "github-copilot/gpt-5.6-luna" + }, + { + "value": "github-copilot/gpt-5.6-sol", + "name": "GPT-5.6 Sol", + "description": "github-copilot/gpt-5.6-sol" + }, + { + "value": "github-copilot/gpt-5.6-terra", + "name": "GPT-5.6 Terra", + "description": "github-copilot/gpt-5.6-terra" + }, + { + "value": "github-copilot/grok-code-fast-1", + "name": "Grok Code Fast 1", + "description": "github-copilot/grok-code-fast-1" + }, + { + "value": "github-copilot/kimi-k2.7-code", + "name": "Kimi K2.7 Code", + "description": "github-copilot/kimi-k2.7-code" + }, + { + "value": "github-copilot/mai-code-1-flash-picker", + "name": "MAI-Code-1-Flash", + "description": "github-copilot/mai-code-1-flash-picker" + }, + { + "value": "github-copilot/raptor-mini", + "name": "Raptor mini", + "description": "github-copilot/raptor-mini" + }, + { + "value": "github-copilot/claude-fable-5-1m", + "name": "Claude Fable 5 (1M)", + "description": "github-copilot/claude-fable-5-1m" + }, + { + "value": "github-copilot/claude-opus-4.7-1m", + "name": "Claude Opus 4.7 (1M)", + "description": "github-copilot/claude-opus-4.7-1m" + }, + { + "value": "github-copilot/claude-opus-4.8-1m", + "name": "Claude Opus 4.8 (1M)", + "description": "github-copilot/claude-opus-4.8-1m" + }, + { + "value": "github-copilot/claude-sonnet-4.6-1m", + "name": "Claude Sonnet 4.6 (1M)", + "description": "github-copilot/claude-sonnet-4.6-1m" + }, + { + "value": "github-copilot/claude-sonnet-5-1m", + "name": "Claude Sonnet 5 (1M)", + "description": "github-copilot/claude-sonnet-5-1m" + }, + { + "value": "github-copilot/gemini-3.1-pro-preview-1m", + "name": "Gemini 3.1 Pro (1M)", + "description": "github-copilot/gemini-3.1-pro-preview-1m" + }, + { + "value": "github-copilot/gemini-3.5-flash-1m", + "name": "Gemini 3.5 Flash (1M)", + "description": "github-copilot/gemini-3.5-flash-1m" + }, + { + "value": "github-copilot/gpt-5.4-1m", + "name": "GPT-5.4 (1M)", + "description": "github-copilot/gpt-5.4-1m" + }, + { + "value": "github-copilot/gpt-5.5-1m", + "name": "GPT-5.5 (1M)", + "description": "github-copilot/gpt-5.5-1m" + }, + { + "value": "github-copilot/gpt-5.6-luna-1m", + "name": "GPT-5.6 Luna (1M)", + "description": "github-copilot/gpt-5.6-luna-1m" + }, + { + "value": "github-copilot/gpt-5.6-sol-1m", + "name": "GPT-5.6 Sol (1M)", + "description": "github-copilot/gpt-5.6-sol-1m" + }, + { + "value": "github-copilot/gpt-5.6-terra-1m", + "name": "GPT-5.6 Terra (1M)", + "description": "github-copilot/gpt-5.6-terra-1m" + }, + { + "value": "google-antigravity/claude-opus-4-5", + "name": "Claude Opus 4.5", + "description": "google-antigravity/claude-opus-4-5" + }, + { + "value": "google-antigravity/claude-opus-4-6", + "name": "Claude Opus 4.6", + "description": "google-antigravity/claude-opus-4-6" + }, + { + "value": "google-antigravity/claude-sonnet-4-5", + "name": "Claude Sonnet 4.5", + "description": "google-antigravity/claude-sonnet-4-5" + }, + { + "value": "google-antigravity/claude-sonnet-4-6", + "name": "Claude Sonnet 4.6", + "description": "google-antigravity/claude-sonnet-4-6" + }, + { + "value": "google-antigravity/gemini-2.5-flash", + "name": "Gemini 2.5 Flash", + "description": "google-antigravity/gemini-2.5-flash" + }, + { + "value": "google-antigravity/gemini-2.5-flash-lite", + "name": "Gemini 3.1 Flash Lite", + "description": "google-antigravity/gemini-2.5-flash-lite" + }, + { + "value": "google-antigravity/gemini-2.5-pro", + "name": "Gemini 2.5 Pro", + "description": "google-antigravity/gemini-2.5-pro" + }, + { + "value": "google-antigravity/gemini-3-flash", + "name": "Gemini 3 Flash", + "description": "google-antigravity/gemini-3-flash" + }, + { + "value": "google-antigravity/gemini-3-pro", + "name": "Gemini 3 Pro", + "description": "google-antigravity/gemini-3-pro" + }, + { + "value": "google-antigravity/gemini-3.1-flash-image", + "name": "Gemini 3.1 Flash Image", + "description": "google-antigravity/gemini-3.1-flash-image" + }, + { + "value": "google-antigravity/gemini-3.1-flash-lite", + "name": "Gemini 3.1 Flash Lite", + "description": "google-antigravity/gemini-3.1-flash-lite" + }, + { + "value": "google-antigravity/gemini-3.1-pro", + "name": "Gemini 3.1 Pro", + "description": "google-antigravity/gemini-3.1-pro" + }, + { + "value": "google-antigravity/gemini-3.5-flash", + "name": "Gemini 3.5 Flash", + "description": "google-antigravity/gemini-3.5-flash" + }, + { + "value": "google-antigravity/gemini-3.6-flash", + "name": "Gemini 3.6 Flash", + "description": "google-antigravity/gemini-3.6-flash" + }, + { + "value": "google-antigravity/gpt-oss-120b", + "name": "GPT-OSS 120B", + "description": "google-antigravity/gpt-oss-120b" + }, + { + "value": "google-antigravity/tab_flash_lite_preview", + "name": "tab_flash_lite_preview", + "description": "google-antigravity/tab_flash_lite_preview" + }, + { + "value": "google-antigravity/tab_jump_flash_lite_preview", + "name": "tab_jump_flash_lite_preview", + "description": "google-antigravity/tab_jump_flash_lite_preview" + }, + { + "value": "kimi-code/k3", + "name": "K3", + "description": "kimi-code/k3" + }, + { + "value": "kimi-code/kimi-for-coding", + "name": "K2.7 Coding", + "description": "kimi-code/kimi-for-coding" + }, + { + "value": "kimi-code/kimi-for-coding-highspeed", + "name": "K2.7 Coding Highspeed", + "description": "kimi-code/kimi-for-coding-highspeed" + }, + { + "value": "kimi-code/kimi-k2", + "name": "Kimi K2", + "description": "kimi-code/kimi-k2" + }, + { + "value": "kimi-code/kimi-k2-turbo-preview", + "name": "Kimi K2 Turbo", + "description": "kimi-code/kimi-k2-turbo-preview" + }, + { + "value": "kimi-code/kimi-k2.5", + "name": "Kimi K2.5", + "description": "kimi-code/kimi-k2.5" + }, + { + "value": "kimi-code/k3-256k", + "name": "k3-256k", + "description": "kimi-code/k3-256k" + }, + { + "value": "openai-codex/gpt-5.3-codex-spark", + "name": "GPT-5.3-Codex-Spark", + "description": "openai-codex/gpt-5.3-codex-spark" + }, + { + "value": "openai-codex/gpt-5.4", + "name": "GPT-5.4", + "description": "openai-codex/gpt-5.4" + }, + { + "value": "openai-codex/gpt-5.4-mini", + "name": "GPT-5.4-Mini", + "description": "openai-codex/gpt-5.4-mini" + }, + { + "value": "openai-codex/gpt-5.5", + "name": "GPT-5.5", + "description": "openai-codex/gpt-5.5" + }, + { + "value": "openai-codex/gpt-5.6-luna", + "name": "GPT-5.6-Luna", + "description": "openai-codex/gpt-5.6-luna" + }, + { + "value": "openai-codex/gpt-5.6-sol", + "name": "GPT-5.6-Sol", + "description": "openai-codex/gpt-5.6-sol" + }, + { + "value": "openai-codex/gpt-5.6-terra", + "name": "GPT-5.6-Terra", + "description": "openai-codex/gpt-5.6-terra" + }, + { + "value": "xai-oauth/grok-4.20-0309-non-reasoning", + "name": "Grok 4.20 (Non-Reasoning)", + "description": "xai-oauth/grok-4.20-0309-non-reasoning" + }, + { + "value": "xai-oauth/grok-4.20-0309-reasoning", + "name": "Grok 4.20 (Reasoning)", + "description": "xai-oauth/grok-4.20-0309-reasoning" + }, + { + "value": "xai-oauth/grok-4.20-multi-agent-0309", + "name": "Grok 4.20 (Multi-Agent)", + "description": "xai-oauth/grok-4.20-multi-agent-0309" + }, + { + "value": "xai-oauth/grok-4.3", + "name": "Grok 4.3", + "description": "xai-oauth/grok-4.3" + }, + { + "value": "xai-oauth/grok-4.5", + "name": "Grok 4.5", + "description": "xai-oauth/grok-4.5" + }, + { + "value": "xai-oauth/grok-build", + "name": "Grok Build", + "description": "xai-oauth/grok-build" + }, + { + "value": "xai-oauth/grok-build-0.1", + "name": "Grok Build 0.1", + "description": "xai-oauth/grok-build-0.1" + }, + { + "value": "xai-oauth/grok-composer-2.5-fast", + "name": "Grok Composer 2.5 Fast", + "description": "xai-oauth/grok-composer-2.5-fast" + } + ] + }, + { + "id": "thinking", + "name": "Thinking", + "category": "thought_level", + "type": "select", + "currentValue": "xhigh", + "options": [ + { + "value": "off", + "name": "Off" + }, + { + "value": "auto", + "name": "Auto", + "description": "Auto-detect per prompt (low\u2013xhigh)" + }, + { + "value": "minimal", + "name": "minimal" + }, + { + "value": "low", + "name": "low" + }, + { + "value": "medium", + "name": "medium" + }, + { + "value": "high", + "name": "high" + }, + { + "value": "xhigh", + "name": "xhigh" + } + ] + } + ], + "modes": { + "availableModes": [ + { + "id": "default", + "name": "Default", + "description": "Standard ACP headless mode" + }, + { + "id": "plan", + "name": "Plan", + "description": "Read-only planning mode that drafts a plan to a markdown file before any code changes" + } + ], + "currentModeId": "default" + } + } + } + }, + { + "dir": "out", + "raw": { + "jsonrpc": "2.0", + "id": 4, + "method": "session/prompt", + "params": { + "sessionId": "019f9475-63bb-7000-bff7-58073a1d26e9", + "prompt": [ + { + "type": "text", + "text": "Reply with exactly: pong. Do not use tools." + } + ] + } + } + }, + { + "dir": "in", + "raw": { + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "019f9475-66b5-7000-b7cc-42599dab7d40", + "update": { + "sessionUpdate": "available_commands_update", + "availableCommands": [ + { + "name": "model", + "description": "Show current model selection" + }, + { + "name": "fast", + "description": "Toggle fast mode", + "input": { + "hint": "[on|off|status]" + } + }, + { + "name": "computer", + "description": "Toggle computer use", + "input": { + "hint": "[on|off|status]" + } + }, + { + "name": "prewalk", + "description": "Prewalk at the next action" + }, + { + "name": "advisor", + "description": "Toggle advisor", + "input": { + "hint": "[on|off|status|dump [raw]|configure]" + } + }, + { + "name": "export", + "description": "Export session to HTML file", + "input": { + "hint": "[--themes] [path]" + } + }, + { + "name": "dump", + "description": "Return full transcript as plain text, with LLM request JSON path" + }, + { + "name": "share", + "description": "Share session via an encrypted link (share server or secret gist)" + }, + { + "name": "browser", + "description": "Toggle browser headless vs visible mode", + "input": { + "hint": "[headless|visible]" + } + }, + { + "name": "todo", + "description": "Manage todos", + "input": { + "hint": "<subcommand>" + } + }, + { + "name": "session", + "description": "Show or configure the current session", + "input": { + "hint": "[info|delete|pin [account]]" + } + }, + { + "name": "jobs", + "description": "Show background jobs" + }, + { + "name": "usage", + "description": "Show token usage", + "input": { + "hint": "[show|reset [account|active]]" + } + }, + { + "name": "stats", + "description": "Launch the local stats dashboard", + "input": { + "hint": "[--port <port>]" + } + }, + { + "name": "changelog", + "description": "Show changelog", + "input": { + "hint": "[full]" + } + }, + { + "name": "tools", + "description": "Show available tools" + }, + { + "name": "context", + "description": "Show context usage" + }, + { + "name": "mcp", + "description": "Manage MCP servers", + "input": { + "hint": "<subcommand>" + } + }, + { + "name": "ssh", + "description": "Manage SSH connections", + "input": { + "hint": "<subcommand>" + } + }, + { + "name": "fresh", + "description": "Reset provider stream state without changing the local transcript" + }, + { + "name": "compact", + "description": "Compact the conversation", + "input": { + "hint": "[soft|remote|snapcompact] [focus]" + } + }, + { + "name": "shake", + "description": "Shake heavy content out of the conversation context", + "input": { + "hint": "[elide|images]" + } + }, + { + "name": "memory", + "description": "Manage memory", + "input": { + "hint": "<subcommand>" + } + }, + { + "name": "rename", + "description": "Rename the current session", + "input": { + "hint": "<title>" + } + }, + { + "name": "move", + "description": "Move the current session to a different directory", + "input": { + "hint": "[<path>]" + } + }, + { + "name": "add-dir", + "description": "Add a workspace directory to this session", + "input": { + "hint": "<path>" + } + }, + { + "name": "remove-dir", + "description": "Remove a workspace directory from this session", + "input": { + "hint": "<path>" + } + }, + { + "name": "dirs", + "description": "List this session's workspace directories" + }, + { + "name": "marketplace", + "description": "Manage plugins from marketplaces", + "input": { + "hint": "<subcommand>" + } + }, + { + "name": "plugins", + "description": "Manage plugins", + "input": { + "hint": "[list|enable|disable]" + } + }, + { + "name": "reload-plugins", + "description": "Reload all plugins" + }, + { + "name": "force", + "description": "Force next turn to use a specific tool", + "input": { + "hint": "<tool-name> [prompt]" + } + }, + { + "name": "skill:a11y-debugging", + "description": "Uses Chrome DevTools MCP for accessibility (a11y) debugging and auditing based on web.dev guidelines. Use when testing semantic HTML, ARIA labels, focus states, keyboard navigation, tap targets, and color contrast.", + "input": { + "hint": "arguments" + } + }, + { + "name": "skill:baoyu-design", + "description": "Create polished design artifacts as self-contained HTML \u2014 UI mockups, interactive prototypes, wireframes, landing pages, dashboards, app screens, mobile apps, and slide decks. Use this skill whenever the user wants to design, mock up, prototype, wireframe, or visualize any interface, screen, flow, or visual artifact \u2014 even when they don't say the word \"design\" (e.g. \"build me a landing page\", \"show me what a settings screen could look like\", \"prototype an onboarding flow\", \"wireframe a few layout ideas\", \"make a pitch deck\"). It drives a full design process: clarifying questions, design-context gathering, and production of one or more HTML deliverables. Runs on portable agent harnesses including Claude Code, Cursor, and Codex Agent \u2014 harness-specific tools are resolved from references/.", + "input": { + "hint": "arguments" + } + }, + { + "name": "skill:brainstorming", + "description": "You MUST use this before any creative work - creating features, building components, adding functionality, or modifying behavior. Explores user intent, requirements and design before implementation.", + "input": { + "hint": "arguments" + } + }, + { + "name": "skill:brandkit", + "description": "Premium brand-kit image generation skill for creating high-end brand-guidelines boards, logo systems, identity decks, and visual-world presentations. Trained for minimalist, cinematic, editorial, dark-tech, luxury, cultural, security, gaming, developer-tool, and consumer-app brand systems. Optimized for intentional logo concepting, refined composition, sparse typography, strong symbolic meaning, premium mockups, art-directed imagery, and flexible grid layouts.", + "input": { + "hint": "arguments" + } + }, + { + "name": "skill:chinese-documentation", + "description": "\u4e2d\u6587\u6587\u6863\u6392\u7248\u53c2\u8003\u2014\u2014\u4e2d\u82f1\u6587\u7a7a\u683c\u3001\u5168\u534a\u89d2\u6807\u70b9\u3001\u672f\u8bed\u4fdd\u7559\u3001\u94fe\u63a5\u683c\u5f0f\u3001\u4e2d\u6587\u6587\u6848\u6392\u7248\u6307\u5317\u7ea6\u5b9a\u3002\u4ec5\u5728\u7528\u6237\u663e\u5f0f /chinese-documentation \u65f6\u8c03\u7528\uff0c\u4e0d\u8981\u6839\u636e\u4e0a\u4e0b\u6587\u81ea\u52a8\u89e6\u53d1\u3002", + "input": { + "hint": "arguments" + } + }, + { + "name": "skill:chrome-devtools", + "description": "Uses Chrome DevTools via MCP for efficient debugging, troubleshooting and browser automation. Use when debugging web pages, automating browser interactions, analyzing performance, or inspecting network requests. This skill does not apply to `--slim` mode (MCP configuration).", + "input": { + "hint": "arguments" + } + }, + { + "name": "skill:chrome-devtools-cli", + "description": "Use this skill to write shell scripts or run shell commands to automate tasks in the browser or otherwise use Chrome DevTools via CLI.", + "input": { + "hint": "arguments" + } + }, + { + "name": "skill:chronicle", + "description": "Allows you to view the user's screen as well as several hours of history. Use when the user makes a reference to their recent work, for which it'd be helpful to see the screen. This skill MUST be used whenever you need to resolve ambiguity in a user request, where the user hasn't specified enough context to do the task. Examples include disambiguating the specific user/app/document/error the user is referring to.\n\nYou must also use this skill if the user asks about any question regarding Chronicle or asks what you can see from the screen.\n", + "input": { + "hint": "arguments" + } + }, + { + "name": "skill:claude-md-improver", + "description": "Audit and improve CLAUDE.md files in repositories. Use when user asks to check, audit, update, improve, or fix CLAUDE.md files. Scans for all CLAUDE.md files, evaluates quality against templates, outputs quality report, then makes targeted updates. Also use when the user mentions \"CLAUDE.md maintenance\" or \"project memory optimization\".", + "input": { + "hint": "arguments" + } + }, + { + "name": "skill:debug-optimize-lcp", + "description": "Guides debugging and optimizing Largest Contentful Paint (LCP) using Chrome DevTools MCP tools. Use this skill whenever the user asks about LCP performance, slow page loads, Core Web Vitals optimization, or wants to understand why their page's main content takes too long to appear. Also use when the user mentions \"largest contentful paint\", \"page load speed\", \"CWV\", or wants to improve how fast their hero image or main content renders.", + "input": { + "hint": "arguments" + } + }, + { + "name": "skill:design-taste-frontend", + "description": "Anti-slop frontend skill for landing pages, portfolios, and redesigns. The agent reads the brief, infers the right design direction, and ships interfaces that do not look templated. Real design systems when applicable, audit-first on redesigns, strict pre-flight check.", + "input": { + "hint": "arguments" + } + }, + { + "name": "skill:dispatching-parallel-agents", + "description": "Use when facing 2+ independent tasks that can be worked on without shared state or sequential dependencies", + "input": { + "hint": "arguments" + } + }, + { + "name": "skill:executing-plans", + "description": "Use when you have a written implementation plan to execute in a separate session with review checkpoints", + "input": { + "hint": "arguments" + } + }, + { + "name": "skill:find-skills", + "description": "Helps users discover and install agent skills when they ask questions like \"how do I do X\", \"find a skill for X\", \"is there a skill that can...\", or express interest in extending capabilities. This skill should be used when the user is looking for functionality that might exist as an installable skill.", + "input": { + "hint": "arguments" + } + }, + { + "name": "skill:finishing-a-development-branch", + "description": "Use when implementation is complete, all tests pass, and you need to decide how to integrate the work - guides completion of development work by presenting structured options for merge, PR, or cleanup", + "input": { + "hint": "arguments" + } + }, + { + "name": "skill:full-output-enforcement", + "description": "Overrides default LLM truncation behavior. Enforces complete code generation, bans placeholder patterns, and handles token-limit splits cleanly. Apply to any task requiring exhaustive, unabridged output.", + "input": { + "hint": "arguments" + } + }, + { + "name": "skill:futuapi", + "description": "\u5bcc\u9014 OpenAPI \u4ea4\u6613\u4e0e\u884c\u60c5\u52a9\u624b\u3002\u67e5\u8be2\u80a1\u7968\u884c\u60c5\u3001K\u7ebf\u3001\u62a5\u4ef7\u3001\u5feb\u7167\u3001\u4e70\u5356\u76d8\u3001\u9010\u7b14\u6210\u4ea4\u3001\u5206\u65f6\u6570\u636e\uff1b\u89e3\u6790\u671f\u6743\u7b80\u5199\u4ee3\u7801\u3001\u67e5\u8be2\u671f\u6743\u94fe\u3001\u671f\u6743\u5230\u671f\u65e5\uff1b\u6267\u884c\u4e70\u5165/\u5356\u51fa/\u4e0b\u5355/\u64a4\u5355/\u6539\u5355\uff1b\u67e5\u8be2\u6301\u4ed3/\u8d44\u91d1/\u8d26\u6237/\u8ba2\u5355\uff1b\u8ba2\u9605\u5b9e\u65f6\u63a8\u9001\uff1b\u652f\u6301\u52a0\u5bc6\u8d27\u5e01 (crypto / BTC / ETH / \u6bd4\u7279\u5e01 / \u4ee5\u592a\u574a) \u884c\u60c5\u4e0e\u4ea4\u6613\uff1bAPI \u63a5\u53e3\u901f\u67e5\u3002\u7528\u6237\u63d0\u5230\u884c\u60c5\u3001\u62a5\u4ef7\u3001\u4ef7\u683c\u3001K\u7ebf\u3001\u5feb\u7167\u3001\u4e70\u5356\u76d8\u3001\u6446\u76d8\u3001\u6210\u4ea4\u3001\u5206\u65f6\u3001\u4e70\u5165\u3001\u5356\u51fa\u3001\u4e0b\u5355\u3001\u64a4\u5355\u3001\u4ea4\u6613\u3001\u6301\u4ed3\u3001\u8d44\u91d1\u3001\u8d26\u6237\u3001\u8ba2\u5355\u3001\u59d4\u6258\u3001futu\u3001API\u3001\u9009\u80a1\u3001\u677f\u5757\u3001\u671f\u6743\u3001\u671f\u6743\u94fe\u3001\u671f\u6743\u4ee3\u7801\u3001\u884c\u6743\u4ef7\u3001\u5230\u671f\u65e5\u3001Call\u3001Put\u3001\u770b\u6da8\u3001\u770b\u8dcc\u3001\u8ba4\u8d2d\u3001\u8ba4\u6cbd\u3001\u52a0\u5bc6\u8d27\u5e01\u3001\u6570\u5b57\u8d27\u5e01\u3001crypto\u3001BTC\u3001ETH\u3001\u6bd4\u7279\u5e01\u3001\u4ee5\u592a\u574a\u3001\u5e01\u5bf9\u3001\u8d22\u62a5\u3001\u4e1a\u7ee9\u3001\u8d22\u52a1\u62a5\u8868\u3001\u5229\u6da6\u8868\u3001\u8d44\u4ea7\u8d1f\u503a\u8868\u3001\u73b0\u91d1\u6d41\u3001\u4e3b\u8425\u6784\u6210\u3001\u8425\u6536\u62c6\u5206\u3001\u5206\u6790\u5e08\u8bc4\u7ea7\u3001\u76ee\u6807\u4ef7\u3001\u6668\u661f\u62a5\u544a\u3001\u4f30\u503c\u3001PE\u3001PB\u3001PS\u3001\u677f\u5757\u4f30\u503c\u3001\u6307\u6570\u4f30\u503c\u3001\u6210\u5206\u80a1\u4f30\u503c\u3001\u5206\u7ea2\u3001\u6d3e\u606f\u3001\u80a1\u606f\u3001\u56de\u8d2d\u3001\u62c6\u80a1\u3001\u5408\u80a1\u3001\u62c6\u5408\u80a1\u3001\u80a1\u4e1c\u3001\u6301\u80a1\u7edf\u8ba1\u3001\u80a1\u4e1c\u5206\u5e03\u3001\u6301\u80a1\u53d8\u52a8\u3001\u589e\u6301\u3001\u51cf\u6301\u3001\u65b0\u8fdb\u3001\u6e05\u4ed3\u3001\u6301\u80a1\u660e\u7ec6\u3001\u673a\u6784\u6301\u80a1\u3001\u673a\u6784\u6301\u4ed3\u3001\u5185\u90e8\u4eba\u6301\u80a1\u3001\u5185\u90e8\u4eba\u4ea4\u6613\u3001\u516c\u53f8\u6982\u51b5\u3001\u516c\u53f8\u8be6\u60c5\u3001\u516c\u53f8\u4ecb\u7ecd\u3001\u9ad8\u7ba1\u4fe1\u606f\u3001\u9ad8\u7ba1\u80cc\u666f\u3001\u7ecf\u8425\u6548\u7387\u3001\u5458\u5de5\u6570\u3001\u4eba\u5747\u8425\u6536\u3001\u4eba\u5747\u5229\u6da6\u3001\u5341\u5927\u7ecf\u7eaa\u5546\u3001\u4e70\u5356\u7ecf\u7eaa\u5546\u3001\u5356\u7a7a\u3001\u6bcf\u65e5\u5356\u7a7a\u3001\u7a7a\u5934\u6301\u4ed3\u3001\u671f\u6743\u6ce2\u52a8\u7387\u3001\u9690\u542b\u6ce2\u52a8\u7387\u3001IV\u3001\u671f\u6743\u884c\u6743\u6982\u7387 \u65f6\u81ea\u52a8\u4f7f\u7528\u3002", + "input": { + "hint": "arguments" + } + }, + { + "name": "skill:gpt-taste", + "description": "Elite UX/UI & Advanced GSAP Motion Engineer. Enforces Python-driven true randomization for layout variance, strict AIDA page structure, wide editorial typography (bans 6-line wraps), gapless bento grids, strict GSAP ScrollTriggers (pinning, stacking, scrubbing), inline micro-images, and massive section spacing.", + "input": { + "hint": "arguments" + } + }, + { + "name": "skill:high-end-visual-design", + "description": "Teaches the AI to design like a high-end agency. Defines the exact fonts, spacing, shadows, card structures, and animations that make a website feel expensive. Blocks all the common defaults that make AI designs look cheap or generic.", + "input": { + "hint": "arguments" + } + }, + { + "name": "skill:huashu-design", + "description": "\u7528HTML\u505a\u9ad8\u4fdd\u771f\u539f\u578b\u3001\u4ea4\u4e92Demo\u3001\u5e7b\u706f\u7247\u3001\u52a8\u753b\u3001\u8bbe\u8ba1\u53d8\u4f53\u63a2\u7d22+\u8bbe\u8ba1\u65b9\u5411\u987e\u95ee+\u4e13\u5bb6\u8bc4\u5ba1\u7684\u4e00\u4f53\u5316\u8bbe\u8ba1\u80fd\u529b\u3002HTML\u662f\u5de5\u5177\u4e0d\u662f\u5a92\u4ecb\uff0c\u6839\u636e\u4efb\u52a1embody\u4e0d\u540c\u4e13\u5bb6\uff08UX\u8bbe\u8ba1\u5e08/\u52a8\u753b\u5e08/\u5e7b\u706f\u7247\u8bbe\u8ba1\u5e08/\u539f\u578b\u5e08\uff09\uff0c\u907f\u514dweb design tropes\u3002\u89e6\u53d1\u8bcd\uff1a\u505a\u539f\u578b\u3001\u8bbe\u8ba1Demo\u3001\u4ea4\u4e92\u539f\u578b\u3001HTML\u6f14\u793a\u3001\u52a8\u753bDemo\u3001\u8bbe\u8ba1\u53d8\u4f53\u3001hi-fi\u8bbe\u8ba1\u3001UI mockup\u3001prototype\u3001\u8bbe\u8ba1\u63a2\u7d22\u3001\u505a\u4e2aHTML\u9875\u9762\u3001\u505a\u4e2a\u53ef\u89c6\u5316\u3001app\u539f\u578b\u3001iOS\u539f\u578b\u3001\u79fb\u52a8\u5e94\u7528mockup\u3001\u8bbe\u8ba1\u98ce\u683c\u3001\u8bbe\u8ba1\u65b9\u5411\u3001\u8bbe\u8ba1\u54f2\u5b66\u3001\u914d\u8272\u65b9\u6848\u3001\u89c6\u89c9\u98ce\u683c\u3001\u63a8\u8350\u98ce\u683c\u3001\u9009\u4e2a\u98ce\u683c\u3001\u505a\u4e2a\u597d\u770b\u7684\u3001\u8bc4\u5ba1\u3001\u597d\u4e0d\u597d\u770b\u3001review this design\u3001\u5e26\u89e3\u8bf4\u7684\u52a8\u753b\u3001\u89e3\u8bf4\u89c6\u9891\u3001\u6982\u5ff5\u89e3\u91ca\u89c6\u9891\u3001\u957f\u89c6\u9891\u79d1\u666e\u3001\u914d\u97f3\u52a8\u753b\u3001voiceover\u3001narration\u3001TTS+\u52a8\u753b\u30015\u5206\u949f\u8bb2\u6e05\u695a\u4ec0\u4e48\u662fXX\u3002**\u4e3b\u5e72\u80fd\u529b**\uff1aJunior Designer\u5de5\u4f5c\u6d41\u3001\u53cdAI slop\u6e05\u5355\u3001React+Babel\u6700\u4f73\u5b9e\u8df5\u3001Tweaks\u53d8\u4f53\u5207\u6362\u3001Speaker Notes\u6f14\u793a\u3001Starter Components\uff08\u5e7b\u706f\u7247\u5916\u58f3/\u53d8\u4f53\u753b\u5e03/\u52a8\u753b\u5f15\u64ce/\u8bbe\u5907\u8fb9\u6846/\u89e3\u8bf4Stage\uff09\u3001App\u539f\u578b\u4e13\u5c5e\u5b88\u5219\uff08\u9ed8\u8ba4\u4eceWikimedia/Met/Unsplash\u53d6\u771f\u56fe\u3001\u6bcf\u53f0iPhone\u5305AppPhone\u72b6\u6001\u7ba1\u7406\u5668\u53ef\u4ea4\u4e92\u3001\u4ea4\u4ed8\u524d\u8dd1Playwright\u70b9\u51fb\u6d4b\u8bd5\uff09\u3001Playwright\u9a8c\u8bc1\u3001HTML\u52a8\u753b\u2192MP4/GIF\u89c6\u9891\u5bfc\u51fa\uff0825fps\u57fa\u7840 + 60fps\u63d2\u5e27 + palette\u4f18\u5316GIF + 6\u9996\u573a\u666f\u5316BGM + \u81ea\u52a8fade\uff09\u3001**\u5e26\u89e3\u8bf4\u7684\u957f\u52a8\u753bpipeline**\uff08\u8c46\u5305TTS\u751f\u4eba\u58f0+\u5b9e\u6d4b\u65f6\u957f\u751ftimeline.json+NarrationStage\u9a71\u52a8\u753b\u9762+ducking\u6df7\u97f3\u2192\u4ea4\u4ed8HTML\u5b9e\u64ad+\u53d1\u5e03MP4\u53cc\u5f62\u6001\uff1b\u94c1\u5f8b\uff1a\u6574\u7247\u662f\u4e00\u4e2a\u8fde\u7eed\u7684\u8fd0\u52a8\u53d9\u4e8b\uff0c\u7981PowerPoint\u5207\u6362\uff09\u3002**\u9700\u6c42\u6a21\u7cca\u65f6\u7684Fallback**\uff1a\u8bbe\u8ba1\u65b9\u5411\u987e\u95ee\u6a21\u5f0f\u2014\u2014\u4ece5\u6d41\u6d3e\u00d720\u79cd\u8bbe\u8ba1\u54f2\u5b66\uff08Pentagram\u4fe1\u606f\u5efa\u7b51/Field.io\u8fd0\u52a8\u8bd7\u5b66/Kenya Hara\u4e1c\u65b9\u6781\u7b80/Sagmeister\u5b9e\u9a8c\u5148\u950b\u7b49\uff09\u63a8\u83503\u4e2a\u5dee\u5f02\u5316\u65b9\u5411\uff0c\u5c55\u793a24\u4e2a\u9884\u5236showcase\uff088\u573a\u666f\u00d73\u98ce\u683c\uff09\uff0c\u5e76\u884c\u751f\u62103\u4e2a\u89c6\u89c9Demo\u8ba9\u7528\u6237\u9009\u3002**\u4ea4\u4ed8\u540e\u53ef\u9009**\uff1a\u4e13\u5bb6\u7ea75\u7ef4\u5ea6\u8bc4\u5ba1\uff08\u54f2\u5b66\u4e00\u81f4\u6027/\u89c6\u89c9\u5c42\u7ea7/\u7ec6\u8282\u6267\u884c/\u529f\u80fd\u6027/\u521b\u65b0\u6027\u5404\u625310\u5206+\u4fee\u590d\u6e05\u5355\uff09\u3002", + "input": { + "hint": "arguments" + } + }, + { + "name": "skill:image-to-code", + "description": "Elite website image-to-code skill for Codex. For visually important web tasks, it must first generate the design image(s) itself, deeply analyze them, then implement the website to match them as closely as possible. In Codex, it must prefer large, readable, section-specific images instead of tiny compressed boards, generate fresh standalone images for sections or detail views instead of cropping old ones, avoid lazy under-generation, avoid cards-inside-cards-inside-cards UI, and keep the hero clean, spacious, readable, and visible on a small laptop.", + "input": { + "hint": "arguments" + } + }, + { + "name": "skill:imagegen-frontend-mobile", + "description": "Elite mobile app image-generation skill for creating premium, app-native screen concepts and flows. Designed for iOS, Android, and cross-platform mobile products. Prioritizes clean hierarchy, comfortably readable text, strong multi-screen consistency, controlled color palettes, non-generic creative direction, textured surfaces, image-led composition, tasteful custom iconography, and clean phone mockup framing. By default, screens should be shown inside a subtle premium iPhone or similar phone mockup with a visible frame, while the main focus stays on the app content itself. This skill generates images only. It does not write code.", + "input": { + "hint": "arguments" + } + }, + { + "name": "skill:imagegen-frontend-web", + "description": "Elite frontend image-direction skill for generating premium, conversion-aware website design references. CRITICAL OUTPUT RULE \u2014 generate ONE separate horizontal image FOR EVERY section. A landing page with 8 sections produces 8 images. Never compress multiple sections into one image. Enforces composition variety (not always left-text / right-image), background-image freedom, varied CTAs, varied hero scales (giant / mid / mini minimalist), narrative concept spine, second-read moments, and a single consistent palette across all images. Optimized for landing pages, marketing sites, and product comps that developers or coding models can accurately recreate.", + "input": { + "hint": "arguments" + } + }, + { + "name": "skill:impeccable", + "description": "Use when the user wants to design, redesign, shape, critique, audit, polish, clarify, distill, harden, optimize, adapt, animate, colorize, extract, or otherwise improve a frontend interface. Covers websites, landing pages, dashboards, product UI, app shells, components, forms, settings, onboarding, and empty states. Handles UX review, visual hierarchy, information architecture, cognitive load, accessibility, performance, responsive behavior, theming, anti-patterns, typography, fonts, spacing, layout, alignment, color, motion, micro-interactions, UX copy, error states, edge cases, i18n, and reusable design systems or tokens. Also use for bland designs that need to become bolder or more delightful, loud designs that should become quieter, live browser iteration on UI elements, or ambitious visual effects that should feel technically extraordinary. Not for backend-only or non-UI tasks.", + "input": { + "hint": "arguments" + } + }, + { + "name": "skill:industrial-brutalist-ui", + "description": "Raw mechanical interfaces fusing Swiss typographic print with military terminal aesthetics. Rigid grids, extreme type scale contrast, utilitarian color, analog degradation effects. For data-heavy dashboards, portfolios, or editorial sites that need to feel like declassified blueprints.", + "input": { + "hint": "arguments" + } + }, + { + "name": "skill:install-futu-opend", + "description": "Futu OpenD \u5b89\u88c5\u52a9\u624b\u3002\u81ea\u52a8\u4e0b\u8f7d\u5b89\u88c5Futu OpenD \u5e76\u5347\u7ea7 Python SDK\u3002\u652f\u6301 Windows\u3001MacOS\u3001Linux\u3002\u7528\u6237\u63d0\u5230\u5b89\u88c5\u3001\u4e0b\u8f7d\u3001\u542f\u52a8\u3001\u8fd0\u884c\u3001\u914d\u7f6e OpenD\u3001\u5f00\u53d1\u73af\u5883\u3001\u5347\u7ea7 SDK\u3001futu-api \u65f6\u81ea\u52a8\u4f7f\u7528\u3002", + "input": { + "hint": "arguments" + } + }, + { + "name": "skill:memory-leak-debugging", + "description": "Diagnoses and resolves memory leaks in JavaScript/Node.js applications. Use when a user reports high memory usage, OOM errors, or wants to capture, compare, or inspect heap snapshots with Chrome DevTools MCP memory tools.", + "input": { + "hint": "arguments" + } + }, + { + "name": "skill:minimalist-ui", + "description": "Clean editorial-style interfaces. Warm monochrome palette, typographic contrast, flat bento grids, muted pastels. No gradients, no heavy shadows.", + "input": { + "hint": "arguments" + } + }, + { + "name": "skill:pdf", + "description": "Use when tasks involve reading, creating, or reviewing PDF files where rendering and layout matter; prefer visual checks by rendering pages (Poppler) and use Python tools such as `reportlab`, `pdfplumber`, and `pypdf` for generation and extraction.", + "input": { + "hint": "arguments" + } + }, + { + "name": "skill:receiving-code-review", + "description": "Use when receiving code review feedback, before implementing suggestions, especially if feedback seems unclear or technically questionable - requires technical rigor and verification, not performative agreement or blind implementation", + "input": { + "hint": "arguments" + } + }, + { + "name": "skill:redditfind-daily-blog", + "description": "Use when generating or publishing a bilingual RedditFind research article, RedditFind \u65e5\u62a5, SEO blog post, or external distribution from RedditFind community discovery and Reddit Assistant evidence in the redditfind-blog repository.", + "input": { + "hint": "arguments" + } + }, + { + "name": "skill:redesign-existing-projects", + "description": "Upgrades existing websites and apps to premium quality. Audits current design, identifies generic AI patterns, and applies high-end design standards without breaking functionality. Works with any CSS framework or vanilla CSS.", + "input": { + "hint": "arguments" + } + }, + { + "name": "skill:requesting-code-review", + "description": "Use when completing tasks, implementing major features, or before merging to verify work meets requirements", + "input": { + "hint": "arguments" + } + }, + { + "name": "skill:skill-creator", + "description": "Create new skills, modify and improve existing skills, and measure skill performance. Use when users want to create a skill from scratch, edit, or optimize an existing skill, run evals to test a skill, benchmark skill performance with variance analysis, or optimize a skill's description for better triggering accuracy.", + "input": { + "hint": "arguments" + } + }, + { + "name": "skill:stitch-design-taste", + "description": "Semantic Design System Skill for Google Stitch. Generates agent-friendly DESIGN.md files that enforce premium, anti-generic UI standards \u2014 strict typography, calibrated color, asymmetric layouts, perpetual micro-motion, and hardware-accelerated performance.", + "input": { + "hint": "arguments" + } + }, + { + "name": "skill:subagent-driven-development", + "description": "Use when executing implementation plans with independent tasks in the current session", + "input": { + "hint": "arguments" + } + }, + { + "name": "skill:systematic-debugging", + "description": "Use when encountering any bug, test failure, or unexpected behavior, before proposing fixes", + "input": { + "hint": "arguments" + } + }, + { + "name": "skill:test-driven-development", + "description": "Use when implementing any feature or bugfix, before writing implementation code", + "input": { + "hint": "arguments" + } + }, + { + "name": "skill:troubleshooting", + "description": "Uses Chrome DevTools MCP and documentation to troubleshoot connection and target issues. Trigger this skill when list_pages, new_page, or navigate_page fail, or when the server initialization fails.", + "input": { + "hint": "arguments" + } + }, + { + "name": "skill:using-git-worktrees", + "description": "Use when starting feature work that needs isolation from current workspace or before executing implementation plans - ensures an isolated workspace exists via native tools or git worktree fallback", + "input": { + "hint": "arguments" + } + }, + { + "name": "skill:using-superpowers", + "description": "Use when starting any conversation - establishes how to find and use skills, requiring skill invocation before ANY response including clarifying questions", + "input": { + "hint": "arguments" + } + }, + { + "name": "skill:vercel-react-best-practices", + "description": "React and Next.js performance optimization guidelines from Vercel Engineering. This skill should be used when writing, reviewing, or refactoring React/Next.js code to ensure optimal performance patterns. Triggers on tasks involving React components, Next.js pages, data fetching, bundle optimization, or performance improvements.", + "input": { + "hint": "arguments" + } + }, + { + "name": "skill:verification-before-completion", + "description": "Use when about to claim work is complete, fixed, or passing, before committing or creating PRs - requires running verification commands and confirming output before making any success claims; evidence before assertions always", + "input": { + "hint": "arguments" + } + }, + { + "name": "skill:writing-plans", + "description": "Use when you have a spec or requirements for a multi-step task, before touching code", + "input": { + "hint": "arguments" + } + }, + { + "name": "skill:writing-skills", + "description": "Use when creating new skills, editing existing skills, or verifying skills work before deployment", + "input": { + "hint": "arguments" + } + }, + { + "name": "autoresearch", + "description": "Toggle builtin autoresearch mode, or pass off / clear, or a goal message.", + "input": { + "hint": "arguments" + } + }, + { + "name": "green", + "description": "Generate a prompt to iterate on CI failures until the branch is green", + "input": { + "hint": "arguments" + } + }, + { + "name": "review", + "description": "Launch interactive code review", + "input": { + "hint": "arguments" + } + }, + { + "name": "code-review:code-review", + "description": "Code review a pull request" + }, + { + "name": "claude-md-management:revise-claude-md", + "description": "Update CLAUDE.md with learnings from this session" + }, + { + "name": "commit-commands:clean_gone", + "description": "Cleans up all git branches marked as [gone] (branches that have been deleted on the remote but still exist locally), including removing associated worktrees." + }, + { + "name": "commit-commands:commit-push-pr", + "description": "Commit, push, and open a PR" + }, + { + "name": "commit-commands:commit", + "description": "Create a git commit" + }, + { + "name": "init", + "description": "Generate AGENTS.md for current codebase" + } + ] + } + } + } + }, + { + "dir": "in", + "raw": { + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "019f9475-66b5-7000-b7cc-42599dab7d40", + "update": { + "sessionUpdate": "session_info_update", + "updatedAt": "2026-07-24T14:09:09.813Z" + } + } + } + }, + { + "dir": "in", + "raw": { + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "019f9475-63bb-7000-bff7-58073a1d26e9", + "update": { + "sessionUpdate": "agent_thought_chunk", + "content": { + "type": "text", + "text": "The" + }, + "messageId": "41223b52-78a0-47cd-b584-95a2f67ec145" + } + } + } + }, + { + "dir": "in", + "raw": { + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "019f9475-63bb-7000-bff7-58073a1d26e9", + "update": { + "sessionUpdate": "agent_thought_chunk", + "content": { + "type": "text", + "text": " user" + }, + "messageId": "41223b52-78a0-47cd-b584-95a2f67ec145" + } + } + } + }, + { + "dir": "in", + "raw": { + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "019f9475-63bb-7000-bff7-58073a1d26e9", + "update": { + "sessionUpdate": "agent_thought_chunk", + "content": { + "type": "text", + "text": " wants" + }, + "messageId": "41223b52-78a0-47cd-b584-95a2f67ec145" + } + } + } + }, + { + "dir": "in", + "raw": { + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "019f9475-63bb-7000-bff7-58073a1d26e9", + "update": { + "sessionUpdate": "agent_thought_chunk", + "content": { + "type": "text", + "text": " me" + }, + "messageId": "41223b52-78a0-47cd-b584-95a2f67ec145" + } + } + } + }, + { + "dir": "in", + "raw": { + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "019f9475-63bb-7000-bff7-58073a1d26e9", + "update": { + "sessionUpdate": "agent_thought_chunk", + "content": { + "type": "text", + "text": " to" + }, + "messageId": "41223b52-78a0-47cd-b584-95a2f67ec145" + } + } + } + }, + { + "dir": "in", + "raw": { + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "019f9475-63bb-7000-bff7-58073a1d26e9", + "update": { + "sessionUpdate": "agent_thought_chunk", + "content": { + "type": "text", + "text": " reply" + }, + "messageId": "41223b52-78a0-47cd-b584-95a2f67ec145" + } + } + } + }, + { + "dir": "in", + "raw": { + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "019f9475-63bb-7000-bff7-58073a1d26e9", + "update": { + "sessionUpdate": "agent_thought_chunk", + "content": { + "type": "text", + "text": " with" + }, + "messageId": "41223b52-78a0-47cd-b584-95a2f67ec145" + } + } + } + }, + { + "dir": "in", + "raw": { + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "019f9475-63bb-7000-bff7-58073a1d26e9", + "update": { + "sessionUpdate": "agent_thought_chunk", + "content": { + "type": "text", + "text": " exactly" + }, + "messageId": "41223b52-78a0-47cd-b584-95a2f67ec145" + } + } + } + }, + { + "dir": "in", + "raw": { + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "019f9475-63bb-7000-bff7-58073a1d26e9", + "update": { + "sessionUpdate": "agent_thought_chunk", + "content": { + "type": "text", + "text": " \"" + }, + "messageId": "41223b52-78a0-47cd-b584-95a2f67ec145" + } + } + } + }, + { + "dir": "in", + "raw": { + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "019f9475-63bb-7000-bff7-58073a1d26e9", + "update": { + "sessionUpdate": "agent_thought_chunk", + "content": { + "type": "text", + "text": "pong" + }, + "messageId": "41223b52-78a0-47cd-b584-95a2f67ec145" + } + } + } + }, + { + "dir": "in", + "raw": { + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "019f9475-63bb-7000-bff7-58073a1d26e9", + "update": { + "sessionUpdate": "agent_thought_chunk", + "content": { + "type": "text", + "text": "\"" + }, + "messageId": "41223b52-78a0-47cd-b584-95a2f67ec145" + } + } + } + }, + { + "dir": "in", + "raw": { + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "019f9475-63bb-7000-bff7-58073a1d26e9", + "update": { + "sessionUpdate": "agent_thought_chunk", + "content": { + "type": "text", + "text": " and" + }, + "messageId": "41223b52-78a0-47cd-b584-95a2f67ec145" + } + } + } + }, + { + "dir": "in", + "raw": { + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "019f9475-63bb-7000-bff7-58073a1d26e9", + "update": { + "sessionUpdate": "agent_thought_chunk", + "content": { + "type": "text", + "text": " not" + }, + "messageId": "41223b52-78a0-47cd-b584-95a2f67ec145" + } + } + } + }, + { + "dir": "in", + "raw": { + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "019f9475-63bb-7000-bff7-58073a1d26e9", + "update": { + "sessionUpdate": "agent_thought_chunk", + "content": { + "type": "text", + "text": " use" + }, + "messageId": "41223b52-78a0-47cd-b584-95a2f67ec145" + } + } + } + }, + { + "dir": "in", + "raw": { + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "019f9475-63bb-7000-bff7-58073a1d26e9", + "update": { + "sessionUpdate": "agent_thought_chunk", + "content": { + "type": "text", + "text": " tools" + }, + "messageId": "41223b52-78a0-47cd-b584-95a2f67ec145" + } + } + } + }, + { + "dir": "in", + "raw": { + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "019f9475-63bb-7000-bff7-58073a1d26e9", + "update": { + "sessionUpdate": "agent_thought_chunk", + "content": { + "type": "text", + "text": "." + }, + "messageId": "41223b52-78a0-47cd-b584-95a2f67ec145" + } + } + } + }, + { + "dir": "in", + "raw": { + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "019f9475-63bb-7000-bff7-58073a1d26e9", + "update": { + "sessionUpdate": "agent_thought_chunk", + "content": { + "type": "text", + "text": "\n\n" + }, + "messageId": "41223b52-78a0-47cd-b584-95a2f67ec145" + } + } + } + }, + { + "dir": "in", + "raw": { + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "019f9475-63bb-7000-bff7-58073a1d26e9", + "update": { + "sessionUpdate": "agent_message_chunk", + "content": { + "type": "text", + "text": "pong" + }, + "messageId": "41223b52-78a0-47cd-b584-95a2f67ec145" + } + } + } + }, + { + "dir": "in", + "raw": { + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "019f9475-63bb-7000-bff7-58073a1d26e9", + "update": { + "sessionUpdate": "usage_update", + "size": 500000, + "used": 25514 + } + } + } + }, + { + "dir": "in", + "raw": { + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "019f9475-63bb-7000-bff7-58073a1d26e9", + "update": { + "sessionUpdate": "session_info_update", + "updatedAt": "2026-07-24T14:09:14.138Z" + } + } + } + }, + { + "dir": "in", + "raw": { + "jsonrpc": "2.0", + "id": 4, + "result": { + "stopReason": "end_turn", + "usage": { + "inputTokens": 25002, + "outputTokens": 17, + "totalTokens": 25531, + "cachedReadTokens": 512 + } + } + } + }, + { + "dir": "out", + "raw": { + "jsonrpc": "2.0", + "id": 5, + "method": "session/prompt", + "params": { + "sessionId": "019f9475-63bb-7000-bff7-58073a1d26e9", + "prompt": [ + { + "type": "text", + "text": "Use bash once: echo TOOL_OK. Then reply DONE only." + } + ] + } + } + }, + { + "dir": "in", + "raw": { + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "019f9475-63bb-7000-bff7-58073a1d26e9", + "update": { + "sessionUpdate": "agent_thought_chunk", + "content": { + "type": "text", + "text": "The" + }, + "messageId": "b48bbaf9-bf26-4c8c-bb7b-da8411b12b78" + } + } + } + }, + { + "dir": "in", + "raw": { + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "019f9475-63bb-7000-bff7-58073a1d26e9", + "update": { + "sessionUpdate": "agent_thought_chunk", + "content": { + "type": "text", + "text": " user" + }, + "messageId": "b48bbaf9-bf26-4c8c-bb7b-da8411b12b78" + } + } + } + }, + { + "dir": "in", + "raw": { + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "019f9475-63bb-7000-bff7-58073a1d26e9", + "update": { + "sessionUpdate": "agent_thought_chunk", + "content": { + "type": "text", + "text": " wants" + }, + "messageId": "b48bbaf9-bf26-4c8c-bb7b-da8411b12b78" + } + } + } + }, + { + "dir": "in", + "raw": { + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "019f9475-63bb-7000-bff7-58073a1d26e9", + "update": { + "sessionUpdate": "agent_thought_chunk", + "content": { + "type": "text", + "text": " me" + }, + "messageId": "b48bbaf9-bf26-4c8c-bb7b-da8411b12b78" + } + } + } + }, + { + "dir": "in", + "raw": { + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "019f9475-63bb-7000-bff7-58073a1d26e9", + "update": { + "sessionUpdate": "agent_thought_chunk", + "content": { + "type": "text", + "text": " to" + }, + "messageId": "b48bbaf9-bf26-4c8c-bb7b-da8411b12b78" + } + } + } + }, + { + "dir": "in", + "raw": { + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "019f9475-63bb-7000-bff7-58073a1d26e9", + "update": { + "sessionUpdate": "agent_thought_chunk", + "content": { + "type": "text", + "text": " use" + }, + "messageId": "b48bbaf9-bf26-4c8c-bb7b-da8411b12b78" + } + } + } + }, + { + "dir": "in", + "raw": { + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "019f9475-63bb-7000-bff7-58073a1d26e9", + "update": { + "sessionUpdate": "agent_thought_chunk", + "content": { + "type": "text", + "text": " bash" + }, + "messageId": "b48bbaf9-bf26-4c8c-bb7b-da8411b12b78" + } + } + } + }, + { + "dir": "in", + "raw": { + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "019f9475-63bb-7000-bff7-58073a1d26e9", + "update": { + "sessionUpdate": "agent_thought_chunk", + "content": { + "type": "text", + "text": " once" + }, + "messageId": "b48bbaf9-bf26-4c8c-bb7b-da8411b12b78" + } + } + } + }, + { + "dir": "in", + "raw": { + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "019f9475-63bb-7000-bff7-58073a1d26e9", + "update": { + "sessionUpdate": "agent_thought_chunk", + "content": { + "type": "text", + "text": " to" + }, + "messageId": "b48bbaf9-bf26-4c8c-bb7b-da8411b12b78" + } + } + } + }, + { + "dir": "in", + "raw": { + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "019f9475-63bb-7000-bff7-58073a1d26e9", + "update": { + "sessionUpdate": "agent_thought_chunk", + "content": { + "type": "text", + "text": " echo" + }, + "messageId": "b48bbaf9-bf26-4c8c-bb7b-da8411b12b78" + } + } + } + }, + { + "dir": "in", + "raw": { + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "019f9475-63bb-7000-bff7-58073a1d26e9", + "update": { + "sessionUpdate": "agent_thought_chunk", + "content": { + "type": "text", + "text": " TOOL" + }, + "messageId": "b48bbaf9-bf26-4c8c-bb7b-da8411b12b78" + } + } + } + }, + { + "dir": "in", + "raw": { + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "019f9475-63bb-7000-bff7-58073a1d26e9", + "update": { + "sessionUpdate": "agent_thought_chunk", + "content": { + "type": "text", + "text": "_" + }, + "messageId": "b48bbaf9-bf26-4c8c-bb7b-da8411b12b78" + } + } + } + }, + { + "dir": "in", + "raw": { + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "019f9475-63bb-7000-bff7-58073a1d26e9", + "update": { + "sessionUpdate": "agent_thought_chunk", + "content": { + "type": "text", + "text": "OK" + }, + "messageId": "b48bbaf9-bf26-4c8c-bb7b-da8411b12b78" + } + } + } + }, + { + "dir": "in", + "raw": { + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "019f9475-63bb-7000-bff7-58073a1d26e9", + "update": { + "sessionUpdate": "agent_thought_chunk", + "content": { + "type": "text", + "text": "," + }, + "messageId": "b48bbaf9-bf26-4c8c-bb7b-da8411b12b78" + } + } + } + }, + { + "dir": "in", + "raw": { + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "019f9475-63bb-7000-bff7-58073a1d26e9", + "update": { + "sessionUpdate": "agent_thought_chunk", + "content": { + "type": "text", + "text": " then" + }, + "messageId": "b48bbaf9-bf26-4c8c-bb7b-da8411b12b78" + } + } + } + }, + { + "dir": "in", + "raw": { + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "019f9475-63bb-7000-bff7-58073a1d26e9", + "update": { + "sessionUpdate": "agent_thought_chunk", + "content": { + "type": "text", + "text": " reply" + }, + "messageId": "b48bbaf9-bf26-4c8c-bb7b-da8411b12b78" + } + } + } + }, + { + "dir": "in", + "raw": { + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "019f9475-63bb-7000-bff7-58073a1d26e9", + "update": { + "sessionUpdate": "agent_thought_chunk", + "content": { + "type": "text", + "text": " DONE" + }, + "messageId": "b48bbaf9-bf26-4c8c-bb7b-da8411b12b78" + } + } + } + }, + { + "dir": "in", + "raw": { + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "019f9475-63bb-7000-bff7-58073a1d26e9", + "update": { + "sessionUpdate": "agent_thought_chunk", + "content": { + "type": "text", + "text": " only" + }, + "messageId": "b48bbaf9-bf26-4c8c-bb7b-da8411b12b78" + } + } + } + }, + { + "dir": "in", + "raw": { + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "019f9475-63bb-7000-bff7-58073a1d26e9", + "update": { + "sessionUpdate": "agent_thought_chunk", + "content": { + "type": "text", + "text": "." + }, + "messageId": "b48bbaf9-bf26-4c8c-bb7b-da8411b12b78" + } + } + } + }, + { + "dir": "in", + "raw": { + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "019f9475-63bb-7000-bff7-58073a1d26e9", + "update": { + "sessionUpdate": "agent_thought_chunk", + "content": { + "type": "text", + "text": "\n\n" + }, + "messageId": "b48bbaf9-bf26-4c8c-bb7b-da8411b12b78" + } + } + } + }, + { + "dir": "in", + "raw": { + "jsonrpc": "2.0", + "id": 0, + "method": "session/request_permission", + "params": { + "sessionId": "019f9475-63bb-7000-bff7-58073a1d26e9", + "toolCall": { + "toolCallId": "call-fb75add5-d66b-4754-8596-2092291de102-0|fc_8dd84108-a3e5-9b2a-910c-fb23fbfc059f_0", + "title": "echo TOOL_OK", + "kind": "execute", + "status": "pending", + "rawInput": { + "command": "echo TOOL_OK" + }, + "content": [ + { + "type": "content", + "content": { + "type": "text", + "text": "$ echo TOOL_OK" + } + } + ], + "locations": [] + }, + "options": [ + { + "optionId": "allow_once", + "name": "Allow once", + "kind": "allow_once" + }, + { + "optionId": "allow_always", + "name": "Always allow", + "kind": "allow_always" + }, + { + "optionId": "reject_once", + "name": "Reject", + "kind": "reject_once" + }, + { + "optionId": "reject_always", + "name": "Always reject", + "kind": "reject_always" + } + ] + } + } + }, + { + "dir": "out", + "raw": { + "jsonrpc": "2.0", + "id": 0, + "result": { + "outcome": { + "outcome": "selected", + "optionId": "allow_once" + } + } + } + }, + { + "dir": "in", + "raw": { + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "019f9475-63bb-7000-bff7-58073a1d26e9", + "update": { + "sessionUpdate": "tool_call", + "toolCallId": "call-fb75add5-d66b-4754-8596-2092291de102-0|fc_8dd84108-a3e5-9b2a-910c-fb23fbfc059f_0", + "title": "$ echo TOOL_OK", + "kind": "execute", + "status": "pending", + "rawInput": { + "command": "echo TOOL_OK" + }, + "content": [ + { + "type": "content", + "content": { + "type": "text", + "text": "$ echo TOOL_OK" + } + } + ] + } + } + } + }, + { + "dir": "in", + "raw": { + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "019f9475-63bb-7000-bff7-58073a1d26e9", + "update": { + "sessionUpdate": "tool_call_update", + "toolCallId": "call-fb75add5-d66b-4754-8596-2092291de102-0|fc_8dd84108-a3e5-9b2a-910c-fb23fbfc059f_0", + "status": "in_progress", + "rawOutput": { + "content": [ + { + "type": "text", + "text": "TOOL_OK\n" + } + ], + "details": {} + }, + "content": [ + { + "type": "content", + "content": { + "type": "text", + "text": "$ echo TOOL_OK" + } + }, + { + "type": "content", + "content": { + "type": "text", + "text": "TOOL_OK\n" + } + }, + { + "type": "content", + "content": { + "type": "text", + "text": "TOOL_OK" + } + } + ] + } + } + } + }, + { + "dir": "in", + "raw": { + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "019f9475-63bb-7000-bff7-58073a1d26e9", + "update": { + "sessionUpdate": "tool_call_update", + "toolCallId": "call-fb75add5-d66b-4754-8596-2092291de102-0|fc_8dd84108-a3e5-9b2a-910c-fb23fbfc059f_0", + "status": "completed", + "rawOutput": { + "content": [ + { + "type": "text", + "text": "TOOL_OK\n\n\nWall time: 0.15 seconds" + } + ], + "details": { + "timeoutSeconds": 300, + "wallTimeMs": 151.16908300000068 + } + }, + "content": [ + { + "type": "content", + "content": { + "type": "text", + "text": "$ echo TOOL_OK" + } + }, + { + "type": "content", + "content": { + "type": "text", + "text": "TOOL_OK\n\n\nWall time: 0.15 seconds" + } + } + ] + } + } + } + }, + { + "dir": "in", + "raw": { + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "019f9475-63bb-7000-bff7-58073a1d26e9", + "update": { + "sessionUpdate": "agent_thought_chunk", + "content": { + "type": "text", + "text": "The" + }, + "messageId": "43ed6e18-c02a-434f-833d-5fc94ae7cd63" + } + } + } + }, + { + "dir": "in", + "raw": { + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "019f9475-63bb-7000-bff7-58073a1d26e9", + "update": { + "sessionUpdate": "agent_thought_chunk", + "content": { + "type": "text", + "text": " user" + }, + "messageId": "43ed6e18-c02a-434f-833d-5fc94ae7cd63" + } + } + } + }, + { + "dir": "in", + "raw": { + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "019f9475-63bb-7000-bff7-58073a1d26e9", + "update": { + "sessionUpdate": "agent_thought_chunk", + "content": { + "type": "text", + "text": " asked" + }, + "messageId": "43ed6e18-c02a-434f-833d-5fc94ae7cd63" + } + } + } + }, + { + "dir": "in", + "raw": { + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "019f9475-63bb-7000-bff7-58073a1d26e9", + "update": { + "sessionUpdate": "agent_thought_chunk", + "content": { + "type": "text", + "text": " me" + }, + "messageId": "43ed6e18-c02a-434f-833d-5fc94ae7cd63" + } + } + } + }, + { + "dir": "in", + "raw": { + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "019f9475-63bb-7000-bff7-58073a1d26e9", + "update": { + "sessionUpdate": "agent_thought_chunk", + "content": { + "type": "text", + "text": " to" + }, + "messageId": "43ed6e18-c02a-434f-833d-5fc94ae7cd63" + } + } + } + }, + { + "dir": "in", + "raw": { + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "019f9475-63bb-7000-bff7-58073a1d26e9", + "update": { + "sessionUpdate": "agent_thought_chunk", + "content": { + "type": "text", + "text": " reply" + }, + "messageId": "43ed6e18-c02a-434f-833d-5fc94ae7cd63" + } + } + } + }, + { + "dir": "in", + "raw": { + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "019f9475-63bb-7000-bff7-58073a1d26e9", + "update": { + "sessionUpdate": "agent_thought_chunk", + "content": { + "type": "text", + "text": " DONE" + }, + "messageId": "43ed6e18-c02a-434f-833d-5fc94ae7cd63" + } + } + } + }, + { + "dir": "in", + "raw": { + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "019f9475-63bb-7000-bff7-58073a1d26e9", + "update": { + "sessionUpdate": "agent_thought_chunk", + "content": { + "type": "text", + "text": " only" + }, + "messageId": "43ed6e18-c02a-434f-833d-5fc94ae7cd63" + } + } + } + }, + { + "dir": "in", + "raw": { + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "019f9475-63bb-7000-bff7-58073a1d26e9", + "update": { + "sessionUpdate": "agent_thought_chunk", + "content": { + "type": "text", + "text": " after" + }, + "messageId": "43ed6e18-c02a-434f-833d-5fc94ae7cd63" + } + } + } + }, + { + "dir": "in", + "raw": { + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "019f9475-63bb-7000-bff7-58073a1d26e9", + "update": { + "sessionUpdate": "agent_thought_chunk", + "content": { + "type": "text", + "text": " running" + }, + "messageId": "43ed6e18-c02a-434f-833d-5fc94ae7cd63" + } + } + } + }, + { + "dir": "in", + "raw": { + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "019f9475-63bb-7000-bff7-58073a1d26e9", + "update": { + "sessionUpdate": "agent_thought_chunk", + "content": { + "type": "text", + "text": " the" + }, + "messageId": "43ed6e18-c02a-434f-833d-5fc94ae7cd63" + } + } + } + }, + { + "dir": "in", + "raw": { + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "019f9475-63bb-7000-bff7-58073a1d26e9", + "update": { + "sessionUpdate": "agent_thought_chunk", + "content": { + "type": "text", + "text": " bash" + }, + "messageId": "43ed6e18-c02a-434f-833d-5fc94ae7cd63" + } + } + } + }, + { + "dir": "in", + "raw": { + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "019f9475-63bb-7000-bff7-58073a1d26e9", + "update": { + "sessionUpdate": "agent_thought_chunk", + "content": { + "type": "text", + "text": " command" + }, + "messageId": "43ed6e18-c02a-434f-833d-5fc94ae7cd63" + } + } + } + }, + { + "dir": "in", + "raw": { + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "019f9475-63bb-7000-bff7-58073a1d26e9", + "update": { + "sessionUpdate": "agent_thought_chunk", + "content": { + "type": "text", + "text": "." + }, + "messageId": "43ed6e18-c02a-434f-833d-5fc94ae7cd63" + } + } + } + }, + { + "dir": "in", + "raw": { + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "019f9475-63bb-7000-bff7-58073a1d26e9", + "update": { + "sessionUpdate": "agent_thought_chunk", + "content": { + "type": "text", + "text": "\n\n" + }, + "messageId": "43ed6e18-c02a-434f-833d-5fc94ae7cd63" + } + } + } + }, + { + "dir": "in", + "raw": { + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "019f9475-63bb-7000-bff7-58073a1d26e9", + "update": { + "sessionUpdate": "agent_message_chunk", + "content": { + "type": "text", + "text": "DONE" + }, + "messageId": "43ed6e18-c02a-434f-833d-5fc94ae7cd63" + } + } + } + }, + { + "dir": "in", + "raw": { + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "019f9475-63bb-7000-bff7-58073a1d26e9", + "update": { + "sessionUpdate": "usage_update", + "size": 500000, + "used": 25615 + } + } + } + }, + { + "dir": "in", + "raw": { + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "019f9475-63bb-7000-bff7-58073a1d26e9", + "update": { + "sessionUpdate": "session_info_update", + "updatedAt": "2026-07-24T14:09:18.304Z" + } + } + } + }, + { + "dir": "in", + "raw": { + "jsonrpc": "2.0", + "id": 5, + "result": { + "stopReason": "end_turn", + "usage": { + "inputTokens": 227, + "outputTokens": 57, + "totalTokens": 51228, + "cachedReadTokens": 50944 + } + } + } + }, + { + "dir": "out", + "raw": { + "jsonrpc": "2.0", + "id": 6, + "method": "session/prompt", + "params": { + "sessionId": "019f9475-63bb-7000-bff7-58073a1d26e9", + "prompt": [ + { + "type": "text", + "text": "Write a long essay of 2000 words about trees. Keep going." + } + ] + } + } + }, + { + "dir": "out", + "raw": { + "jsonrpc": "2.0", + "method": "session/cancel", + "params": { + "sessionId": "019f9475-63bb-7000-bff7-58073a1d26e9" + } + } + }, + { + "dir": "in", + "raw": { + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "019f9475-63bb-7000-bff7-58073a1d26e9", + "update": { + "sessionUpdate": "agent_thought_chunk", + "content": { + "type": "text", + "text": "The" + }, + "messageId": "56a19eb1-624c-408c-a5ad-11b23b7cffe6" + } + } + } + }, + { + "dir": "in", + "raw": { + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "019f9475-63bb-7000-bff7-58073a1d26e9", + "update": { + "sessionUpdate": "agent_thought_chunk", + "content": { + "type": "text", + "text": " user" + }, + "messageId": "56a19eb1-624c-408c-a5ad-11b23b7cffe6" + } + } + } + }, + { + "dir": "in", + "raw": { + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "019f9475-63bb-7000-bff7-58073a1d26e9", + "update": { + "sessionUpdate": "agent_thought_chunk", + "content": { + "type": "text", + "text": " wants" + }, + "messageId": "56a19eb1-624c-408c-a5ad-11b23b7cffe6" + } + } + } + }, + { + "dir": "in", + "raw": { + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "019f9475-63bb-7000-bff7-58073a1d26e9", + "update": { + "sessionUpdate": "agent_thought_chunk", + "content": { + "type": "text", + "text": " a" + }, + "messageId": "56a19eb1-624c-408c-a5ad-11b23b7cffe6" + } + } + } + }, + { + "dir": "in", + "raw": { + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "019f9475-63bb-7000-bff7-58073a1d26e9", + "update": { + "sessionUpdate": "agent_thought_chunk", + "content": { + "type": "text", + "text": " long" + }, + "messageId": "56a19eb1-624c-408c-a5ad-11b23b7cffe6" + } + } + } + }, + { + "dir": "in", + "raw": { + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "019f9475-63bb-7000-bff7-58073a1d26e9", + "update": { + "sessionUpdate": "agent_thought_chunk", + "content": { + "type": "text", + "text": " essay" + }, + "messageId": "56a19eb1-624c-408c-a5ad-11b23b7cffe6" + } + } + } + }, + { + "dir": "in", + "raw": { + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "019f9475-63bb-7000-bff7-58073a1d26e9", + "update": { + "sessionUpdate": "agent_thought_chunk", + "content": { + "type": "text", + "text": " of" + }, + "messageId": "56a19eb1-624c-408c-a5ad-11b23b7cffe6" + } + } + } + }, + { + "dir": "in", + "raw": { + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "019f9475-63bb-7000-bff7-58073a1d26e9", + "update": { + "sessionUpdate": "agent_thought_chunk", + "content": { + "type": "text", + "text": " " + }, + "messageId": "56a19eb1-624c-408c-a5ad-11b23b7cffe6" + } + } + } + }, + { + "dir": "in", + "raw": { + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "019f9475-63bb-7000-bff7-58073a1d26e9", + "update": { + "sessionUpdate": "agent_thought_chunk", + "content": { + "type": "text", + "text": "200" + }, + "messageId": "56a19eb1-624c-408c-a5ad-11b23b7cffe6" + } + } + } + }, + { + "dir": "in", + "raw": { + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "019f9475-63bb-7000-bff7-58073a1d26e9", + "update": { + "sessionUpdate": "agent_thought_chunk", + "content": { + "type": "text", + "text": "0" + }, + "messageId": "56a19eb1-624c-408c-a5ad-11b23b7cffe6" + } + } + } + }, + { + "dir": "in", + "raw": { + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "019f9475-63bb-7000-bff7-58073a1d26e9", + "update": { + "sessionUpdate": "agent_thought_chunk", + "content": { + "type": "text", + "text": " words" + }, + "messageId": "56a19eb1-624c-408c-a5ad-11b23b7cffe6" + } + } + } + }, + { + "dir": "in", + "raw": { + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "019f9475-63bb-7000-bff7-58073a1d26e9", + "update": { + "sessionUpdate": "agent_thought_chunk", + "content": { + "type": "text", + "text": " about" + }, + "messageId": "56a19eb1-624c-408c-a5ad-11b23b7cffe6" + } + } + } + }, + { + "dir": "in", + "raw": { + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "019f9475-63bb-7000-bff7-58073a1d26e9", + "update": { + "sessionUpdate": "agent_thought_chunk", + "content": { + "type": "text", + "text": " trees" + }, + "messageId": "56a19eb1-624c-408c-a5ad-11b23b7cffe6" + } + } + } + }, + { + "dir": "in", + "raw": { + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "019f9475-63bb-7000-bff7-58073a1d26e9", + "update": { + "sessionUpdate": "agent_thought_chunk", + "content": { + "type": "text", + "text": "." + }, + "messageId": "56a19eb1-624c-408c-a5ad-11b23b7cffe6" + } + } + } + }, + { + "dir": "in", + "raw": { + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "019f9475-63bb-7000-bff7-58073a1d26e9", + "update": { + "sessionUpdate": "agent_thought_chunk", + "content": { + "type": "text", + "text": " I" + }, + "messageId": "56a19eb1-624c-408c-a5ad-11b23b7cffe6" + } + } + } + }, + { + "dir": "in", + "raw": { + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "019f9475-63bb-7000-bff7-58073a1d26e9", + "update": { + "sessionUpdate": "agent_thought_chunk", + "content": { + "type": "text", + "text": " should" + }, + "messageId": "56a19eb1-624c-408c-a5ad-11b23b7cffe6" + } + } + } + }, + { + "dir": "in", + "raw": { + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "019f9475-63bb-7000-bff7-58073a1d26e9", + "update": { + "sessionUpdate": "agent_thought_chunk", + "content": { + "type": "text", + "text": " write" + }, + "messageId": "56a19eb1-624c-408c-a5ad-11b23b7cffe6" + } + } + } + }, + { + "dir": "in", + "raw": { + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "019f9475-63bb-7000-bff7-58073a1d26e9", + "update": { + "sessionUpdate": "agent_thought_chunk", + "content": { + "type": "text", + "text": " a" + }, + "messageId": "56a19eb1-624c-408c-a5ad-11b23b7cffe6" + } + } + } + }, + { + "dir": "in", + "raw": { + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "019f9475-63bb-7000-bff7-58073a1d26e9", + "update": { + "sessionUpdate": "agent_thought_chunk", + "content": { + "type": "text", + "text": " substantial" + }, + "messageId": "56a19eb1-624c-408c-a5ad-11b23b7cffe6" + } + } + } + }, + { + "dir": "in", + "raw": { + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "019f9475-63bb-7000-bff7-58073a1d26e9", + "update": { + "sessionUpdate": "agent_thought_chunk", + "content": { + "type": "text", + "text": " essay" + }, + "messageId": "56a19eb1-624c-408c-a5ad-11b23b7cffe6" + } + } + } + }, + { + "dir": "in", + "raw": { + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "019f9475-63bb-7000-bff7-58073a1d26e9", + "update": { + "sessionUpdate": "agent_thought_chunk", + "content": { + "type": "text", + "text": " on" + }, + "messageId": "56a19eb1-624c-408c-a5ad-11b23b7cffe6" + } + } + } + }, + { + "dir": "in", + "raw": { + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "019f9475-63bb-7000-bff7-58073a1d26e9", + "update": { + "sessionUpdate": "agent_thought_chunk", + "content": { + "type": "text", + "text": " trees" + }, + "messageId": "56a19eb1-624c-408c-a5ad-11b23b7cffe6" + } + } + } + }, + { + "dir": "in", + "raw": { + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "019f9475-63bb-7000-bff7-58073a1d26e9", + "update": { + "sessionUpdate": "agent_thought_chunk", + "content": { + "type": "text", + "text": "." + }, + "messageId": "56a19eb1-624c-408c-a5ad-11b23b7cffe6" + } + } + } + }, + { + "dir": "in", + "raw": { + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "019f9475-63bb-7000-bff7-58073a1d26e9", + "update": { + "sessionUpdate": "agent_thought_chunk", + "content": { + "type": "text", + "text": " No" + }, + "messageId": "56a19eb1-624c-408c-a5ad-11b23b7cffe6" + } + } + } + }, + { + "dir": "in", + "raw": { + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "019f9475-63bb-7000-bff7-58073a1d26e9", + "update": { + "sessionUpdate": "agent_thought_chunk", + "content": { + "type": "text", + "text": " tools" + }, + "messageId": "56a19eb1-624c-408c-a5ad-11b23b7cffe6" + } + } + } + }, + { + "dir": "in", + "raw": { + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "019f9475-63bb-7000-bff7-58073a1d26e9", + "update": { + "sessionUpdate": "agent_thought_chunk", + "content": { + "type": "text", + "text": " needed" + }, + "messageId": "56a19eb1-624c-408c-a5ad-11b23b7cffe6" + } + } + } + }, + { + "dir": "in", + "raw": { + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "019f9475-63bb-7000-bff7-58073a1d26e9", + "update": { + "sessionUpdate": "agent_thought_chunk", + "content": { + "type": "text", + "text": "." + }, + "messageId": "56a19eb1-624c-408c-a5ad-11b23b7cffe6" + } + } + } + }, + { + "dir": "in", + "raw": { + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "019f9475-63bb-7000-bff7-58073a1d26e9", + "update": { + "sessionUpdate": "agent_thought_chunk", + "content": { + "type": "text", + "text": "\n\n" + }, + "messageId": "56a19eb1-624c-408c-a5ad-11b23b7cffe6" + } + } + } + }, + { + "dir": "in", + "raw": { + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "019f9475-63bb-7000-bff7-58073a1d26e9", + "update": { + "sessionUpdate": "agent_message_chunk", + "content": { + "type": "text", + "text": "#" + }, + "messageId": "56a19eb1-624c-408c-a5ad-11b23b7cffe6" + } + } + } + }, + { + "dir": "in", + "raw": { + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "019f9475-63bb-7000-bff7-58073a1d26e9", + "update": { + "sessionUpdate": "agent_message_chunk", + "content": { + "type": "text", + "text": " The" + }, + "messageId": "56a19eb1-624c-408c-a5ad-11b23b7cffe6" + } + } + } + }, + { + "dir": "in", + "raw": { + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "019f9475-63bb-7000-bff7-58073a1d26e9", + "update": { + "sessionUpdate": "agent_message_chunk", + "content": { + "type": "text", + "text": " Quiet" + }, + "messageId": "56a19eb1-624c-408c-a5ad-11b23b7cffe6" + } + } + } + }, + { + "dir": "in", + "raw": { + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "019f9475-63bb-7000-bff7-58073a1d26e9", + "update": { + "sessionUpdate": "agent_message_chunk", + "content": { + "type": "text", + "text": " Architecture" + }, + "messageId": "56a19eb1-624c-408c-a5ad-11b23b7cffe6" + } + } + } + }, + { + "dir": "in", + "raw": { + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "019f9475-63bb-7000-bff7-58073a1d26e9", + "update": { + "sessionUpdate": "agent_message_chunk", + "content": { + "type": "text", + "text": " of" + }, + "messageId": "56a19eb1-624c-408c-a5ad-11b23b7cffe6" + } + } + } + }, + { + "dir": "in", + "raw": { + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "019f9475-63bb-7000-bff7-58073a1d26e9", + "update": { + "sessionUpdate": "agent_message_chunk", + "content": { + "type": "text", + "text": " Trees" + }, + "messageId": "56a19eb1-624c-408c-a5ad-11b23b7cffe6" + } + } + } + }, + { + "dir": "in", + "raw": { + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "019f9475-63bb-7000-bff7-58073a1d26e9", + "update": { + "sessionUpdate": "agent_message_chunk", + "content": { + "type": "text", + "text": "\n\n" + }, + "messageId": "56a19eb1-624c-408c-a5ad-11b23b7cffe6" + } + } + } + }, + { + "dir": "in", + "raw": { + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "019f9475-63bb-7000-bff7-58073a1d26e9", + "update": { + "sessionUpdate": "agent_message_chunk", + "content": { + "type": "text", + "text": "Trees" + }, + "messageId": "56a19eb1-624c-408c-a5ad-11b23b7cffe6" + } + } + } + }, + { + "dir": "in", + "raw": { + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "019f9475-63bb-7000-bff7-58073a1d26e9", + "update": { + "sessionUpdate": "agent_message_chunk", + "content": { + "type": "text", + "text": " are" + }, + "messageId": "56a19eb1-624c-408c-a5ad-11b23b7cffe6" + } + } + } + }, + { + "dir": "in", + "raw": { + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "019f9475-63bb-7000-bff7-58073a1d26e9", + "update": { + "sessionUpdate": "agent_message_chunk", + "content": { + "type": "text", + "text": " among" + }, + "messageId": "56a19eb1-624c-408c-a5ad-11b23b7cffe6" + } + } + } + }, + { + "dir": "in", + "raw": { + "jsonrpc": "2.0", + "id": 6, + "result": { + "stopReason": "cancelled" + } + } + }, + { + "dir": "out", + "raw": { + "jsonrpc": "2.0", + "id": 7, + "method": "session/fork", + "params": { + "sessionId": "019f9475-63bb-7000-bff7-58073a1d26e9" + } + } + }, + { + "dir": "in", + "raw": { + "jsonrpc": "2.0", + "id": 7, + "error": { + "code": -32602, + "message": "Invalid params", + "data": { + "_errors": [], + "cwd": { + "_errors": [ + "Invalid input: expected string, received undefined" + ] + } + } + } + } + }, + { + "dir": "out", + "raw": { + "jsonrpc": "2.0", + "id": 8, + "method": "session/fork", + "params": { + "sessionId": "019f9475-63bb-7000-bff7-58073a1d26e9", + "cwd": "/Users/solojiang/workspace/weft/.worktrees/feat/omp-acp-runtime" + } + } + }, + { + "dir": "in", + "raw": { + "jsonrpc": "2.0", + "id": 8, + "result": { + "sessionId": "019f9475-9196-7000-a561-014fbb0afad2", + "configOptions": [ + { + "id": "mode", + "name": "Mode", + "category": "mode", + "type": "select", + "currentValue": "default", + "options": [ + { + "value": "default", + "name": "Default", + "description": "Standard ACP headless mode" + }, + { + "value": "plan", + "name": "Plan", + "description": "Read-only planning mode that drafts a plan to a markdown file before any code changes" + } + ] + }, + { + "id": "model", + "name": "Model", + "category": "model", + "type": "select", + "currentValue": "xai-oauth/grok-4.5", + "options": [ + { + "value": "github-copilot/claude-fable-5", + "name": "Claude Fable 5", + "description": "github-copilot/claude-fable-5" + }, + { + "value": "github-copilot/claude-haiku-4.5", + "name": "Claude Haiku 4.5", + "description": "github-copilot/claude-haiku-4.5" + }, + { + "value": "github-copilot/claude-opus-4.5", + "name": "Claude Opus 4.5", + "description": "github-copilot/claude-opus-4.5" + }, + { + "value": "github-copilot/claude-opus-4.6", + "name": "Claude Opus 4.6", + "description": "github-copilot/claude-opus-4.6" + }, + { + "value": "github-copilot/claude-opus-4.7", + "name": "Claude Opus 4.7", + "description": "github-copilot/claude-opus-4.7" + }, + { + "value": "github-copilot/claude-opus-4.8", + "name": "Claude Opus 4.8", + "description": "github-copilot/claude-opus-4.8" + }, + { + "value": "github-copilot/claude-sonnet-4", + "name": "Claude Sonnet 4", + "description": "github-copilot/claude-sonnet-4" + }, + { + "value": "github-copilot/claude-sonnet-4.5", + "name": "Claude Sonnet 4.5", + "description": "github-copilot/claude-sonnet-4.5" + }, + { + "value": "github-copilot/claude-sonnet-4.6", + "name": "Claude Sonnet 4.6", + "description": "github-copilot/claude-sonnet-4.6" + }, + { + "value": "github-copilot/claude-sonnet-5", + "name": "Claude Sonnet 5", + "description": "github-copilot/claude-sonnet-5" + }, + { + "value": "github-copilot/gemini-2.5-pro", + "name": "Gemini 2.5 Pro", + "description": "github-copilot/gemini-2.5-pro" + }, + { + "value": "github-copilot/gemini-3-flash-preview", + "name": "Gemini 3 Flash (Preview)", + "description": "github-copilot/gemini-3-flash-preview" + }, + { + "value": "github-copilot/gemini-3-pro-preview", + "name": "Gemini 3 Pro Preview", + "description": "github-copilot/gemini-3-pro-preview" + }, + { + "value": "github-copilot/gemini-3.1-pro-preview", + "name": "Gemini 3.1 Pro", + "description": "github-copilot/gemini-3.1-pro-preview" + }, + { + "value": "github-copilot/gemini-3.5-flash", + "name": "Gemini 3.5 Flash", + "description": "github-copilot/gemini-3.5-flash" + }, + { + "value": "github-copilot/gpt-4.1", + "name": "GPT-4.1", + "description": "github-copilot/gpt-4.1" + }, + { + "value": "github-copilot/gpt-4o", + "name": "GPT-4o", + "description": "github-copilot/gpt-4o" + }, + { + "value": "github-copilot/gpt-5", + "name": "GPT-5", + "description": "github-copilot/gpt-5" + }, + { + "value": "github-copilot/gpt-5-mini", + "name": "GPT-5 mini", + "description": "github-copilot/gpt-5-mini" + }, + { + "value": "github-copilot/gpt-5.1", + "name": "GPT-5.1", + "description": "github-copilot/gpt-5.1" + }, + { + "value": "github-copilot/gpt-5.1-codex", + "name": "GPT-5.1 Codex", + "description": "github-copilot/gpt-5.1-codex" + }, + { + "value": "github-copilot/gpt-5.1-codex-max", + "name": "GPT-5.1 Codex Max", + "description": "github-copilot/gpt-5.1-codex-max" + }, + { + "value": "github-copilot/gpt-5.1-codex-mini", + "name": "GPT-5.1 Codex mini", + "description": "github-copilot/gpt-5.1-codex-mini" + }, + { + "value": "github-copilot/gpt-5.2", + "name": "GPT-5.2", + "description": "github-copilot/gpt-5.2" + }, + { + "value": "github-copilot/gpt-5.2-codex", + "name": "GPT-5.2 Codex", + "description": "github-copilot/gpt-5.2-codex" + }, + { + "value": "github-copilot/gpt-5.3-codex", + "name": "GPT-5.3-Codex", + "description": "github-copilot/gpt-5.3-codex" + }, + { + "value": "github-copilot/gpt-5.4", + "name": "GPT-5.4", + "description": "github-copilot/gpt-5.4" + }, + { + "value": "github-copilot/gpt-5.4-mini", + "name": "GPT-5.4 mini", + "description": "github-copilot/gpt-5.4-mini" + }, + { + "value": "github-copilot/gpt-5.4-nano", + "name": "GPT-5.4 nano", + "description": "github-copilot/gpt-5.4-nano" + }, + { + "value": "github-copilot/gpt-5.5", + "name": "GPT-5.5", + "description": "github-copilot/gpt-5.5" + }, + { + "value": "github-copilot/gpt-5.6-luna", + "name": "GPT-5.6 Luna", + "description": "github-copilot/gpt-5.6-luna" + }, + { + "value": "github-copilot/gpt-5.6-sol", + "name": "GPT-5.6 Sol", + "description": "github-copilot/gpt-5.6-sol" + }, + { + "value": "github-copilot/gpt-5.6-terra", + "name": "GPT-5.6 Terra", + "description": "github-copilot/gpt-5.6-terra" + }, + { + "value": "github-copilot/grok-code-fast-1", + "name": "Grok Code Fast 1", + "description": "github-copilot/grok-code-fast-1" + }, + { + "value": "github-copilot/kimi-k2.7-code", + "name": "Kimi K2.7 Code", + "description": "github-copilot/kimi-k2.7-code" + }, + { + "value": "github-copilot/mai-code-1-flash-picker", + "name": "MAI-Code-1-Flash", + "description": "github-copilot/mai-code-1-flash-picker" + }, + { + "value": "github-copilot/raptor-mini", + "name": "Raptor mini", + "description": "github-copilot/raptor-mini" + }, + { + "value": "github-copilot/claude-fable-5-1m", + "name": "Claude Fable 5 (1M)", + "description": "github-copilot/claude-fable-5-1m" + }, + { + "value": "github-copilot/claude-opus-4.7-1m", + "name": "Claude Opus 4.7 (1M)", + "description": "github-copilot/claude-opus-4.7-1m" + }, + { + "value": "github-copilot/claude-opus-4.8-1m", + "name": "Claude Opus 4.8 (1M)", + "description": "github-copilot/claude-opus-4.8-1m" + }, + { + "value": "github-copilot/claude-sonnet-4.6-1m", + "name": "Claude Sonnet 4.6 (1M)", + "description": "github-copilot/claude-sonnet-4.6-1m" + }, + { + "value": "github-copilot/claude-sonnet-5-1m", + "name": "Claude Sonnet 5 (1M)", + "description": "github-copilot/claude-sonnet-5-1m" + }, + { + "value": "github-copilot/gemini-3.1-pro-preview-1m", + "name": "Gemini 3.1 Pro (1M)", + "description": "github-copilot/gemini-3.1-pro-preview-1m" + }, + { + "value": "github-copilot/gemini-3.5-flash-1m", + "name": "Gemini 3.5 Flash (1M)", + "description": "github-copilot/gemini-3.5-flash-1m" + }, + { + "value": "github-copilot/gpt-5.4-1m", + "name": "GPT-5.4 (1M)", + "description": "github-copilot/gpt-5.4-1m" + }, + { + "value": "github-copilot/gpt-5.5-1m", + "name": "GPT-5.5 (1M)", + "description": "github-copilot/gpt-5.5-1m" + }, + { + "value": "github-copilot/gpt-5.6-luna-1m", + "name": "GPT-5.6 Luna (1M)", + "description": "github-copilot/gpt-5.6-luna-1m" + }, + { + "value": "github-copilot/gpt-5.6-sol-1m", + "name": "GPT-5.6 Sol (1M)", + "description": "github-copilot/gpt-5.6-sol-1m" + }, + { + "value": "github-copilot/gpt-5.6-terra-1m", + "name": "GPT-5.6 Terra (1M)", + "description": "github-copilot/gpt-5.6-terra-1m" + }, + { + "value": "google-antigravity/claude-opus-4-5", + "name": "Claude Opus 4.5", + "description": "google-antigravity/claude-opus-4-5" + }, + { + "value": "google-antigravity/claude-opus-4-6", + "name": "Claude Opus 4.6", + "description": "google-antigravity/claude-opus-4-6" + }, + { + "value": "google-antigravity/claude-sonnet-4-5", + "name": "Claude Sonnet 4.5", + "description": "google-antigravity/claude-sonnet-4-5" + }, + { + "value": "google-antigravity/claude-sonnet-4-6", + "name": "Claude Sonnet 4.6", + "description": "google-antigravity/claude-sonnet-4-6" + }, + { + "value": "google-antigravity/gemini-2.5-flash", + "name": "Gemini 2.5 Flash", + "description": "google-antigravity/gemini-2.5-flash" + }, + { + "value": "google-antigravity/gemini-2.5-flash-lite", + "name": "Gemini 3.1 Flash Lite", + "description": "google-antigravity/gemini-2.5-flash-lite" + }, + { + "value": "google-antigravity/gemini-2.5-pro", + "name": "Gemini 2.5 Pro", + "description": "google-antigravity/gemini-2.5-pro" + }, + { + "value": "google-antigravity/gemini-3-flash", + "name": "Gemini 3 Flash", + "description": "google-antigravity/gemini-3-flash" + }, + { + "value": "google-antigravity/gemini-3-pro", + "name": "Gemini 3 Pro", + "description": "google-antigravity/gemini-3-pro" + }, + { + "value": "google-antigravity/gemini-3.1-flash-image", + "name": "Gemini 3.1 Flash Image", + "description": "google-antigravity/gemini-3.1-flash-image" + }, + { + "value": "google-antigravity/gemini-3.1-flash-lite", + "name": "Gemini 3.1 Flash Lite", + "description": "google-antigravity/gemini-3.1-flash-lite" + }, + { + "value": "google-antigravity/gemini-3.1-pro", + "name": "Gemini 3.1 Pro", + "description": "google-antigravity/gemini-3.1-pro" + }, + { + "value": "google-antigravity/gemini-3.5-flash", + "name": "Gemini 3.5 Flash", + "description": "google-antigravity/gemini-3.5-flash" + }, + { + "value": "google-antigravity/gemini-3.6-flash", + "name": "Gemini 3.6 Flash", + "description": "google-antigravity/gemini-3.6-flash" + }, + { + "value": "google-antigravity/gpt-oss-120b", + "name": "GPT-OSS 120B", + "description": "google-antigravity/gpt-oss-120b" + }, + { + "value": "google-antigravity/tab_flash_lite_preview", + "name": "tab_flash_lite_preview", + "description": "google-antigravity/tab_flash_lite_preview" + }, + { + "value": "google-antigravity/tab_jump_flash_lite_preview", + "name": "tab_jump_flash_lite_preview", + "description": "google-antigravity/tab_jump_flash_lite_preview" + }, + { + "value": "kimi-code/k3", + "name": "K3", + "description": "kimi-code/k3" + }, + { + "value": "kimi-code/kimi-for-coding", + "name": "K2.7 Coding", + "description": "kimi-code/kimi-for-coding" + }, + { + "value": "kimi-code/kimi-for-coding-highspeed", + "name": "K2.7 Coding Highspeed", + "description": "kimi-code/kimi-for-coding-highspeed" + }, + { + "value": "kimi-code/kimi-k2", + "name": "Kimi K2", + "description": "kimi-code/kimi-k2" + }, + { + "value": "kimi-code/kimi-k2-turbo-preview", + "name": "Kimi K2 Turbo", + "description": "kimi-code/kimi-k2-turbo-preview" + }, + { + "value": "kimi-code/kimi-k2.5", + "name": "Kimi K2.5", + "description": "kimi-code/kimi-k2.5" + }, + { + "value": "kimi-code/k3-256k", + "name": "k3-256k", + "description": "kimi-code/k3-256k" + }, + { + "value": "openai-codex/gpt-5.3-codex-spark", + "name": "GPT-5.3-Codex-Spark", + "description": "openai-codex/gpt-5.3-codex-spark" + }, + { + "value": "openai-codex/gpt-5.4", + "name": "GPT-5.4", + "description": "openai-codex/gpt-5.4" + }, + { + "value": "openai-codex/gpt-5.4-mini", + "name": "GPT-5.4-Mini", + "description": "openai-codex/gpt-5.4-mini" + }, + { + "value": "openai-codex/gpt-5.5", + "name": "GPT-5.5", + "description": "openai-codex/gpt-5.5" + }, + { + "value": "openai-codex/gpt-5.6-luna", + "name": "GPT-5.6-Luna", + "description": "openai-codex/gpt-5.6-luna" + }, + { + "value": "openai-codex/gpt-5.6-sol", + "name": "GPT-5.6-Sol", + "description": "openai-codex/gpt-5.6-sol" + }, + { + "value": "openai-codex/gpt-5.6-terra", + "name": "GPT-5.6-Terra", + "description": "openai-codex/gpt-5.6-terra" + }, + { + "value": "xai-oauth/grok-4.20-0309-non-reasoning", + "name": "Grok 4.20 (Non-Reasoning)", + "description": "xai-oauth/grok-4.20-0309-non-reasoning" + }, + { + "value": "xai-oauth/grok-4.20-0309-reasoning", + "name": "Grok 4.20 (Reasoning)", + "description": "xai-oauth/grok-4.20-0309-reasoning" + }, + { + "value": "xai-oauth/grok-4.20-multi-agent-0309", + "name": "Grok 4.20 (Multi-Agent)", + "description": "xai-oauth/grok-4.20-multi-agent-0309" + }, + { + "value": "xai-oauth/grok-4.3", + "name": "Grok 4.3", + "description": "xai-oauth/grok-4.3" + }, + { + "value": "xai-oauth/grok-4.5", + "name": "Grok 4.5", + "description": "xai-oauth/grok-4.5" + }, + { + "value": "xai-oauth/grok-build", + "name": "Grok Build", + "description": "xai-oauth/grok-build" + }, + { + "value": "xai-oauth/grok-build-0.1", + "name": "Grok Build 0.1", + "description": "xai-oauth/grok-build-0.1" + }, + { + "value": "xai-oauth/grok-composer-2.5-fast", + "name": "Grok Composer 2.5 Fast", + "description": "xai-oauth/grok-composer-2.5-fast" + } + ] + }, + { + "id": "thinking", + "name": "Thinking", + "category": "thought_level", + "type": "select", + "currentValue": "xhigh", + "options": [ + { + "value": "off", + "name": "Off" + }, + { + "value": "auto", + "name": "Auto", + "description": "Auto-detect per prompt (low\u2013xhigh)" + }, + { + "value": "minimal", + "name": "minimal" + }, + { + "value": "low", + "name": "low" + }, + { + "value": "medium", + "name": "medium" + }, + { + "value": "high", + "name": "high" + }, + { + "value": "xhigh", + "name": "xhigh" + } + ] + } + ], + "modes": { + "availableModes": [ + { + "id": "default", + "name": "Default", + "description": "Standard ACP headless mode" + }, + { + "id": "plan", + "name": "Plan", + "description": "Read-only planning mode that drafts a plan to a markdown file before any code changes" + } + ], + "currentModeId": "default" + } + } + } + }, + { + "dir": "out", + "raw": { + "jsonrpc": "2.0", + "id": 9, + "method": "session/fork", + "params": { + "sessionId": "019f9475-63bb-7000-bff7-58073a1d26e9", + "messageId": "0" + } + } + }, + { + "dir": "in", + "raw": { + "jsonrpc": "2.0", + "id": 9, + "error": { + "code": -32602, + "message": "Invalid params", + "data": { + "_errors": [], + "cwd": { + "_errors": [ + "Invalid input: expected string, received undefined" + ] + } + } + } + } + }, + { + "dir": "out", + "raw": { + "jsonrpc": "2.0", + "id": 10, + "method": "session/fork", + "params": { + "sessionId": "019f9475-63bb-7000-bff7-58073a1d26e9", + "atMessageId": "0" + } + } + }, + { + "dir": "in", + "raw": { + "jsonrpc": "2.0", + "id": 10, + "error": { + "code": -32602, + "message": "Invalid params", + "data": { + "_errors": [], + "cwd": { + "_errors": [ + "Invalid input: expected string, received undefined" + ] + } + } + } + } + }, + { + "dir": "out", + "raw": { + "jsonrpc": "2.0", + "id": 11, + "method": "session/resume", + "params": { + "sessionId": "019f9475-63bb-7000-bff7-58073a1d26e9", + "cwd": "/Users/solojiang/workspace/weft/.worktrees/feat/omp-acp-runtime", + "mcpServers": [] + } + } + }, + { + "dir": "in", + "raw": { + "jsonrpc": "2.0", + "id": 11, + "result": { + "configOptions": [ + { + "id": "mode", + "name": "Mode", + "category": "mode", + "type": "select", + "currentValue": "default", + "options": [ + { + "value": "default", + "name": "Default", + "description": "Standard ACP headless mode" + }, + { + "value": "plan", + "name": "Plan", + "description": "Read-only planning mode that drafts a plan to a markdown file before any code changes" + } + ] + }, + { + "id": "model", + "name": "Model", + "category": "model", + "type": "select", + "currentValue": "xai-oauth/grok-4.5", + "options": [ + { + "value": "github-copilot/claude-fable-5", + "name": "Claude Fable 5", + "description": "github-copilot/claude-fable-5" + }, + { + "value": "github-copilot/claude-haiku-4.5", + "name": "Claude Haiku 4.5", + "description": "github-copilot/claude-haiku-4.5" + }, + { + "value": "github-copilot/claude-opus-4.5", + "name": "Claude Opus 4.5", + "description": "github-copilot/claude-opus-4.5" + }, + { + "value": "github-copilot/claude-opus-4.6", + "name": "Claude Opus 4.6", + "description": "github-copilot/claude-opus-4.6" + }, + { + "value": "github-copilot/claude-opus-4.7", + "name": "Claude Opus 4.7", + "description": "github-copilot/claude-opus-4.7" + }, + { + "value": "github-copilot/claude-opus-4.8", + "name": "Claude Opus 4.8", + "description": "github-copilot/claude-opus-4.8" + }, + { + "value": "github-copilot/claude-sonnet-4", + "name": "Claude Sonnet 4", + "description": "github-copilot/claude-sonnet-4" + }, + { + "value": "github-copilot/claude-sonnet-4.5", + "name": "Claude Sonnet 4.5", + "description": "github-copilot/claude-sonnet-4.5" + }, + { + "value": "github-copilot/claude-sonnet-4.6", + "name": "Claude Sonnet 4.6", + "description": "github-copilot/claude-sonnet-4.6" + }, + { + "value": "github-copilot/claude-sonnet-5", + "name": "Claude Sonnet 5", + "description": "github-copilot/claude-sonnet-5" + }, + { + "value": "github-copilot/gemini-2.5-pro", + "name": "Gemini 2.5 Pro", + "description": "github-copilot/gemini-2.5-pro" + }, + { + "value": "github-copilot/gemini-3-flash-preview", + "name": "Gemini 3 Flash (Preview)", + "description": "github-copilot/gemini-3-flash-preview" + }, + { + "value": "github-copilot/gemini-3-pro-preview", + "name": "Gemini 3 Pro Preview", + "description": "github-copilot/gemini-3-pro-preview" + }, + { + "value": "github-copilot/gemini-3.1-pro-preview", + "name": "Gemini 3.1 Pro", + "description": "github-copilot/gemini-3.1-pro-preview" + }, + { + "value": "github-copilot/gemini-3.5-flash", + "name": "Gemini 3.5 Flash", + "description": "github-copilot/gemini-3.5-flash" + }, + { + "value": "github-copilot/gpt-4.1", + "name": "GPT-4.1", + "description": "github-copilot/gpt-4.1" + }, + { + "value": "github-copilot/gpt-4o", + "name": "GPT-4o", + "description": "github-copilot/gpt-4o" + }, + { + "value": "github-copilot/gpt-5", + "name": "GPT-5", + "description": "github-copilot/gpt-5" + }, + { + "value": "github-copilot/gpt-5-mini", + "name": "GPT-5 mini", + "description": "github-copilot/gpt-5-mini" + }, + { + "value": "github-copilot/gpt-5.1", + "name": "GPT-5.1", + "description": "github-copilot/gpt-5.1" + }, + { + "value": "github-copilot/gpt-5.1-codex", + "name": "GPT-5.1 Codex", + "description": "github-copilot/gpt-5.1-codex" + }, + { + "value": "github-copilot/gpt-5.1-codex-max", + "name": "GPT-5.1 Codex Max", + "description": "github-copilot/gpt-5.1-codex-max" + }, + { + "value": "github-copilot/gpt-5.1-codex-mini", + "name": "GPT-5.1 Codex mini", + "description": "github-copilot/gpt-5.1-codex-mini" + }, + { + "value": "github-copilot/gpt-5.2", + "name": "GPT-5.2", + "description": "github-copilot/gpt-5.2" + }, + { + "value": "github-copilot/gpt-5.2-codex", + "name": "GPT-5.2 Codex", + "description": "github-copilot/gpt-5.2-codex" + }, + { + "value": "github-copilot/gpt-5.3-codex", + "name": "GPT-5.3-Codex", + "description": "github-copilot/gpt-5.3-codex" + }, + { + "value": "github-copilot/gpt-5.4", + "name": "GPT-5.4", + "description": "github-copilot/gpt-5.4" + }, + { + "value": "github-copilot/gpt-5.4-mini", + "name": "GPT-5.4 mini", + "description": "github-copilot/gpt-5.4-mini" + }, + { + "value": "github-copilot/gpt-5.4-nano", + "name": "GPT-5.4 nano", + "description": "github-copilot/gpt-5.4-nano" + }, + { + "value": "github-copilot/gpt-5.5", + "name": "GPT-5.5", + "description": "github-copilot/gpt-5.5" + }, + { + "value": "github-copilot/gpt-5.6-luna", + "name": "GPT-5.6 Luna", + "description": "github-copilot/gpt-5.6-luna" + }, + { + "value": "github-copilot/gpt-5.6-sol", + "name": "GPT-5.6 Sol", + "description": "github-copilot/gpt-5.6-sol" + }, + { + "value": "github-copilot/gpt-5.6-terra", + "name": "GPT-5.6 Terra", + "description": "github-copilot/gpt-5.6-terra" + }, + { + "value": "github-copilot/grok-code-fast-1", + "name": "Grok Code Fast 1", + "description": "github-copilot/grok-code-fast-1" + }, + { + "value": "github-copilot/kimi-k2.7-code", + "name": "Kimi K2.7 Code", + "description": "github-copilot/kimi-k2.7-code" + }, + { + "value": "github-copilot/mai-code-1-flash-picker", + "name": "MAI-Code-1-Flash", + "description": "github-copilot/mai-code-1-flash-picker" + }, + { + "value": "github-copilot/raptor-mini", + "name": "Raptor mini", + "description": "github-copilot/raptor-mini" + }, + { + "value": "github-copilot/claude-fable-5-1m", + "name": "Claude Fable 5 (1M)", + "description": "github-copilot/claude-fable-5-1m" + }, + { + "value": "github-copilot/claude-opus-4.7-1m", + "name": "Claude Opus 4.7 (1M)", + "description": "github-copilot/claude-opus-4.7-1m" + }, + { + "value": "github-copilot/claude-opus-4.8-1m", + "name": "Claude Opus 4.8 (1M)", + "description": "github-copilot/claude-opus-4.8-1m" + }, + { + "value": "github-copilot/claude-sonnet-4.6-1m", + "name": "Claude Sonnet 4.6 (1M)", + "description": "github-copilot/claude-sonnet-4.6-1m" + }, + { + "value": "github-copilot/claude-sonnet-5-1m", + "name": "Claude Sonnet 5 (1M)", + "description": "github-copilot/claude-sonnet-5-1m" + }, + { + "value": "github-copilot/gemini-3.1-pro-preview-1m", + "name": "Gemini 3.1 Pro (1M)", + "description": "github-copilot/gemini-3.1-pro-preview-1m" + }, + { + "value": "github-copilot/gemini-3.5-flash-1m", + "name": "Gemini 3.5 Flash (1M)", + "description": "github-copilot/gemini-3.5-flash-1m" + }, + { + "value": "github-copilot/gpt-5.4-1m", + "name": "GPT-5.4 (1M)", + "description": "github-copilot/gpt-5.4-1m" + }, + { + "value": "github-copilot/gpt-5.5-1m", + "name": "GPT-5.5 (1M)", + "description": "github-copilot/gpt-5.5-1m" + }, + { + "value": "github-copilot/gpt-5.6-luna-1m", + "name": "GPT-5.6 Luna (1M)", + "description": "github-copilot/gpt-5.6-luna-1m" + }, + { + "value": "github-copilot/gpt-5.6-sol-1m", + "name": "GPT-5.6 Sol (1M)", + "description": "github-copilot/gpt-5.6-sol-1m" + }, + { + "value": "github-copilot/gpt-5.6-terra-1m", + "name": "GPT-5.6 Terra (1M)", + "description": "github-copilot/gpt-5.6-terra-1m" + }, + { + "value": "google-antigravity/claude-opus-4-5", + "name": "Claude Opus 4.5", + "description": "google-antigravity/claude-opus-4-5" + }, + { + "value": "google-antigravity/claude-opus-4-6", + "name": "Claude Opus 4.6", + "description": "google-antigravity/claude-opus-4-6" + }, + { + "value": "google-antigravity/claude-sonnet-4-5", + "name": "Claude Sonnet 4.5", + "description": "google-antigravity/claude-sonnet-4-5" + }, + { + "value": "google-antigravity/claude-sonnet-4-6", + "name": "Claude Sonnet 4.6", + "description": "google-antigravity/claude-sonnet-4-6" + }, + { + "value": "google-antigravity/gemini-2.5-flash", + "name": "Gemini 2.5 Flash", + "description": "google-antigravity/gemini-2.5-flash" + }, + { + "value": "google-antigravity/gemini-2.5-flash-lite", + "name": "Gemini 3.1 Flash Lite", + "description": "google-antigravity/gemini-2.5-flash-lite" + }, + { + "value": "google-antigravity/gemini-2.5-pro", + "name": "Gemini 2.5 Pro", + "description": "google-antigravity/gemini-2.5-pro" + }, + { + "value": "google-antigravity/gemini-3-flash", + "name": "Gemini 3 Flash", + "description": "google-antigravity/gemini-3-flash" + }, + { + "value": "google-antigravity/gemini-3-pro", + "name": "Gemini 3 Pro", + "description": "google-antigravity/gemini-3-pro" + }, + { + "value": "google-antigravity/gemini-3.1-flash-image", + "name": "Gemini 3.1 Flash Image", + "description": "google-antigravity/gemini-3.1-flash-image" + }, + { + "value": "google-antigravity/gemini-3.1-flash-lite", + "name": "Gemini 3.1 Flash Lite", + "description": "google-antigravity/gemini-3.1-flash-lite" + }, + { + "value": "google-antigravity/gemini-3.1-pro", + "name": "Gemini 3.1 Pro", + "description": "google-antigravity/gemini-3.1-pro" + }, + { + "value": "google-antigravity/gemini-3.5-flash", + "name": "Gemini 3.5 Flash", + "description": "google-antigravity/gemini-3.5-flash" + }, + { + "value": "google-antigravity/gemini-3.6-flash", + "name": "Gemini 3.6 Flash", + "description": "google-antigravity/gemini-3.6-flash" + }, + { + "value": "google-antigravity/gpt-oss-120b", + "name": "GPT-OSS 120B", + "description": "google-antigravity/gpt-oss-120b" + }, + { + "value": "google-antigravity/tab_flash_lite_preview", + "name": "tab_flash_lite_preview", + "description": "google-antigravity/tab_flash_lite_preview" + }, + { + "value": "google-antigravity/tab_jump_flash_lite_preview", + "name": "tab_jump_flash_lite_preview", + "description": "google-antigravity/tab_jump_flash_lite_preview" + }, + { + "value": "kimi-code/k3", + "name": "K3", + "description": "kimi-code/k3" + }, + { + "value": "kimi-code/kimi-for-coding", + "name": "K2.7 Coding", + "description": "kimi-code/kimi-for-coding" + }, + { + "value": "kimi-code/kimi-for-coding-highspeed", + "name": "K2.7 Coding Highspeed", + "description": "kimi-code/kimi-for-coding-highspeed" + }, + { + "value": "kimi-code/kimi-k2", + "name": "Kimi K2", + "description": "kimi-code/kimi-k2" + }, + { + "value": "kimi-code/kimi-k2-turbo-preview", + "name": "Kimi K2 Turbo", + "description": "kimi-code/kimi-k2-turbo-preview" + }, + { + "value": "kimi-code/kimi-k2.5", + "name": "Kimi K2.5", + "description": "kimi-code/kimi-k2.5" + }, + { + "value": "kimi-code/k3-256k", + "name": "k3-256k", + "description": "kimi-code/k3-256k" + }, + { + "value": "openai-codex/gpt-5.3-codex-spark", + "name": "GPT-5.3-Codex-Spark", + "description": "openai-codex/gpt-5.3-codex-spark" + }, + { + "value": "openai-codex/gpt-5.4", + "name": "GPT-5.4", + "description": "openai-codex/gpt-5.4" + }, + { + "value": "openai-codex/gpt-5.4-mini", + "name": "GPT-5.4-Mini", + "description": "openai-codex/gpt-5.4-mini" + }, + { + "value": "openai-codex/gpt-5.5", + "name": "GPT-5.5", + "description": "openai-codex/gpt-5.5" + }, + { + "value": "openai-codex/gpt-5.6-luna", + "name": "GPT-5.6-Luna", + "description": "openai-codex/gpt-5.6-luna" + }, + { + "value": "openai-codex/gpt-5.6-sol", + "name": "GPT-5.6-Sol", + "description": "openai-codex/gpt-5.6-sol" + }, + { + "value": "openai-codex/gpt-5.6-terra", + "name": "GPT-5.6-Terra", + "description": "openai-codex/gpt-5.6-terra" + }, + { + "value": "xai-oauth/grok-4.20-0309-non-reasoning", + "name": "Grok 4.20 (Non-Reasoning)", + "description": "xai-oauth/grok-4.20-0309-non-reasoning" + }, + { + "value": "xai-oauth/grok-4.20-0309-reasoning", + "name": "Grok 4.20 (Reasoning)", + "description": "xai-oauth/grok-4.20-0309-reasoning" + }, + { + "value": "xai-oauth/grok-4.20-multi-agent-0309", + "name": "Grok 4.20 (Multi-Agent)", + "description": "xai-oauth/grok-4.20-multi-agent-0309" + }, + { + "value": "xai-oauth/grok-4.3", + "name": "Grok 4.3", + "description": "xai-oauth/grok-4.3" + }, + { + "value": "xai-oauth/grok-4.5", + "name": "Grok 4.5", + "description": "xai-oauth/grok-4.5" + }, + { + "value": "xai-oauth/grok-build", + "name": "Grok Build", + "description": "xai-oauth/grok-build" + }, + { + "value": "xai-oauth/grok-build-0.1", + "name": "Grok Build 0.1", + "description": "xai-oauth/grok-build-0.1" + }, + { + "value": "xai-oauth/grok-composer-2.5-fast", + "name": "Grok Composer 2.5 Fast", + "description": "xai-oauth/grok-composer-2.5-fast" + } + ] + }, + { + "id": "thinking", + "name": "Thinking", + "category": "thought_level", + "type": "select", + "currentValue": "xhigh", + "options": [ + { + "value": "off", + "name": "Off" + }, + { + "value": "auto", + "name": "Auto", + "description": "Auto-detect per prompt (low\u2013xhigh)" + }, + { + "value": "minimal", + "name": "minimal" + }, + { + "value": "low", + "name": "low" + }, + { + "value": "medium", + "name": "medium" + }, + { + "value": "high", + "name": "high" + }, + { + "value": "xhigh", + "name": "xhigh" + } + ] + } + ], + "modes": { + "availableModes": [ + { + "id": "default", + "name": "Default", + "description": "Standard ACP headless mode" + }, + { + "id": "plan", + "name": "Plan", + "description": "Read-only planning mode that drafts a plan to a markdown file before any code changes" + } + ], + "currentModeId": "default" + } + } + } + }, + { + "dir": "out", + "raw": { + "jsonrpc": "2.0", + "id": 12, + "method": "session/list", + "params": {} + } + }, + { + "dir": "in", + "raw": { + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "019f9475-9196-7000-a561-014fbb0afad2", + "update": { + "sessionUpdate": "available_commands_update", + "availableCommands": [ + { + "name": "model", + "description": "Show current model selection" + }, + { + "name": "fast", + "description": "Toggle fast mode", + "input": { + "hint": "[on|off|status]" + } + }, + { + "name": "computer", + "description": "Toggle computer use", + "input": { + "hint": "[on|off|status]" + } + }, + { + "name": "prewalk", + "description": "Prewalk at the next action" + }, + { + "name": "advisor", + "description": "Toggle advisor", + "input": { + "hint": "[on|off|status|dump [raw]|configure]" + } + }, + { + "name": "export", + "description": "Export session to HTML file", + "input": { + "hint": "[--themes] [path]" + } + }, + { + "name": "dump", + "description": "Return full transcript as plain text, with LLM request JSON path" + }, + { + "name": "share", + "description": "Share session via an encrypted link (share server or secret gist)" + }, + { + "name": "browser", + "description": "Toggle browser headless vs visible mode", + "input": { + "hint": "[headless|visible]" + } + }, + { + "name": "todo", + "description": "Manage todos", + "input": { + "hint": "<subcommand>" + } + }, + { + "name": "session", + "description": "Show or configure the current session", + "input": { + "hint": "[info|delete|pin [account]]" + } + }, + { + "name": "jobs", + "description": "Show background jobs" + }, + { + "name": "usage", + "description": "Show token usage", + "input": { + "hint": "[show|reset [account|active]]" + } + }, + { + "name": "stats", + "description": "Launch the local stats dashboard", + "input": { + "hint": "[--port <port>]" + } + }, + { + "name": "changelog", + "description": "Show changelog", + "input": { + "hint": "[full]" + } + }, + { + "name": "tools", + "description": "Show available tools" + }, + { + "name": "context", + "description": "Show context usage" + }, + { + "name": "mcp", + "description": "Manage MCP servers", + "input": { + "hint": "<subcommand>" + } + }, + { + "name": "ssh", + "description": "Manage SSH connections", + "input": { + "hint": "<subcommand>" + } + }, + { + "name": "fresh", + "description": "Reset provider stream state without changing the local transcript" + }, + { + "name": "compact", + "description": "Compact the conversation", + "input": { + "hint": "[soft|remote|snapcompact] [focus]" + } + }, + { + "name": "shake", + "description": "Shake heavy content out of the conversation context", + "input": { + "hint": "[elide|images]" + } + }, + { + "name": "memory", + "description": "Manage memory", + "input": { + "hint": "<subcommand>" + } + }, + { + "name": "rename", + "description": "Rename the current session", + "input": { + "hint": "<title>" + } + }, + { + "name": "move", + "description": "Move the current session to a different directory", + "input": { + "hint": "[<path>]" + } + }, + { + "name": "add-dir", + "description": "Add a workspace directory to this session", + "input": { + "hint": "<path>" + } + }, + { + "name": "remove-dir", + "description": "Remove a workspace directory from this session", + "input": { + "hint": "<path>" + } + }, + { + "name": "dirs", + "description": "List this session's workspace directories" + }, + { + "name": "marketplace", + "description": "Manage plugins from marketplaces", + "input": { + "hint": "<subcommand>" + } + }, + { + "name": "plugins", + "description": "Manage plugins", + "input": { + "hint": "[list|enable|disable]" + } + }, + { + "name": "reload-plugins", + "description": "Reload all plugins" + }, + { + "name": "force", + "description": "Force next turn to use a specific tool", + "input": { + "hint": "<tool-name> [prompt]" + } + }, + { + "name": "skill:a11y-debugging", + "description": "Uses Chrome DevTools MCP for accessibility (a11y) debugging and auditing based on web.dev guidelines. Use when testing semantic HTML, ARIA labels, focus states, keyboard navigation, tap targets, and color contrast.", + "input": { + "hint": "arguments" + } + }, + { + "name": "skill:baoyu-design", + "description": "Create polished design artifacts as self-contained HTML \u2014 UI mockups, interactive prototypes, wireframes, landing pages, dashboards, app screens, mobile apps, and slide decks. Use this skill whenever the user wants to design, mock up, prototype, wireframe, or visualize any interface, screen, flow, or visual artifact \u2014 even when they don't say the word \"design\" (e.g. \"build me a landing page\", \"show me what a settings screen could look like\", \"prototype an onboarding flow\", \"wireframe a few layout ideas\", \"make a pitch deck\"). It drives a full design process: clarifying questions, design-context gathering, and production of one or more HTML deliverables. Runs on portable agent harnesses including Claude Code, Cursor, and Codex Agent \u2014 harness-specific tools are resolved from references/.", + "input": { + "hint": "arguments" + } + }, + { + "name": "skill:brainstorming", + "description": "You MUST use this before any creative work - creating features, building components, adding functionality, or modifying behavior. Explores user intent, requirements and design before implementation.", + "input": { + "hint": "arguments" + } + }, + { + "name": "skill:brandkit", + "description": "Premium brand-kit image generation skill for creating high-end brand-guidelines boards, logo systems, identity decks, and visual-world presentations. Trained for minimalist, cinematic, editorial, dark-tech, luxury, cultural, security, gaming, developer-tool, and consumer-app brand systems. Optimized for intentional logo concepting, refined composition, sparse typography, strong symbolic meaning, premium mockups, art-directed imagery, and flexible grid layouts.", + "input": { + "hint": "arguments" + } + }, + { + "name": "skill:chinese-documentation", + "description": "\u4e2d\u6587\u6587\u6863\u6392\u7248\u53c2\u8003\u2014\u2014\u4e2d\u82f1\u6587\u7a7a\u683c\u3001\u5168\u534a\u89d2\u6807\u70b9\u3001\u672f\u8bed\u4fdd\u7559\u3001\u94fe\u63a5\u683c\u5f0f\u3001\u4e2d\u6587\u6587\u6848\u6392\u7248\u6307\u5317\u7ea6\u5b9a\u3002\u4ec5\u5728\u7528\u6237\u663e\u5f0f /chinese-documentation \u65f6\u8c03\u7528\uff0c\u4e0d\u8981\u6839\u636e\u4e0a\u4e0b\u6587\u81ea\u52a8\u89e6\u53d1\u3002", + "input": { + "hint": "arguments" + } + }, + { + "name": "skill:chrome-devtools", + "description": "Uses Chrome DevTools via MCP for efficient debugging, troubleshooting and browser automation. Use when debugging web pages, automating browser interactions, analyzing performance, or inspecting network requests. This skill does not apply to `--slim` mode (MCP configuration).", + "input": { + "hint": "arguments" + } + }, + { + "name": "skill:chrome-devtools-cli", + "description": "Use this skill to write shell scripts or run shell commands to automate tasks in the browser or otherwise use Chrome DevTools via CLI.", + "input": { + "hint": "arguments" + } + }, + { + "name": "skill:chronicle", + "description": "Allows you to view the user's screen as well as several hours of history. Use when the user makes a reference to their recent work, for which it'd be helpful to see the screen. This skill MUST be used whenever you need to resolve ambiguity in a user request, where the user hasn't specified enough context to do the task. Examples include disambiguating the specific user/app/document/error the user is referring to.\n\nYou must also use this skill if the user asks about any question regarding Chronicle or asks what you can see from the screen.\n", + "input": { + "hint": "arguments" + } + }, + { + "name": "skill:claude-md-improver", + "description": "Audit and improve CLAUDE.md files in repositories. Use when user asks to check, audit, update, improve, or fix CLAUDE.md files. Scans for all CLAUDE.md files, evaluates quality against templates, outputs quality report, then makes targeted updates. Also use when the user mentions \"CLAUDE.md maintenance\" or \"project memory optimization\".", + "input": { + "hint": "arguments" + } + }, + { + "name": "skill:debug-optimize-lcp", + "description": "Guides debugging and optimizing Largest Contentful Paint (LCP) using Chrome DevTools MCP tools. Use this skill whenever the user asks about LCP performance, slow page loads, Core Web Vitals optimization, or wants to understand why their page's main content takes too long to appear. Also use when the user mentions \"largest contentful paint\", \"page load speed\", \"CWV\", or wants to improve how fast their hero image or main content renders.", + "input": { + "hint": "arguments" + } + }, + { + "name": "skill:design-taste-frontend", + "description": "Anti-slop frontend skill for landing pages, portfolios, and redesigns. The agent reads the brief, infers the right design direction, and ships interfaces that do not look templated. Real design systems when applicable, audit-first on redesigns, strict pre-flight check.", + "input": { + "hint": "arguments" + } + }, + { + "name": "skill:dispatching-parallel-agents", + "description": "Use when facing 2+ independent tasks that can be worked on without shared state or sequential dependencies", + "input": { + "hint": "arguments" + } + }, + { + "name": "skill:executing-plans", + "description": "Use when you have a written implementation plan to execute in a separate session with review checkpoints", + "input": { + "hint": "arguments" + } + }, + { + "name": "skill:find-skills", + "description": "Helps users discover and install agent skills when they ask questions like \"how do I do X\", \"find a skill for X\", \"is there a skill that can...\", or express interest in extending capabilities. This skill should be used when the user is looking for functionality that might exist as an installable skill.", + "input": { + "hint": "arguments" + } + }, + { + "name": "skill:finishing-a-development-branch", + "description": "Use when implementation is complete, all tests pass, and you need to decide how to integrate the work - guides completion of development work by presenting structured options for merge, PR, or cleanup", + "input": { + "hint": "arguments" + } + }, + { + "name": "skill:full-output-enforcement", + "description": "Overrides default LLM truncation behavior. Enforces complete code generation, bans placeholder patterns, and handles token-limit splits cleanly. Apply to any task requiring exhaustive, unabridged output.", + "input": { + "hint": "arguments" + } + }, + { + "name": "skill:futuapi", + "description": "\u5bcc\u9014 OpenAPI \u4ea4\u6613\u4e0e\u884c\u60c5\u52a9\u624b\u3002\u67e5\u8be2\u80a1\u7968\u884c\u60c5\u3001K\u7ebf\u3001\u62a5\u4ef7\u3001\u5feb\u7167\u3001\u4e70\u5356\u76d8\u3001\u9010\u7b14\u6210\u4ea4\u3001\u5206\u65f6\u6570\u636e\uff1b\u89e3\u6790\u671f\u6743\u7b80\u5199\u4ee3\u7801\u3001\u67e5\u8be2\u671f\u6743\u94fe\u3001\u671f\u6743\u5230\u671f\u65e5\uff1b\u6267\u884c\u4e70\u5165/\u5356\u51fa/\u4e0b\u5355/\u64a4\u5355/\u6539\u5355\uff1b\u67e5\u8be2\u6301\u4ed3/\u8d44\u91d1/\u8d26\u6237/\u8ba2\u5355\uff1b\u8ba2\u9605\u5b9e\u65f6\u63a8\u9001\uff1b\u652f\u6301\u52a0\u5bc6\u8d27\u5e01 (crypto / BTC / ETH / \u6bd4\u7279\u5e01 / \u4ee5\u592a\u574a) \u884c\u60c5\u4e0e\u4ea4\u6613\uff1bAPI \u63a5\u53e3\u901f\u67e5\u3002\u7528\u6237\u63d0\u5230\u884c\u60c5\u3001\u62a5\u4ef7\u3001\u4ef7\u683c\u3001K\u7ebf\u3001\u5feb\u7167\u3001\u4e70\u5356\u76d8\u3001\u6446\u76d8\u3001\u6210\u4ea4\u3001\u5206\u65f6\u3001\u4e70\u5165\u3001\u5356\u51fa\u3001\u4e0b\u5355\u3001\u64a4\u5355\u3001\u4ea4\u6613\u3001\u6301\u4ed3\u3001\u8d44\u91d1\u3001\u8d26\u6237\u3001\u8ba2\u5355\u3001\u59d4\u6258\u3001futu\u3001API\u3001\u9009\u80a1\u3001\u677f\u5757\u3001\u671f\u6743\u3001\u671f\u6743\u94fe\u3001\u671f\u6743\u4ee3\u7801\u3001\u884c\u6743\u4ef7\u3001\u5230\u671f\u65e5\u3001Call\u3001Put\u3001\u770b\u6da8\u3001\u770b\u8dcc\u3001\u8ba4\u8d2d\u3001\u8ba4\u6cbd\u3001\u52a0\u5bc6\u8d27\u5e01\u3001\u6570\u5b57\u8d27\u5e01\u3001crypto\u3001BTC\u3001ETH\u3001\u6bd4\u7279\u5e01\u3001\u4ee5\u592a\u574a\u3001\u5e01\u5bf9\u3001\u8d22\u62a5\u3001\u4e1a\u7ee9\u3001\u8d22\u52a1\u62a5\u8868\u3001\u5229\u6da6\u8868\u3001\u8d44\u4ea7\u8d1f\u503a\u8868\u3001\u73b0\u91d1\u6d41\u3001\u4e3b\u8425\u6784\u6210\u3001\u8425\u6536\u62c6\u5206\u3001\u5206\u6790\u5e08\u8bc4\u7ea7\u3001\u76ee\u6807\u4ef7\u3001\u6668\u661f\u62a5\u544a\u3001\u4f30\u503c\u3001PE\u3001PB\u3001PS\u3001\u677f\u5757\u4f30\u503c\u3001\u6307\u6570\u4f30\u503c\u3001\u6210\u5206\u80a1\u4f30\u503c\u3001\u5206\u7ea2\u3001\u6d3e\u606f\u3001\u80a1\u606f\u3001\u56de\u8d2d\u3001\u62c6\u80a1\u3001\u5408\u80a1\u3001\u62c6\u5408\u80a1\u3001\u80a1\u4e1c\u3001\u6301\u80a1\u7edf\u8ba1\u3001\u80a1\u4e1c\u5206\u5e03\u3001\u6301\u80a1\u53d8\u52a8\u3001\u589e\u6301\u3001\u51cf\u6301\u3001\u65b0\u8fdb\u3001\u6e05\u4ed3\u3001\u6301\u80a1\u660e\u7ec6\u3001\u673a\u6784\u6301\u80a1\u3001\u673a\u6784\u6301\u4ed3\u3001\u5185\u90e8\u4eba\u6301\u80a1\u3001\u5185\u90e8\u4eba\u4ea4\u6613\u3001\u516c\u53f8\u6982\u51b5\u3001\u516c\u53f8\u8be6\u60c5\u3001\u516c\u53f8\u4ecb\u7ecd\u3001\u9ad8\u7ba1\u4fe1\u606f\u3001\u9ad8\u7ba1\u80cc\u666f\u3001\u7ecf\u8425\u6548\u7387\u3001\u5458\u5de5\u6570\u3001\u4eba\u5747\u8425\u6536\u3001\u4eba\u5747\u5229\u6da6\u3001\u5341\u5927\u7ecf\u7eaa\u5546\u3001\u4e70\u5356\u7ecf\u7eaa\u5546\u3001\u5356\u7a7a\u3001\u6bcf\u65e5\u5356\u7a7a\u3001\u7a7a\u5934\u6301\u4ed3\u3001\u671f\u6743\u6ce2\u52a8\u7387\u3001\u9690\u542b\u6ce2\u52a8\u7387\u3001IV\u3001\u671f\u6743\u884c\u6743\u6982\u7387 \u65f6\u81ea\u52a8\u4f7f\u7528\u3002", + "input": { + "hint": "arguments" + } + }, + { + "name": "skill:gpt-taste", + "description": "Elite UX/UI & Advanced GSAP Motion Engineer. Enforces Python-driven true randomization for layout variance, strict AIDA page structure, wide editorial typography (bans 6-line wraps), gapless bento grids, strict GSAP ScrollTriggers (pinning, stacking, scrubbing), inline micro-images, and massive section spacing.", + "input": { + "hint": "arguments" + } + }, + { + "name": "skill:high-end-visual-design", + "description": "Teaches the AI to design like a high-end agency. Defines the exact fonts, spacing, shadows, card structures, and animations that make a website feel expensive. Blocks all the common defaults that make AI designs look cheap or generic.", + "input": { + "hint": "arguments" + } + }, + { + "name": "skill:huashu-design", + "description": "\u7528HTML\u505a\u9ad8\u4fdd\u771f\u539f\u578b\u3001\u4ea4\u4e92Demo\u3001\u5e7b\u706f\u7247\u3001\u52a8\u753b\u3001\u8bbe\u8ba1\u53d8\u4f53\u63a2\u7d22+\u8bbe\u8ba1\u65b9\u5411\u987e\u95ee+\u4e13\u5bb6\u8bc4\u5ba1\u7684\u4e00\u4f53\u5316\u8bbe\u8ba1\u80fd\u529b\u3002HTML\u662f\u5de5\u5177\u4e0d\u662f\u5a92\u4ecb\uff0c\u6839\u636e\u4efb\u52a1embody\u4e0d\u540c\u4e13\u5bb6\uff08UX\u8bbe\u8ba1\u5e08/\u52a8\u753b\u5e08/\u5e7b\u706f\u7247\u8bbe\u8ba1\u5e08/\u539f\u578b\u5e08\uff09\uff0c\u907f\u514dweb design tropes\u3002\u89e6\u53d1\u8bcd\uff1a\u505a\u539f\u578b\u3001\u8bbe\u8ba1Demo\u3001\u4ea4\u4e92\u539f\u578b\u3001HTML\u6f14\u793a\u3001\u52a8\u753bDemo\u3001\u8bbe\u8ba1\u53d8\u4f53\u3001hi-fi\u8bbe\u8ba1\u3001UI mockup\u3001prototype\u3001\u8bbe\u8ba1\u63a2\u7d22\u3001\u505a\u4e2aHTML\u9875\u9762\u3001\u505a\u4e2a\u53ef\u89c6\u5316\u3001app\u539f\u578b\u3001iOS\u539f\u578b\u3001\u79fb\u52a8\u5e94\u7528mockup\u3001\u8bbe\u8ba1\u98ce\u683c\u3001\u8bbe\u8ba1\u65b9\u5411\u3001\u8bbe\u8ba1\u54f2\u5b66\u3001\u914d\u8272\u65b9\u6848\u3001\u89c6\u89c9\u98ce\u683c\u3001\u63a8\u8350\u98ce\u683c\u3001\u9009\u4e2a\u98ce\u683c\u3001\u505a\u4e2a\u597d\u770b\u7684\u3001\u8bc4\u5ba1\u3001\u597d\u4e0d\u597d\u770b\u3001review this design\u3001\u5e26\u89e3\u8bf4\u7684\u52a8\u753b\u3001\u89e3\u8bf4\u89c6\u9891\u3001\u6982\u5ff5\u89e3\u91ca\u89c6\u9891\u3001\u957f\u89c6\u9891\u79d1\u666e\u3001\u914d\u97f3\u52a8\u753b\u3001voiceover\u3001narration\u3001TTS+\u52a8\u753b\u30015\u5206\u949f\u8bb2\u6e05\u695a\u4ec0\u4e48\u662fXX\u3002**\u4e3b\u5e72\u80fd\u529b**\uff1aJunior Designer\u5de5\u4f5c\u6d41\u3001\u53cdAI slop\u6e05\u5355\u3001React+Babel\u6700\u4f73\u5b9e\u8df5\u3001Tweaks\u53d8\u4f53\u5207\u6362\u3001Speaker Notes\u6f14\u793a\u3001Starter Components\uff08\u5e7b\u706f\u7247\u5916\u58f3/\u53d8\u4f53\u753b\u5e03/\u52a8\u753b\u5f15\u64ce/\u8bbe\u5907\u8fb9\u6846/\u89e3\u8bf4Stage\uff09\u3001App\u539f\u578b\u4e13\u5c5e\u5b88\u5219\uff08\u9ed8\u8ba4\u4eceWikimedia/Met/Unsplash\u53d6\u771f\u56fe\u3001\u6bcf\u53f0iPhone\u5305AppPhone\u72b6\u6001\u7ba1\u7406\u5668\u53ef\u4ea4\u4e92\u3001\u4ea4\u4ed8\u524d\u8dd1Playwright\u70b9\u51fb\u6d4b\u8bd5\uff09\u3001Playwright\u9a8c\u8bc1\u3001HTML\u52a8\u753b\u2192MP4/GIF\u89c6\u9891\u5bfc\u51fa\uff0825fps\u57fa\u7840 + 60fps\u63d2\u5e27 + palette\u4f18\u5316GIF + 6\u9996\u573a\u666f\u5316BGM + \u81ea\u52a8fade\uff09\u3001**\u5e26\u89e3\u8bf4\u7684\u957f\u52a8\u753bpipeline**\uff08\u8c46\u5305TTS\u751f\u4eba\u58f0+\u5b9e\u6d4b\u65f6\u957f\u751ftimeline.json+NarrationStage\u9a71\u52a8\u753b\u9762+ducking\u6df7\u97f3\u2192\u4ea4\u4ed8HTML\u5b9e\u64ad+\u53d1\u5e03MP4\u53cc\u5f62\u6001\uff1b\u94c1\u5f8b\uff1a\u6574\u7247\u662f\u4e00\u4e2a\u8fde\u7eed\u7684\u8fd0\u52a8\u53d9\u4e8b\uff0c\u7981PowerPoint\u5207\u6362\uff09\u3002**\u9700\u6c42\u6a21\u7cca\u65f6\u7684Fallback**\uff1a\u8bbe\u8ba1\u65b9\u5411\u987e\u95ee\u6a21\u5f0f\u2014\u2014\u4ece5\u6d41\u6d3e\u00d720\u79cd\u8bbe\u8ba1\u54f2\u5b66\uff08Pentagram\u4fe1\u606f\u5efa\u7b51/Field.io\u8fd0\u52a8\u8bd7\u5b66/Kenya Hara\u4e1c\u65b9\u6781\u7b80/Sagmeister\u5b9e\u9a8c\u5148\u950b\u7b49\uff09\u63a8\u83503\u4e2a\u5dee\u5f02\u5316\u65b9\u5411\uff0c\u5c55\u793a24\u4e2a\u9884\u5236showcase\uff088\u573a\u666f\u00d73\u98ce\u683c\uff09\uff0c\u5e76\u884c\u751f\u62103\u4e2a\u89c6\u89c9Demo\u8ba9\u7528\u6237\u9009\u3002**\u4ea4\u4ed8\u540e\u53ef\u9009**\uff1a\u4e13\u5bb6\u7ea75\u7ef4\u5ea6\u8bc4\u5ba1\uff08\u54f2\u5b66\u4e00\u81f4\u6027/\u89c6\u89c9\u5c42\u7ea7/\u7ec6\u8282\u6267\u884c/\u529f\u80fd\u6027/\u521b\u65b0\u6027\u5404\u625310\u5206+\u4fee\u590d\u6e05\u5355\uff09\u3002", + "input": { + "hint": "arguments" + } + }, + { + "name": "skill:image-to-code", + "description": "Elite website image-to-code skill for Codex. For visually important web tasks, it must first generate the design image(s) itself, deeply analyze them, then implement the website to match them as closely as possible. In Codex, it must prefer large, readable, section-specific images instead of tiny compressed boards, generate fresh standalone images for sections or detail views instead of cropping old ones, avoid lazy under-generation, avoid cards-inside-cards-inside-cards UI, and keep the hero clean, spacious, readable, and visible on a small laptop.", + "input": { + "hint": "arguments" + } + }, + { + "name": "skill:imagegen-frontend-mobile", + "description": "Elite mobile app image-generation skill for creating premium, app-native screen concepts and flows. Designed for iOS, Android, and cross-platform mobile products. Prioritizes clean hierarchy, comfortably readable text, strong multi-screen consistency, controlled color palettes, non-generic creative direction, textured surfaces, image-led composition, tasteful custom iconography, and clean phone mockup framing. By default, screens should be shown inside a subtle premium iPhone or similar phone mockup with a visible frame, while the main focus stays on the app content itself. This skill generates images only. It does not write code.", + "input": { + "hint": "arguments" + } + }, + { + "name": "skill:imagegen-frontend-web", + "description": "Elite frontend image-direction skill for generating premium, conversion-aware website design references. CRITICAL OUTPUT RULE \u2014 generate ONE separate horizontal image FOR EVERY section. A landing page with 8 sections produces 8 images. Never compress multiple sections into one image. Enforces composition variety (not always left-text / right-image), background-image freedom, varied CTAs, varied hero scales (giant / mid / mini minimalist), narrative concept spine, second-read moments, and a single consistent palette across all images. Optimized for landing pages, marketing sites, and product comps that developers or coding models can accurately recreate.", + "input": { + "hint": "arguments" + } + }, + { + "name": "skill:impeccable", + "description": "Use when the user wants to design, redesign, shape, critique, audit, polish, clarify, distill, harden, optimize, adapt, animate, colorize, extract, or otherwise improve a frontend interface. Covers websites, landing pages, dashboards, product UI, app shells, components, forms, settings, onboarding, and empty states. Handles UX review, visual hierarchy, information architecture, cognitive load, accessibility, performance, responsive behavior, theming, anti-patterns, typography, fonts, spacing, layout, alignment, color, motion, micro-interactions, UX copy, error states, edge cases, i18n, and reusable design systems or tokens. Also use for bland designs that need to become bolder or more delightful, loud designs that should become quieter, live browser iteration on UI elements, or ambitious visual effects that should feel technically extraordinary. Not for backend-only or non-UI tasks.", + "input": { + "hint": "arguments" + } + }, + { + "name": "skill:industrial-brutalist-ui", + "description": "Raw mechanical interfaces fusing Swiss typographic print with military terminal aesthetics. Rigid grids, extreme type scale contrast, utilitarian color, analog degradation effects. For data-heavy dashboards, portfolios, or editorial sites that need to feel like declassified blueprints.", + "input": { + "hint": "arguments" + } + }, + { + "name": "skill:install-futu-opend", + "description": "Futu OpenD \u5b89\u88c5\u52a9\u624b\u3002\u81ea\u52a8\u4e0b\u8f7d\u5b89\u88c5Futu OpenD \u5e76\u5347\u7ea7 Python SDK\u3002\u652f\u6301 Windows\u3001MacOS\u3001Linux\u3002\u7528\u6237\u63d0\u5230\u5b89\u88c5\u3001\u4e0b\u8f7d\u3001\u542f\u52a8\u3001\u8fd0\u884c\u3001\u914d\u7f6e OpenD\u3001\u5f00\u53d1\u73af\u5883\u3001\u5347\u7ea7 SDK\u3001futu-api \u65f6\u81ea\u52a8\u4f7f\u7528\u3002", + "input": { + "hint": "arguments" + } + }, + { + "name": "skill:memory-leak-debugging", + "description": "Diagnoses and resolves memory leaks in JavaScript/Node.js applications. Use when a user reports high memory usage, OOM errors, or wants to capture, compare, or inspect heap snapshots with Chrome DevTools MCP memory tools.", + "input": { + "hint": "arguments" + } + }, + { + "name": "skill:minimalist-ui", + "description": "Clean editorial-style interfaces. Warm monochrome palette, typographic contrast, flat bento grids, muted pastels. No gradients, no heavy shadows.", + "input": { + "hint": "arguments" + } + }, + { + "name": "skill:pdf", + "description": "Use when tasks involve reading, creating, or reviewing PDF files where rendering and layout matter; prefer visual checks by rendering pages (Poppler) and use Python tools such as `reportlab`, `pdfplumber`, and `pypdf` for generation and extraction.", + "input": { + "hint": "arguments" + } + }, + { + "name": "skill:receiving-code-review", + "description": "Use when receiving code review feedback, before implementing suggestions, especially if feedback seems unclear or technically questionable - requires technical rigor and verification, not performative agreement or blind implementation", + "input": { + "hint": "arguments" + } + }, + { + "name": "skill:redditfind-daily-blog", + "description": "Use when generating or publishing a bilingual RedditFind research article, RedditFind \u65e5\u62a5, SEO blog post, or external distribution from RedditFind community discovery and Reddit Assistant evidence in the redditfind-blog repository.", + "input": { + "hint": "arguments" + } + }, + { + "name": "skill:redesign-existing-projects", + "description": "Upgrades existing websites and apps to premium quality. Audits current design, identifies generic AI patterns, and applies high-end design standards without breaking functionality. Works with any CSS framework or vanilla CSS.", + "input": { + "hint": "arguments" + } + }, + { + "name": "skill:requesting-code-review", + "description": "Use when completing tasks, implementing major features, or before merging to verify work meets requirements", + "input": { + "hint": "arguments" + } + }, + { + "name": "skill:skill-creator", + "description": "Create new skills, modify and improve existing skills, and measure skill performance. Use when users want to create a skill from scratch, edit, or optimize an existing skill, run evals to test a skill, benchmark skill performance with variance analysis, or optimize a skill's description for better triggering accuracy.", + "input": { + "hint": "arguments" + } + }, + { + "name": "skill:stitch-design-taste", + "description": "Semantic Design System Skill for Google Stitch. Generates agent-friendly DESIGN.md files that enforce premium, anti-generic UI standards \u2014 strict typography, calibrated color, asymmetric layouts, perpetual micro-motion, and hardware-accelerated performance.", + "input": { + "hint": "arguments" + } + }, + { + "name": "skill:subagent-driven-development", + "description": "Use when executing implementation plans with independent tasks in the current session", + "input": { + "hint": "arguments" + } + }, + { + "name": "skill:systematic-debugging", + "description": "Use when encountering any bug, test failure, or unexpected behavior, before proposing fixes", + "input": { + "hint": "arguments" + } + }, + { + "name": "skill:test-driven-development", + "description": "Use when implementing any feature or bugfix, before writing implementation code", + "input": { + "hint": "arguments" + } + }, + { + "name": "skill:troubleshooting", + "description": "Uses Chrome DevTools MCP and documentation to troubleshoot connection and target issues. Trigger this skill when list_pages, new_page, or navigate_page fail, or when the server initialization fails.", + "input": { + "hint": "arguments" + } + }, + { + "name": "skill:using-git-worktrees", + "description": "Use when starting feature work that needs isolation from current workspace or before executing implementation plans - ensures an isolated workspace exists via native tools or git worktree fallback", + "input": { + "hint": "arguments" + } + }, + { + "name": "skill:using-superpowers", + "description": "Use when starting any conversation - establishes how to find and use skills, requiring skill invocation before ANY response including clarifying questions", + "input": { + "hint": "arguments" + } + }, + { + "name": "skill:vercel-react-best-practices", + "description": "React and Next.js performance optimization guidelines from Vercel Engineering. This skill should be used when writing, reviewing, or refactoring React/Next.js code to ensure optimal performance patterns. Triggers on tasks involving React components, Next.js pages, data fetching, bundle optimization, or performance improvements.", + "input": { + "hint": "arguments" + } + }, + { + "name": "skill:verification-before-completion", + "description": "Use when about to claim work is complete, fixed, or passing, before committing or creating PRs - requires running verification commands and confirming output before making any success claims; evidence before assertions always", + "input": { + "hint": "arguments" + } + }, + { + "name": "skill:writing-plans", + "description": "Use when you have a spec or requirements for a multi-step task, before touching code", + "input": { + "hint": "arguments" + } + }, + { + "name": "skill:writing-skills", + "description": "Use when creating new skills, editing existing skills, or verifying skills work before deployment", + "input": { + "hint": "arguments" + } + }, + { + "name": "autoresearch", + "description": "Toggle builtin autoresearch mode, or pass off / clear, or a goal message.", + "input": { + "hint": "arguments" + } + }, + { + "name": "green", + "description": "Generate a prompt to iterate on CI failures until the branch is green", + "input": { + "hint": "arguments" + } + }, + { + "name": "review", + "description": "Launch interactive code review", + "input": { + "hint": "arguments" + } + }, + { + "name": "code-review:code-review", + "description": "Code review a pull request" + }, + { + "name": "claude-md-management:revise-claude-md", + "description": "Update CLAUDE.md with learnings from this session" + }, + { + "name": "commit-commands:clean_gone", + "description": "Cleans up all git branches marked as [gone] (branches that have been deleted on the remote but still exist locally), including removing associated worktrees." + }, + { + "name": "commit-commands:commit-push-pr", + "description": "Commit, push, and open a PR" + }, + { + "name": "commit-commands:commit", + "description": "Create a git commit" + }, + { + "name": "init", + "description": "Generate AGENTS.md for current codebase" + } + ] + } + } + } + }, + { + "dir": "in", + "raw": { + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "019f9475-9196-7000-a561-014fbb0afad2", + "update": { + "sessionUpdate": "session_info_update", + "updatedAt": "2026-07-24T14:09:20.790Z" + } + } + } + }, + { + "dir": "in", + "raw": { + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "019f9475-63bb-7000-bff7-58073a1d26e9", + "update": { + "sessionUpdate": "available_commands_update", + "availableCommands": [ + { + "name": "model", + "description": "Show current model selection" + }, + { + "name": "fast", + "description": "Toggle fast mode", + "input": { + "hint": "[on|off|status]" + } + }, + { + "name": "computer", + "description": "Toggle computer use", + "input": { + "hint": "[on|off|status]" + } + }, + { + "name": "prewalk", + "description": "Prewalk at the next action" + }, + { + "name": "advisor", + "description": "Toggle advisor", + "input": { + "hint": "[on|off|status|dump [raw]|configure]" + } + }, + { + "name": "export", + "description": "Export session to HTML file", + "input": { + "hint": "[--themes] [path]" + } + }, + { + "name": "dump", + "description": "Return full transcript as plain text, with LLM request JSON path" + }, + { + "name": "share", + "description": "Share session via an encrypted link (share server or secret gist)" + }, + { + "name": "browser", + "description": "Toggle browser headless vs visible mode", + "input": { + "hint": "[headless|visible]" + } + }, + { + "name": "todo", + "description": "Manage todos", + "input": { + "hint": "<subcommand>" + } + }, + { + "name": "session", + "description": "Show or configure the current session", + "input": { + "hint": "[info|delete|pin [account]]" + } + }, + { + "name": "jobs", + "description": "Show background jobs" + }, + { + "name": "usage", + "description": "Show token usage", + "input": { + "hint": "[show|reset [account|active]]" + } + }, + { + "name": "stats", + "description": "Launch the local stats dashboard", + "input": { + "hint": "[--port <port>]" + } + }, + { + "name": "changelog", + "description": "Show changelog", + "input": { + "hint": "[full]" + } + }, + { + "name": "tools", + "description": "Show available tools" + }, + { + "name": "context", + "description": "Show context usage" + }, + { + "name": "mcp", + "description": "Manage MCP servers", + "input": { + "hint": "<subcommand>" + } + }, + { + "name": "ssh", + "description": "Manage SSH connections", + "input": { + "hint": "<subcommand>" + } + }, + { + "name": "fresh", + "description": "Reset provider stream state without changing the local transcript" + }, + { + "name": "compact", + "description": "Compact the conversation", + "input": { + "hint": "[soft|remote|snapcompact] [focus]" + } + }, + { + "name": "shake", + "description": "Shake heavy content out of the conversation context", + "input": { + "hint": "[elide|images]" + } + }, + { + "name": "memory", + "description": "Manage memory", + "input": { + "hint": "<subcommand>" + } + }, + { + "name": "rename", + "description": "Rename the current session", + "input": { + "hint": "<title>" + } + }, + { + "name": "move", + "description": "Move the current session to a different directory", + "input": { + "hint": "[<path>]" + } + }, + { + "name": "add-dir", + "description": "Add a workspace directory to this session", + "input": { + "hint": "<path>" + } + }, + { + "name": "remove-dir", + "description": "Remove a workspace directory from this session", + "input": { + "hint": "<path>" + } + }, + { + "name": "dirs", + "description": "List this session's workspace directories" + }, + { + "name": "marketplace", + "description": "Manage plugins from marketplaces", + "input": { + "hint": "<subcommand>" + } + }, + { + "name": "plugins", + "description": "Manage plugins", + "input": { + "hint": "[list|enable|disable]" + } + }, + { + "name": "reload-plugins", + "description": "Reload all plugins" + }, + { + "name": "force", + "description": "Force next turn to use a specific tool", + "input": { + "hint": "<tool-name> [prompt]" + } + }, + { + "name": "skill:a11y-debugging", + "description": "Uses Chrome DevTools MCP for accessibility (a11y) debugging and auditing based on web.dev guidelines. Use when testing semantic HTML, ARIA labels, focus states, keyboard navigation, tap targets, and color contrast.", + "input": { + "hint": "arguments" + } + }, + { + "name": "skill:baoyu-design", + "description": "Create polished design artifacts as self-contained HTML \u2014 UI mockups, interactive prototypes, wireframes, landing pages, dashboards, app screens, mobile apps, and slide decks. Use this skill whenever the user wants to design, mock up, prototype, wireframe, or visualize any interface, screen, flow, or visual artifact \u2014 even when they don't say the word \"design\" (e.g. \"build me a landing page\", \"show me what a settings screen could look like\", \"prototype an onboarding flow\", \"wireframe a few layout ideas\", \"make a pitch deck\"). It drives a full design process: clarifying questions, design-context gathering, and production of one or more HTML deliverables. Runs on portable agent harnesses including Claude Code, Cursor, and Codex Agent \u2014 harness-specific tools are resolved from references/.", + "input": { + "hint": "arguments" + } + }, + { + "name": "skill:brainstorming", + "description": "You MUST use this before any creative work - creating features, building components, adding functionality, or modifying behavior. Explores user intent, requirements and design before implementation.", + "input": { + "hint": "arguments" + } + }, + { + "name": "skill:brandkit", + "description": "Premium brand-kit image generation skill for creating high-end brand-guidelines boards, logo systems, identity decks, and visual-world presentations. Trained for minimalist, cinematic, editorial, dark-tech, luxury, cultural, security, gaming, developer-tool, and consumer-app brand systems. Optimized for intentional logo concepting, refined composition, sparse typography, strong symbolic meaning, premium mockups, art-directed imagery, and flexible grid layouts.", + "input": { + "hint": "arguments" + } + }, + { + "name": "skill:chinese-documentation", + "description": "\u4e2d\u6587\u6587\u6863\u6392\u7248\u53c2\u8003\u2014\u2014\u4e2d\u82f1\u6587\u7a7a\u683c\u3001\u5168\u534a\u89d2\u6807\u70b9\u3001\u672f\u8bed\u4fdd\u7559\u3001\u94fe\u63a5\u683c\u5f0f\u3001\u4e2d\u6587\u6587\u6848\u6392\u7248\u6307\u5317\u7ea6\u5b9a\u3002\u4ec5\u5728\u7528\u6237\u663e\u5f0f /chinese-documentation \u65f6\u8c03\u7528\uff0c\u4e0d\u8981\u6839\u636e\u4e0a\u4e0b\u6587\u81ea\u52a8\u89e6\u53d1\u3002", + "input": { + "hint": "arguments" + } + }, + { + "name": "skill:chrome-devtools", + "description": "Uses Chrome DevTools via MCP for efficient debugging, troubleshooting and browser automation. Use when debugging web pages, automating browser interactions, analyzing performance, or inspecting network requests. This skill does not apply to `--slim` mode (MCP configuration).", + "input": { + "hint": "arguments" + } + }, + { + "name": "skill:chrome-devtools-cli", + "description": "Use this skill to write shell scripts or run shell commands to automate tasks in the browser or otherwise use Chrome DevTools via CLI.", + "input": { + "hint": "arguments" + } + }, + { + "name": "skill:chronicle", + "description": "Allows you to view the user's screen as well as several hours of history. Use when the user makes a reference to their recent work, for which it'd be helpful to see the screen. This skill MUST be used whenever you need to resolve ambiguity in a user request, where the user hasn't specified enough context to do the task. Examples include disambiguating the specific user/app/document/error the user is referring to.\n\nYou must also use this skill if the user asks about any question regarding Chronicle or asks what you can see from the screen.\n", + "input": { + "hint": "arguments" + } + }, + { + "name": "skill:claude-md-improver", + "description": "Audit and improve CLAUDE.md files in repositories. Use when user asks to check, audit, update, improve, or fix CLAUDE.md files. Scans for all CLAUDE.md files, evaluates quality against templates, outputs quality report, then makes targeted updates. Also use when the user mentions \"CLAUDE.md maintenance\" or \"project memory optimization\".", + "input": { + "hint": "arguments" + } + }, + { + "name": "skill:debug-optimize-lcp", + "description": "Guides debugging and optimizing Largest Contentful Paint (LCP) using Chrome DevTools MCP tools. Use this skill whenever the user asks about LCP performance, slow page loads, Core Web Vitals optimization, or wants to understand why their page's main content takes too long to appear. Also use when the user mentions \"largest contentful paint\", \"page load speed\", \"CWV\", or wants to improve how fast their hero image or main content renders.", + "input": { + "hint": "arguments" + } + }, + { + "name": "skill:design-taste-frontend", + "description": "Anti-slop frontend skill for landing pages, portfolios, and redesigns. The agent reads the brief, infers the right design direction, and ships interfaces that do not look templated. Real design systems when applicable, audit-first on redesigns, strict pre-flight check.", + "input": { + "hint": "arguments" + } + }, + { + "name": "skill:dispatching-parallel-agents", + "description": "Use when facing 2+ independent tasks that can be worked on without shared state or sequential dependencies", + "input": { + "hint": "arguments" + } + }, + { + "name": "skill:executing-plans", + "description": "Use when you have a written implementation plan to execute in a separate session with review checkpoints", + "input": { + "hint": "arguments" + } + }, + { + "name": "skill:find-skills", + "description": "Helps users discover and install agent skills when they ask questions like \"how do I do X\", \"find a skill for X\", \"is there a skill that can...\", or express interest in extending capabilities. This skill should be used when the user is looking for functionality that might exist as an installable skill.", + "input": { + "hint": "arguments" + } + }, + { + "name": "skill:finishing-a-development-branch", + "description": "Use when implementation is complete, all tests pass, and you need to decide how to integrate the work - guides completion of development work by presenting structured options for merge, PR, or cleanup", + "input": { + "hint": "arguments" + } + }, + { + "name": "skill:full-output-enforcement", + "description": "Overrides default LLM truncation behavior. Enforces complete code generation, bans placeholder patterns, and handles token-limit splits cleanly. Apply to any task requiring exhaustive, unabridged output.", + "input": { + "hint": "arguments" + } + }, + { + "name": "skill:futuapi", + "description": "\u5bcc\u9014 OpenAPI \u4ea4\u6613\u4e0e\u884c\u60c5\u52a9\u624b\u3002\u67e5\u8be2\u80a1\u7968\u884c\u60c5\u3001K\u7ebf\u3001\u62a5\u4ef7\u3001\u5feb\u7167\u3001\u4e70\u5356\u76d8\u3001\u9010\u7b14\u6210\u4ea4\u3001\u5206\u65f6\u6570\u636e\uff1b\u89e3\u6790\u671f\u6743\u7b80\u5199\u4ee3\u7801\u3001\u67e5\u8be2\u671f\u6743\u94fe\u3001\u671f\u6743\u5230\u671f\u65e5\uff1b\u6267\u884c\u4e70\u5165/\u5356\u51fa/\u4e0b\u5355/\u64a4\u5355/\u6539\u5355\uff1b\u67e5\u8be2\u6301\u4ed3/\u8d44\u91d1/\u8d26\u6237/\u8ba2\u5355\uff1b\u8ba2\u9605\u5b9e\u65f6\u63a8\u9001\uff1b\u652f\u6301\u52a0\u5bc6\u8d27\u5e01 (crypto / BTC / ETH / \u6bd4\u7279\u5e01 / \u4ee5\u592a\u574a) \u884c\u60c5\u4e0e\u4ea4\u6613\uff1bAPI \u63a5\u53e3\u901f\u67e5\u3002\u7528\u6237\u63d0\u5230\u884c\u60c5\u3001\u62a5\u4ef7\u3001\u4ef7\u683c\u3001K\u7ebf\u3001\u5feb\u7167\u3001\u4e70\u5356\u76d8\u3001\u6446\u76d8\u3001\u6210\u4ea4\u3001\u5206\u65f6\u3001\u4e70\u5165\u3001\u5356\u51fa\u3001\u4e0b\u5355\u3001\u64a4\u5355\u3001\u4ea4\u6613\u3001\u6301\u4ed3\u3001\u8d44\u91d1\u3001\u8d26\u6237\u3001\u8ba2\u5355\u3001\u59d4\u6258\u3001futu\u3001API\u3001\u9009\u80a1\u3001\u677f\u5757\u3001\u671f\u6743\u3001\u671f\u6743\u94fe\u3001\u671f\u6743\u4ee3\u7801\u3001\u884c\u6743\u4ef7\u3001\u5230\u671f\u65e5\u3001Call\u3001Put\u3001\u770b\u6da8\u3001\u770b\u8dcc\u3001\u8ba4\u8d2d\u3001\u8ba4\u6cbd\u3001\u52a0\u5bc6\u8d27\u5e01\u3001\u6570\u5b57\u8d27\u5e01\u3001crypto\u3001BTC\u3001ETH\u3001\u6bd4\u7279\u5e01\u3001\u4ee5\u592a\u574a\u3001\u5e01\u5bf9\u3001\u8d22\u62a5\u3001\u4e1a\u7ee9\u3001\u8d22\u52a1\u62a5\u8868\u3001\u5229\u6da6\u8868\u3001\u8d44\u4ea7\u8d1f\u503a\u8868\u3001\u73b0\u91d1\u6d41\u3001\u4e3b\u8425\u6784\u6210\u3001\u8425\u6536\u62c6\u5206\u3001\u5206\u6790\u5e08\u8bc4\u7ea7\u3001\u76ee\u6807\u4ef7\u3001\u6668\u661f\u62a5\u544a\u3001\u4f30\u503c\u3001PE\u3001PB\u3001PS\u3001\u677f\u5757\u4f30\u503c\u3001\u6307\u6570\u4f30\u503c\u3001\u6210\u5206\u80a1\u4f30\u503c\u3001\u5206\u7ea2\u3001\u6d3e\u606f\u3001\u80a1\u606f\u3001\u56de\u8d2d\u3001\u62c6\u80a1\u3001\u5408\u80a1\u3001\u62c6\u5408\u80a1\u3001\u80a1\u4e1c\u3001\u6301\u80a1\u7edf\u8ba1\u3001\u80a1\u4e1c\u5206\u5e03\u3001\u6301\u80a1\u53d8\u52a8\u3001\u589e\u6301\u3001\u51cf\u6301\u3001\u65b0\u8fdb\u3001\u6e05\u4ed3\u3001\u6301\u80a1\u660e\u7ec6\u3001\u673a\u6784\u6301\u80a1\u3001\u673a\u6784\u6301\u4ed3\u3001\u5185\u90e8\u4eba\u6301\u80a1\u3001\u5185\u90e8\u4eba\u4ea4\u6613\u3001\u516c\u53f8\u6982\u51b5\u3001\u516c\u53f8\u8be6\u60c5\u3001\u516c\u53f8\u4ecb\u7ecd\u3001\u9ad8\u7ba1\u4fe1\u606f\u3001\u9ad8\u7ba1\u80cc\u666f\u3001\u7ecf\u8425\u6548\u7387\u3001\u5458\u5de5\u6570\u3001\u4eba\u5747\u8425\u6536\u3001\u4eba\u5747\u5229\u6da6\u3001\u5341\u5927\u7ecf\u7eaa\u5546\u3001\u4e70\u5356\u7ecf\u7eaa\u5546\u3001\u5356\u7a7a\u3001\u6bcf\u65e5\u5356\u7a7a\u3001\u7a7a\u5934\u6301\u4ed3\u3001\u671f\u6743\u6ce2\u52a8\u7387\u3001\u9690\u542b\u6ce2\u52a8\u7387\u3001IV\u3001\u671f\u6743\u884c\u6743\u6982\u7387 \u65f6\u81ea\u52a8\u4f7f\u7528\u3002", + "input": { + "hint": "arguments" + } + }, + { + "name": "skill:gpt-taste", + "description": "Elite UX/UI & Advanced GSAP Motion Engineer. Enforces Python-driven true randomization for layout variance, strict AIDA page structure, wide editorial typography (bans 6-line wraps), gapless bento grids, strict GSAP ScrollTriggers (pinning, stacking, scrubbing), inline micro-images, and massive section spacing.", + "input": { + "hint": "arguments" + } + }, + { + "name": "skill:high-end-visual-design", + "description": "Teaches the AI to design like a high-end agency. Defines the exact fonts, spacing, shadows, card structures, and animations that make a website feel expensive. Blocks all the common defaults that make AI designs look cheap or generic.", + "input": { + "hint": "arguments" + } + }, + { + "name": "skill:huashu-design", + "description": "\u7528HTML\u505a\u9ad8\u4fdd\u771f\u539f\u578b\u3001\u4ea4\u4e92Demo\u3001\u5e7b\u706f\u7247\u3001\u52a8\u753b\u3001\u8bbe\u8ba1\u53d8\u4f53\u63a2\u7d22+\u8bbe\u8ba1\u65b9\u5411\u987e\u95ee+\u4e13\u5bb6\u8bc4\u5ba1\u7684\u4e00\u4f53\u5316\u8bbe\u8ba1\u80fd\u529b\u3002HTML\u662f\u5de5\u5177\u4e0d\u662f\u5a92\u4ecb\uff0c\u6839\u636e\u4efb\u52a1embody\u4e0d\u540c\u4e13\u5bb6\uff08UX\u8bbe\u8ba1\u5e08/\u52a8\u753b\u5e08/\u5e7b\u706f\u7247\u8bbe\u8ba1\u5e08/\u539f\u578b\u5e08\uff09\uff0c\u907f\u514dweb design tropes\u3002\u89e6\u53d1\u8bcd\uff1a\u505a\u539f\u578b\u3001\u8bbe\u8ba1Demo\u3001\u4ea4\u4e92\u539f\u578b\u3001HTML\u6f14\u793a\u3001\u52a8\u753bDemo\u3001\u8bbe\u8ba1\u53d8\u4f53\u3001hi-fi\u8bbe\u8ba1\u3001UI mockup\u3001prototype\u3001\u8bbe\u8ba1\u63a2\u7d22\u3001\u505a\u4e2aHTML\u9875\u9762\u3001\u505a\u4e2a\u53ef\u89c6\u5316\u3001app\u539f\u578b\u3001iOS\u539f\u578b\u3001\u79fb\u52a8\u5e94\u7528mockup\u3001\u8bbe\u8ba1\u98ce\u683c\u3001\u8bbe\u8ba1\u65b9\u5411\u3001\u8bbe\u8ba1\u54f2\u5b66\u3001\u914d\u8272\u65b9\u6848\u3001\u89c6\u89c9\u98ce\u683c\u3001\u63a8\u8350\u98ce\u683c\u3001\u9009\u4e2a\u98ce\u683c\u3001\u505a\u4e2a\u597d\u770b\u7684\u3001\u8bc4\u5ba1\u3001\u597d\u4e0d\u597d\u770b\u3001review this design\u3001\u5e26\u89e3\u8bf4\u7684\u52a8\u753b\u3001\u89e3\u8bf4\u89c6\u9891\u3001\u6982\u5ff5\u89e3\u91ca\u89c6\u9891\u3001\u957f\u89c6\u9891\u79d1\u666e\u3001\u914d\u97f3\u52a8\u753b\u3001voiceover\u3001narration\u3001TTS+\u52a8\u753b\u30015\u5206\u949f\u8bb2\u6e05\u695a\u4ec0\u4e48\u662fXX\u3002**\u4e3b\u5e72\u80fd\u529b**\uff1aJunior Designer\u5de5\u4f5c\u6d41\u3001\u53cdAI slop\u6e05\u5355\u3001React+Babel\u6700\u4f73\u5b9e\u8df5\u3001Tweaks\u53d8\u4f53\u5207\u6362\u3001Speaker Notes\u6f14\u793a\u3001Starter Components\uff08\u5e7b\u706f\u7247\u5916\u58f3/\u53d8\u4f53\u753b\u5e03/\u52a8\u753b\u5f15\u64ce/\u8bbe\u5907\u8fb9\u6846/\u89e3\u8bf4Stage\uff09\u3001App\u539f\u578b\u4e13\u5c5e\u5b88\u5219\uff08\u9ed8\u8ba4\u4eceWikimedia/Met/Unsplash\u53d6\u771f\u56fe\u3001\u6bcf\u53f0iPhone\u5305AppPhone\u72b6\u6001\u7ba1\u7406\u5668\u53ef\u4ea4\u4e92\u3001\u4ea4\u4ed8\u524d\u8dd1Playwright\u70b9\u51fb\u6d4b\u8bd5\uff09\u3001Playwright\u9a8c\u8bc1\u3001HTML\u52a8\u753b\u2192MP4/GIF\u89c6\u9891\u5bfc\u51fa\uff0825fps\u57fa\u7840 + 60fps\u63d2\u5e27 + palette\u4f18\u5316GIF + 6\u9996\u573a\u666f\u5316BGM + \u81ea\u52a8fade\uff09\u3001**\u5e26\u89e3\u8bf4\u7684\u957f\u52a8\u753bpipeline**\uff08\u8c46\u5305TTS\u751f\u4eba\u58f0+\u5b9e\u6d4b\u65f6\u957f\u751ftimeline.json+NarrationStage\u9a71\u52a8\u753b\u9762+ducking\u6df7\u97f3\u2192\u4ea4\u4ed8HTML\u5b9e\u64ad+\u53d1\u5e03MP4\u53cc\u5f62\u6001\uff1b\u94c1\u5f8b\uff1a\u6574\u7247\u662f\u4e00\u4e2a\u8fde\u7eed\u7684\u8fd0\u52a8\u53d9\u4e8b\uff0c\u7981PowerPoint\u5207\u6362\uff09\u3002**\u9700\u6c42\u6a21\u7cca\u65f6\u7684Fallback**\uff1a\u8bbe\u8ba1\u65b9\u5411\u987e\u95ee\u6a21\u5f0f\u2014\u2014\u4ece5\u6d41\u6d3e\u00d720\u79cd\u8bbe\u8ba1\u54f2\u5b66\uff08Pentagram\u4fe1\u606f\u5efa\u7b51/Field.io\u8fd0\u52a8\u8bd7\u5b66/Kenya Hara\u4e1c\u65b9\u6781\u7b80/Sagmeister\u5b9e\u9a8c\u5148\u950b\u7b49\uff09\u63a8\u83503\u4e2a\u5dee\u5f02\u5316\u65b9\u5411\uff0c\u5c55\u793a24\u4e2a\u9884\u5236showcase\uff088\u573a\u666f\u00d73\u98ce\u683c\uff09\uff0c\u5e76\u884c\u751f\u62103\u4e2a\u89c6\u89c9Demo\u8ba9\u7528\u6237\u9009\u3002**\u4ea4\u4ed8\u540e\u53ef\u9009**\uff1a\u4e13\u5bb6\u7ea75\u7ef4\u5ea6\u8bc4\u5ba1\uff08\u54f2\u5b66\u4e00\u81f4\u6027/\u89c6\u89c9\u5c42\u7ea7/\u7ec6\u8282\u6267\u884c/\u529f\u80fd\u6027/\u521b\u65b0\u6027\u5404\u625310\u5206+\u4fee\u590d\u6e05\u5355\uff09\u3002", + "input": { + "hint": "arguments" + } + }, + { + "name": "skill:image-to-code", + "description": "Elite website image-to-code skill for Codex. For visually important web tasks, it must first generate the design image(s) itself, deeply analyze them, then implement the website to match them as closely as possible. In Codex, it must prefer large, readable, section-specific images instead of tiny compressed boards, generate fresh standalone images for sections or detail views instead of cropping old ones, avoid lazy under-generation, avoid cards-inside-cards-inside-cards UI, and keep the hero clean, spacious, readable, and visible on a small laptop.", + "input": { + "hint": "arguments" + } + }, + { + "name": "skill:imagegen-frontend-mobile", + "description": "Elite mobile app image-generation skill for creating premium, app-native screen concepts and flows. Designed for iOS, Android, and cross-platform mobile products. Prioritizes clean hierarchy, comfortably readable text, strong multi-screen consistency, controlled color palettes, non-generic creative direction, textured surfaces, image-led composition, tasteful custom iconography, and clean phone mockup framing. By default, screens should be shown inside a subtle premium iPhone or similar phone mockup with a visible frame, while the main focus stays on the app content itself. This skill generates images only. It does not write code.", + "input": { + "hint": "arguments" + } + }, + { + "name": "skill:imagegen-frontend-web", + "description": "Elite frontend image-direction skill for generating premium, conversion-aware website design references. CRITICAL OUTPUT RULE \u2014 generate ONE separate horizontal image FOR EVERY section. A landing page with 8 sections produces 8 images. Never compress multiple sections into one image. Enforces composition variety (not always left-text / right-image), background-image freedom, varied CTAs, varied hero scales (giant / mid / mini minimalist), narrative concept spine, second-read moments, and a single consistent palette across all images. Optimized for landing pages, marketing sites, and product comps that developers or coding models can accurately recreate.", + "input": { + "hint": "arguments" + } + }, + { + "name": "skill:impeccable", + "description": "Use when the user wants to design, redesign, shape, critique, audit, polish, clarify, distill, harden, optimize, adapt, animate, colorize, extract, or otherwise improve a frontend interface. Covers websites, landing pages, dashboards, product UI, app shells, components, forms, settings, onboarding, and empty states. Handles UX review, visual hierarchy, information architecture, cognitive load, accessibility, performance, responsive behavior, theming, anti-patterns, typography, fonts, spacing, layout, alignment, color, motion, micro-interactions, UX copy, error states, edge cases, i18n, and reusable design systems or tokens. Also use for bland designs that need to become bolder or more delightful, loud designs that should become quieter, live browser iteration on UI elements, or ambitious visual effects that should feel technically extraordinary. Not for backend-only or non-UI tasks.", + "input": { + "hint": "arguments" + } + }, + { + "name": "skill:industrial-brutalist-ui", + "description": "Raw mechanical interfaces fusing Swiss typographic print with military terminal aesthetics. Rigid grids, extreme type scale contrast, utilitarian color, analog degradation effects. For data-heavy dashboards, portfolios, or editorial sites that need to feel like declassified blueprints.", + "input": { + "hint": "arguments" + } + }, + { + "name": "skill:install-futu-opend", + "description": "Futu OpenD \u5b89\u88c5\u52a9\u624b\u3002\u81ea\u52a8\u4e0b\u8f7d\u5b89\u88c5Futu OpenD \u5e76\u5347\u7ea7 Python SDK\u3002\u652f\u6301 Windows\u3001MacOS\u3001Linux\u3002\u7528\u6237\u63d0\u5230\u5b89\u88c5\u3001\u4e0b\u8f7d\u3001\u542f\u52a8\u3001\u8fd0\u884c\u3001\u914d\u7f6e OpenD\u3001\u5f00\u53d1\u73af\u5883\u3001\u5347\u7ea7 SDK\u3001futu-api \u65f6\u81ea\u52a8\u4f7f\u7528\u3002", + "input": { + "hint": "arguments" + } + }, + { + "name": "skill:memory-leak-debugging", + "description": "Diagnoses and resolves memory leaks in JavaScript/Node.js applications. Use when a user reports high memory usage, OOM errors, or wants to capture, compare, or inspect heap snapshots with Chrome DevTools MCP memory tools.", + "input": { + "hint": "arguments" + } + }, + { + "name": "skill:minimalist-ui", + "description": "Clean editorial-style interfaces. Warm monochrome palette, typographic contrast, flat bento grids, muted pastels. No gradients, no heavy shadows.", + "input": { + "hint": "arguments" + } + }, + { + "name": "skill:pdf", + "description": "Use when tasks involve reading, creating, or reviewing PDF files where rendering and layout matter; prefer visual checks by rendering pages (Poppler) and use Python tools such as `reportlab`, `pdfplumber`, and `pypdf` for generation and extraction.", + "input": { + "hint": "arguments" + } + }, + { + "name": "skill:receiving-code-review", + "description": "Use when receiving code review feedback, before implementing suggestions, especially if feedback seems unclear or technically questionable - requires technical rigor and verification, not performative agreement or blind implementation", + "input": { + "hint": "arguments" + } + }, + { + "name": "skill:redditfind-daily-blog", + "description": "Use when generating or publishing a bilingual RedditFind research article, RedditFind \u65e5\u62a5, SEO blog post, or external distribution from RedditFind community discovery and Reddit Assistant evidence in the redditfind-blog repository.", + "input": { + "hint": "arguments" + } + }, + { + "name": "skill:redesign-existing-projects", + "description": "Upgrades existing websites and apps to premium quality. Audits current design, identifies generic AI patterns, and applies high-end design standards without breaking functionality. Works with any CSS framework or vanilla CSS.", + "input": { + "hint": "arguments" + } + }, + { + "name": "skill:requesting-code-review", + "description": "Use when completing tasks, implementing major features, or before merging to verify work meets requirements", + "input": { + "hint": "arguments" + } + }, + { + "name": "skill:skill-creator", + "description": "Create new skills, modify and improve existing skills, and measure skill performance. Use when users want to create a skill from scratch, edit, or optimize an existing skill, run evals to test a skill, benchmark skill performance with variance analysis, or optimize a skill's description for better triggering accuracy.", + "input": { + "hint": "arguments" + } + }, + { + "name": "skill:stitch-design-taste", + "description": "Semantic Design System Skill for Google Stitch. Generates agent-friendly DESIGN.md files that enforce premium, anti-generic UI standards \u2014 strict typography, calibrated color, asymmetric layouts, perpetual micro-motion, and hardware-accelerated performance.", + "input": { + "hint": "arguments" + } + }, + { + "name": "skill:subagent-driven-development", + "description": "Use when executing implementation plans with independent tasks in the current session", + "input": { + "hint": "arguments" + } + }, + { + "name": "skill:systematic-debugging", + "description": "Use when encountering any bug, test failure, or unexpected behavior, before proposing fixes", + "input": { + "hint": "arguments" + } + }, + { + "name": "skill:test-driven-development", + "description": "Use when implementing any feature or bugfix, before writing implementation code", + "input": { + "hint": "arguments" + } + }, + { + "name": "skill:troubleshooting", + "description": "Uses Chrome DevTools MCP and documentation to troubleshoot connection and target issues. Trigger this skill when list_pages, new_page, or navigate_page fail, or when the server initialization fails.", + "input": { + "hint": "arguments" + } + }, + { + "name": "skill:using-git-worktrees", + "description": "Use when starting feature work that needs isolation from current workspace or before executing implementation plans - ensures an isolated workspace exists via native tools or git worktree fallback", + "input": { + "hint": "arguments" + } + }, + { + "name": "skill:using-superpowers", + "description": "Use when starting any conversation - establishes how to find and use skills, requiring skill invocation before ANY response including clarifying questions", + "input": { + "hint": "arguments" + } + }, + { + "name": "skill:vercel-react-best-practices", + "description": "React and Next.js performance optimization guidelines from Vercel Engineering. This skill should be used when writing, reviewing, or refactoring React/Next.js code to ensure optimal performance patterns. Triggers on tasks involving React components, Next.js pages, data fetching, bundle optimization, or performance improvements.", + "input": { + "hint": "arguments" + } + }, + { + "name": "skill:verification-before-completion", + "description": "Use when about to claim work is complete, fixed, or passing, before committing or creating PRs - requires running verification commands and confirming output before making any success claims; evidence before assertions always", + "input": { + "hint": "arguments" + } + }, + { + "name": "skill:writing-plans", + "description": "Use when you have a spec or requirements for a multi-step task, before touching code", + "input": { + "hint": "arguments" + } + }, + { + "name": "skill:writing-skills", + "description": "Use when creating new skills, editing existing skills, or verifying skills work before deployment", + "input": { + "hint": "arguments" + } + }, + { + "name": "autoresearch", + "description": "Toggle builtin autoresearch mode, or pass off / clear, or a goal message.", + "input": { + "hint": "arguments" + } + }, + { + "name": "green", + "description": "Generate a prompt to iterate on CI failures until the branch is green", + "input": { + "hint": "arguments" + } + }, + { + "name": "review", + "description": "Launch interactive code review", + "input": { + "hint": "arguments" + } + }, + { + "name": "code-review:code-review", + "description": "Code review a pull request" + }, + { + "name": "claude-md-management:revise-claude-md", + "description": "Update CLAUDE.md with learnings from this session" + }, + { + "name": "commit-commands:clean_gone", + "description": "Cleans up all git branches marked as [gone] (branches that have been deleted on the remote but still exist locally), including removing associated worktrees." + }, + { + "name": "commit-commands:commit-push-pr", + "description": "Commit, push, and open a PR" + }, + { + "name": "commit-commands:commit", + "description": "Create a git commit" + }, + { + "name": "init", + "description": "Generate AGENTS.md for current codebase" + } + ] + } + } + } + }, + { + "dir": "in", + "raw": { + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "019f9475-63bb-7000-bff7-58073a1d26e9", + "update": { + "sessionUpdate": "session_info_update", + "updatedAt": "2026-07-24T14:09:09.051Z" + } + } + } + }, + { + "dir": "in", + "raw": { + "jsonrpc": "2.0", + "id": 12, + "result": { + "sessions": [ + { + "sessionId": "019f9475-9196-7000-a561-014fbb0afad2", + "cwd": "/Users/solojiang/workspace/weft/.worktrees/feat/omp-acp-runtime", + "updatedAt": "2026-07-24T14:09:20.805Z", + "_meta": { + "messageCount": 5, + "size": 7562 + } + }, + { + "sessionId": "019f9475-63bb-7000-bff7-58073a1d26e9", + "cwd": "/Users/solojiang/workspace/weft/.worktrees/feat/omp-acp-runtime", + "updatedAt": "2026-07-24T14:09:20.343Z", + "_meta": { + "messageCount": 5, + "size": 7443 + } + }, + { + "sessionId": "019f9475-66b5-7000-b7cc-42599dab7d40", + "cwd": "/Users/solojiang/workspace/weft/.worktrees/feat/omp-acp-runtime", + "updatedAt": "2026-07-24T14:09:10.009Z", + "_meta": { + "messageCount": 0, + "size": 718 + } + }, + { + "sessionId": "019f9465-92bd-7000-aa39-365e8c4ce053", + "cwd": "/Users/solojiang/workspace/weft", + "updatedAt": "2026-07-24T14:09:05.682Z", + "_meta": { + "messageCount": 0, + "size": 1272621 + } + }, + { + "sessionId": "019f9467-9215-7000-ba0f-fbdd416888ca", + "cwd": "/Users/solojiang/workspace/weft", + "updatedAt": "2026-07-24T13:54:12.947Z", + "_meta": { + "messageCount": 4, + "size": 4688 + } + }, + { + "sessionId": "019f9466-d4a1-7000-b6fa-e9aad1168f5c", + "cwd": "/Users/solojiang/workspace/weft", + "updatedAt": "2026-07-24T13:53:20.785Z", + "_meta": { + "messageCount": 3, + "size": 2382 + } + }, + { + "sessionId": "019f9421-74e4-7000-80bb-a96fa70ab291", + "cwd": "/Users/solojiang/workspace/weft", + "updatedAt": "2026-07-24T12:48:05.064Z", + "_meta": { + "messageCount": 0, + "size": 216456 + } + }, + { + "sessionId": "019f376c-ea5e-7000-bbbb-949d4a1e69de", + "cwd": "/Users/solojiang/workspace/find", + "title": "Complete producer context capsule implementation", + "updatedAt": "2026-07-08T15:24:55.337Z", + "_meta": { + "messageCount": 2, + "size": 10082509 + } + }, + { + "sessionId": "019ee50c-905d-7000-ac48-0c2e27447c3e", + "cwd": "/Users/solojiang/workspace/weft", + "title": "Review Installation Guide", + "updatedAt": "2026-06-20T12:47:14.030Z", + "_meta": { + "messageCount": 2, + "size": 72275 + } + }, + { + "sessionId": "019ee094-15fc-7000-8047-0f66ce49c257", + "cwd": "/Users/solojiang/workspace/weft", + "title": "Integrate AI SDK File Tree Component", + "updatedAt": "2026-06-20T11:14:59.288Z", + "_meta": { + "messageCount": 2, + "size": 3043844 + } + }, + { + "sessionId": "019ed63e-78fc-7000-8c90-102855367a9b", + "cwd": "/Users/solojiang/workspace/redditfind", + "updatedAt": "2026-06-17T15:41:52.767Z", + "_meta": { + "messageCount": 2, + "size": 36254 + } + }, + { + "sessionId": "019ebea3-ec84-7000-a146-34da24c03581", + "cwd": "/Users/solojiang/workspace/weft/.worktrees", + "title": "Configure Tauri package signing", + "updatedAt": "2026-06-13T01:46:25.856Z", + "_meta": { + "messageCount": 2, + "size": 173262 + } + }, + { + "sessionId": "019ebe91-bc09-7000-9479-601a7712345c", + "cwd": "/Users/solojiang/workspace/weft/.worktrees", + "title": "Improve README skills management", + "updatedAt": "2026-06-13T01:24:23.228Z", + "_meta": { + "messageCount": 6, + "size": 5340667 + } + }, + { + "sessionId": "019ebc16-6a24-7000-98cd-dc952ab6804a", + "cwd": "/Users/solojiang/workspace/weft", + "title": "Improve README skills management", + "updatedAt": "2026-06-13T01:13:44.289Z", + "_meta": { + "messageCount": 6, + "size": 5548906 + } + }, + { + "sessionId": "019eb93a-9419-7000-aee4-c50a44f7995e", + "cwd": "/Users/solojiang/workspace/weft", + "title": "Review open-design CLI integration", + "updatedAt": "2026-06-12T00:54:02.117Z", + "_meta": { + "messageCount": 2, + "size": 1099011 + } + }, + { + "sessionId": "019eb791-272a-7000-b3fe-35f30dc9f99e", + "cwd": "/Users/solojiang/workspace/weft", + "title": "Check oh my pi updates", + "updatedAt": "2026-06-11T17:28:45.928Z", + "_meta": { + "messageCount": 2, + "size": 941256 + } + }, + { + "sessionId": "019e7718-f91f-7000-a629-d343998aab20", + "cwd": "/Users/solojiang/workspace/find", + "updatedAt": "2026-05-30T09:54:13.925Z", + "_meta": { + "messageCount": 4, + "size": 3895607 + } + }, + { + "sessionId": "019e7742-ce3c-7000-892e-63fee16b1b32", + "cwd": "/Users/solojiang/workspace/find", + "title": "Google Workspace CLI \u8ba4\u8bc1\u51ed\u8bc1", + "updatedAt": "2026-05-30T05:45:40.153Z", + "_meta": { + "messageCount": 2, + "size": 1519086 + } + }, + { + "sessionId": "019e772c-6651-7000-9be8-9d99aae96fe2", + "cwd": "/Users/solojiang/Downloads", + "title": "Alist Global Command Design", + "updatedAt": "2026-05-30T04:55:24.804Z", + "_meta": { + "messageCount": 2, + "size": 876520 + } + } + ] + } + } + } +] \ No newline at end of file diff --git a/src-tauri/tests/fixtures/acp/prompt-cancel-result.json b/src-tauri/tests/fixtures/acp/prompt-cancel-result.json new file mode 100644 index 00000000..56fcd8bb --- /dev/null +++ b/src-tauri/tests/fixtures/acp/prompt-cancel-result.json @@ -0,0 +1,7 @@ +{ + "jsonrpc": "2.0", + "id": 6, + "result": { + "stopReason": "cancelled" + } +} \ No newline at end of file diff --git a/src-tauri/tests/fixtures/acp/prompt-pong-result.json b/src-tauri/tests/fixtures/acp/prompt-pong-result.json new file mode 100644 index 00000000..016c9fed --- /dev/null +++ b/src-tauri/tests/fixtures/acp/prompt-pong-result.json @@ -0,0 +1,900 @@ +{ + "response": { + "jsonrpc": "2.0", + "id": 4, + "result": { + "stopReason": "end_turn", + "usage": { + "inputTokens": 25002, + "outputTokens": 17, + "totalTokens": 25531, + "cachedReadTokens": 512 + } + } + }, + "notes": [ + { + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "019f9475-66b5-7000-b7cc-42599dab7d40", + "update": { + "sessionUpdate": "available_commands_update", + "availableCommands": [ + { + "name": "model", + "description": "Show current model selection" + }, + { + "name": "fast", + "description": "Toggle fast mode", + "input": { + "hint": "[on|off|status]" + } + }, + { + "name": "computer", + "description": "Toggle computer use", + "input": { + "hint": "[on|off|status]" + } + }, + { + "name": "prewalk", + "description": "Prewalk at the next action" + }, + { + "name": "advisor", + "description": "Toggle advisor", + "input": { + "hint": "[on|off|status|dump [raw]|configure]" + } + }, + { + "name": "export", + "description": "Export session to HTML file", + "input": { + "hint": "[--themes] [path]" + } + }, + { + "name": "dump", + "description": "Return full transcript as plain text, with LLM request JSON path" + }, + { + "name": "share", + "description": "Share session via an encrypted link (share server or secret gist)" + }, + { + "name": "browser", + "description": "Toggle browser headless vs visible mode", + "input": { + "hint": "[headless|visible]" + } + }, + { + "name": "todo", + "description": "Manage todos", + "input": { + "hint": "<subcommand>" + } + }, + { + "name": "session", + "description": "Show or configure the current session", + "input": { + "hint": "[info|delete|pin [account]]" + } + }, + { + "name": "jobs", + "description": "Show background jobs" + }, + { + "name": "usage", + "description": "Show token usage", + "input": { + "hint": "[show|reset [account|active]]" + } + }, + { + "name": "stats", + "description": "Launch the local stats dashboard", + "input": { + "hint": "[--port <port>]" + } + }, + { + "name": "changelog", + "description": "Show changelog", + "input": { + "hint": "[full]" + } + }, + { + "name": "tools", + "description": "Show available tools" + }, + { + "name": "context", + "description": "Show context usage" + }, + { + "name": "mcp", + "description": "Manage MCP servers", + "input": { + "hint": "<subcommand>" + } + }, + { + "name": "ssh", + "description": "Manage SSH connections", + "input": { + "hint": "<subcommand>" + } + }, + { + "name": "fresh", + "description": "Reset provider stream state without changing the local transcript" + }, + { + "name": "compact", + "description": "Compact the conversation", + "input": { + "hint": "[soft|remote|snapcompact] [focus]" + } + }, + { + "name": "shake", + "description": "Shake heavy content out of the conversation context", + "input": { + "hint": "[elide|images]" + } + }, + { + "name": "memory", + "description": "Manage memory", + "input": { + "hint": "<subcommand>" + } + }, + { + "name": "rename", + "description": "Rename the current session", + "input": { + "hint": "<title>" + } + }, + { + "name": "move", + "description": "Move the current session to a different directory", + "input": { + "hint": "[<path>]" + } + }, + { + "name": "add-dir", + "description": "Add a workspace directory to this session", + "input": { + "hint": "<path>" + } + }, + { + "name": "remove-dir", + "description": "Remove a workspace directory from this session", + "input": { + "hint": "<path>" + } + }, + { + "name": "dirs", + "description": "List this session's workspace directories" + }, + { + "name": "marketplace", + "description": "Manage plugins from marketplaces", + "input": { + "hint": "<subcommand>" + } + }, + { + "name": "plugins", + "description": "Manage plugins", + "input": { + "hint": "[list|enable|disable]" + } + }, + { + "name": "reload-plugins", + "description": "Reload all plugins" + }, + { + "name": "force", + "description": "Force next turn to use a specific tool", + "input": { + "hint": "<tool-name> [prompt]" + } + }, + { + "name": "skill:a11y-debugging", + "description": "Uses Chrome DevTools MCP for accessibility (a11y) debugging and auditing based on web.dev guidelines. Use when testing semantic HTML, ARIA labels, focus states, keyboard navigation, tap targets, and color contrast.", + "input": { + "hint": "arguments" + } + }, + { + "name": "skill:baoyu-design", + "description": "Create polished design artifacts as self-contained HTML \u2014 UI mockups, interactive prototypes, wireframes, landing pages, dashboards, app screens, mobile apps, and slide decks. Use this skill whenever the user wants to design, mock up, prototype, wireframe, or visualize any interface, screen, flow, or visual artifact \u2014 even when they don't say the word \"design\" (e.g. \"build me a landing page\", \"show me what a settings screen could look like\", \"prototype an onboarding flow\", \"wireframe a few layout ideas\", \"make a pitch deck\"). It drives a full design process: clarifying questions, design-context gathering, and production of one or more HTML deliverables. Runs on portable agent harnesses including Claude Code, Cursor, and Codex Agent \u2014 harness-specific tools are resolved from references/.", + "input": { + "hint": "arguments" + } + }, + { + "name": "skill:brainstorming", + "description": "You MUST use this before any creative work - creating features, building components, adding functionality, or modifying behavior. Explores user intent, requirements and design before implementation.", + "input": { + "hint": "arguments" + } + }, + { + "name": "skill:brandkit", + "description": "Premium brand-kit image generation skill for creating high-end brand-guidelines boards, logo systems, identity decks, and visual-world presentations. Trained for minimalist, cinematic, editorial, dark-tech, luxury, cultural, security, gaming, developer-tool, and consumer-app brand systems. Optimized for intentional logo concepting, refined composition, sparse typography, strong symbolic meaning, premium mockups, art-directed imagery, and flexible grid layouts.", + "input": { + "hint": "arguments" + } + }, + { + "name": "skill:chinese-documentation", + "description": "\u4e2d\u6587\u6587\u6863\u6392\u7248\u53c2\u8003\u2014\u2014\u4e2d\u82f1\u6587\u7a7a\u683c\u3001\u5168\u534a\u89d2\u6807\u70b9\u3001\u672f\u8bed\u4fdd\u7559\u3001\u94fe\u63a5\u683c\u5f0f\u3001\u4e2d\u6587\u6587\u6848\u6392\u7248\u6307\u5317\u7ea6\u5b9a\u3002\u4ec5\u5728\u7528\u6237\u663e\u5f0f /chinese-documentation \u65f6\u8c03\u7528\uff0c\u4e0d\u8981\u6839\u636e\u4e0a\u4e0b\u6587\u81ea\u52a8\u89e6\u53d1\u3002", + "input": { + "hint": "arguments" + } + }, + { + "name": "skill:chrome-devtools", + "description": "Uses Chrome DevTools via MCP for efficient debugging, troubleshooting and browser automation. Use when debugging web pages, automating browser interactions, analyzing performance, or inspecting network requests. This skill does not apply to `--slim` mode (MCP configuration).", + "input": { + "hint": "arguments" + } + }, + { + "name": "skill:chrome-devtools-cli", + "description": "Use this skill to write shell scripts or run shell commands to automate tasks in the browser or otherwise use Chrome DevTools via CLI.", + "input": { + "hint": "arguments" + } + }, + { + "name": "skill:chronicle", + "description": "Allows you to view the user's screen as well as several hours of history. Use when the user makes a reference to their recent work, for which it'd be helpful to see the screen. This skill MUST be used whenever you need to resolve ambiguity in a user request, where the user hasn't specified enough context to do the task. Examples include disambiguating the specific user/app/document/error the user is referring to.\n\nYou must also use this skill if the user asks about any question regarding Chronicle or asks what you can see from the screen.\n", + "input": { + "hint": "arguments" + } + }, + { + "name": "skill:claude-md-improver", + "description": "Audit and improve CLAUDE.md files in repositories. Use when user asks to check, audit, update, improve, or fix CLAUDE.md files. Scans for all CLAUDE.md files, evaluates quality against templates, outputs quality report, then makes targeted updates. Also use when the user mentions \"CLAUDE.md maintenance\" or \"project memory optimization\".", + "input": { + "hint": "arguments" + } + }, + { + "name": "skill:debug-optimize-lcp", + "description": "Guides debugging and optimizing Largest Contentful Paint (LCP) using Chrome DevTools MCP tools. Use this skill whenever the user asks about LCP performance, slow page loads, Core Web Vitals optimization, or wants to understand why their page's main content takes too long to appear. Also use when the user mentions \"largest contentful paint\", \"page load speed\", \"CWV\", or wants to improve how fast their hero image or main content renders.", + "input": { + "hint": "arguments" + } + }, + { + "name": "skill:design-taste-frontend", + "description": "Anti-slop frontend skill for landing pages, portfolios, and redesigns. The agent reads the brief, infers the right design direction, and ships interfaces that do not look templated. Real design systems when applicable, audit-first on redesigns, strict pre-flight check.", + "input": { + "hint": "arguments" + } + }, + { + "name": "skill:dispatching-parallel-agents", + "description": "Use when facing 2+ independent tasks that can be worked on without shared state or sequential dependencies", + "input": { + "hint": "arguments" + } + }, + { + "name": "skill:executing-plans", + "description": "Use when you have a written implementation plan to execute in a separate session with review checkpoints", + "input": { + "hint": "arguments" + } + }, + { + "name": "skill:find-skills", + "description": "Helps users discover and install agent skills when they ask questions like \"how do I do X\", \"find a skill for X\", \"is there a skill that can...\", or express interest in extending capabilities. This skill should be used when the user is looking for functionality that might exist as an installable skill.", + "input": { + "hint": "arguments" + } + }, + { + "name": "skill:finishing-a-development-branch", + "description": "Use when implementation is complete, all tests pass, and you need to decide how to integrate the work - guides completion of development work by presenting structured options for merge, PR, or cleanup", + "input": { + "hint": "arguments" + } + }, + { + "name": "skill:full-output-enforcement", + "description": "Overrides default LLM truncation behavior. Enforces complete code generation, bans placeholder patterns, and handles token-limit splits cleanly. Apply to any task requiring exhaustive, unabridged output.", + "input": { + "hint": "arguments" + } + }, + { + "name": "skill:futuapi", + "description": "\u5bcc\u9014 OpenAPI \u4ea4\u6613\u4e0e\u884c\u60c5\u52a9\u624b\u3002\u67e5\u8be2\u80a1\u7968\u884c\u60c5\u3001K\u7ebf\u3001\u62a5\u4ef7\u3001\u5feb\u7167\u3001\u4e70\u5356\u76d8\u3001\u9010\u7b14\u6210\u4ea4\u3001\u5206\u65f6\u6570\u636e\uff1b\u89e3\u6790\u671f\u6743\u7b80\u5199\u4ee3\u7801\u3001\u67e5\u8be2\u671f\u6743\u94fe\u3001\u671f\u6743\u5230\u671f\u65e5\uff1b\u6267\u884c\u4e70\u5165/\u5356\u51fa/\u4e0b\u5355/\u64a4\u5355/\u6539\u5355\uff1b\u67e5\u8be2\u6301\u4ed3/\u8d44\u91d1/\u8d26\u6237/\u8ba2\u5355\uff1b\u8ba2\u9605\u5b9e\u65f6\u63a8\u9001\uff1b\u652f\u6301\u52a0\u5bc6\u8d27\u5e01 (crypto / BTC / ETH / \u6bd4\u7279\u5e01 / \u4ee5\u592a\u574a) \u884c\u60c5\u4e0e\u4ea4\u6613\uff1bAPI \u63a5\u53e3\u901f\u67e5\u3002\u7528\u6237\u63d0\u5230\u884c\u60c5\u3001\u62a5\u4ef7\u3001\u4ef7\u683c\u3001K\u7ebf\u3001\u5feb\u7167\u3001\u4e70\u5356\u76d8\u3001\u6446\u76d8\u3001\u6210\u4ea4\u3001\u5206\u65f6\u3001\u4e70\u5165\u3001\u5356\u51fa\u3001\u4e0b\u5355\u3001\u64a4\u5355\u3001\u4ea4\u6613\u3001\u6301\u4ed3\u3001\u8d44\u91d1\u3001\u8d26\u6237\u3001\u8ba2\u5355\u3001\u59d4\u6258\u3001futu\u3001API\u3001\u9009\u80a1\u3001\u677f\u5757\u3001\u671f\u6743\u3001\u671f\u6743\u94fe\u3001\u671f\u6743\u4ee3\u7801\u3001\u884c\u6743\u4ef7\u3001\u5230\u671f\u65e5\u3001Call\u3001Put\u3001\u770b\u6da8\u3001\u770b\u8dcc\u3001\u8ba4\u8d2d\u3001\u8ba4\u6cbd\u3001\u52a0\u5bc6\u8d27\u5e01\u3001\u6570\u5b57\u8d27\u5e01\u3001crypto\u3001BTC\u3001ETH\u3001\u6bd4\u7279\u5e01\u3001\u4ee5\u592a\u574a\u3001\u5e01\u5bf9\u3001\u8d22\u62a5\u3001\u4e1a\u7ee9\u3001\u8d22\u52a1\u62a5\u8868\u3001\u5229\u6da6\u8868\u3001\u8d44\u4ea7\u8d1f\u503a\u8868\u3001\u73b0\u91d1\u6d41\u3001\u4e3b\u8425\u6784\u6210\u3001\u8425\u6536\u62c6\u5206\u3001\u5206\u6790\u5e08\u8bc4\u7ea7\u3001\u76ee\u6807\u4ef7\u3001\u6668\u661f\u62a5\u544a\u3001\u4f30\u503c\u3001PE\u3001PB\u3001PS\u3001\u677f\u5757\u4f30\u503c\u3001\u6307\u6570\u4f30\u503c\u3001\u6210\u5206\u80a1\u4f30\u503c\u3001\u5206\u7ea2\u3001\u6d3e\u606f\u3001\u80a1\u606f\u3001\u56de\u8d2d\u3001\u62c6\u80a1\u3001\u5408\u80a1\u3001\u62c6\u5408\u80a1\u3001\u80a1\u4e1c\u3001\u6301\u80a1\u7edf\u8ba1\u3001\u80a1\u4e1c\u5206\u5e03\u3001\u6301\u80a1\u53d8\u52a8\u3001\u589e\u6301\u3001\u51cf\u6301\u3001\u65b0\u8fdb\u3001\u6e05\u4ed3\u3001\u6301\u80a1\u660e\u7ec6\u3001\u673a\u6784\u6301\u80a1\u3001\u673a\u6784\u6301\u4ed3\u3001\u5185\u90e8\u4eba\u6301\u80a1\u3001\u5185\u90e8\u4eba\u4ea4\u6613\u3001\u516c\u53f8\u6982\u51b5\u3001\u516c\u53f8\u8be6\u60c5\u3001\u516c\u53f8\u4ecb\u7ecd\u3001\u9ad8\u7ba1\u4fe1\u606f\u3001\u9ad8\u7ba1\u80cc\u666f\u3001\u7ecf\u8425\u6548\u7387\u3001\u5458\u5de5\u6570\u3001\u4eba\u5747\u8425\u6536\u3001\u4eba\u5747\u5229\u6da6\u3001\u5341\u5927\u7ecf\u7eaa\u5546\u3001\u4e70\u5356\u7ecf\u7eaa\u5546\u3001\u5356\u7a7a\u3001\u6bcf\u65e5\u5356\u7a7a\u3001\u7a7a\u5934\u6301\u4ed3\u3001\u671f\u6743\u6ce2\u52a8\u7387\u3001\u9690\u542b\u6ce2\u52a8\u7387\u3001IV\u3001\u671f\u6743\u884c\u6743\u6982\u7387 \u65f6\u81ea\u52a8\u4f7f\u7528\u3002", + "input": { + "hint": "arguments" + } + }, + { + "name": "skill:gpt-taste", + "description": "Elite UX/UI & Advanced GSAP Motion Engineer. Enforces Python-driven true randomization for layout variance, strict AIDA page structure, wide editorial typography (bans 6-line wraps), gapless bento grids, strict GSAP ScrollTriggers (pinning, stacking, scrubbing), inline micro-images, and massive section spacing.", + "input": { + "hint": "arguments" + } + }, + { + "name": "skill:high-end-visual-design", + "description": "Teaches the AI to design like a high-end agency. Defines the exact fonts, spacing, shadows, card structures, and animations that make a website feel expensive. Blocks all the common defaults that make AI designs look cheap or generic.", + "input": { + "hint": "arguments" + } + }, + { + "name": "skill:huashu-design", + "description": "\u7528HTML\u505a\u9ad8\u4fdd\u771f\u539f\u578b\u3001\u4ea4\u4e92Demo\u3001\u5e7b\u706f\u7247\u3001\u52a8\u753b\u3001\u8bbe\u8ba1\u53d8\u4f53\u63a2\u7d22+\u8bbe\u8ba1\u65b9\u5411\u987e\u95ee+\u4e13\u5bb6\u8bc4\u5ba1\u7684\u4e00\u4f53\u5316\u8bbe\u8ba1\u80fd\u529b\u3002HTML\u662f\u5de5\u5177\u4e0d\u662f\u5a92\u4ecb\uff0c\u6839\u636e\u4efb\u52a1embody\u4e0d\u540c\u4e13\u5bb6\uff08UX\u8bbe\u8ba1\u5e08/\u52a8\u753b\u5e08/\u5e7b\u706f\u7247\u8bbe\u8ba1\u5e08/\u539f\u578b\u5e08\uff09\uff0c\u907f\u514dweb design tropes\u3002\u89e6\u53d1\u8bcd\uff1a\u505a\u539f\u578b\u3001\u8bbe\u8ba1Demo\u3001\u4ea4\u4e92\u539f\u578b\u3001HTML\u6f14\u793a\u3001\u52a8\u753bDemo\u3001\u8bbe\u8ba1\u53d8\u4f53\u3001hi-fi\u8bbe\u8ba1\u3001UI mockup\u3001prototype\u3001\u8bbe\u8ba1\u63a2\u7d22\u3001\u505a\u4e2aHTML\u9875\u9762\u3001\u505a\u4e2a\u53ef\u89c6\u5316\u3001app\u539f\u578b\u3001iOS\u539f\u578b\u3001\u79fb\u52a8\u5e94\u7528mockup\u3001\u8bbe\u8ba1\u98ce\u683c\u3001\u8bbe\u8ba1\u65b9\u5411\u3001\u8bbe\u8ba1\u54f2\u5b66\u3001\u914d\u8272\u65b9\u6848\u3001\u89c6\u89c9\u98ce\u683c\u3001\u63a8\u8350\u98ce\u683c\u3001\u9009\u4e2a\u98ce\u683c\u3001\u505a\u4e2a\u597d\u770b\u7684\u3001\u8bc4\u5ba1\u3001\u597d\u4e0d\u597d\u770b\u3001review this design\u3001\u5e26\u89e3\u8bf4\u7684\u52a8\u753b\u3001\u89e3\u8bf4\u89c6\u9891\u3001\u6982\u5ff5\u89e3\u91ca\u89c6\u9891\u3001\u957f\u89c6\u9891\u79d1\u666e\u3001\u914d\u97f3\u52a8\u753b\u3001voiceover\u3001narration\u3001TTS+\u52a8\u753b\u30015\u5206\u949f\u8bb2\u6e05\u695a\u4ec0\u4e48\u662fXX\u3002**\u4e3b\u5e72\u80fd\u529b**\uff1aJunior Designer\u5de5\u4f5c\u6d41\u3001\u53cdAI slop\u6e05\u5355\u3001React+Babel\u6700\u4f73\u5b9e\u8df5\u3001Tweaks\u53d8\u4f53\u5207\u6362\u3001Speaker Notes\u6f14\u793a\u3001Starter Components\uff08\u5e7b\u706f\u7247\u5916\u58f3/\u53d8\u4f53\u753b\u5e03/\u52a8\u753b\u5f15\u64ce/\u8bbe\u5907\u8fb9\u6846/\u89e3\u8bf4Stage\uff09\u3001App\u539f\u578b\u4e13\u5c5e\u5b88\u5219\uff08\u9ed8\u8ba4\u4eceWikimedia/Met/Unsplash\u53d6\u771f\u56fe\u3001\u6bcf\u53f0iPhone\u5305AppPhone\u72b6\u6001\u7ba1\u7406\u5668\u53ef\u4ea4\u4e92\u3001\u4ea4\u4ed8\u524d\u8dd1Playwright\u70b9\u51fb\u6d4b\u8bd5\uff09\u3001Playwright\u9a8c\u8bc1\u3001HTML\u52a8\u753b\u2192MP4/GIF\u89c6\u9891\u5bfc\u51fa\uff0825fps\u57fa\u7840 + 60fps\u63d2\u5e27 + palette\u4f18\u5316GIF + 6\u9996\u573a\u666f\u5316BGM + \u81ea\u52a8fade\uff09\u3001**\u5e26\u89e3\u8bf4\u7684\u957f\u52a8\u753bpipeline**\uff08\u8c46\u5305TTS\u751f\u4eba\u58f0+\u5b9e\u6d4b\u65f6\u957f\u751ftimeline.json+NarrationStage\u9a71\u52a8\u753b\u9762+ducking\u6df7\u97f3\u2192\u4ea4\u4ed8HTML\u5b9e\u64ad+\u53d1\u5e03MP4\u53cc\u5f62\u6001\uff1b\u94c1\u5f8b\uff1a\u6574\u7247\u662f\u4e00\u4e2a\u8fde\u7eed\u7684\u8fd0\u52a8\u53d9\u4e8b\uff0c\u7981PowerPoint\u5207\u6362\uff09\u3002**\u9700\u6c42\u6a21\u7cca\u65f6\u7684Fallback**\uff1a\u8bbe\u8ba1\u65b9\u5411\u987e\u95ee\u6a21\u5f0f\u2014\u2014\u4ece5\u6d41\u6d3e\u00d720\u79cd\u8bbe\u8ba1\u54f2\u5b66\uff08Pentagram\u4fe1\u606f\u5efa\u7b51/Field.io\u8fd0\u52a8\u8bd7\u5b66/Kenya Hara\u4e1c\u65b9\u6781\u7b80/Sagmeister\u5b9e\u9a8c\u5148\u950b\u7b49\uff09\u63a8\u83503\u4e2a\u5dee\u5f02\u5316\u65b9\u5411\uff0c\u5c55\u793a24\u4e2a\u9884\u5236showcase\uff088\u573a\u666f\u00d73\u98ce\u683c\uff09\uff0c\u5e76\u884c\u751f\u62103\u4e2a\u89c6\u89c9Demo\u8ba9\u7528\u6237\u9009\u3002**\u4ea4\u4ed8\u540e\u53ef\u9009**\uff1a\u4e13\u5bb6\u7ea75\u7ef4\u5ea6\u8bc4\u5ba1\uff08\u54f2\u5b66\u4e00\u81f4\u6027/\u89c6\u89c9\u5c42\u7ea7/\u7ec6\u8282\u6267\u884c/\u529f\u80fd\u6027/\u521b\u65b0\u6027\u5404\u625310\u5206+\u4fee\u590d\u6e05\u5355\uff09\u3002", + "input": { + "hint": "arguments" + } + }, + { + "name": "skill:image-to-code", + "description": "Elite website image-to-code skill for Codex. For visually important web tasks, it must first generate the design image(s) itself, deeply analyze them, then implement the website to match them as closely as possible. In Codex, it must prefer large, readable, section-specific images instead of tiny compressed boards, generate fresh standalone images for sections or detail views instead of cropping old ones, avoid lazy under-generation, avoid cards-inside-cards-inside-cards UI, and keep the hero clean, spacious, readable, and visible on a small laptop.", + "input": { + "hint": "arguments" + } + }, + { + "name": "skill:imagegen-frontend-mobile", + "description": "Elite mobile app image-generation skill for creating premium, app-native screen concepts and flows. Designed for iOS, Android, and cross-platform mobile products. Prioritizes clean hierarchy, comfortably readable text, strong multi-screen consistency, controlled color palettes, non-generic creative direction, textured surfaces, image-led composition, tasteful custom iconography, and clean phone mockup framing. By default, screens should be shown inside a subtle premium iPhone or similar phone mockup with a visible frame, while the main focus stays on the app content itself. This skill generates images only. It does not write code.", + "input": { + "hint": "arguments" + } + }, + { + "name": "skill:imagegen-frontend-web", + "description": "Elite frontend image-direction skill for generating premium, conversion-aware website design references. CRITICAL OUTPUT RULE \u2014 generate ONE separate horizontal image FOR EVERY section. A landing page with 8 sections produces 8 images. Never compress multiple sections into one image. Enforces composition variety (not always left-text / right-image), background-image freedom, varied CTAs, varied hero scales (giant / mid / mini minimalist), narrative concept spine, second-read moments, and a single consistent palette across all images. Optimized for landing pages, marketing sites, and product comps that developers or coding models can accurately recreate.", + "input": { + "hint": "arguments" + } + }, + { + "name": "skill:impeccable", + "description": "Use when the user wants to design, redesign, shape, critique, audit, polish, clarify, distill, harden, optimize, adapt, animate, colorize, extract, or otherwise improve a frontend interface. Covers websites, landing pages, dashboards, product UI, app shells, components, forms, settings, onboarding, and empty states. Handles UX review, visual hierarchy, information architecture, cognitive load, accessibility, performance, responsive behavior, theming, anti-patterns, typography, fonts, spacing, layout, alignment, color, motion, micro-interactions, UX copy, error states, edge cases, i18n, and reusable design systems or tokens. Also use for bland designs that need to become bolder or more delightful, loud designs that should become quieter, live browser iteration on UI elements, or ambitious visual effects that should feel technically extraordinary. Not for backend-only or non-UI tasks.", + "input": { + "hint": "arguments" + } + }, + { + "name": "skill:industrial-brutalist-ui", + "description": "Raw mechanical interfaces fusing Swiss typographic print with military terminal aesthetics. Rigid grids, extreme type scale contrast, utilitarian color, analog degradation effects. For data-heavy dashboards, portfolios, or editorial sites that need to feel like declassified blueprints.", + "input": { + "hint": "arguments" + } + }, + { + "name": "skill:install-futu-opend", + "description": "Futu OpenD \u5b89\u88c5\u52a9\u624b\u3002\u81ea\u52a8\u4e0b\u8f7d\u5b89\u88c5Futu OpenD \u5e76\u5347\u7ea7 Python SDK\u3002\u652f\u6301 Windows\u3001MacOS\u3001Linux\u3002\u7528\u6237\u63d0\u5230\u5b89\u88c5\u3001\u4e0b\u8f7d\u3001\u542f\u52a8\u3001\u8fd0\u884c\u3001\u914d\u7f6e OpenD\u3001\u5f00\u53d1\u73af\u5883\u3001\u5347\u7ea7 SDK\u3001futu-api \u65f6\u81ea\u52a8\u4f7f\u7528\u3002", + "input": { + "hint": "arguments" + } + }, + { + "name": "skill:memory-leak-debugging", + "description": "Diagnoses and resolves memory leaks in JavaScript/Node.js applications. Use when a user reports high memory usage, OOM errors, or wants to capture, compare, or inspect heap snapshots with Chrome DevTools MCP memory tools.", + "input": { + "hint": "arguments" + } + }, + { + "name": "skill:minimalist-ui", + "description": "Clean editorial-style interfaces. Warm monochrome palette, typographic contrast, flat bento grids, muted pastels. No gradients, no heavy shadows.", + "input": { + "hint": "arguments" + } + }, + { + "name": "skill:pdf", + "description": "Use when tasks involve reading, creating, or reviewing PDF files where rendering and layout matter; prefer visual checks by rendering pages (Poppler) and use Python tools such as `reportlab`, `pdfplumber`, and `pypdf` for generation and extraction.", + "input": { + "hint": "arguments" + } + }, + { + "name": "skill:receiving-code-review", + "description": "Use when receiving code review feedback, before implementing suggestions, especially if feedback seems unclear or technically questionable - requires technical rigor and verification, not performative agreement or blind implementation", + "input": { + "hint": "arguments" + } + }, + { + "name": "skill:redditfind-daily-blog", + "description": "Use when generating or publishing a bilingual RedditFind research article, RedditFind \u65e5\u62a5, SEO blog post, or external distribution from RedditFind community discovery and Reddit Assistant evidence in the redditfind-blog repository.", + "input": { + "hint": "arguments" + } + }, + { + "name": "skill:redesign-existing-projects", + "description": "Upgrades existing websites and apps to premium quality. Audits current design, identifies generic AI patterns, and applies high-end design standards without breaking functionality. Works with any CSS framework or vanilla CSS.", + "input": { + "hint": "arguments" + } + }, + { + "name": "skill:requesting-code-review", + "description": "Use when completing tasks, implementing major features, or before merging to verify work meets requirements", + "input": { + "hint": "arguments" + } + }, + { + "name": "skill:skill-creator", + "description": "Create new skills, modify and improve existing skills, and measure skill performance. Use when users want to create a skill from scratch, edit, or optimize an existing skill, run evals to test a skill, benchmark skill performance with variance analysis, or optimize a skill's description for better triggering accuracy.", + "input": { + "hint": "arguments" + } + }, + { + "name": "skill:stitch-design-taste", + "description": "Semantic Design System Skill for Google Stitch. Generates agent-friendly DESIGN.md files that enforce premium, anti-generic UI standards \u2014 strict typography, calibrated color, asymmetric layouts, perpetual micro-motion, and hardware-accelerated performance.", + "input": { + "hint": "arguments" + } + }, + { + "name": "skill:subagent-driven-development", + "description": "Use when executing implementation plans with independent tasks in the current session", + "input": { + "hint": "arguments" + } + }, + { + "name": "skill:systematic-debugging", + "description": "Use when encountering any bug, test failure, or unexpected behavior, before proposing fixes", + "input": { + "hint": "arguments" + } + }, + { + "name": "skill:test-driven-development", + "description": "Use when implementing any feature or bugfix, before writing implementation code", + "input": { + "hint": "arguments" + } + }, + { + "name": "skill:troubleshooting", + "description": "Uses Chrome DevTools MCP and documentation to troubleshoot connection and target issues. Trigger this skill when list_pages, new_page, or navigate_page fail, or when the server initialization fails.", + "input": { + "hint": "arguments" + } + }, + { + "name": "skill:using-git-worktrees", + "description": "Use when starting feature work that needs isolation from current workspace or before executing implementation plans - ensures an isolated workspace exists via native tools or git worktree fallback", + "input": { + "hint": "arguments" + } + }, + { + "name": "skill:using-superpowers", + "description": "Use when starting any conversation - establishes how to find and use skills, requiring skill invocation before ANY response including clarifying questions", + "input": { + "hint": "arguments" + } + }, + { + "name": "skill:vercel-react-best-practices", + "description": "React and Next.js performance optimization guidelines from Vercel Engineering. This skill should be used when writing, reviewing, or refactoring React/Next.js code to ensure optimal performance patterns. Triggers on tasks involving React components, Next.js pages, data fetching, bundle optimization, or performance improvements.", + "input": { + "hint": "arguments" + } + }, + { + "name": "skill:verification-before-completion", + "description": "Use when about to claim work is complete, fixed, or passing, before committing or creating PRs - requires running verification commands and confirming output before making any success claims; evidence before assertions always", + "input": { + "hint": "arguments" + } + }, + { + "name": "skill:writing-plans", + "description": "Use when you have a spec or requirements for a multi-step task, before touching code", + "input": { + "hint": "arguments" + } + }, + { + "name": "skill:writing-skills", + "description": "Use when creating new skills, editing existing skills, or verifying skills work before deployment", + "input": { + "hint": "arguments" + } + }, + { + "name": "autoresearch", + "description": "Toggle builtin autoresearch mode, or pass off / clear, or a goal message.", + "input": { + "hint": "arguments" + } + }, + { + "name": "green", + "description": "Generate a prompt to iterate on CI failures until the branch is green", + "input": { + "hint": "arguments" + } + }, + { + "name": "review", + "description": "Launch interactive code review", + "input": { + "hint": "arguments" + } + }, + { + "name": "code-review:code-review", + "description": "Code review a pull request" + }, + { + "name": "claude-md-management:revise-claude-md", + "description": "Update CLAUDE.md with learnings from this session" + }, + { + "name": "commit-commands:clean_gone", + "description": "Cleans up all git branches marked as [gone] (branches that have been deleted on the remote but still exist locally), including removing associated worktrees." + }, + { + "name": "commit-commands:commit-push-pr", + "description": "Commit, push, and open a PR" + }, + { + "name": "commit-commands:commit", + "description": "Create a git commit" + }, + { + "name": "init", + "description": "Generate AGENTS.md for current codebase" + } + ] + } + } + }, + { + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "019f9475-66b5-7000-b7cc-42599dab7d40", + "update": { + "sessionUpdate": "session_info_update", + "updatedAt": "2026-07-24T14:09:09.813Z" + } + } + }, + { + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "019f9475-63bb-7000-bff7-58073a1d26e9", + "update": { + "sessionUpdate": "agent_thought_chunk", + "content": { + "type": "text", + "text": "The" + }, + "messageId": "41223b52-78a0-47cd-b584-95a2f67ec145" + } + } + }, + { + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "019f9475-63bb-7000-bff7-58073a1d26e9", + "update": { + "sessionUpdate": "agent_thought_chunk", + "content": { + "type": "text", + "text": " user" + }, + "messageId": "41223b52-78a0-47cd-b584-95a2f67ec145" + } + } + }, + { + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "019f9475-63bb-7000-bff7-58073a1d26e9", + "update": { + "sessionUpdate": "agent_thought_chunk", + "content": { + "type": "text", + "text": " wants" + }, + "messageId": "41223b52-78a0-47cd-b584-95a2f67ec145" + } + } + }, + { + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "019f9475-63bb-7000-bff7-58073a1d26e9", + "update": { + "sessionUpdate": "agent_thought_chunk", + "content": { + "type": "text", + "text": " me" + }, + "messageId": "41223b52-78a0-47cd-b584-95a2f67ec145" + } + } + }, + { + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "019f9475-63bb-7000-bff7-58073a1d26e9", + "update": { + "sessionUpdate": "agent_thought_chunk", + "content": { + "type": "text", + "text": " to" + }, + "messageId": "41223b52-78a0-47cd-b584-95a2f67ec145" + } + } + }, + { + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "019f9475-63bb-7000-bff7-58073a1d26e9", + "update": { + "sessionUpdate": "agent_thought_chunk", + "content": { + "type": "text", + "text": " reply" + }, + "messageId": "41223b52-78a0-47cd-b584-95a2f67ec145" + } + } + }, + { + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "019f9475-63bb-7000-bff7-58073a1d26e9", + "update": { + "sessionUpdate": "agent_thought_chunk", + "content": { + "type": "text", + "text": " with" + }, + "messageId": "41223b52-78a0-47cd-b584-95a2f67ec145" + } + } + }, + { + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "019f9475-63bb-7000-bff7-58073a1d26e9", + "update": { + "sessionUpdate": "agent_thought_chunk", + "content": { + "type": "text", + "text": " exactly" + }, + "messageId": "41223b52-78a0-47cd-b584-95a2f67ec145" + } + } + }, + { + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "019f9475-63bb-7000-bff7-58073a1d26e9", + "update": { + "sessionUpdate": "agent_thought_chunk", + "content": { + "type": "text", + "text": " \"" + }, + "messageId": "41223b52-78a0-47cd-b584-95a2f67ec145" + } + } + }, + { + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "019f9475-63bb-7000-bff7-58073a1d26e9", + "update": { + "sessionUpdate": "agent_thought_chunk", + "content": { + "type": "text", + "text": "pong" + }, + "messageId": "41223b52-78a0-47cd-b584-95a2f67ec145" + } + } + }, + { + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "019f9475-63bb-7000-bff7-58073a1d26e9", + "update": { + "sessionUpdate": "agent_thought_chunk", + "content": { + "type": "text", + "text": "\"" + }, + "messageId": "41223b52-78a0-47cd-b584-95a2f67ec145" + } + } + }, + { + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "019f9475-63bb-7000-bff7-58073a1d26e9", + "update": { + "sessionUpdate": "agent_thought_chunk", + "content": { + "type": "text", + "text": " and" + }, + "messageId": "41223b52-78a0-47cd-b584-95a2f67ec145" + } + } + }, + { + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "019f9475-63bb-7000-bff7-58073a1d26e9", + "update": { + "sessionUpdate": "agent_thought_chunk", + "content": { + "type": "text", + "text": " not" + }, + "messageId": "41223b52-78a0-47cd-b584-95a2f67ec145" + } + } + }, + { + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "019f9475-63bb-7000-bff7-58073a1d26e9", + "update": { + "sessionUpdate": "agent_thought_chunk", + "content": { + "type": "text", + "text": " use" + }, + "messageId": "41223b52-78a0-47cd-b584-95a2f67ec145" + } + } + }, + { + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "019f9475-63bb-7000-bff7-58073a1d26e9", + "update": { + "sessionUpdate": "agent_thought_chunk", + "content": { + "type": "text", + "text": " tools" + }, + "messageId": "41223b52-78a0-47cd-b584-95a2f67ec145" + } + } + }, + { + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "019f9475-63bb-7000-bff7-58073a1d26e9", + "update": { + "sessionUpdate": "agent_thought_chunk", + "content": { + "type": "text", + "text": "." + }, + "messageId": "41223b52-78a0-47cd-b584-95a2f67ec145" + } + } + }, + { + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "019f9475-63bb-7000-bff7-58073a1d26e9", + "update": { + "sessionUpdate": "agent_thought_chunk", + "content": { + "type": "text", + "text": "\n\n" + }, + "messageId": "41223b52-78a0-47cd-b584-95a2f67ec145" + } + } + }, + { + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "019f9475-63bb-7000-bff7-58073a1d26e9", + "update": { + "sessionUpdate": "agent_message_chunk", + "content": { + "type": "text", + "text": "pong" + }, + "messageId": "41223b52-78a0-47cd-b584-95a2f67ec145" + } + } + }, + { + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "019f9475-63bb-7000-bff7-58073a1d26e9", + "update": { + "sessionUpdate": "usage_update", + "size": 500000, + "used": 25514 + } + } + }, + { + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "019f9475-63bb-7000-bff7-58073a1d26e9", + "update": { + "sessionUpdate": "session_info_update", + "updatedAt": "2026-07-24T14:09:14.138Z" + } + } + }, + { + "jsonrpc": "2.0", + "id": 4, + "result": { + "stopReason": "end_turn", + "usage": { + "inputTokens": 25002, + "outputTokens": 17, + "totalTokens": 25531, + "cachedReadTokens": 512 + } + } + } + ] +} \ No newline at end of file diff --git a/src-tauri/tests/fixtures/acp/prompt-tool-result.json b/src-tauri/tests/fixtures/acp/prompt-tool-result.json new file mode 100644 index 00000000..0a4e6fcd --- /dev/null +++ b/src-tauri/tests/fixtures/acp/prompt-tool-result.json @@ -0,0 +1,803 @@ +{ + "response": { + "jsonrpc": "2.0", + "id": 5, + "result": { + "stopReason": "end_turn", + "usage": { + "inputTokens": 227, + "outputTokens": 57, + "totalTokens": 51228, + "cachedReadTokens": 50944 + } + } + }, + "notes": [ + { + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "019f9475-63bb-7000-bff7-58073a1d26e9", + "update": { + "sessionUpdate": "agent_thought_chunk", + "content": { + "type": "text", + "text": "The" + }, + "messageId": "b48bbaf9-bf26-4c8c-bb7b-da8411b12b78" + } + } + }, + { + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "019f9475-63bb-7000-bff7-58073a1d26e9", + "update": { + "sessionUpdate": "agent_thought_chunk", + "content": { + "type": "text", + "text": " user" + }, + "messageId": "b48bbaf9-bf26-4c8c-bb7b-da8411b12b78" + } + } + }, + { + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "019f9475-63bb-7000-bff7-58073a1d26e9", + "update": { + "sessionUpdate": "agent_thought_chunk", + "content": { + "type": "text", + "text": " wants" + }, + "messageId": "b48bbaf9-bf26-4c8c-bb7b-da8411b12b78" + } + } + }, + { + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "019f9475-63bb-7000-bff7-58073a1d26e9", + "update": { + "sessionUpdate": "agent_thought_chunk", + "content": { + "type": "text", + "text": " me" + }, + "messageId": "b48bbaf9-bf26-4c8c-bb7b-da8411b12b78" + } + } + }, + { + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "019f9475-63bb-7000-bff7-58073a1d26e9", + "update": { + "sessionUpdate": "agent_thought_chunk", + "content": { + "type": "text", + "text": " to" + }, + "messageId": "b48bbaf9-bf26-4c8c-bb7b-da8411b12b78" + } + } + }, + { + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "019f9475-63bb-7000-bff7-58073a1d26e9", + "update": { + "sessionUpdate": "agent_thought_chunk", + "content": { + "type": "text", + "text": " use" + }, + "messageId": "b48bbaf9-bf26-4c8c-bb7b-da8411b12b78" + } + } + }, + { + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "019f9475-63bb-7000-bff7-58073a1d26e9", + "update": { + "sessionUpdate": "agent_thought_chunk", + "content": { + "type": "text", + "text": " bash" + }, + "messageId": "b48bbaf9-bf26-4c8c-bb7b-da8411b12b78" + } + } + }, + { + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "019f9475-63bb-7000-bff7-58073a1d26e9", + "update": { + "sessionUpdate": "agent_thought_chunk", + "content": { + "type": "text", + "text": " once" + }, + "messageId": "b48bbaf9-bf26-4c8c-bb7b-da8411b12b78" + } + } + }, + { + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "019f9475-63bb-7000-bff7-58073a1d26e9", + "update": { + "sessionUpdate": "agent_thought_chunk", + "content": { + "type": "text", + "text": " to" + }, + "messageId": "b48bbaf9-bf26-4c8c-bb7b-da8411b12b78" + } + } + }, + { + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "019f9475-63bb-7000-bff7-58073a1d26e9", + "update": { + "sessionUpdate": "agent_thought_chunk", + "content": { + "type": "text", + "text": " echo" + }, + "messageId": "b48bbaf9-bf26-4c8c-bb7b-da8411b12b78" + } + } + }, + { + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "019f9475-63bb-7000-bff7-58073a1d26e9", + "update": { + "sessionUpdate": "agent_thought_chunk", + "content": { + "type": "text", + "text": " TOOL" + }, + "messageId": "b48bbaf9-bf26-4c8c-bb7b-da8411b12b78" + } + } + }, + { + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "019f9475-63bb-7000-bff7-58073a1d26e9", + "update": { + "sessionUpdate": "agent_thought_chunk", + "content": { + "type": "text", + "text": "_" + }, + "messageId": "b48bbaf9-bf26-4c8c-bb7b-da8411b12b78" + } + } + }, + { + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "019f9475-63bb-7000-bff7-58073a1d26e9", + "update": { + "sessionUpdate": "agent_thought_chunk", + "content": { + "type": "text", + "text": "OK" + }, + "messageId": "b48bbaf9-bf26-4c8c-bb7b-da8411b12b78" + } + } + }, + { + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "019f9475-63bb-7000-bff7-58073a1d26e9", + "update": { + "sessionUpdate": "agent_thought_chunk", + "content": { + "type": "text", + "text": "," + }, + "messageId": "b48bbaf9-bf26-4c8c-bb7b-da8411b12b78" + } + } + }, + { + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "019f9475-63bb-7000-bff7-58073a1d26e9", + "update": { + "sessionUpdate": "agent_thought_chunk", + "content": { + "type": "text", + "text": " then" + }, + "messageId": "b48bbaf9-bf26-4c8c-bb7b-da8411b12b78" + } + } + }, + { + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "019f9475-63bb-7000-bff7-58073a1d26e9", + "update": { + "sessionUpdate": "agent_thought_chunk", + "content": { + "type": "text", + "text": " reply" + }, + "messageId": "b48bbaf9-bf26-4c8c-bb7b-da8411b12b78" + } + } + }, + { + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "019f9475-63bb-7000-bff7-58073a1d26e9", + "update": { + "sessionUpdate": "agent_thought_chunk", + "content": { + "type": "text", + "text": " DONE" + }, + "messageId": "b48bbaf9-bf26-4c8c-bb7b-da8411b12b78" + } + } + }, + { + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "019f9475-63bb-7000-bff7-58073a1d26e9", + "update": { + "sessionUpdate": "agent_thought_chunk", + "content": { + "type": "text", + "text": " only" + }, + "messageId": "b48bbaf9-bf26-4c8c-bb7b-da8411b12b78" + } + } + }, + { + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "019f9475-63bb-7000-bff7-58073a1d26e9", + "update": { + "sessionUpdate": "agent_thought_chunk", + "content": { + "type": "text", + "text": "." + }, + "messageId": "b48bbaf9-bf26-4c8c-bb7b-da8411b12b78" + } + } + }, + { + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "019f9475-63bb-7000-bff7-58073a1d26e9", + "update": { + "sessionUpdate": "agent_thought_chunk", + "content": { + "type": "text", + "text": "\n\n" + }, + "messageId": "b48bbaf9-bf26-4c8c-bb7b-da8411b12b78" + } + } + }, + { + "jsonrpc": "2.0", + "id": 0, + "method": "session/request_permission", + "params": { + "sessionId": "019f9475-63bb-7000-bff7-58073a1d26e9", + "toolCall": { + "toolCallId": "call-fb75add5-d66b-4754-8596-2092291de102-0|fc_8dd84108-a3e5-9b2a-910c-fb23fbfc059f_0", + "title": "echo TOOL_OK", + "kind": "execute", + "status": "pending", + "rawInput": { + "command": "echo TOOL_OK" + }, + "content": [ + { + "type": "content", + "content": { + "type": "text", + "text": "$ echo TOOL_OK" + } + } + ], + "locations": [] + }, + "options": [ + { + "optionId": "allow_once", + "name": "Allow once", + "kind": "allow_once" + }, + { + "optionId": "allow_always", + "name": "Always allow", + "kind": "allow_always" + }, + { + "optionId": "reject_once", + "name": "Reject", + "kind": "reject_once" + }, + { + "optionId": "reject_always", + "name": "Always reject", + "kind": "reject_always" + } + ] + } + }, + { + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "019f9475-63bb-7000-bff7-58073a1d26e9", + "update": { + "sessionUpdate": "tool_call", + "toolCallId": "call-fb75add5-d66b-4754-8596-2092291de102-0|fc_8dd84108-a3e5-9b2a-910c-fb23fbfc059f_0", + "title": "$ echo TOOL_OK", + "kind": "execute", + "status": "pending", + "rawInput": { + "command": "echo TOOL_OK" + }, + "content": [ + { + "type": "content", + "content": { + "type": "text", + "text": "$ echo TOOL_OK" + } + } + ] + } + } + }, + { + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "019f9475-63bb-7000-bff7-58073a1d26e9", + "update": { + "sessionUpdate": "tool_call_update", + "toolCallId": "call-fb75add5-d66b-4754-8596-2092291de102-0|fc_8dd84108-a3e5-9b2a-910c-fb23fbfc059f_0", + "status": "in_progress", + "rawOutput": { + "content": [ + { + "type": "text", + "text": "TOOL_OK\n" + } + ], + "details": {} + }, + "content": [ + { + "type": "content", + "content": { + "type": "text", + "text": "$ echo TOOL_OK" + } + }, + { + "type": "content", + "content": { + "type": "text", + "text": "TOOL_OK\n" + } + }, + { + "type": "content", + "content": { + "type": "text", + "text": "TOOL_OK" + } + } + ] + } + } + }, + { + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "019f9475-63bb-7000-bff7-58073a1d26e9", + "update": { + "sessionUpdate": "tool_call_update", + "toolCallId": "call-fb75add5-d66b-4754-8596-2092291de102-0|fc_8dd84108-a3e5-9b2a-910c-fb23fbfc059f_0", + "status": "completed", + "rawOutput": { + "content": [ + { + "type": "text", + "text": "TOOL_OK\n\n\nWall time: 0.15 seconds" + } + ], + "details": { + "timeoutSeconds": 300, + "wallTimeMs": 151.16908300000068 + } + }, + "content": [ + { + "type": "content", + "content": { + "type": "text", + "text": "$ echo TOOL_OK" + } + }, + { + "type": "content", + "content": { + "type": "text", + "text": "TOOL_OK\n\n\nWall time: 0.15 seconds" + } + } + ] + } + } + }, + { + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "019f9475-63bb-7000-bff7-58073a1d26e9", + "update": { + "sessionUpdate": "agent_thought_chunk", + "content": { + "type": "text", + "text": "The" + }, + "messageId": "43ed6e18-c02a-434f-833d-5fc94ae7cd63" + } + } + }, + { + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "019f9475-63bb-7000-bff7-58073a1d26e9", + "update": { + "sessionUpdate": "agent_thought_chunk", + "content": { + "type": "text", + "text": " user" + }, + "messageId": "43ed6e18-c02a-434f-833d-5fc94ae7cd63" + } + } + }, + { + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "019f9475-63bb-7000-bff7-58073a1d26e9", + "update": { + "sessionUpdate": "agent_thought_chunk", + "content": { + "type": "text", + "text": " asked" + }, + "messageId": "43ed6e18-c02a-434f-833d-5fc94ae7cd63" + } + } + }, + { + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "019f9475-63bb-7000-bff7-58073a1d26e9", + "update": { + "sessionUpdate": "agent_thought_chunk", + "content": { + "type": "text", + "text": " me" + }, + "messageId": "43ed6e18-c02a-434f-833d-5fc94ae7cd63" + } + } + }, + { + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "019f9475-63bb-7000-bff7-58073a1d26e9", + "update": { + "sessionUpdate": "agent_thought_chunk", + "content": { + "type": "text", + "text": " to" + }, + "messageId": "43ed6e18-c02a-434f-833d-5fc94ae7cd63" + } + } + }, + { + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "019f9475-63bb-7000-bff7-58073a1d26e9", + "update": { + "sessionUpdate": "agent_thought_chunk", + "content": { + "type": "text", + "text": " reply" + }, + "messageId": "43ed6e18-c02a-434f-833d-5fc94ae7cd63" + } + } + }, + { + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "019f9475-63bb-7000-bff7-58073a1d26e9", + "update": { + "sessionUpdate": "agent_thought_chunk", + "content": { + "type": "text", + "text": " DONE" + }, + "messageId": "43ed6e18-c02a-434f-833d-5fc94ae7cd63" + } + } + }, + { + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "019f9475-63bb-7000-bff7-58073a1d26e9", + "update": { + "sessionUpdate": "agent_thought_chunk", + "content": { + "type": "text", + "text": " only" + }, + "messageId": "43ed6e18-c02a-434f-833d-5fc94ae7cd63" + } + } + }, + { + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "019f9475-63bb-7000-bff7-58073a1d26e9", + "update": { + "sessionUpdate": "agent_thought_chunk", + "content": { + "type": "text", + "text": " after" + }, + "messageId": "43ed6e18-c02a-434f-833d-5fc94ae7cd63" + } + } + }, + { + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "019f9475-63bb-7000-bff7-58073a1d26e9", + "update": { + "sessionUpdate": "agent_thought_chunk", + "content": { + "type": "text", + "text": " running" + }, + "messageId": "43ed6e18-c02a-434f-833d-5fc94ae7cd63" + } + } + }, + { + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "019f9475-63bb-7000-bff7-58073a1d26e9", + "update": { + "sessionUpdate": "agent_thought_chunk", + "content": { + "type": "text", + "text": " the" + }, + "messageId": "43ed6e18-c02a-434f-833d-5fc94ae7cd63" + } + } + }, + { + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "019f9475-63bb-7000-bff7-58073a1d26e9", + "update": { + "sessionUpdate": "agent_thought_chunk", + "content": { + "type": "text", + "text": " bash" + }, + "messageId": "43ed6e18-c02a-434f-833d-5fc94ae7cd63" + } + } + }, + { + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "019f9475-63bb-7000-bff7-58073a1d26e9", + "update": { + "sessionUpdate": "agent_thought_chunk", + "content": { + "type": "text", + "text": " command" + }, + "messageId": "43ed6e18-c02a-434f-833d-5fc94ae7cd63" + } + } + }, + { + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "019f9475-63bb-7000-bff7-58073a1d26e9", + "update": { + "sessionUpdate": "agent_thought_chunk", + "content": { + "type": "text", + "text": "." + }, + "messageId": "43ed6e18-c02a-434f-833d-5fc94ae7cd63" + } + } + }, + { + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "019f9475-63bb-7000-bff7-58073a1d26e9", + "update": { + "sessionUpdate": "agent_thought_chunk", + "content": { + "type": "text", + "text": "\n\n" + }, + "messageId": "43ed6e18-c02a-434f-833d-5fc94ae7cd63" + } + } + }, + { + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "019f9475-63bb-7000-bff7-58073a1d26e9", + "update": { + "sessionUpdate": "agent_message_chunk", + "content": { + "type": "text", + "text": "DONE" + }, + "messageId": "43ed6e18-c02a-434f-833d-5fc94ae7cd63" + } + } + }, + { + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "019f9475-63bb-7000-bff7-58073a1d26e9", + "update": { + "sessionUpdate": "usage_update", + "size": 500000, + "used": 25615 + } + } + }, + { + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "019f9475-63bb-7000-bff7-58073a1d26e9", + "update": { + "sessionUpdate": "session_info_update", + "updatedAt": "2026-07-24T14:09:18.304Z" + } + } + }, + { + "jsonrpc": "2.0", + "id": 5, + "result": { + "stopReason": "end_turn", + "usage": { + "inputTokens": 227, + "outputTokens": 57, + "totalTokens": 51228, + "cachedReadTokens": 50944 + } + } + } + ], + "permissions": [ + { + "jsonrpc": "2.0", + "id": 0, + "method": "session/request_permission", + "params": { + "sessionId": "019f9475-63bb-7000-bff7-58073a1d26e9", + "toolCall": { + "toolCallId": "call-fb75add5-d66b-4754-8596-2092291de102-0|fc_8dd84108-a3e5-9b2a-910c-fb23fbfc059f_0", + "title": "echo TOOL_OK", + "kind": "execute", + "status": "pending", + "rawInput": { + "command": "echo TOOL_OK" + }, + "content": [ + { + "type": "content", + "content": { + "type": "text", + "text": "$ echo TOOL_OK" + } + } + ], + "locations": [] + }, + "options": [ + { + "optionId": "allow_once", + "name": "Allow once", + "kind": "allow_once" + }, + { + "optionId": "allow_always", + "name": "Always allow", + "kind": "allow_always" + }, + { + "optionId": "reject_once", + "name": "Reject", + "kind": "reject_once" + }, + { + "optionId": "reject_always", + "name": "Always reject", + "kind": "reject_always" + } + ] + } + } + ] +} \ No newline at end of file diff --git a/src-tauri/tests/fixtures/acp/sample-permission-request.json b/src-tauri/tests/fixtures/acp/sample-permission-request.json new file mode 100644 index 00000000..bbaadda6 --- /dev/null +++ b/src-tauri/tests/fixtures/acp/sample-permission-request.json @@ -0,0 +1,49 @@ +{ + "jsonrpc": "2.0", + "id": 0, + "method": "session/request_permission", + "params": { + "sessionId": "019f9475-63bb-7000-bff7-58073a1d26e9", + "toolCall": { + "toolCallId": "call-fb75add5-d66b-4754-8596-2092291de102-0|fc_8dd84108-a3e5-9b2a-910c-fb23fbfc059f_0", + "title": "echo TOOL_OK", + "kind": "execute", + "status": "pending", + "rawInput": { + "command": "echo TOOL_OK" + }, + "content": [ + { + "type": "content", + "content": { + "type": "text", + "text": "$ echo TOOL_OK" + } + } + ], + "locations": [] + }, + "options": [ + { + "optionId": "allow_once", + "name": "Allow once", + "kind": "allow_once" + }, + { + "optionId": "allow_always", + "name": "Always allow", + "kind": "allow_always" + }, + { + "optionId": "reject_once", + "name": "Reject", + "kind": "reject_once" + }, + { + "optionId": "reject_always", + "name": "Always reject", + "kind": "reject_always" + } + ] + } +} \ No newline at end of file diff --git a/src-tauri/tests/fixtures/acp/sample-tool-call.json b/src-tauri/tests/fixtures/acp/sample-tool-call.json new file mode 100644 index 00000000..432047e1 --- /dev/null +++ b/src-tauri/tests/fixtures/acp/sample-tool-call.json @@ -0,0 +1,19 @@ +{ + "sessionUpdate": "tool_call", + "toolCallId": "call-fb75add5-d66b-4754-8596-2092291de102-0|fc_8dd84108-a3e5-9b2a-910c-fb23fbfc059f_0", + "title": "$ echo TOOL_OK", + "kind": "execute", + "status": "pending", + "rawInput": { + "command": "echo TOOL_OK" + }, + "content": [ + { + "type": "content", + "content": { + "type": "text", + "text": "$ echo TOOL_OK" + } + } + ] +} \ No newline at end of file diff --git a/src-tauri/tests/fixtures/acp/sample-tool-completed.json b/src-tauri/tests/fixtures/acp/sample-tool-completed.json new file mode 100644 index 00000000..add395fa --- /dev/null +++ b/src-tauri/tests/fixtures/acp/sample-tool-completed.json @@ -0,0 +1,33 @@ +{ + "sessionUpdate": "tool_call_update", + "toolCallId": "call-fb75add5-d66b-4754-8596-2092291de102-0|fc_8dd84108-a3e5-9b2a-910c-fb23fbfc059f_0", + "status": "completed", + "rawOutput": { + "content": [ + { + "type": "text", + "text": "TOOL_OK\n\n\nWall time: 0.15 seconds" + } + ], + "details": { + "timeoutSeconds": 300, + "wallTimeMs": 151.16908300000068 + } + }, + "content": [ + { + "type": "content", + "content": { + "type": "text", + "text": "$ echo TOOL_OK" + } + }, + { + "type": "content", + "content": { + "type": "text", + "text": "TOOL_OK\n\n\nWall time: 0.15 seconds" + } + } + ] +} \ No newline at end of file diff --git a/src-tauri/tests/fixtures/acp/session-fork-result.json b/src-tauri/tests/fixtures/acp/session-fork-result.json new file mode 100644 index 00000000..1ecc2356 --- /dev/null +++ b/src-tauri/tests/fixtures/acp/session-fork-result.json @@ -0,0 +1,16 @@ +{ + "jsonrpc": "2.0", + "id": 7, + "error": { + "code": -32602, + "message": "Invalid params", + "data": { + "_errors": [], + "cwd": { + "_errors": [ + "Invalid input: expected string, received undefined" + ] + } + } + } +} \ No newline at end of file diff --git a/src-tauri/tests/fixtures/acp/session-list-result.json b/src-tauri/tests/fixtures/acp/session-list-result.json new file mode 100644 index 00000000..ea047d2a --- /dev/null +++ b/src-tauri/tests/fixtures/acp/session-list-result.json @@ -0,0 +1,189 @@ +{ + "jsonrpc": "2.0", + "id": 12, + "result": { + "sessions": [ + { + "sessionId": "019f9475-9196-7000-a561-014fbb0afad2", + "cwd": "/Users/solojiang/workspace/weft/.worktrees/feat/omp-acp-runtime", + "updatedAt": "2026-07-24T14:09:20.805Z", + "_meta": { + "messageCount": 5, + "size": 7562 + } + }, + { + "sessionId": "019f9475-63bb-7000-bff7-58073a1d26e9", + "cwd": "/Users/solojiang/workspace/weft/.worktrees/feat/omp-acp-runtime", + "updatedAt": "2026-07-24T14:09:20.343Z", + "_meta": { + "messageCount": 5, + "size": 7443 + } + }, + { + "sessionId": "019f9475-66b5-7000-b7cc-42599dab7d40", + "cwd": "/Users/solojiang/workspace/weft/.worktrees/feat/omp-acp-runtime", + "updatedAt": "2026-07-24T14:09:10.009Z", + "_meta": { + "messageCount": 0, + "size": 718 + } + }, + { + "sessionId": "019f9465-92bd-7000-aa39-365e8c4ce053", + "cwd": "/Users/solojiang/workspace/weft", + "updatedAt": "2026-07-24T14:09:05.682Z", + "_meta": { + "messageCount": 0, + "size": 1272621 + } + }, + { + "sessionId": "019f9467-9215-7000-ba0f-fbdd416888ca", + "cwd": "/Users/solojiang/workspace/weft", + "updatedAt": "2026-07-24T13:54:12.947Z", + "_meta": { + "messageCount": 4, + "size": 4688 + } + }, + { + "sessionId": "019f9466-d4a1-7000-b6fa-e9aad1168f5c", + "cwd": "/Users/solojiang/workspace/weft", + "updatedAt": "2026-07-24T13:53:20.785Z", + "_meta": { + "messageCount": 3, + "size": 2382 + } + }, + { + "sessionId": "019f9421-74e4-7000-80bb-a96fa70ab291", + "cwd": "/Users/solojiang/workspace/weft", + "updatedAt": "2026-07-24T12:48:05.064Z", + "_meta": { + "messageCount": 0, + "size": 216456 + } + }, + { + "sessionId": "019f376c-ea5e-7000-bbbb-949d4a1e69de", + "cwd": "/Users/solojiang/workspace/find", + "title": "Complete producer context capsule implementation", + "updatedAt": "2026-07-08T15:24:55.337Z", + "_meta": { + "messageCount": 2, + "size": 10082509 + } + }, + { + "sessionId": "019ee50c-905d-7000-ac48-0c2e27447c3e", + "cwd": "/Users/solojiang/workspace/weft", + "title": "Review Installation Guide", + "updatedAt": "2026-06-20T12:47:14.030Z", + "_meta": { + "messageCount": 2, + "size": 72275 + } + }, + { + "sessionId": "019ee094-15fc-7000-8047-0f66ce49c257", + "cwd": "/Users/solojiang/workspace/weft", + "title": "Integrate AI SDK File Tree Component", + "updatedAt": "2026-06-20T11:14:59.288Z", + "_meta": { + "messageCount": 2, + "size": 3043844 + } + }, + { + "sessionId": "019ed63e-78fc-7000-8c90-102855367a9b", + "cwd": "/Users/solojiang/workspace/redditfind", + "updatedAt": "2026-06-17T15:41:52.767Z", + "_meta": { + "messageCount": 2, + "size": 36254 + } + }, + { + "sessionId": "019ebea3-ec84-7000-a146-34da24c03581", + "cwd": "/Users/solojiang/workspace/weft/.worktrees", + "title": "Configure Tauri package signing", + "updatedAt": "2026-06-13T01:46:25.856Z", + "_meta": { + "messageCount": 2, + "size": 173262 + } + }, + { + "sessionId": "019ebe91-bc09-7000-9479-601a7712345c", + "cwd": "/Users/solojiang/workspace/weft/.worktrees", + "title": "Improve README skills management", + "updatedAt": "2026-06-13T01:24:23.228Z", + "_meta": { + "messageCount": 6, + "size": 5340667 + } + }, + { + "sessionId": "019ebc16-6a24-7000-98cd-dc952ab6804a", + "cwd": "/Users/solojiang/workspace/weft", + "title": "Improve README skills management", + "updatedAt": "2026-06-13T01:13:44.289Z", + "_meta": { + "messageCount": 6, + "size": 5548906 + } + }, + { + "sessionId": "019eb93a-9419-7000-aee4-c50a44f7995e", + "cwd": "/Users/solojiang/workspace/weft", + "title": "Review open-design CLI integration", + "updatedAt": "2026-06-12T00:54:02.117Z", + "_meta": { + "messageCount": 2, + "size": 1099011 + } + }, + { + "sessionId": "019eb791-272a-7000-b3fe-35f30dc9f99e", + "cwd": "/Users/solojiang/workspace/weft", + "title": "Check oh my pi updates", + "updatedAt": "2026-06-11T17:28:45.928Z", + "_meta": { + "messageCount": 2, + "size": 941256 + } + }, + { + "sessionId": "019e7718-f91f-7000-a629-d343998aab20", + "cwd": "/Users/solojiang/workspace/find", + "updatedAt": "2026-05-30T09:54:13.925Z", + "_meta": { + "messageCount": 4, + "size": 3895607 + } + }, + { + "sessionId": "019e7742-ce3c-7000-892e-63fee16b1b32", + "cwd": "/Users/solojiang/workspace/find", + "title": "Google Workspace CLI \u8ba4\u8bc1\u51ed\u8bc1", + "updatedAt": "2026-05-30T05:45:40.153Z", + "_meta": { + "messageCount": 2, + "size": 1519086 + } + }, + { + "sessionId": "019e772c-6651-7000-9be8-9d99aae96fe2", + "cwd": "/Users/solojiang/Downloads", + "title": "Alist Global Command Design", + "updatedAt": "2026-05-30T04:55:24.804Z", + "_meta": { + "messageCount": 2, + "size": 876520 + } + } + ] + } +} \ No newline at end of file diff --git a/src-tauri/tests/fixtures/acp/session-new-http-result.json b/src-tauri/tests/fixtures/acp/session-new-http-result.json new file mode 100644 index 00000000..3cbbebdd --- /dev/null +++ b/src-tauri/tests/fixtures/acp/session-new-http-result.json @@ -0,0 +1,530 @@ +{ + "jsonrpc": "2.0", + "id": 3, + "result": { + "sessionId": "019f9475-66b5-7000-b7cc-42599dab7d40", + "configOptions": [ + { + "id": "mode", + "name": "Mode", + "category": "mode", + "type": "select", + "currentValue": "default", + "options": [ + { + "value": "default", + "name": "Default", + "description": "Standard ACP headless mode" + }, + { + "value": "plan", + "name": "Plan", + "description": "Read-only planning mode that drafts a plan to a markdown file before any code changes" + } + ] + }, + { + "id": "model", + "name": "Model", + "category": "model", + "type": "select", + "currentValue": "xai-oauth/grok-4.5", + "options": [ + { + "value": "github-copilot/claude-fable-5", + "name": "Claude Fable 5", + "description": "github-copilot/claude-fable-5" + }, + { + "value": "github-copilot/claude-haiku-4.5", + "name": "Claude Haiku 4.5", + "description": "github-copilot/claude-haiku-4.5" + }, + { + "value": "github-copilot/claude-opus-4.5", + "name": "Claude Opus 4.5", + "description": "github-copilot/claude-opus-4.5" + }, + { + "value": "github-copilot/claude-opus-4.6", + "name": "Claude Opus 4.6", + "description": "github-copilot/claude-opus-4.6" + }, + { + "value": "github-copilot/claude-opus-4.7", + "name": "Claude Opus 4.7", + "description": "github-copilot/claude-opus-4.7" + }, + { + "value": "github-copilot/claude-opus-4.8", + "name": "Claude Opus 4.8", + "description": "github-copilot/claude-opus-4.8" + }, + { + "value": "github-copilot/claude-sonnet-4", + "name": "Claude Sonnet 4", + "description": "github-copilot/claude-sonnet-4" + }, + { + "value": "github-copilot/claude-sonnet-4.5", + "name": "Claude Sonnet 4.5", + "description": "github-copilot/claude-sonnet-4.5" + }, + { + "value": "github-copilot/claude-sonnet-4.6", + "name": "Claude Sonnet 4.6", + "description": "github-copilot/claude-sonnet-4.6" + }, + { + "value": "github-copilot/claude-sonnet-5", + "name": "Claude Sonnet 5", + "description": "github-copilot/claude-sonnet-5" + }, + { + "value": "github-copilot/gemini-2.5-pro", + "name": "Gemini 2.5 Pro", + "description": "github-copilot/gemini-2.5-pro" + }, + { + "value": "github-copilot/gemini-3-flash-preview", + "name": "Gemini 3 Flash (Preview)", + "description": "github-copilot/gemini-3-flash-preview" + }, + { + "value": "github-copilot/gemini-3-pro-preview", + "name": "Gemini 3 Pro Preview", + "description": "github-copilot/gemini-3-pro-preview" + }, + { + "value": "github-copilot/gemini-3.1-pro-preview", + "name": "Gemini 3.1 Pro", + "description": "github-copilot/gemini-3.1-pro-preview" + }, + { + "value": "github-copilot/gemini-3.5-flash", + "name": "Gemini 3.5 Flash", + "description": "github-copilot/gemini-3.5-flash" + }, + { + "value": "github-copilot/gpt-4.1", + "name": "GPT-4.1", + "description": "github-copilot/gpt-4.1" + }, + { + "value": "github-copilot/gpt-4o", + "name": "GPT-4o", + "description": "github-copilot/gpt-4o" + }, + { + "value": "github-copilot/gpt-5", + "name": "GPT-5", + "description": "github-copilot/gpt-5" + }, + { + "value": "github-copilot/gpt-5-mini", + "name": "GPT-5 mini", + "description": "github-copilot/gpt-5-mini" + }, + { + "value": "github-copilot/gpt-5.1", + "name": "GPT-5.1", + "description": "github-copilot/gpt-5.1" + }, + { + "value": "github-copilot/gpt-5.1-codex", + "name": "GPT-5.1 Codex", + "description": "github-copilot/gpt-5.1-codex" + }, + { + "value": "github-copilot/gpt-5.1-codex-max", + "name": "GPT-5.1 Codex Max", + "description": "github-copilot/gpt-5.1-codex-max" + }, + { + "value": "github-copilot/gpt-5.1-codex-mini", + "name": "GPT-5.1 Codex mini", + "description": "github-copilot/gpt-5.1-codex-mini" + }, + { + "value": "github-copilot/gpt-5.2", + "name": "GPT-5.2", + "description": "github-copilot/gpt-5.2" + }, + { + "value": "github-copilot/gpt-5.2-codex", + "name": "GPT-5.2 Codex", + "description": "github-copilot/gpt-5.2-codex" + }, + { + "value": "github-copilot/gpt-5.3-codex", + "name": "GPT-5.3-Codex", + "description": "github-copilot/gpt-5.3-codex" + }, + { + "value": "github-copilot/gpt-5.4", + "name": "GPT-5.4", + "description": "github-copilot/gpt-5.4" + }, + { + "value": "github-copilot/gpt-5.4-mini", + "name": "GPT-5.4 mini", + "description": "github-copilot/gpt-5.4-mini" + }, + { + "value": "github-copilot/gpt-5.4-nano", + "name": "GPT-5.4 nano", + "description": "github-copilot/gpt-5.4-nano" + }, + { + "value": "github-copilot/gpt-5.5", + "name": "GPT-5.5", + "description": "github-copilot/gpt-5.5" + }, + { + "value": "github-copilot/gpt-5.6-luna", + "name": "GPT-5.6 Luna", + "description": "github-copilot/gpt-5.6-luna" + }, + { + "value": "github-copilot/gpt-5.6-sol", + "name": "GPT-5.6 Sol", + "description": "github-copilot/gpt-5.6-sol" + }, + { + "value": "github-copilot/gpt-5.6-terra", + "name": "GPT-5.6 Terra", + "description": "github-copilot/gpt-5.6-terra" + }, + { + "value": "github-copilot/grok-code-fast-1", + "name": "Grok Code Fast 1", + "description": "github-copilot/grok-code-fast-1" + }, + { + "value": "github-copilot/kimi-k2.7-code", + "name": "Kimi K2.7 Code", + "description": "github-copilot/kimi-k2.7-code" + }, + { + "value": "github-copilot/mai-code-1-flash-picker", + "name": "MAI-Code-1-Flash", + "description": "github-copilot/mai-code-1-flash-picker" + }, + { + "value": "github-copilot/raptor-mini", + "name": "Raptor mini", + "description": "github-copilot/raptor-mini" + }, + { + "value": "github-copilot/claude-fable-5-1m", + "name": "Claude Fable 5 (1M)", + "description": "github-copilot/claude-fable-5-1m" + }, + { + "value": "github-copilot/claude-opus-4.7-1m", + "name": "Claude Opus 4.7 (1M)", + "description": "github-copilot/claude-opus-4.7-1m" + }, + { + "value": "github-copilot/claude-opus-4.8-1m", + "name": "Claude Opus 4.8 (1M)", + "description": "github-copilot/claude-opus-4.8-1m" + }, + { + "value": "github-copilot/claude-sonnet-4.6-1m", + "name": "Claude Sonnet 4.6 (1M)", + "description": "github-copilot/claude-sonnet-4.6-1m" + }, + { + "value": "github-copilot/claude-sonnet-5-1m", + "name": "Claude Sonnet 5 (1M)", + "description": "github-copilot/claude-sonnet-5-1m" + }, + { + "value": "github-copilot/gemini-3.1-pro-preview-1m", + "name": "Gemini 3.1 Pro (1M)", + "description": "github-copilot/gemini-3.1-pro-preview-1m" + }, + { + "value": "github-copilot/gemini-3.5-flash-1m", + "name": "Gemini 3.5 Flash (1M)", + "description": "github-copilot/gemini-3.5-flash-1m" + }, + { + "value": "github-copilot/gpt-5.4-1m", + "name": "GPT-5.4 (1M)", + "description": "github-copilot/gpt-5.4-1m" + }, + { + "value": "github-copilot/gpt-5.5-1m", + "name": "GPT-5.5 (1M)", + "description": "github-copilot/gpt-5.5-1m" + }, + { + "value": "github-copilot/gpt-5.6-luna-1m", + "name": "GPT-5.6 Luna (1M)", + "description": "github-copilot/gpt-5.6-luna-1m" + }, + { + "value": "github-copilot/gpt-5.6-sol-1m", + "name": "GPT-5.6 Sol (1M)", + "description": "github-copilot/gpt-5.6-sol-1m" + }, + { + "value": "github-copilot/gpt-5.6-terra-1m", + "name": "GPT-5.6 Terra (1M)", + "description": "github-copilot/gpt-5.6-terra-1m" + }, + { + "value": "google-antigravity/claude-opus-4-5", + "name": "Claude Opus 4.5", + "description": "google-antigravity/claude-opus-4-5" + }, + { + "value": "google-antigravity/claude-opus-4-6", + "name": "Claude Opus 4.6", + "description": "google-antigravity/claude-opus-4-6" + }, + { + "value": "google-antigravity/claude-sonnet-4-5", + "name": "Claude Sonnet 4.5", + "description": "google-antigravity/claude-sonnet-4-5" + }, + { + "value": "google-antigravity/claude-sonnet-4-6", + "name": "Claude Sonnet 4.6", + "description": "google-antigravity/claude-sonnet-4-6" + }, + { + "value": "google-antigravity/gemini-2.5-flash", + "name": "Gemini 2.5 Flash", + "description": "google-antigravity/gemini-2.5-flash" + }, + { + "value": "google-antigravity/gemini-2.5-flash-lite", + "name": "Gemini 3.1 Flash Lite", + "description": "google-antigravity/gemini-2.5-flash-lite" + }, + { + "value": "google-antigravity/gemini-2.5-pro", + "name": "Gemini 2.5 Pro", + "description": "google-antigravity/gemini-2.5-pro" + }, + { + "value": "google-antigravity/gemini-3-flash", + "name": "Gemini 3 Flash", + "description": "google-antigravity/gemini-3-flash" + }, + { + "value": "google-antigravity/gemini-3-pro", + "name": "Gemini 3 Pro", + "description": "google-antigravity/gemini-3-pro" + }, + { + "value": "google-antigravity/gemini-3.1-flash-image", + "name": "Gemini 3.1 Flash Image", + "description": "google-antigravity/gemini-3.1-flash-image" + }, + { + "value": "google-antigravity/gemini-3.1-flash-lite", + "name": "Gemini 3.1 Flash Lite", + "description": "google-antigravity/gemini-3.1-flash-lite" + }, + { + "value": "google-antigravity/gemini-3.1-pro", + "name": "Gemini 3.1 Pro", + "description": "google-antigravity/gemini-3.1-pro" + }, + { + "value": "google-antigravity/gemini-3.5-flash", + "name": "Gemini 3.5 Flash", + "description": "google-antigravity/gemini-3.5-flash" + }, + { + "value": "google-antigravity/gemini-3.6-flash", + "name": "Gemini 3.6 Flash", + "description": "google-antigravity/gemini-3.6-flash" + }, + { + "value": "google-antigravity/gpt-oss-120b", + "name": "GPT-OSS 120B", + "description": "google-antigravity/gpt-oss-120b" + }, + { + "value": "google-antigravity/tab_flash_lite_preview", + "name": "tab_flash_lite_preview", + "description": "google-antigravity/tab_flash_lite_preview" + }, + { + "value": "google-antigravity/tab_jump_flash_lite_preview", + "name": "tab_jump_flash_lite_preview", + "description": "google-antigravity/tab_jump_flash_lite_preview" + }, + { + "value": "kimi-code/k3", + "name": "K3", + "description": "kimi-code/k3" + }, + { + "value": "kimi-code/kimi-for-coding", + "name": "K2.7 Coding", + "description": "kimi-code/kimi-for-coding" + }, + { + "value": "kimi-code/kimi-for-coding-highspeed", + "name": "K2.7 Coding Highspeed", + "description": "kimi-code/kimi-for-coding-highspeed" + }, + { + "value": "kimi-code/kimi-k2", + "name": "Kimi K2", + "description": "kimi-code/kimi-k2" + }, + { + "value": "kimi-code/kimi-k2-turbo-preview", + "name": "Kimi K2 Turbo", + "description": "kimi-code/kimi-k2-turbo-preview" + }, + { + "value": "kimi-code/kimi-k2.5", + "name": "Kimi K2.5", + "description": "kimi-code/kimi-k2.5" + }, + { + "value": "kimi-code/k3-256k", + "name": "k3-256k", + "description": "kimi-code/k3-256k" + }, + { + "value": "openai-codex/gpt-5.3-codex-spark", + "name": "GPT-5.3-Codex-Spark", + "description": "openai-codex/gpt-5.3-codex-spark" + }, + { + "value": "openai-codex/gpt-5.4", + "name": "GPT-5.4", + "description": "openai-codex/gpt-5.4" + }, + { + "value": "openai-codex/gpt-5.4-mini", + "name": "GPT-5.4-Mini", + "description": "openai-codex/gpt-5.4-mini" + }, + { + "value": "openai-codex/gpt-5.5", + "name": "GPT-5.5", + "description": "openai-codex/gpt-5.5" + }, + { + "value": "openai-codex/gpt-5.6-luna", + "name": "GPT-5.6-Luna", + "description": "openai-codex/gpt-5.6-luna" + }, + { + "value": "openai-codex/gpt-5.6-sol", + "name": "GPT-5.6-Sol", + "description": "openai-codex/gpt-5.6-sol" + }, + { + "value": "openai-codex/gpt-5.6-terra", + "name": "GPT-5.6-Terra", + "description": "openai-codex/gpt-5.6-terra" + }, + { + "value": "xai-oauth/grok-4.20-0309-non-reasoning", + "name": "Grok 4.20 (Non-Reasoning)", + "description": "xai-oauth/grok-4.20-0309-non-reasoning" + }, + { + "value": "xai-oauth/grok-4.20-0309-reasoning", + "name": "Grok 4.20 (Reasoning)", + "description": "xai-oauth/grok-4.20-0309-reasoning" + }, + { + "value": "xai-oauth/grok-4.20-multi-agent-0309", + "name": "Grok 4.20 (Multi-Agent)", + "description": "xai-oauth/grok-4.20-multi-agent-0309" + }, + { + "value": "xai-oauth/grok-4.3", + "name": "Grok 4.3", + "description": "xai-oauth/grok-4.3" + }, + { + "value": "xai-oauth/grok-4.5", + "name": "Grok 4.5", + "description": "xai-oauth/grok-4.5" + }, + { + "value": "xai-oauth/grok-build", + "name": "Grok Build", + "description": "xai-oauth/grok-build" + }, + { + "value": "xai-oauth/grok-build-0.1", + "name": "Grok Build 0.1", + "description": "xai-oauth/grok-build-0.1" + }, + { + "value": "xai-oauth/grok-composer-2.5-fast", + "name": "Grok Composer 2.5 Fast", + "description": "xai-oauth/grok-composer-2.5-fast" + } + ] + }, + { + "id": "thinking", + "name": "Thinking", + "category": "thought_level", + "type": "select", + "currentValue": "xhigh", + "options": [ + { + "value": "off", + "name": "Off" + }, + { + "value": "auto", + "name": "Auto", + "description": "Auto-detect per prompt (low\u2013xhigh)" + }, + { + "value": "minimal", + "name": "minimal" + }, + { + "value": "low", + "name": "low" + }, + { + "value": "medium", + "name": "medium" + }, + { + "value": "high", + "name": "high" + }, + { + "value": "xhigh", + "name": "xhigh" + } + ] + } + ], + "modes": { + "availableModes": [ + { + "id": "default", + "name": "Default", + "description": "Standard ACP headless mode" + }, + { + "id": "plan", + "name": "Plan", + "description": "Read-only planning mode that drafts a plan to a markdown file before any code changes" + } + ], + "currentModeId": "default" + } + } +} \ No newline at end of file diff --git a/src-tauri/tests/fixtures/acp/session-new-result.json b/src-tauri/tests/fixtures/acp/session-new-result.json new file mode 100644 index 00000000..4fb949c6 --- /dev/null +++ b/src-tauri/tests/fixtures/acp/session-new-result.json @@ -0,0 +1,530 @@ +{ + "jsonrpc": "2.0", + "id": 2, + "result": { + "sessionId": "019f9475-63bb-7000-bff7-58073a1d26e9", + "configOptions": [ + { + "id": "mode", + "name": "Mode", + "category": "mode", + "type": "select", + "currentValue": "default", + "options": [ + { + "value": "default", + "name": "Default", + "description": "Standard ACP headless mode" + }, + { + "value": "plan", + "name": "Plan", + "description": "Read-only planning mode that drafts a plan to a markdown file before any code changes" + } + ] + }, + { + "id": "model", + "name": "Model", + "category": "model", + "type": "select", + "currentValue": "xai-oauth/grok-4.5", + "options": [ + { + "value": "github-copilot/claude-fable-5", + "name": "Claude Fable 5", + "description": "github-copilot/claude-fable-5" + }, + { + "value": "github-copilot/claude-haiku-4.5", + "name": "Claude Haiku 4.5", + "description": "github-copilot/claude-haiku-4.5" + }, + { + "value": "github-copilot/claude-opus-4.5", + "name": "Claude Opus 4.5", + "description": "github-copilot/claude-opus-4.5" + }, + { + "value": "github-copilot/claude-opus-4.6", + "name": "Claude Opus 4.6", + "description": "github-copilot/claude-opus-4.6" + }, + { + "value": "github-copilot/claude-opus-4.7", + "name": "Claude Opus 4.7", + "description": "github-copilot/claude-opus-4.7" + }, + { + "value": "github-copilot/claude-opus-4.8", + "name": "Claude Opus 4.8", + "description": "github-copilot/claude-opus-4.8" + }, + { + "value": "github-copilot/claude-sonnet-4", + "name": "Claude Sonnet 4", + "description": "github-copilot/claude-sonnet-4" + }, + { + "value": "github-copilot/claude-sonnet-4.5", + "name": "Claude Sonnet 4.5", + "description": "github-copilot/claude-sonnet-4.5" + }, + { + "value": "github-copilot/claude-sonnet-4.6", + "name": "Claude Sonnet 4.6", + "description": "github-copilot/claude-sonnet-4.6" + }, + { + "value": "github-copilot/claude-sonnet-5", + "name": "Claude Sonnet 5", + "description": "github-copilot/claude-sonnet-5" + }, + { + "value": "github-copilot/gemini-2.5-pro", + "name": "Gemini 2.5 Pro", + "description": "github-copilot/gemini-2.5-pro" + }, + { + "value": "github-copilot/gemini-3-flash-preview", + "name": "Gemini 3 Flash (Preview)", + "description": "github-copilot/gemini-3-flash-preview" + }, + { + "value": "github-copilot/gemini-3-pro-preview", + "name": "Gemini 3 Pro Preview", + "description": "github-copilot/gemini-3-pro-preview" + }, + { + "value": "github-copilot/gemini-3.1-pro-preview", + "name": "Gemini 3.1 Pro", + "description": "github-copilot/gemini-3.1-pro-preview" + }, + { + "value": "github-copilot/gemini-3.5-flash", + "name": "Gemini 3.5 Flash", + "description": "github-copilot/gemini-3.5-flash" + }, + { + "value": "github-copilot/gpt-4.1", + "name": "GPT-4.1", + "description": "github-copilot/gpt-4.1" + }, + { + "value": "github-copilot/gpt-4o", + "name": "GPT-4o", + "description": "github-copilot/gpt-4o" + }, + { + "value": "github-copilot/gpt-5", + "name": "GPT-5", + "description": "github-copilot/gpt-5" + }, + { + "value": "github-copilot/gpt-5-mini", + "name": "GPT-5 mini", + "description": "github-copilot/gpt-5-mini" + }, + { + "value": "github-copilot/gpt-5.1", + "name": "GPT-5.1", + "description": "github-copilot/gpt-5.1" + }, + { + "value": "github-copilot/gpt-5.1-codex", + "name": "GPT-5.1 Codex", + "description": "github-copilot/gpt-5.1-codex" + }, + { + "value": "github-copilot/gpt-5.1-codex-max", + "name": "GPT-5.1 Codex Max", + "description": "github-copilot/gpt-5.1-codex-max" + }, + { + "value": "github-copilot/gpt-5.1-codex-mini", + "name": "GPT-5.1 Codex mini", + "description": "github-copilot/gpt-5.1-codex-mini" + }, + { + "value": "github-copilot/gpt-5.2", + "name": "GPT-5.2", + "description": "github-copilot/gpt-5.2" + }, + { + "value": "github-copilot/gpt-5.2-codex", + "name": "GPT-5.2 Codex", + "description": "github-copilot/gpt-5.2-codex" + }, + { + "value": "github-copilot/gpt-5.3-codex", + "name": "GPT-5.3-Codex", + "description": "github-copilot/gpt-5.3-codex" + }, + { + "value": "github-copilot/gpt-5.4", + "name": "GPT-5.4", + "description": "github-copilot/gpt-5.4" + }, + { + "value": "github-copilot/gpt-5.4-mini", + "name": "GPT-5.4 mini", + "description": "github-copilot/gpt-5.4-mini" + }, + { + "value": "github-copilot/gpt-5.4-nano", + "name": "GPT-5.4 nano", + "description": "github-copilot/gpt-5.4-nano" + }, + { + "value": "github-copilot/gpt-5.5", + "name": "GPT-5.5", + "description": "github-copilot/gpt-5.5" + }, + { + "value": "github-copilot/gpt-5.6-luna", + "name": "GPT-5.6 Luna", + "description": "github-copilot/gpt-5.6-luna" + }, + { + "value": "github-copilot/gpt-5.6-sol", + "name": "GPT-5.6 Sol", + "description": "github-copilot/gpt-5.6-sol" + }, + { + "value": "github-copilot/gpt-5.6-terra", + "name": "GPT-5.6 Terra", + "description": "github-copilot/gpt-5.6-terra" + }, + { + "value": "github-copilot/grok-code-fast-1", + "name": "Grok Code Fast 1", + "description": "github-copilot/grok-code-fast-1" + }, + { + "value": "github-copilot/kimi-k2.7-code", + "name": "Kimi K2.7 Code", + "description": "github-copilot/kimi-k2.7-code" + }, + { + "value": "github-copilot/mai-code-1-flash-picker", + "name": "MAI-Code-1-Flash", + "description": "github-copilot/mai-code-1-flash-picker" + }, + { + "value": "github-copilot/raptor-mini", + "name": "Raptor mini", + "description": "github-copilot/raptor-mini" + }, + { + "value": "github-copilot/claude-fable-5-1m", + "name": "Claude Fable 5 (1M)", + "description": "github-copilot/claude-fable-5-1m" + }, + { + "value": "github-copilot/claude-opus-4.7-1m", + "name": "Claude Opus 4.7 (1M)", + "description": "github-copilot/claude-opus-4.7-1m" + }, + { + "value": "github-copilot/claude-opus-4.8-1m", + "name": "Claude Opus 4.8 (1M)", + "description": "github-copilot/claude-opus-4.8-1m" + }, + { + "value": "github-copilot/claude-sonnet-4.6-1m", + "name": "Claude Sonnet 4.6 (1M)", + "description": "github-copilot/claude-sonnet-4.6-1m" + }, + { + "value": "github-copilot/claude-sonnet-5-1m", + "name": "Claude Sonnet 5 (1M)", + "description": "github-copilot/claude-sonnet-5-1m" + }, + { + "value": "github-copilot/gemini-3.1-pro-preview-1m", + "name": "Gemini 3.1 Pro (1M)", + "description": "github-copilot/gemini-3.1-pro-preview-1m" + }, + { + "value": "github-copilot/gemini-3.5-flash-1m", + "name": "Gemini 3.5 Flash (1M)", + "description": "github-copilot/gemini-3.5-flash-1m" + }, + { + "value": "github-copilot/gpt-5.4-1m", + "name": "GPT-5.4 (1M)", + "description": "github-copilot/gpt-5.4-1m" + }, + { + "value": "github-copilot/gpt-5.5-1m", + "name": "GPT-5.5 (1M)", + "description": "github-copilot/gpt-5.5-1m" + }, + { + "value": "github-copilot/gpt-5.6-luna-1m", + "name": "GPT-5.6 Luna (1M)", + "description": "github-copilot/gpt-5.6-luna-1m" + }, + { + "value": "github-copilot/gpt-5.6-sol-1m", + "name": "GPT-5.6 Sol (1M)", + "description": "github-copilot/gpt-5.6-sol-1m" + }, + { + "value": "github-copilot/gpt-5.6-terra-1m", + "name": "GPT-5.6 Terra (1M)", + "description": "github-copilot/gpt-5.6-terra-1m" + }, + { + "value": "google-antigravity/claude-opus-4-5", + "name": "Claude Opus 4.5", + "description": "google-antigravity/claude-opus-4-5" + }, + { + "value": "google-antigravity/claude-opus-4-6", + "name": "Claude Opus 4.6", + "description": "google-antigravity/claude-opus-4-6" + }, + { + "value": "google-antigravity/claude-sonnet-4-5", + "name": "Claude Sonnet 4.5", + "description": "google-antigravity/claude-sonnet-4-5" + }, + { + "value": "google-antigravity/claude-sonnet-4-6", + "name": "Claude Sonnet 4.6", + "description": "google-antigravity/claude-sonnet-4-6" + }, + { + "value": "google-antigravity/gemini-2.5-flash", + "name": "Gemini 2.5 Flash", + "description": "google-antigravity/gemini-2.5-flash" + }, + { + "value": "google-antigravity/gemini-2.5-flash-lite", + "name": "Gemini 3.1 Flash Lite", + "description": "google-antigravity/gemini-2.5-flash-lite" + }, + { + "value": "google-antigravity/gemini-2.5-pro", + "name": "Gemini 2.5 Pro", + "description": "google-antigravity/gemini-2.5-pro" + }, + { + "value": "google-antigravity/gemini-3-flash", + "name": "Gemini 3 Flash", + "description": "google-antigravity/gemini-3-flash" + }, + { + "value": "google-antigravity/gemini-3-pro", + "name": "Gemini 3 Pro", + "description": "google-antigravity/gemini-3-pro" + }, + { + "value": "google-antigravity/gemini-3.1-flash-image", + "name": "Gemini 3.1 Flash Image", + "description": "google-antigravity/gemini-3.1-flash-image" + }, + { + "value": "google-antigravity/gemini-3.1-flash-lite", + "name": "Gemini 3.1 Flash Lite", + "description": "google-antigravity/gemini-3.1-flash-lite" + }, + { + "value": "google-antigravity/gemini-3.1-pro", + "name": "Gemini 3.1 Pro", + "description": "google-antigravity/gemini-3.1-pro" + }, + { + "value": "google-antigravity/gemini-3.5-flash", + "name": "Gemini 3.5 Flash", + "description": "google-antigravity/gemini-3.5-flash" + }, + { + "value": "google-antigravity/gemini-3.6-flash", + "name": "Gemini 3.6 Flash", + "description": "google-antigravity/gemini-3.6-flash" + }, + { + "value": "google-antigravity/gpt-oss-120b", + "name": "GPT-OSS 120B", + "description": "google-antigravity/gpt-oss-120b" + }, + { + "value": "google-antigravity/tab_flash_lite_preview", + "name": "tab_flash_lite_preview", + "description": "google-antigravity/tab_flash_lite_preview" + }, + { + "value": "google-antigravity/tab_jump_flash_lite_preview", + "name": "tab_jump_flash_lite_preview", + "description": "google-antigravity/tab_jump_flash_lite_preview" + }, + { + "value": "kimi-code/k3", + "name": "K3", + "description": "kimi-code/k3" + }, + { + "value": "kimi-code/kimi-for-coding", + "name": "K2.7 Coding", + "description": "kimi-code/kimi-for-coding" + }, + { + "value": "kimi-code/kimi-for-coding-highspeed", + "name": "K2.7 Coding Highspeed", + "description": "kimi-code/kimi-for-coding-highspeed" + }, + { + "value": "kimi-code/kimi-k2", + "name": "Kimi K2", + "description": "kimi-code/kimi-k2" + }, + { + "value": "kimi-code/kimi-k2-turbo-preview", + "name": "Kimi K2 Turbo", + "description": "kimi-code/kimi-k2-turbo-preview" + }, + { + "value": "kimi-code/kimi-k2.5", + "name": "Kimi K2.5", + "description": "kimi-code/kimi-k2.5" + }, + { + "value": "kimi-code/k3-256k", + "name": "k3-256k", + "description": "kimi-code/k3-256k" + }, + { + "value": "openai-codex/gpt-5.3-codex-spark", + "name": "GPT-5.3-Codex-Spark", + "description": "openai-codex/gpt-5.3-codex-spark" + }, + { + "value": "openai-codex/gpt-5.4", + "name": "GPT-5.4", + "description": "openai-codex/gpt-5.4" + }, + { + "value": "openai-codex/gpt-5.4-mini", + "name": "GPT-5.4-Mini", + "description": "openai-codex/gpt-5.4-mini" + }, + { + "value": "openai-codex/gpt-5.5", + "name": "GPT-5.5", + "description": "openai-codex/gpt-5.5" + }, + { + "value": "openai-codex/gpt-5.6-luna", + "name": "GPT-5.6-Luna", + "description": "openai-codex/gpt-5.6-luna" + }, + { + "value": "openai-codex/gpt-5.6-sol", + "name": "GPT-5.6-Sol", + "description": "openai-codex/gpt-5.6-sol" + }, + { + "value": "openai-codex/gpt-5.6-terra", + "name": "GPT-5.6-Terra", + "description": "openai-codex/gpt-5.6-terra" + }, + { + "value": "xai-oauth/grok-4.20-0309-non-reasoning", + "name": "Grok 4.20 (Non-Reasoning)", + "description": "xai-oauth/grok-4.20-0309-non-reasoning" + }, + { + "value": "xai-oauth/grok-4.20-0309-reasoning", + "name": "Grok 4.20 (Reasoning)", + "description": "xai-oauth/grok-4.20-0309-reasoning" + }, + { + "value": "xai-oauth/grok-4.20-multi-agent-0309", + "name": "Grok 4.20 (Multi-Agent)", + "description": "xai-oauth/grok-4.20-multi-agent-0309" + }, + { + "value": "xai-oauth/grok-4.3", + "name": "Grok 4.3", + "description": "xai-oauth/grok-4.3" + }, + { + "value": "xai-oauth/grok-4.5", + "name": "Grok 4.5", + "description": "xai-oauth/grok-4.5" + }, + { + "value": "xai-oauth/grok-build", + "name": "Grok Build", + "description": "xai-oauth/grok-build" + }, + { + "value": "xai-oauth/grok-build-0.1", + "name": "Grok Build 0.1", + "description": "xai-oauth/grok-build-0.1" + }, + { + "value": "xai-oauth/grok-composer-2.5-fast", + "name": "Grok Composer 2.5 Fast", + "description": "xai-oauth/grok-composer-2.5-fast" + } + ] + }, + { + "id": "thinking", + "name": "Thinking", + "category": "thought_level", + "type": "select", + "currentValue": "xhigh", + "options": [ + { + "value": "off", + "name": "Off" + }, + { + "value": "auto", + "name": "Auto", + "description": "Auto-detect per prompt (low\u2013xhigh)" + }, + { + "value": "minimal", + "name": "minimal" + }, + { + "value": "low", + "name": "low" + }, + { + "value": "medium", + "name": "medium" + }, + { + "value": "high", + "name": "high" + }, + { + "value": "xhigh", + "name": "xhigh" + } + ] + } + ], + "modes": { + "availableModes": [ + { + "id": "default", + "name": "Default", + "description": "Standard ACP headless mode" + }, + { + "id": "plan", + "name": "Plan", + "description": "Read-only planning mode that drafts a plan to a markdown file before any code changes" + } + ], + "currentModeId": "default" + } + } +} \ No newline at end of file diff --git a/src-tauri/tests/fixtures/acp/session-resume-result.json b/src-tauri/tests/fixtures/acp/session-resume-result.json new file mode 100644 index 00000000..d67a4690 --- /dev/null +++ b/src-tauri/tests/fixtures/acp/session-resume-result.json @@ -0,0 +1,529 @@ +{ + "jsonrpc": "2.0", + "id": 11, + "result": { + "configOptions": [ + { + "id": "mode", + "name": "Mode", + "category": "mode", + "type": "select", + "currentValue": "default", + "options": [ + { + "value": "default", + "name": "Default", + "description": "Standard ACP headless mode" + }, + { + "value": "plan", + "name": "Plan", + "description": "Read-only planning mode that drafts a plan to a markdown file before any code changes" + } + ] + }, + { + "id": "model", + "name": "Model", + "category": "model", + "type": "select", + "currentValue": "xai-oauth/grok-4.5", + "options": [ + { + "value": "github-copilot/claude-fable-5", + "name": "Claude Fable 5", + "description": "github-copilot/claude-fable-5" + }, + { + "value": "github-copilot/claude-haiku-4.5", + "name": "Claude Haiku 4.5", + "description": "github-copilot/claude-haiku-4.5" + }, + { + "value": "github-copilot/claude-opus-4.5", + "name": "Claude Opus 4.5", + "description": "github-copilot/claude-opus-4.5" + }, + { + "value": "github-copilot/claude-opus-4.6", + "name": "Claude Opus 4.6", + "description": "github-copilot/claude-opus-4.6" + }, + { + "value": "github-copilot/claude-opus-4.7", + "name": "Claude Opus 4.7", + "description": "github-copilot/claude-opus-4.7" + }, + { + "value": "github-copilot/claude-opus-4.8", + "name": "Claude Opus 4.8", + "description": "github-copilot/claude-opus-4.8" + }, + { + "value": "github-copilot/claude-sonnet-4", + "name": "Claude Sonnet 4", + "description": "github-copilot/claude-sonnet-4" + }, + { + "value": "github-copilot/claude-sonnet-4.5", + "name": "Claude Sonnet 4.5", + "description": "github-copilot/claude-sonnet-4.5" + }, + { + "value": "github-copilot/claude-sonnet-4.6", + "name": "Claude Sonnet 4.6", + "description": "github-copilot/claude-sonnet-4.6" + }, + { + "value": "github-copilot/claude-sonnet-5", + "name": "Claude Sonnet 5", + "description": "github-copilot/claude-sonnet-5" + }, + { + "value": "github-copilot/gemini-2.5-pro", + "name": "Gemini 2.5 Pro", + "description": "github-copilot/gemini-2.5-pro" + }, + { + "value": "github-copilot/gemini-3-flash-preview", + "name": "Gemini 3 Flash (Preview)", + "description": "github-copilot/gemini-3-flash-preview" + }, + { + "value": "github-copilot/gemini-3-pro-preview", + "name": "Gemini 3 Pro Preview", + "description": "github-copilot/gemini-3-pro-preview" + }, + { + "value": "github-copilot/gemini-3.1-pro-preview", + "name": "Gemini 3.1 Pro", + "description": "github-copilot/gemini-3.1-pro-preview" + }, + { + "value": "github-copilot/gemini-3.5-flash", + "name": "Gemini 3.5 Flash", + "description": "github-copilot/gemini-3.5-flash" + }, + { + "value": "github-copilot/gpt-4.1", + "name": "GPT-4.1", + "description": "github-copilot/gpt-4.1" + }, + { + "value": "github-copilot/gpt-4o", + "name": "GPT-4o", + "description": "github-copilot/gpt-4o" + }, + { + "value": "github-copilot/gpt-5", + "name": "GPT-5", + "description": "github-copilot/gpt-5" + }, + { + "value": "github-copilot/gpt-5-mini", + "name": "GPT-5 mini", + "description": "github-copilot/gpt-5-mini" + }, + { + "value": "github-copilot/gpt-5.1", + "name": "GPT-5.1", + "description": "github-copilot/gpt-5.1" + }, + { + "value": "github-copilot/gpt-5.1-codex", + "name": "GPT-5.1 Codex", + "description": "github-copilot/gpt-5.1-codex" + }, + { + "value": "github-copilot/gpt-5.1-codex-max", + "name": "GPT-5.1 Codex Max", + "description": "github-copilot/gpt-5.1-codex-max" + }, + { + "value": "github-copilot/gpt-5.1-codex-mini", + "name": "GPT-5.1 Codex mini", + "description": "github-copilot/gpt-5.1-codex-mini" + }, + { + "value": "github-copilot/gpt-5.2", + "name": "GPT-5.2", + "description": "github-copilot/gpt-5.2" + }, + { + "value": "github-copilot/gpt-5.2-codex", + "name": "GPT-5.2 Codex", + "description": "github-copilot/gpt-5.2-codex" + }, + { + "value": "github-copilot/gpt-5.3-codex", + "name": "GPT-5.3-Codex", + "description": "github-copilot/gpt-5.3-codex" + }, + { + "value": "github-copilot/gpt-5.4", + "name": "GPT-5.4", + "description": "github-copilot/gpt-5.4" + }, + { + "value": "github-copilot/gpt-5.4-mini", + "name": "GPT-5.4 mini", + "description": "github-copilot/gpt-5.4-mini" + }, + { + "value": "github-copilot/gpt-5.4-nano", + "name": "GPT-5.4 nano", + "description": "github-copilot/gpt-5.4-nano" + }, + { + "value": "github-copilot/gpt-5.5", + "name": "GPT-5.5", + "description": "github-copilot/gpt-5.5" + }, + { + "value": "github-copilot/gpt-5.6-luna", + "name": "GPT-5.6 Luna", + "description": "github-copilot/gpt-5.6-luna" + }, + { + "value": "github-copilot/gpt-5.6-sol", + "name": "GPT-5.6 Sol", + "description": "github-copilot/gpt-5.6-sol" + }, + { + "value": "github-copilot/gpt-5.6-terra", + "name": "GPT-5.6 Terra", + "description": "github-copilot/gpt-5.6-terra" + }, + { + "value": "github-copilot/grok-code-fast-1", + "name": "Grok Code Fast 1", + "description": "github-copilot/grok-code-fast-1" + }, + { + "value": "github-copilot/kimi-k2.7-code", + "name": "Kimi K2.7 Code", + "description": "github-copilot/kimi-k2.7-code" + }, + { + "value": "github-copilot/mai-code-1-flash-picker", + "name": "MAI-Code-1-Flash", + "description": "github-copilot/mai-code-1-flash-picker" + }, + { + "value": "github-copilot/raptor-mini", + "name": "Raptor mini", + "description": "github-copilot/raptor-mini" + }, + { + "value": "github-copilot/claude-fable-5-1m", + "name": "Claude Fable 5 (1M)", + "description": "github-copilot/claude-fable-5-1m" + }, + { + "value": "github-copilot/claude-opus-4.7-1m", + "name": "Claude Opus 4.7 (1M)", + "description": "github-copilot/claude-opus-4.7-1m" + }, + { + "value": "github-copilot/claude-opus-4.8-1m", + "name": "Claude Opus 4.8 (1M)", + "description": "github-copilot/claude-opus-4.8-1m" + }, + { + "value": "github-copilot/claude-sonnet-4.6-1m", + "name": "Claude Sonnet 4.6 (1M)", + "description": "github-copilot/claude-sonnet-4.6-1m" + }, + { + "value": "github-copilot/claude-sonnet-5-1m", + "name": "Claude Sonnet 5 (1M)", + "description": "github-copilot/claude-sonnet-5-1m" + }, + { + "value": "github-copilot/gemini-3.1-pro-preview-1m", + "name": "Gemini 3.1 Pro (1M)", + "description": "github-copilot/gemini-3.1-pro-preview-1m" + }, + { + "value": "github-copilot/gemini-3.5-flash-1m", + "name": "Gemini 3.5 Flash (1M)", + "description": "github-copilot/gemini-3.5-flash-1m" + }, + { + "value": "github-copilot/gpt-5.4-1m", + "name": "GPT-5.4 (1M)", + "description": "github-copilot/gpt-5.4-1m" + }, + { + "value": "github-copilot/gpt-5.5-1m", + "name": "GPT-5.5 (1M)", + "description": "github-copilot/gpt-5.5-1m" + }, + { + "value": "github-copilot/gpt-5.6-luna-1m", + "name": "GPT-5.6 Luna (1M)", + "description": "github-copilot/gpt-5.6-luna-1m" + }, + { + "value": "github-copilot/gpt-5.6-sol-1m", + "name": "GPT-5.6 Sol (1M)", + "description": "github-copilot/gpt-5.6-sol-1m" + }, + { + "value": "github-copilot/gpt-5.6-terra-1m", + "name": "GPT-5.6 Terra (1M)", + "description": "github-copilot/gpt-5.6-terra-1m" + }, + { + "value": "google-antigravity/claude-opus-4-5", + "name": "Claude Opus 4.5", + "description": "google-antigravity/claude-opus-4-5" + }, + { + "value": "google-antigravity/claude-opus-4-6", + "name": "Claude Opus 4.6", + "description": "google-antigravity/claude-opus-4-6" + }, + { + "value": "google-antigravity/claude-sonnet-4-5", + "name": "Claude Sonnet 4.5", + "description": "google-antigravity/claude-sonnet-4-5" + }, + { + "value": "google-antigravity/claude-sonnet-4-6", + "name": "Claude Sonnet 4.6", + "description": "google-antigravity/claude-sonnet-4-6" + }, + { + "value": "google-antigravity/gemini-2.5-flash", + "name": "Gemini 2.5 Flash", + "description": "google-antigravity/gemini-2.5-flash" + }, + { + "value": "google-antigravity/gemini-2.5-flash-lite", + "name": "Gemini 3.1 Flash Lite", + "description": "google-antigravity/gemini-2.5-flash-lite" + }, + { + "value": "google-antigravity/gemini-2.5-pro", + "name": "Gemini 2.5 Pro", + "description": "google-antigravity/gemini-2.5-pro" + }, + { + "value": "google-antigravity/gemini-3-flash", + "name": "Gemini 3 Flash", + "description": "google-antigravity/gemini-3-flash" + }, + { + "value": "google-antigravity/gemini-3-pro", + "name": "Gemini 3 Pro", + "description": "google-antigravity/gemini-3-pro" + }, + { + "value": "google-antigravity/gemini-3.1-flash-image", + "name": "Gemini 3.1 Flash Image", + "description": "google-antigravity/gemini-3.1-flash-image" + }, + { + "value": "google-antigravity/gemini-3.1-flash-lite", + "name": "Gemini 3.1 Flash Lite", + "description": "google-antigravity/gemini-3.1-flash-lite" + }, + { + "value": "google-antigravity/gemini-3.1-pro", + "name": "Gemini 3.1 Pro", + "description": "google-antigravity/gemini-3.1-pro" + }, + { + "value": "google-antigravity/gemini-3.5-flash", + "name": "Gemini 3.5 Flash", + "description": "google-antigravity/gemini-3.5-flash" + }, + { + "value": "google-antigravity/gemini-3.6-flash", + "name": "Gemini 3.6 Flash", + "description": "google-antigravity/gemini-3.6-flash" + }, + { + "value": "google-antigravity/gpt-oss-120b", + "name": "GPT-OSS 120B", + "description": "google-antigravity/gpt-oss-120b" + }, + { + "value": "google-antigravity/tab_flash_lite_preview", + "name": "tab_flash_lite_preview", + "description": "google-antigravity/tab_flash_lite_preview" + }, + { + "value": "google-antigravity/tab_jump_flash_lite_preview", + "name": "tab_jump_flash_lite_preview", + "description": "google-antigravity/tab_jump_flash_lite_preview" + }, + { + "value": "kimi-code/k3", + "name": "K3", + "description": "kimi-code/k3" + }, + { + "value": "kimi-code/kimi-for-coding", + "name": "K2.7 Coding", + "description": "kimi-code/kimi-for-coding" + }, + { + "value": "kimi-code/kimi-for-coding-highspeed", + "name": "K2.7 Coding Highspeed", + "description": "kimi-code/kimi-for-coding-highspeed" + }, + { + "value": "kimi-code/kimi-k2", + "name": "Kimi K2", + "description": "kimi-code/kimi-k2" + }, + { + "value": "kimi-code/kimi-k2-turbo-preview", + "name": "Kimi K2 Turbo", + "description": "kimi-code/kimi-k2-turbo-preview" + }, + { + "value": "kimi-code/kimi-k2.5", + "name": "Kimi K2.5", + "description": "kimi-code/kimi-k2.5" + }, + { + "value": "kimi-code/k3-256k", + "name": "k3-256k", + "description": "kimi-code/k3-256k" + }, + { + "value": "openai-codex/gpt-5.3-codex-spark", + "name": "GPT-5.3-Codex-Spark", + "description": "openai-codex/gpt-5.3-codex-spark" + }, + { + "value": "openai-codex/gpt-5.4", + "name": "GPT-5.4", + "description": "openai-codex/gpt-5.4" + }, + { + "value": "openai-codex/gpt-5.4-mini", + "name": "GPT-5.4-Mini", + "description": "openai-codex/gpt-5.4-mini" + }, + { + "value": "openai-codex/gpt-5.5", + "name": "GPT-5.5", + "description": "openai-codex/gpt-5.5" + }, + { + "value": "openai-codex/gpt-5.6-luna", + "name": "GPT-5.6-Luna", + "description": "openai-codex/gpt-5.6-luna" + }, + { + "value": "openai-codex/gpt-5.6-sol", + "name": "GPT-5.6-Sol", + "description": "openai-codex/gpt-5.6-sol" + }, + { + "value": "openai-codex/gpt-5.6-terra", + "name": "GPT-5.6-Terra", + "description": "openai-codex/gpt-5.6-terra" + }, + { + "value": "xai-oauth/grok-4.20-0309-non-reasoning", + "name": "Grok 4.20 (Non-Reasoning)", + "description": "xai-oauth/grok-4.20-0309-non-reasoning" + }, + { + "value": "xai-oauth/grok-4.20-0309-reasoning", + "name": "Grok 4.20 (Reasoning)", + "description": "xai-oauth/grok-4.20-0309-reasoning" + }, + { + "value": "xai-oauth/grok-4.20-multi-agent-0309", + "name": "Grok 4.20 (Multi-Agent)", + "description": "xai-oauth/grok-4.20-multi-agent-0309" + }, + { + "value": "xai-oauth/grok-4.3", + "name": "Grok 4.3", + "description": "xai-oauth/grok-4.3" + }, + { + "value": "xai-oauth/grok-4.5", + "name": "Grok 4.5", + "description": "xai-oauth/grok-4.5" + }, + { + "value": "xai-oauth/grok-build", + "name": "Grok Build", + "description": "xai-oauth/grok-build" + }, + { + "value": "xai-oauth/grok-build-0.1", + "name": "Grok Build 0.1", + "description": "xai-oauth/grok-build-0.1" + }, + { + "value": "xai-oauth/grok-composer-2.5-fast", + "name": "Grok Composer 2.5 Fast", + "description": "xai-oauth/grok-composer-2.5-fast" + } + ] + }, + { + "id": "thinking", + "name": "Thinking", + "category": "thought_level", + "type": "select", + "currentValue": "xhigh", + "options": [ + { + "value": "off", + "name": "Off" + }, + { + "value": "auto", + "name": "Auto", + "description": "Auto-detect per prompt (low\u2013xhigh)" + }, + { + "value": "minimal", + "name": "minimal" + }, + { + "value": "low", + "name": "low" + }, + { + "value": "medium", + "name": "medium" + }, + { + "value": "high", + "name": "high" + }, + { + "value": "xhigh", + "name": "xhigh" + } + ] + } + ], + "modes": { + "availableModes": [ + { + "id": "default", + "name": "Default", + "description": "Standard ACP headless mode" + }, + { + "id": "plan", + "name": "Plan", + "description": "Read-only planning mode that drafts a plan to a markdown file before any code changes" + } + ], + "currentModeId": "default" + } + } +} \ No newline at end of file diff --git a/src/board/NeedsRows.tsx b/src/board/NeedsRows.tsx index 06ca4e6f..ee34d46a 100644 --- a/src/board/NeedsRows.tsx +++ b/src/board/NeedsRows.tsx @@ -20,6 +20,7 @@ import { ToolIcon, toolFullName } from "../components/ToolIcon"; import { PermissionConfirmationCard } from "../components/ConfirmationCard"; import { routeLabelKey, routeReasonKey, routeToolName } from "../lib/engineRoutingDisplay"; import { needsRoutingControlOf } from "./needsRoutingControl"; +import { noticeTokenKey } from "../lib/noticeTokens"; import { ASK_CARD_TONE, ASK_DOT_TONE, NOTICE_FOOTER_VIEW } from "./needsCardView"; export function WriteTriggerRow({ item }: { item: WriteTrigger }) { @@ -195,6 +196,12 @@ export function PermissionRow({ ask }: { ask: PermissionAsk }) { ); } +function askBodyText(t: TFunction, text: string): string { + const key = noticeTokenKey(text); + if (key) return t(key); + return text; +} + function NoticeFooter({ item, retrying, @@ -295,7 +302,7 @@ export function AskRow({ item }: { item: NeedItem }) { </div> <p className="px-3.5 pb-3 pt-1.5 text-[14px] leading-relaxed text-ink"> - {item.text} + {askBodyText(t, item.text)} </p> {item.kind === "question" ? ( diff --git a/src/board/RepoGraph.tsx b/src/board/RepoGraph.tsx index b7587e95..a4b45152 100644 --- a/src/board/RepoGraph.tsx +++ b/src/board/RepoGraph.tsx @@ -109,6 +109,15 @@ function analysisErrorText( error: string | null | undefined, ): string { if (error === "checkout-missing") return t("repomap.analysisErrorCheckoutMissing"); + // The curator drives agents over exec/app-server argv and refuses an ACP + // engine outright rather than silently substituting a different provider + // (see `curator::curator_exec_tool`). Say which engine, and what to do. + const unsupportedEnginePrefix = "curator_unsupported_engine:"; + if (error?.startsWith(unsupportedEnginePrefix)) { + return t("repomap.analysisErrorUnsupportedEngine", { + tool: error.slice(unsupportedEnginePrefix.length), + }); + } const routeBlockedPrefix = "engine-routing-blocked:"; if (error?.startsWith(routeBlockedPrefix)) { const reason = error.slice(routeBlockedPrefix.length); diff --git a/src/components/ConfirmationCard.tsx b/src/components/ConfirmationCard.tsx index 49c0c336..c824b1ca 100644 --- a/src/components/ConfirmationCard.tsx +++ b/src/components/ConfirmationCard.tsx @@ -369,7 +369,9 @@ export function PermissionConfirmationCard({ className="ml-1.5 font-mono text-[11.5px] text-ink" title={detailTitle} > - {ask.summary} + {ask.summary === "acp.permission_required" + ? t("needs.acpPermissionRequired") + : ask.summary} </span> )} </ConfirmationTitle> @@ -385,7 +387,9 @@ export function PermissionConfirmationCard({ className="truncate font-mono text-[13px] text-ink" title={detailTitle} > - {ask.summary} + {ask.summary === "acp.permission_required" + ? t("needs.acpPermissionRequired") + : ask.summary} </p> )} {isBlockSummary && hasHiddenDetail && <DetailPreview detail={ask.detail} />} diff --git a/src/components/ToolIcon.tsx b/src/components/ToolIcon.tsx index 17b1d38e..703c828d 100644 --- a/src/components/ToolIcon.tsx +++ b/src/components/ToolIcon.tsx @@ -4,19 +4,21 @@ const SRC: Record<string, string> = { claude: "/tools/claude.svg", codex: "/tools/codex.svg", opencode: "/tools/opencode.svg", + omp: "/tools/omp.svg", }; const FULL_NAME: Record<string, string> = { claude: "Claude Code", codex: "Codex", opencode: "OpenCode", + omp: "Oh My Pi", }; export function toolFullName(tool: string) { return FULL_NAME[tool] ?? tool; } -/** The official mark for a coding tool (claude / codex / opencode). */ +/** The official mark for a coding tool (claude / codex / opencode / omp). */ export function ToolIcon({ tool, size = 14, diff --git a/src/i18n/en.ts b/src/i18n/en.ts index 4eb30d55..e00c092e 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -327,6 +327,12 @@ export const en = { blockedToast: "Process capacity is still constrained. Your message was kept; try again after recovery.", }, needs: { + acpPermissionRequired: "Permission required", + // Authoritative copy. `scripts/gen-notice-copy.mjs` generates the + // backend-readable mirror the IM bridge renders from — edit here, run the + // script; `noticeCopy.test.ts` fails if the mirror drifts. + acpForceResetNotice: + "⏹️ The agent did not answer the cancellation after Stop, so the turn was force-interrupted and continues on a brand-new session. The conversation stays in the timeline, but the new session carries no native context — if later replies seem to have \"forgotten\" earlier details, re-state the key points.", title: "Needs you", subtitle: "approvals and questions only you can answer", wantsPermission: "wants permission", @@ -390,6 +396,7 @@ export const en = { toolRunning: "Running", toolSyncing: "Syncing", toolOrganizing: "Organizing", + toolThinking: "Thinking", toolDelegating: "Delegating", toolCalling: "Calling", toolEdited: "Edited", @@ -398,6 +405,7 @@ export const en = { toolRan: "Ran", toolSynced: "Synced", toolOrganized: "Organized", + toolThought: "Thought", toolDelegated: "Delegated", toolCalled: "Called", resumeMenu: "Resume…", @@ -409,6 +417,11 @@ export const en = { rewindTitle: "Rewind the conversation?", rewindBody: "Messages after this one will be removed and the agent will forget them. This message's text goes back into the input box so you can edit and resend it.", + acpSessionOpenFailed: "Could not open the agent session. Check that the agent is installed and try again.", + ompSessionMissing: "Could not find this conversation on disk. The agent may have cleaned its session files — try sending a new message instead of rewinding.", + ompUserMissing: "Could not locate that message in the agent session file. Try rewinding a different message.", + ompScanTruncated: + "There are too many files in the agent session folder to search it fully, so this session could not be located. Clearing out old session files will let the search reach it.", rewindConfirm: "Rewind", rewindModeConversation: "Conversation only", rewindModeConversationDesc: @@ -509,6 +522,7 @@ export const en = { title: "Session", used: "{{pct}}% used", pending: "Available after first message", + noMcp: "No user MCP servers in this session", noSkills: "No skills enabled", skillsInjected: "Injected", skillsInjectedHint: "Skills Weft enabled for this workspace", @@ -587,6 +601,8 @@ export const en = { analysisRunning: "Analyzing…", analysisFailed: "Analysis failed", analysisErrorCheckoutMissing: "Checkout not found on disk", + analysisErrorUnsupportedEngine: + "Repository analysis can't run on {{tool}} — pick another engine for the curator.", retryAnalysis: "Retry", viewOverview: "Overview", viewExpanded: "Expanded", @@ -753,7 +769,7 @@ export const en = { defaultTool: "Default coding tool", defaultToolHint: "Applies to new issues and new tasks only (a write-approval card can still override it per task). Doesn't retroactively change an issue's lead or a task's worker already running — switch those from their own Session panel.", - noTools: "No coding CLI detected — install codex, claude, or opencode.", + noTools: "No coding CLI detected — install codex, claude, opencode, or omp.", toolFallback: "{{configured}} is not installed — using {{tool}}.", agentCommands: "Agent commands", agentCommandsHint: diff --git a/src/i18n/zh.ts b/src/i18n/zh.ts index 96980abc..ec59264d 100644 --- a/src/i18n/zh.ts +++ b/src/i18n/zh.ts @@ -317,6 +317,12 @@ export const zh = { blockedToast: "进程额度仍处于降级状态。消息已保留,请在恢复后重试。", }, needs: { + acpPermissionRequired: "需要授权", + // Authoritative copy. `scripts/gen-notice-copy.mjs` generates the + // backend-readable mirror the IM bridge renders from — edit here, run the + // script; `noticeCopy.test.ts` fails if the mirror drifts. + acpForceResetNotice: + "⏹️ 停止后 agent 未响应取消请求,已强制中断并重置为全新会话继续。历史对话仍保留在时间线里,但新会话不带原生上下文;如果后续回复像「忘记」了之前的内容,请重新提示一下关键信息。", title: "待你处理", subtitle: "只有你能回答的审批与提问", wantsPermission: "请求权限", @@ -379,6 +385,7 @@ export const zh = { toolRunning: "正在运行", toolSyncing: "正在同步", toolOrganizing: "正在整理", + toolThinking: "正在思考", toolDelegating: "正在委派", toolCalling: "正在调用", toolEdited: "已编辑", @@ -387,6 +394,7 @@ export const zh = { toolRan: "已运行", toolSynced: "已同步", toolOrganized: "已整理", + toolThought: "已思考", toolDelegated: "已委派", toolCalled: "已调用", resumeMenu: "恢复…", @@ -397,6 +405,10 @@ export const zh = { rewindTip: "回退到这条消息之前", rewindTitle: "回退对话?", rewindBody: "这条消息之后的对话将被移除,agent 将不再记得它们;这条消息的文本会回填到输入框,可编辑后重发。", + acpSessionOpenFailed: "无法打开 agent 会话。请确认 agent 已安装后重试。", + ompSessionMissing: "找不到本地会话文件(agent 可能已清理)。请直接发新消息,而不是回退。", + ompUserMissing: "在 agent 会话文件里找不到这条消息,请换一条再试。", + ompScanTruncated: "agent 会话目录里的文件太多,未能完整搜索,因此没能定位到这个会话。清理旧的会话文件后即可搜到。", rewindConfirm: "回退", rewindModeConversation: "仅回退对话", rewindModeConversationDesc: @@ -490,6 +502,7 @@ export const zh = { title: "会话信息", used: "已用 {{pct}}%", pending: "首条消息后可见", + noMcp: "本会话没有用户配置的 MCP server", noSkills: "未启用 skill", skillsInjected: "注入", skillsInjectedHint: "Weft 为该工作区启用的 skill", @@ -564,6 +577,7 @@ export const zh = { analysisRunning: "分析中…", analysisFailed: "分析失败", analysisErrorCheckoutMissing: "本地检出未找到", + analysisErrorUnsupportedEngine: "仓库分析暂不支持 {{tool}},请为 curator 选择其他引擎。", retryAnalysis: "重试", viewOverview: "概览", viewExpanded: "展开", @@ -729,7 +743,7 @@ export const zh = { defaultTool: "默认编程工具", defaultToolHint: "只影响新建的 issue 和新建的任务(批准写入时卡片上仍可逐条覆盖)。不会追溯改动已经在跑的 issue 的 lead 或任务的 worker——那些去各自的会话信息面板里切换。", - noTools: "未检测到编程 CLI,请先安装 codex / claude / opencode。", + noTools: "未检测到编程 CLI,请先安装 codex / claude / opencode / omp。", toolFallback: "{{configured}} 未安装,当前生效 {{tool}}。", agentCommands: "Agent 命令", agentCommandsHint: diff --git a/src/lib/noticeTokens.ts b/src/lib/noticeTokens.ts new file mode 100644 index 00000000..df189007 --- /dev/null +++ b/src/lib/noticeTokens.ts @@ -0,0 +1,22 @@ +/** Stable machine tokens a bus notice may carry in place of prose. + * + * Some notices are raised from detached backend tasks — a watchdog sweep, a + * force-reset timer — which have no locale to render with: `lang` reaches the + * backend per command invocation rather than being persisted. Those paths emit + * a token and the copy lives here, in the catalogs the webview already owns. + * Same shape `ConfirmationCard` uses for `acp.permission_required`. + * + * Anything not listed is real prose from an agent and renders verbatim. + * + * Each key is a CONTRACT with the Rust constant that emits it; the backend + * pins its own side (see `engine::ACP_FORCE_RESET_NOTICE`). If the two drift, + * the raw token shows up as the notice body. + */ +export const NOTICE_TOKENS: Record<string, string> = { + "acp.force_reset_notice": "needs.acpForceResetNotice", +}; + +/** The i18n key for a notice body, or null when it is prose to show as-is. */ +export function noticeTokenKey(text: string): string | null { + return NOTICE_TOKENS[text] ?? null; +} diff --git a/src/lib/resume.ts b/src/lib/resume.ts index 77c9dccd..b68fd788 100644 --- a/src/lib/resume.ts +++ b/src/lib/resume.ts @@ -17,15 +17,18 @@ export function resumeCommand( nativeId: string, command?: string, ): string { - const bin = command?.trim() || tool; + const rawBin = command?.trim() || tool; + const bin = shq(rawBin); const at = `cd ${shq(cwd)} && `; switch (tool) { case "claude": - return `${at}${bin} --resume ${nativeId}`; + return `${at}${bin} --resume ${shq(nativeId)}`; case "codex": - return `${at}${bin} resume ${nativeId}`; + return `${at}${bin} resume ${shq(nativeId)}`; case "opencode": - return `${at}${bin} . --session ${nativeId}`; + return `${at}${bin} . --session ${shq(nativeId)}`; + case "omp": + return `${at}${bin} --resume ${shq(nativeId)}`; default: return at + bin; } diff --git a/src/lib/types.ts b/src/lib/types.ts index 7f492f4c..b8fafefb 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -1,6 +1,6 @@ // Mirrors the SeaORM models (serde serializes Rust field names as-is: snake_case). -export type Tool = "claude" | "codex" | "opencode" | "none"; +export type Tool = "claude" | "codex" | "opencode" | "omp" | "none"; export type ThreadKind = "feature" | "bugfix" | "refactor" | "spike"; /** Process-pressure state reported by the backend governor. The frontend renders @@ -410,6 +410,8 @@ export type LeadChatPush = tools: string[]; model: string | null; window: number | null; + /** Engine intentionally produced this MCP list (incl. empty). */ + mcp_known?: boolean; } | { /** 每个 turn 结束推一次当前上下文占用。 */ diff --git a/src/session/ChatTimeline.tsx b/src/session/ChatTimeline.tsx index 2b7adce1..20a5b636 100644 --- a/src/session/ChatTimeline.tsx +++ b/src/session/ChatTimeline.tsx @@ -363,7 +363,8 @@ function BusyIndicator({ cwd?: string; }) { const { t } = useTranslation(); - if (activity) return <ToolStatus name={activity.name} summary={activity.summary} cwd={cwd} />; + // Empty name = explicit clear from the engine (e.g. thinking → first answer token). + if (activity?.name) return <ToolStatus name={activity.name} summary={activity.summary} cwd={cwd} />; return ( <div className="flex items-center gap-1.5 px-1 text-[11px] text-ink-faint"> <span className="h-1.5 w-1.5 animate-pulse rounded-full bg-running" /> @@ -389,18 +390,20 @@ function ToolStatus({ name, summary, cwd }: { name: string; summary: string; cwd const labelKey = toolLabelKey(name); const { target, targetToken, added, removed } = compactToolTarget(name, summary); // For unrecognized tools (MCP etc.) the generic "Calling" says nothing — - // show the cleaned tool identity instead. + // show the cleaned tool identity instead. Thinking streams freeform text in + // `summary` — show that tail rather than a path-like target. const generic = labelKey === "session.toolCalling"; + const thinking = labelKey === "session.toolThinking"; return ( <ToolActivity icon={Icon} label={generic ? cleanToolName(name) : t(labelKey)} - target={generic ? undefined : target} - targetToken={generic ? undefined : targetToken} + target={generic || thinking ? undefined : target} + targetToken={generic || thinking ? undefined : targetToken} cwd={cwd} - summary={generic ? summary : undefined} - added={added} - removed={removed} + summary={generic || thinking ? summary : undefined} + added={thinking ? undefined : added} + removed={thinking ? undefined : removed} /> ); } diff --git a/src/session/LeadTab.tsx b/src/session/LeadTab.tsx index 6e9ae01a..d2d923c7 100644 --- a/src/session/LeadTab.tsx +++ b/src/session/LeadTab.tsx @@ -310,7 +310,7 @@ export function LeadTab({ })(); // Rewind is scoped to claude/codex/opencode leads — the tools with native // fork support (same gate as the worker); the lead rewinds conversation-only. - const canRewind = leadTool === "claude" || leadTool === "codex" || leadTool === "opencode"; + const canRewind = leadTool === "claude" || leadTool === "codex" || leadTool === "opencode" || leadTool === "omp"; // Dialog confirm: the backend truncates from the picked message on and // returns its text — prefill the composer with it. The "rewound" push diff --git a/src/session/RewindDialog.tsx b/src/session/RewindDialog.tsx index 0778caac..a4301a0e 100644 --- a/src/session/RewindDialog.tsx +++ b/src/session/RewindDialog.tsx @@ -85,8 +85,20 @@ export function RewindDialog({ await onConfirm(mode); onOpenChange(false); } catch (e) { - setErr(String(e)); - if (import.meta.env.DEV) console.error("chat rewind failed:", String(e)); + const raw = String(e); + let mapped = raw; + if (raw.includes("omp_session_not_found")) { + mapped = t("session.ompSessionMissing"); + } else if (raw.includes("omp_user_not_found")) { + mapped = t("session.ompUserMissing"); + } else if (raw.includes("omp_session_scan_truncated")) { + // Distinct from "missing" on purpose: the session may well exist, the + // scan just stopped at its cap. Saying "not found" would send the user + // looking for a deleted session that is actually there. + mapped = t("session.ompScanTruncated"); + } + setErr(mapped); + if (import.meta.env.DEV) console.error("chat rewind failed:", raw); setBusy(false); } } diff --git a/src/session/SessionInfoPanel.tsx b/src/session/SessionInfoPanel.tsx index 81806e40..582a4f99 100644 --- a/src/session/SessionInfoPanel.tsx +++ b/src/session/SessionInfoPanel.tsx @@ -5,7 +5,6 @@ import type { SessionMeta, EnabledSkill, Direction } from "../lib/types"; import type { ReadOnlyScope } from "../lib/grants"; import { ToolIcon, toolFullName } from "../components/ToolIcon"; -type NamedSkill = { name: string; description: string }; /** * 常驻右栏「会话信息」:Engine、Sub-tasks、Skills、MCP。Context 的 token/% @@ -14,11 +13,9 @@ type NamedSkill = { name: string; description: string }; * 且这是切换入口(onSwitchEngine)自然的落点,与"哪个引擎在跑"这条最基础的 * 信息放在一起。Sub-tasks/MCP 是 <Section> 静态头 + <OverflowList>(head+ * show-more):头常驻只读,长列表用同一个 head + "Show N more" 控件折叠尾部。 - * Skills 是两个独立口径的 <SkillGroup>(各自的头 + OverflowList)——workspace - * 静态注入 vs 引擎运行时探测,见该段内注释与 #108;运行时探测那组按 `tool` - * 门控(opencode 无此探测能力,见 {@link skillDiscoverySupported} 与 #114 - * review)。纯展示 + 一个切换入口——数据由 store 的 leadMeta/workerMeta + - * workspaceSkills + directionsByThread 喂,切换的实际执行/确认交给调用方。 + * Skills 是单一扁平列表(workspace 启用 ∪ 引擎 cwd 发现,按名去重;opencode 无 + * 引擎探测则仅 workspace)。纯展示——数据由 store 的 leadMeta/workerMeta + + * workspaceSkills + directionsByThread 喂。 */ export function SessionInfoPanel({ meta, @@ -35,9 +32,7 @@ export function SessionInfoPanel({ }: { meta: SessionMeta | undefined; skills: EnabledSkill[]; - /** lead_tool / ObserveRef.tool — gates the "Discovered" Skills group to - * engines that can actually report it (see {@link skillDiscoverySupported}). - * Omitted/undefined (tool identity not resolved yet) still renders. */ + /** lead_tool / ObserveRef.tool — optional session tool identity. */ tool?: string; /** 该 thread 已创建的子任务(lead 专用;worker 不传 → 不渲染该段)。 */ subtasks?: Direction[]; @@ -80,6 +75,28 @@ export function SessionInfoPanel({ const servers = meta?.mcpServers ?? []; + // One flat chip list (no 注入/发现 subgroups). + // - codex/claude: workspace-enabled ∪ engine-discovered (name-deduped) + // - omp/opencode: workspace-enabled only — engine "discovery" is either + // absent (opencode) or just a cwd re-scan of Weft-materialized builtins (omp) + const mergeEngineSkills = tool !== "opencode"; + const unifiedSkills = useMemo(() => { + const byName = new Map<string, { name: string; description?: string }>(); + for (const s of skills) { + byName.set(s.name, { name: s.name, description: s.description }); + } + if (mergeEngineSkills) { + for (const s of meta?.engineSkills ?? []) { + if (!byName.has(s.name)) { + byName.set(s.name, { name: s.name, description: s.description }); + } + } + } + return [...byName.values()]; + }, [skills, meta?.engineSkills, mergeEngineSkills]); + const skillsPending = + mergeEngineSkills && skills.length === 0 && meta?.engineSkills == null; + return ( <aside className="flex h-full w-[270px] shrink-0 flex-col overflow-hidden border-l border-border bg-bg"> <header className="flex items-center gap-2 border-b border-border px-3 py-2"> @@ -186,41 +203,30 @@ export function SessionInfoPanel({ </Section> )} - {/* Skills — two independently-sourced readings, kept apart rather than - merged into one count (issue #108). `skills` is Weft's injected - catalog for this workspace (policy, static — from workspace_skills). - `meta.engineSkills` is what the engine actually found on disk in the - session cwd at the last probe (ground truth, often a superset — e.g. - pre-existing/plugin skills outside Weft's catalog). A dedup-merge of - the two used to back the section's single count, which visibly - jumped (e.g. 41→79) the moment the turn-triggered engine probe - resolved after the workspace fetch. Each group now owns its count. */} - <Section key="skills" title={t("sessionInfo.skills")}> - <div className="mt-1.5"> - <SkillGroup - label={t("sessionInfo.skillsInjected")} - hint={t("sessionInfo.skillsInjectedHint")} - skills={skills} - emptyText={t("sessionInfo.noSkills")} + {/* Skills — one flat list for this session. Workspace-enabled skills + plus anything the engine probe found in the cwd (deduped by name). + No injected/discovered split: users just want "what skills are on". */} + <Section key="skills" title={t("sessionInfo.skills")} count={unifiedSkills.length}> + {unifiedSkills.length > 0 ? ( + <OverflowList + items={unifiedSkills} + head={10} + layout="wrap" + renderItem={(s) => ( + <span + key={s.name} + title={s.description} + className="rounded-[var(--radius-sm)] border border-border bg-surface px-2 py-0.5 text-[11.5px] text-ink" + > + {s.name} + </span> + )} /> - {/* opencode has no discovery probe at all (session_meta.rs's - gather_opencode never sets `skills`) — engineSkills would stay - `undefined` forever, so the group would be stuck reading - "pending" turn after turn instead of ever resolving. That's not - the same as "not probed yet"; hide the reading entirely rather - than show a promise that can't be kept (PR #114 review). */} - {skillDiscoverySupported(tool) && ( - <div className="mt-3"> - <SkillGroup - label={t("sessionInfo.skillsDiscovered")} - hint={t("sessionInfo.skillsDiscoveredHint")} - skills={meta?.engineSkills} - emptyText={t("sessionInfo.noEngineSkills")} - pendingText={t("sessionInfo.pending")} - /> - </div> - )} - </div> + ) : ( + <div className="mt-1.5 text-[11px] text-ink-faint"> + {skillsPending ? t("sessionInfo.pending") : t("sessionInfo.noSkills")} + </div> + )} </Section> {/* MCP — servers cap at 3, each row expands its tools. */} @@ -235,7 +241,11 @@ export function SessionInfoPanel({ )} /> ) : ( - <div className="mt-1.5 text-[11px] text-ink-faint">{t("sessionInfo.pending")}</div> + <div className="mt-1.5 text-[11px] text-ink-faint"> + {meta?.mcpAuthoritative + ? t("sessionInfo.noMcp") + : t("sessionInfo.pending")} + </div> )} </Section> </div> @@ -329,105 +339,7 @@ function OverflowList<T>({ ); } -/** Whether this session's engine has ANY mechanism to report which skills it - * actually loaded — mirrors `session_meta::gather`'s dispatch in - * `src-tauri/src/session_meta.rs`: claude scans the session cwd's - * `.claude/skills`, codex has its own skill discovery; opencode has no - * equivalent probe, so `gather_opencode` never sets `skills` and - * `meta.engineSkills` would stay `undefined` for the life of the session — - * not "pending", just permanently unavailable. `tool` unresolved - * (`undefined`, e.g. the worker surface before its session lookup lands) - * still counts as supported: the session_meta effects that would populate - * `engineSkills` already gate on the tool being known first (see - * LeadTab/WorkerConversation), so there's no real window where this default - * would show a reading that never arrives. */ -function skillDiscoverySupported(tool: string | undefined): boolean { - return tool !== "opencode"; -} - -/** One Skills reading's tri-state: `undefined` means no authoritative result - * has landed yet (the initial value, or every probe so far has failed — - * `sessionMeta.ts`'s merges keep this as `undefined`/prev rather than ever - * synthesizing a `[]`), `[]` means a probe DID land and confirmed zero, and a - * non-empty array is the actual list. Modeled as one discriminated value - * (instead of re-deriving `isPending`/`isEmpty` booleans at each call site) - * so "no signal yet" can never be silently displayed as "confirmed zero". */ -type SkillReadingState = "pending" | "empty" | "list"; - -function skillReadingState(skills: unknown[] | undefined): SkillReadingState { - if (skills == null) return "pending"; - return skills.length === 0 ? "empty" : "list"; -} - -/** Skills section body for one reading: an eyebrow label + its own count, over - * chips (head 10, dense) or an empty/pending hint. Exhaustive over - * `SkillReadingState` via {@link SkillGroupBody} rather than a nested ternary. */ -function SkillGroup<T extends NamedSkill>({ - label, - hint, - skills, - emptyText, - pendingText = emptyText, -}: { - label: string; - hint: string; - skills: T[] | undefined; - /** Shown when the reading is authoritative and empty. */ - emptyText: string; - /** Shown when the reading hasn't landed yet. Defaults to `emptyText` for - * readings that have no pending state (e.g. the injected list, always a - * concrete — if possibly not-yet-fetched-once — array). */ - pendingText?: string; -}) { - const state = skillReadingState(skills); - const list = skills ?? []; - return ( - <div> - <div className="flex items-center" title={hint}> - <span className="text-[10.5px] font-medium uppercase tracking-wide text-ink-faint"> - {label} - </span> - {state !== "pending" && ( - <span className="ml-auto text-[10.5px] text-ink-faint">{list.length}</span> - )} - </div> - <SkillGroupBody state={state} list={list} pendingText={pendingText} emptyText={emptyText} /> - </div> - ); -} -function SkillGroupBody<T extends NamedSkill>({ - state, - list, - pendingText, - emptyText, -}: { - state: SkillReadingState; - list: T[]; - pendingText: string; - emptyText: string; -}) { - if (state === "list") { - return ( - <OverflowList - items={list} - head={10} - layout="wrap" - renderItem={(s) => ( - <span - key={s.name} - title={s.description} - className="rounded-[var(--radius-sm)] border border-border bg-surface px-2 py-0.5 text-[11.5px] text-ink" - > - {s.name} - </span> - )} - /> - ); - } - const text: Record<Exclude<SkillReadingState, "list">, string> = { pending: pendingText, empty: emptyText }; - return <div className="mt-1.5 text-[11px] text-ink-faint">{text[state]}</div>; -} /** created_at → epoch for ordering. Store writes Unix seconds as a string; * tolerate an ISO value too. Unparseable → 0 (sinks to the bottom). */ @@ -455,6 +367,7 @@ function subtaskDot(status: string): string { /** MCP server connection status → dot color. */ function mcpDot(status: string): string { if (status === "connected") return "bg-running"; + if (status === "weft") return "bg-brand"; if (status === "failed") return "bg-danger"; return "bg-idle"; } diff --git a/src/session/WorkerConversation.tsx b/src/session/WorkerConversation.tsx index 92530da8..4af3df28 100644 --- a/src/session/WorkerConversation.tsx +++ b/src/session/WorkerConversation.tsx @@ -250,7 +250,7 @@ export function WorkerConversation() { // Conversation rewind (Phase 1) is scoped to claude/codex/opencode workers — // the tools with native fork support; others get no rewind affordance at all. const canRewind = - sid != null && (ref?.tool === "claude" || ref?.tool === "codex" || ref?.tool === "opencode"); + sid != null && (ref?.tool === "claude" || ref?.tool === "codex" || ref?.tool === "opencode" || ref?.tool === "omp"); // Dialog confirm: the backend truncates from the picked message on (and, for // the code modes, restores the worktree), then returns the message's text — diff --git a/src/session/sessionMeta.ts b/src/session/sessionMeta.ts index 97ddc3e8..f5aa3135 100644 --- a/src/session/sessionMeta.ts +++ b/src/session/sessionMeta.ts @@ -5,16 +5,14 @@ import type { McpServerInfo, SessionMeta } from "../lib/types"; const norm = (s: string) => s.toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/^_+|_+$/g, ""); -/** Weft 自己在 spawn 时注入的内部协调 MCP(见 `bus/inject.rs`):是 Weft 的管道,不是 - * 用户配置的 MCP。面板只展示用户的 MCP,所以三家统一过滤掉。claude 的 `system/init` - * 会带上它们(codex/opencode 的探测本就不含),在此统一隐藏以保持一致。 - * **精确名单,不是前缀匹配** —— 开放的 `^weft[-_]` 会误伤用户自己命名的 `weft_analytics` - * 这类真实 server(claude 的 `mcp_servers[].name` 原样来自用户 `.mcp.json`)。 */ -const WEFT_INTERNAL_MCP = new Set(["weft_bus", "weft_planner", "weft_global"]); +/** Weft 自己在 spawn 时注入的内部协调 MCP(见 `bus/inject.rs`)。精确名单,不是前缀 + * 匹配。面板展示时 status 标为 `weft`,与用户配置的 server 区分。 */ +const WEFT_INTERNAL_MCP = new Set(["weft_bus", "weft_planner", "weft_global", "weft_curator"]); const isInternalMcp = (name: string) => WEFT_INTERNAL_MCP.has(name.toLowerCase()); /** claude init:把扁平 tools 里 `mcp__<server>__<tool>` 按 server 归到对应条目。 - * codex/opencode 不传 tools,这里只产出 server + 状态(tools 为空)。weft_* 内部 server 过滤掉。 */ + * codex/opencode 不传 tools,这里只产出 server + 状态(tools 为空)。 + * Weft 内部 server status 标为 `weft`(见 WEFT_INTERNAL_MCP)。 */ export function groupMcpTools( servers: { name: string; status: string }[], tools: string[], @@ -28,13 +26,17 @@ export function groupMcpTools( list.push(m[2]); byPrefix.set(key, list); } - return servers - .filter((s) => !isInternalMcp(s.name)) - .map((s) => ({ - name: s.name, - status: s.status, - tools: byPrefix.get(norm(s.name)) ?? [], - })); + // Show every server on the session, including Weft pipes (weft_bus / + // weft_planner / weft_global). Hiding them made ACP/omp look MCP-less even + // though those pipes are what the agent actually has. Tag Weft pipes so the + // row still reads as infrastructure. + return servers.map((s) => ({ + name: s.name, + status: isInternalMcp(s.name) && (s.status === "connected" || !s.status) + ? "weft" + : s.status, + tools: byPrefix.get(norm(s.name)) ?? [], + })); } /** init push → SessionMeta(init 不带 usage,保留旧 contextTokens)。 @@ -49,10 +51,18 @@ export function metaFromInit( tools: string[]; model: string | null; window: number | null; + /** Explicit MCP authority (ACP open seeds true even when list empty). */ + mcp_known?: boolean; }, ): SessionMeta { const grouped = groupMcpTools(p.mcp_servers, p.tools); - const authoritative = p.model != null; + // Authority discriminators (in order): + // 1. explicit mcp_known from engine (ACP open always knows what it injected) + // 2. claude system/init carries model — empty mcp_servers is real "none" + // 3. non-empty raw list is always a real reading + // Placeholder inits (model null, empty servers, no flag) keep previous rows. + const authoritative = + p.mcp_known === true || p.model != null || p.mcp_servers.length > 0; const keepPrevServers = !authoritative && grouped.length === 0; return { contextTokens: prev?.contextTokens, @@ -117,12 +127,22 @@ export function metaFromSnapshot(snap: { model: string | null; mcp_servers: { name: string; status: string }[]; tools: string[]; + /** True only when the engine/live path actually produced this MCP list. */ + mcp_known?: boolean; }): SessionMeta { + const mcpServers = groupMcpTools(snap.mcp_servers, snap.tools); + // Never fabricate authority from model/tokens. Non-empty list is a real + // reading; empty list is authoritative only when mcp_known is explicit. + let mcpAuthoritative: true | undefined; + if (snap.mcp_known === true || snap.mcp_servers.length > 0) { + mcpAuthoritative = true; + } return { contextTokens: snap.context_tokens ?? undefined, window: snap.window ?? undefined, model: snap.model ?? undefined, - mcpServers: groupMcpTools(snap.mcp_servers, snap.tools), + mcpServers, + mcpAuthoritative, }; } @@ -139,7 +159,9 @@ export function fillMetaHoles(prev: SessionMeta | undefined, snap: SessionMeta): window: prev.window ?? snap.window, model: prev.model ?? snap.model, mcpServers: fillServers ? snap.mcpServers : prev.mcpServers, - mcpAuthoritative: prev.mcpAuthoritative, + // Promote authority when the snapshot is a real engine reading (even if + // user-visible servers are empty after filtering Weft-internal pipes). + mcpAuthoritative: prev.mcpAuthoritative || snap.mcpAuthoritative, engineSkills: prev.engineSkills ?? snap.engineSkills, reasoningEffort: prev.reasoningEffort ?? snap.reasoningEffort, }; diff --git a/src/session/transcriptBits.ts b/src/session/transcriptBits.ts index 88410be3..f2ebd187 100644 --- a/src/session/transcriptBits.ts +++ b/src/session/transcriptBits.ts @@ -3,6 +3,7 @@ import type { ComponentType } from "react"; import { + Brain, FilePen, FileText, ListTodo, @@ -21,7 +22,7 @@ const MANIFEST_FILE_TARGET_RE = /(?:^|[\s"'`])((?:~[\\/]|(?:[A-Za-z]:[\\/])?)[^\s"'`,;!?,。;!?、]*(?:Dockerfile|Makefile|\.gitignore|\.env(?:\.[\w.-]+)?))(?:$|[\s"'`),.;:!?,。;!?、])/; const PATH_SEP_TARGET_RE = /(?:^|[\s"'`])((?:~[\\/]|(?:[A-Za-z]:[\\/])?)[^\s"'`,;!?,。;!?、]+[\\/][^\s"'`,;!?,。;!?、]+)(?:$|[\s"'`),.;:!?,。;!?、])/; -type ToolKind = "command" | "edit" | "search" | "read" | "sync" | "todo" | "collab" | "generic"; +type ToolKind = "command" | "edit" | "search" | "read" | "sync" | "todo" | "thinking" | "collab" | "generic"; /** Map a (cleaned) tool name to a glyph so the pills are scannable. */ export function toolIcon(name: string): ComponentType<LucideProps> { @@ -32,6 +33,7 @@ export function toolIcon(name: string): ComponentType<LucideProps> { read: FileText, sync: Radio, todo: ListTodo, + thinking: Brain, collab: Users, generic: Wrench, }; @@ -54,6 +56,7 @@ export function toolLabelKey(name: string) { read: "session.toolReading", sync: "session.toolSyncing", todo: "session.toolOrganizing", + thinking: "session.toolThinking", collab: "session.toolDelegating", generic: "session.toolCalling", }; @@ -70,6 +73,7 @@ export function toolDoneLabelKey(name: string) { read: "session.toolRead", sync: "session.toolSynced", todo: "session.toolOrganized", + thinking: "session.toolThought", collab: "session.toolDelegated", generic: "session.toolCalled", }; @@ -177,6 +181,7 @@ function toolNameTokens(name: string): string[] { function toolKind(name: string): ToolKind { const tokens = toolNameTokens(name); const has = (token: string) => tokens.includes(token); + if (has("thinking") || has("thought") || has("reasoning")) return "thinking"; if (has("write") || has("edit") || has("patch")) return "edit"; if (has("filechange") || (has("file") && has("change"))) return "edit"; if (has("apply") && has("patch")) return "edit"; diff --git a/src/state/store.tsx b/src/state/store.tsx index 3fa55335..b336a998 100644 --- a/src/state/store.tsx +++ b/src/state/store.tsx @@ -169,6 +169,8 @@ function notifySendFailed(error: unknown) { let msg: string; if (raw === "queue_full") { msg = i18n.t("lead.queueFull"); + } else if (raw.includes("acp_session_open_failed")) { + msg = i18n.t("session.acpSessionOpenFailed"); } else if (raw.includes("engine_switch_in_progress")) { msg = i18n.t("session.switchInProgress"); } else { @@ -1829,7 +1831,8 @@ export function StoreProvider({ children }: { children: ReactNode }) { [p.thread_id]: applyLeadConsumed(m[p.thread_id] ?? [], p.message_id, p.consumed_at), })); } else if (p.type === "activity") { - const act = { name: p.name, summary: p.summary }; + // Empty name = explicit clear (thinking → first answer token). + const act = p.name ? { name: p.name, summary: p.summary } : null; if (p.session_id != null) { const sid = p.session_id; setWorkerActivity((a) => ({ ...a, [sid]: act })); @@ -1961,9 +1964,10 @@ export function StoreProvider({ children }: { children: ReactNode }) { } try { const cmds = await api.discoverSlash(threadId, null); - if (cmds.length > 0) { - setLeadSlash((s) => ({ ...s, [threadId]: cmds })); - } + // Same reasoning as the worker path below: an empty list cannot be + // told apart from a failed discovery, so it must not clear a palette + // that is already populated. + if (cmds.length > 0) setLeadSlash((s) => ({ ...s, [threadId]: cmds })); } catch (e) { /* slash discovery is best-effort */ console.error(e); @@ -2025,7 +2029,15 @@ export function StoreProvider({ children }: { children: ReactNode }) { // the only source of context/model/MCP until the next turn. setLeadMeta((m) => ({ ...m, - [threadId]: fillMetaHoles(m[threadId], metaFromSnapshot(st)), + [threadId]: fillMetaHoles( + m[threadId], + metaFromSnapshot({ + ...st, + // Live engine cache is an MCP reading. Stopped/missing engines that + // only hydrated model from disk must not wipe pending MCP rows. + mcp_known: st.state === "idle" || st.state === "busy", + }), + ), })); } catch (e) { /* engine state is cosmetic at load time */ @@ -2045,7 +2057,15 @@ export function StoreProvider({ children }: { children: ReactNode }) { void api .discoverSlash(null, sessionId) .then((cmds) => { - if (cmds.length > 0) setWorkerSlash((s) => ({ ...s, [sessionId]: cmds })); + // Enforce the best-effort contract stated above instead of only + // documenting it. `discoverSlash` returns an empty list for an + // authoritative empty palette AND for startup / HTTP / parse failures + // — opencode's live `GET /command` can fail transiently — and the two + // are indistinguishable from here. Overwriting on empty therefore wiped + // a working palette after any hiccup. Keeping a stale-but-valid list + // costs at most a few extra entries; clearing it costs the feature. + if (cmds.length === 0) return; + setWorkerSlash((s) => ({ ...s, [sessionId]: cmds })); }) .catch(() => {}); }, []); diff --git a/tests/frontend/noticeCopy.test.ts b/tests/frontend/noticeCopy.test.ts new file mode 100644 index 00000000..5e8bb356 --- /dev/null +++ b/tests/frontend/noticeCopy.test.ts @@ -0,0 +1,22 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { buildNoticeCopy, serialize } from "../../scripts/gen-notice-copy.mjs"; + +const GENERATED = new URL( + "../../src-tauri/src/bus/notices.generated.json", + import.meta.url, +); + +/** The catalogs are authoritative; the generated file is a mirror the IM + * bridge renders from. If they drift, remote and in-app users see different + * sentences — the exact duplication this generation step replaced. */ +test("the generated notice mirror matches the catalogs", () => { + const onDisk = readFileSync(GENERATED, "utf8"); + const expected = serialize(buildNoticeCopy()); + assert.equal( + onDisk, + expected, + "src-tauri/src/bus/notices.generated.json is stale — run `node --experimental-strip-types scripts/gen-notice-copy.mjs`", + ); +}); diff --git a/tests/frontend/noticeTokens.test.ts b/tests/frontend/noticeTokens.test.ts new file mode 100644 index 00000000..0992ace7 --- /dev/null +++ b/tests/frontend/noticeTokens.test.ts @@ -0,0 +1,42 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { NOTICE_TOKENS, noticeTokenKey } from "../../src/lib/noticeTokens.ts"; +import { en } from "../../src/i18n/en.ts"; +import { zh } from "../../src/i18n/zh.ts"; + +/** Walk a dotted i18n key ("needs.acpForceResetNotice") through a catalog. */ +function lookup(catalog: unknown, key: string): unknown { + return key + .split(".") + .reduce<unknown>( + (node, part) => + node && typeof node === "object" ? (node as Record<string, unknown>)[part] : undefined, + catalog, + ); +} + +test("every notice token resolves to real copy in BOTH catalogs", () => { + const keys = Object.values(NOTICE_TOKENS); + assert.ok(keys.length > 0, "the map should not be empty"); + for (const key of keys) { + for (const [name, catalog] of [ + ["en", en], + ["zh", zh], + ] as const) { + const value = lookup(catalog, key); + assert.equal(typeof value, "string", `${name} is missing ${key}`); + assert.ok((value as string).length > 0, `${name}'s ${key} is empty`); + } + } +}); + +/** The backend pins the same literal (engine::ACP_FORCE_RESET_NOTICE). If the + * two drift, the raw token renders as the notice body instead of the copy. */ +test("the force-reset token matches the one the backend emits", () => { + assert.equal(noticeTokenKey("acp.force_reset_notice"), "needs.acpForceResetNotice"); +}); + +test("prose passes through untouched", () => { + assert.equal(noticeTokenKey("Should I bump the major version?"), null); + assert.equal(noticeTokenKey(""), null); +});