diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 45c1416e..dec57644 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -142,7 +142,7 @@ jobs: - name: Run proxy tool tests run: | go test -tags "re2_cgo re2_static" -race -count=1 -timeout 5m -v \ - ./pkg/tools/proxy/ + ./tools/proxy/ - name: Run tmux command tests run: | @@ -182,7 +182,7 @@ jobs: run: | go test -tags "full re2_cgo re2_static" -count=1 -timeout 5m -v \ -run 'Test(ScannerFunctionalRegression|FullScannerFunctionalRegression)$' \ - ./pkg/tools + ./tools # ── Generated templates tests (depends on tidy) ─────────────── diff --git a/cmd/agent/imports.go b/cmd/agent/imports.go index 3cd79600..5cb8d167 100644 --- a/cmd/agent/imports.go +++ b/cmd/agent/imports.go @@ -1,3 +1,3 @@ package main -import _ "github.com/chainreactors/aiscan/pkg/tools/arsenal" +import _ "github.com/chainreactors/aiscan/tools/arsenal" diff --git a/cmd/aiscan/imports.go b/cmd/aiscan/imports.go index ac30b23f..73cd56db 100644 --- a/cmd/aiscan/imports.go +++ b/cmd/aiscan/imports.go @@ -4,14 +4,14 @@ package main // Each package has a register.go that calls command.RegisterFactory(). import ( - _ "github.com/chainreactors/aiscan/pkg/tools" - _ "github.com/chainreactors/aiscan/pkg/tools/arsenal" - _ "github.com/chainreactors/aiscan/pkg/tools/gogo" - _ "github.com/chainreactors/aiscan/pkg/tools/ioa" - _ "github.com/chainreactors/aiscan/pkg/tools/neutron" - _ "github.com/chainreactors/aiscan/pkg/tools/proton" - _ "github.com/chainreactors/aiscan/pkg/tools/proxy" - _ "github.com/chainreactors/aiscan/pkg/tools/search" - _ "github.com/chainreactors/aiscan/pkg/tools/spray" - _ "github.com/chainreactors/aiscan/pkg/tools/zombie" + _ "github.com/chainreactors/aiscan/tools" + _ "github.com/chainreactors/aiscan/tools/arsenal" + _ "github.com/chainreactors/aiscan/tools/gogo" + _ "github.com/chainreactors/aiscan/tools/ioa" + _ "github.com/chainreactors/aiscan/tools/neutron" + _ "github.com/chainreactors/aiscan/tools/proton" + _ "github.com/chainreactors/aiscan/tools/proxy" + _ "github.com/chainreactors/aiscan/tools/search" + _ "github.com/chainreactors/aiscan/tools/spray" + _ "github.com/chainreactors/aiscan/tools/zombie" ) diff --git a/cmd/aiscan/imports_full.go b/cmd/aiscan/imports_full.go index 3bd0b7b8..cdf24a34 100644 --- a/cmd/aiscan/imports_full.go +++ b/cmd/aiscan/imports_full.go @@ -3,7 +3,7 @@ package main import ( - _ "github.com/chainreactors/aiscan/pkg/tools/katana" - _ "github.com/chainreactors/aiscan/pkg/tools/passive" - _ "github.com/chainreactors/aiscan/pkg/tools/playwright" + _ "github.com/chainreactors/aiscan/tools/katana" + _ "github.com/chainreactors/aiscan/tools/passive" + _ "github.com/chainreactors/aiscan/tools/playwright" ) diff --git a/cmd/aiscan/setup.go b/cmd/aiscan/setup.go index 7bb65080..87f16108 100644 --- a/cmd/aiscan/setup.go +++ b/cmd/aiscan/setup.go @@ -17,10 +17,10 @@ import ( "github.com/chainreactors/aiscan/pkg/aop" "github.com/chainreactors/aiscan/pkg/commands" "github.com/chainreactors/aiscan/pkg/telemetry" - "github.com/chainreactors/aiscan/pkg/tools/scan" - "github.com/chainreactors/aiscan/pkg/tools/scan/engine" "github.com/chainreactors/aiscan/pkg/tui" "github.com/chainreactors/aiscan/skills" + "github.com/chainreactors/aiscan/tools/scan" + "github.com/chainreactors/aiscan/tools/scan/engine" ioaclient "github.com/chainreactors/ioa/client" "github.com/chainreactors/ioa/protocols" ioaserver "github.com/chainreactors/ioa/server" diff --git a/cmd/runner/imports.go b/cmd/runner/imports.go index cba2dca8..6a000c8d 100644 --- a/cmd/runner/imports.go +++ b/cmd/runner/imports.go @@ -1,11 +1,11 @@ package main import ( - _ "github.com/chainreactors/aiscan/pkg/tools" - _ "github.com/chainreactors/aiscan/pkg/tools/arsenal" - _ "github.com/chainreactors/aiscan/pkg/tools/gogo" - _ "github.com/chainreactors/aiscan/pkg/tools/neutron" - _ "github.com/chainreactors/aiscan/pkg/tools/proton" - _ "github.com/chainreactors/aiscan/pkg/tools/spray" - _ "github.com/chainreactors/aiscan/pkg/tools/zombie" + _ "github.com/chainreactors/aiscan/tools" + _ "github.com/chainreactors/aiscan/tools/arsenal" + _ "github.com/chainreactors/aiscan/tools/gogo" + _ "github.com/chainreactors/aiscan/tools/neutron" + _ "github.com/chainreactors/aiscan/tools/proton" + _ "github.com/chainreactors/aiscan/tools/spray" + _ "github.com/chainreactors/aiscan/tools/zombie" ) diff --git a/cmd/runner/main.go b/cmd/runner/main.go index 48dd2941..0a5d4293 100644 --- a/cmd/runner/main.go +++ b/cmd/runner/main.go @@ -15,8 +15,8 @@ import ( "github.com/chainreactors/aiscan/core/resources" "github.com/chainreactors/aiscan/pkg/commands" "github.com/chainreactors/aiscan/pkg/telemetry" - "github.com/chainreactors/aiscan/pkg/tools/scan/engine" "github.com/chainreactors/aiscan/pkg/webagent" + "github.com/chainreactors/aiscan/tools/scan/engine" ) func main() { diff --git a/core/config/scanner_katana.go b/core/config/scanner_katana.go index ee35992d..8870151a 100644 --- a/core/config/scanner_katana.go +++ b/core/config/scanner_katana.go @@ -2,7 +2,7 @@ package config -import katanacmd "github.com/chainreactors/aiscan/pkg/tools/katana" +import katanacmd "github.com/chainreactors/aiscan/tools/katana" func init() { ExtraCommands["katana"] = true diff --git a/core/output/record.go b/core/output/record.go index 93bcb2ab..a75c0bf9 100644 --- a/core/output/record.go +++ b/core/output/record.go @@ -18,7 +18,6 @@ const ( TypeSpray RecordType = "spray" TypeZombie RecordType = "zombie" TypeNeutron RecordType = "neutron" - TypeAgent RecordType = "agent" TypeScanEnd RecordType = "scan_end" TypeError RecordType = "error" @@ -61,7 +60,6 @@ func (r Record) Marshal() []byte { return b } - func ParseRecord(line []byte) (Record, error) { var r Record err := json.Unmarshal(line, &r) diff --git a/core/output/timeline.go b/core/output/timeline.go index 30371dc0..22b0ba77 100644 --- a/core/output/timeline.go +++ b/core/output/timeline.go @@ -10,6 +10,8 @@ import ( "sync" "time" + "github.com/chainreactors/aiscan/pkg/aop" + xcommand "github.com/chainreactors/aiscan/pkg/aop/x/command" "github.com/chainreactors/utils/parsers" "github.com/charmbracelet/glamour" "github.com/muesli/termenv" @@ -52,9 +54,12 @@ func ParseTimelineFile(path string) ([]TimelineEntry, error) { } func parseLine(line []byte) (TimelineEntry, bool) { - var event AOPTimelineEntry + var event aop.Event if json.Unmarshal(line, &event) == nil && event.Valid() { - return TimelineEntry{Timestamp: event.Timestamp, Type: event.Type, Data: &event}, true + timestamp, err := time.Parse(time.RFC3339Nano, event.TS) + if err == nil { + return TimelineEntry{Timestamp: timestamp, Type: event.Type, Data: &event}, true + } } rec, err := ParseRecord(line) if err != nil || rec.Type == "" { @@ -77,8 +82,6 @@ func parseRecordData(rec Record) any { return unmarshalItem[parsers.GOGOResult](rec.Data) case TypeSpray: return unmarshalItem[parsers.SprayResult](rec.Data) - case TypeAgent: - return unmarshalItem[AgentEvent](rec.Data) case TypeScanEnd: return unmarshalItem[ScanEnd](rec.Data) } @@ -122,10 +125,8 @@ func BuildTimelineMarkdown(entries []TimelineEntry) string { writeSprayMarkdown(&sb, d) case *parsers.Loot: writeLootMarkdown(&sb, d) - case *AgentEvent: - d.writeMarkdown(&sb) - case *AOPTimelineEntry: - d.writeMarkdown(&sb) + case *aop.Event: + writeAOPMarkdown(&sb, d) case *ScanEnd: d.writeMarkdown(&sb) } @@ -162,109 +163,6 @@ func writeHeader(sb *strings.Builder, sess *sessionMeta) { } } -// --------------------------------------------------------------------------- -// AgentEvent -// --------------------------------------------------------------------------- - -type AgentEvent struct { - Type string `json:"type"` - SessionID string `json:"session_id"` - ParentSessionID string `json:"parent_session_id"` - Turn int `json:"turn"` - ToolCallID string `json:"tool_call_id"` - ToolName string `json:"tool_name"` - Arguments string `json:"arguments"` - Result string `json:"result"` - IsError bool `json:"is_error"` - Error string `json:"error"` - Stop string `json:"stop"` - Message *AgentEventMsg `json:"message"` - ToolResults []AgentEventMsg `json:"tool_results"` - Usage *AgentEventUsage `json:"usage"` - ContextTokens int `json:"context_tokens"` - NewMessages int `json:"new_messages"` - RequestModel string `json:"request_model"` - RequestMessages int `json:"request_messages"` - RequestTools int `json:"request_tools"` -} - -type AgentEventMsg struct { - Role string `json:"role"` - Content string `json:"content"` - ToolCalls []agentToolCall `json:"tool_calls"` - ToolCallID string `json:"tool_call_id"` -} - -type AgentEventUsage struct { - PromptTokens int `json:"prompt_tokens"` - CompletionTokens int `json:"completion_tokens"` - TotalTokens int `json:"total_tokens"` - CacheReadTokens int `json:"cache_read_tokens"` - CacheWriteTokens int `json:"cache_write_tokens"` -} - -type agentToolCall struct { - ID string `json:"id"` - Type string `json:"type"` - Function struct { - Name string `json:"name"` - Arguments string `json:"arguments"` - } `json:"function"` -} - -func (ev *AgentEvent) writeMarkdown(sb *strings.Builder) { - switch ev.Type { - case "turn_start": - sb.WriteString(fmt.Sprintf("## Turn %d\n\n", ev.Turn)) - - case "message_end": - if ev.Message == nil { - return - } - switch ev.Message.Role { - case "user": - sb.WriteString(fmt.Sprintf("> %s\n\n", TruncateStr(ev.Message.Content, 200))) - case "assistant": - if len(ev.Message.ToolCalls) > 0 { - return - } - if ev.Message.Content != "" { - sb.WriteString(ev.Message.Content + "\n\n") - } - } - - case "tool_execution_start": - args := summarizeToolArgs(ev.ToolName, ev.Arguments) - if args != "" { - sb.WriteString(fmt.Sprintf("- **%s** `%s`\n", ev.ToolName, args)) - } else { - sb.WriteString(fmt.Sprintf("- **%s**\n", ev.ToolName)) - } - - case "tool_execution_end": - if ev.IsError || ev.Error != "" { - errMsg := ev.Error - if errMsg == "" { - errMsg = TruncateStr(ev.Result, 120) - } - sb.WriteString(fmt.Sprintf(" - ✗ `%s`\n", TruncateStr(errMsg, 120))) - } else { - sb.WriteString(fmt.Sprintf(" - ✓ %s\n", compactResult(ev.Result, 150))) - } - - case "turn_end": - if ev.Usage != nil && ev.Usage.TotalTokens > 0 { - usage := fmt.Sprintf("*%d tokens", ev.Usage.TotalTokens) - if ev.Usage.CacheReadTokens > 0 && ev.Usage.PromptTokens > 0 { - pct := float64(ev.Usage.CacheReadTokens) / float64(ev.Usage.PromptTokens) * 100 - usage += fmt.Sprintf(", cache %.0f%%", pct) - } - sb.WriteString("\n" + usage + "*\n") - } - sb.WriteString("\n") - } -} - // --------------------------------------------------------------------------- // Scan types // --------------------------------------------------------------------------- @@ -299,56 +197,34 @@ func collectSessionMeta(entries []TimelineEntry) sessionMeta { var m sessionMeta for _, e := range entries { switch d := e.Data.(type) { - case *AgentEvent: + case *aop.Event: if m.id == "" { m.id = d.SessionID - m.parentID = d.ParentSessionID - } - if d.RequestModel != "" && m.model == "" { - m.model = d.RequestModel - } - switch d.Type { - case "agent_start": - m.startTS = e.Timestamp - case "agent_end": - m.endTS = e.Timestamp - m.stop = d.Stop - case "turn_start": - m.turns++ - case "turn_end": - if d.Usage != nil { - m.totalTokens = d.Usage.TotalTokens - } } - case *AOPTimelineEntry: switch d.Type { - case "session.start": + case aop.TypeSessionStart: m.startTS = e.Timestamp - var sd struct { - Model string `json:"model"` + if data, err := aop.DecodeData[aop.SessionStartData](*d); err == nil { + m.parentID = data.ParentSessionID + if data.Model != "" && m.model == "" { + m.model = data.Model + } } - _ = json.Unmarshal(d.Data, &sd) - if sd.Model != "" && m.model == "" { - m.model = sd.Model - } - case "session.end": + case aop.TypeSessionEnd: m.endTS = e.Timestamp - case "turn.start": + case aop.TypeTurnStart: m.turns++ - case "turn.end": + case aop.TypeTurnEnd: m.endTS = e.Timestamp - var td struct { - Stop string `json:"stop"` - } - _ = json.Unmarshal(d.Data, &td) - m.stop = td.Stop - case "usage": - var ud struct { - TotalTokens int `json:"total_tokens"` + if data, err := aop.DecodeData[aop.TurnEndData](*d); err == nil { + m.stop = data.Stop + if data.Usage != nil && data.Usage.TotalTokens > 0 { + m.totalTokens = data.Usage.TotalTokens + } } - _ = json.Unmarshal(d.Data, &ud) - if ud.TotalTokens > 0 { - m.totalTokens = ud.TotalTokens + case aop.TypeUsage: + if data, err := aop.DecodeData[aop.UsageData](*d); err == nil && data.TotalTokens > 0 { + m.totalTokens = data.TotalTokens } } } @@ -476,110 +352,110 @@ func compactResult(result string, maxLen int) string { return TruncateStr(first, maxLen-20) + fmt.Sprintf(" (+%d lines)", len(lines)-1) } -// --------------------------------------------------------------------------- -// AOP event support -// --------------------------------------------------------------------------- - -type AOPTimelineEntry struct { - Type string `json:"type"` - Timestamp time.Time `json:"ts"` - SessionID string `json:"session_id"` - TurnID string `json:"turn_id,omitempty"` - Agent string `json:"agent"` - Data json.RawMessage `json:"data"` -} - -func (e AOPTimelineEntry) Valid() bool { - return e.Type != "" && !e.Timestamp.IsZero() && e.SessionID != "" && e.Agent != "" && len(e.Data) > 0 -} - -func (e *AOPTimelineEntry) writeMarkdown(sb *strings.Builder) { - switch e.Type { - case "turn.start": - sb.WriteString(fmt.Sprintf("## Run %s\n\n", e.TurnID)) +func writeAOPMarkdown(sb *strings.Builder, event *aop.Event) { + if event == nil { + return + } + switch event.Type { + case aop.TypeTurnStart: + sb.WriteString(fmt.Sprintf("## Run %s\n\n", event.TurnID)) - case "text": - var d struct { - Content string `json:"content"` - Role string `json:"role"` - Delta bool `json:"delta"` + case aop.TypeMessage: + data, err := aop.DecodeData[aop.MessageData](*event) + if err != nil { + return } - _ = json.Unmarshal(e.Data, &d) - if d.Delta || d.Content == "" { + var textParts []string + for _, part := range data.Parts { + if part.Type == aop.PartText && part.Text != "" { + textParts = append(textParts, part.Text) + } + } + text := strings.Join(textParts, "\n") + if text == "" { return } - if d.Role == "user" { - sb.WriteString(fmt.Sprintf("> %s\n\n", TruncateStr(d.Content, 200))) + if data.Role == "user" { + sb.WriteString(fmt.Sprintf("> %s\n\n", TruncateStr(text, 200))) } else { - sb.WriteString(d.Content + "\n\n") + detail, ok, _ := xcommand.GetDetail(*event) + if ok && detail.Presentation == "preformatted" { + sb.WriteString(markdownCodeFence(text) + "\n\n") + } else { + sb.WriteString(text + "\n\n") + } } - case "tool.call": - var d struct { - ToolName string `json:"tool_name"` - Args any `json:"args"` + case aop.TypeToolCall: + data, err := aop.DecodeData[aop.ToolCallData](*event) + if err != nil { + return } - _ = json.Unmarshal(e.Data, &d) argsStr := "" - switch a := d.Args.(type) { + switch args := data.Args.(type) { case string: - argsStr = a + argsStr = args case map[string]any: - raw, _ := json.Marshal(a) + raw, _ := json.Marshal(args) argsStr = string(raw) } - args := summarizeToolArgs(d.ToolName, argsStr) - if args != "" { - sb.WriteString(fmt.Sprintf("- **%s** `%s`\n", d.ToolName, args)) + summary := summarizeToolArgs(data.ToolName, argsStr) + if summary != "" { + sb.WriteString(fmt.Sprintf("- **%s** `%s`\n", data.ToolName, summary)) } else { - sb.WriteString(fmt.Sprintf("- **%s**\n", d.ToolName)) + sb.WriteString(fmt.Sprintf("- **%s**\n", data.ToolName)) } - case "tool.result": - var d struct { - ToolName string `json:"tool_name"` - Content any `json:"content"` - IsError bool `json:"is_error"` - } - _ = json.Unmarshal(e.Data, &d) - result := "" - if s, ok := d.Content.(string); ok { - result = s + case aop.TypeToolResult: + data, err := aop.DecodeData[aop.ToolResultData](*event) + if err != nil { + return } - if d.IsError { + result := aop.ToolResultText(data.Content) + if data.IsError { sb.WriteString(fmt.Sprintf(" - ✗ `%s`\n", TruncateStr(result, 120))) } else { sb.WriteString(fmt.Sprintf(" - ✓ %s\n", compactResult(result, 150))) } - case "usage": - var d struct { - TotalTokens int `json:"total_tokens"` - CacheReadTokens int `json:"cache_read_tokens"` - InputTokens int `json:"input_tokens"` + case aop.TypeUsage: + data, err := aop.DecodeData[aop.UsageData](*event) + if err != nil { + return } - _ = json.Unmarshal(e.Data, &d) - if d.TotalTokens > 0 { - usage := fmt.Sprintf("*%d tokens", d.TotalTokens) - if d.CacheReadTokens > 0 && d.InputTokens > 0 { - pct := float64(d.CacheReadTokens) / float64(d.InputTokens) * 100 + if data.TotalTokens > 0 { + usage := fmt.Sprintf("*%d tokens", data.TotalTokens) + if data.CacheReadTokens > 0 && data.InputTokens > 0 { + pct := float64(data.CacheReadTokens) / float64(data.InputTokens) * 100 usage += fmt.Sprintf(", cache %.0f%%", pct) } sb.WriteString("\n" + usage + "*\n\n") } - case "turn.end": - var d struct { - Stop string `json:"stop"` + case aop.TypeError: + data, err := aop.DecodeData[aop.ErrorData](*event) + if err == nil && data.Message != "" { + sb.WriteString(fmt.Sprintf("\n> **error:** %s\n\n", data.Message)) + } + + case aop.TypeTurnEnd: + data, err := aop.DecodeData[aop.TurnEndData](*event) + if err == nil { + sb.WriteString(fmt.Sprintf("\n> **run done** (stop=%s)\n\n", data.Stop)) } - _ = json.Unmarshal(e.Data, &d) - sb.WriteString(fmt.Sprintf("\n> **run done** (stop=%s)\n\n", d.Stop)) - case "session.end": - var d struct { - Reason string `json:"reason"` + case aop.TypeSessionEnd: + data, err := aop.DecodeData[aop.SessionEndData](*event) + if err == nil { + sb.WriteString(fmt.Sprintf("\n> **session closed** (reason=%s)\n\n", data.Reason)) } - _ = json.Unmarshal(e.Data, &d) - sb.WriteString(fmt.Sprintf("\n> **session closed** (reason=%s)\n\n", d.Reason)) } } + +func markdownCodeFence(text string) string { + fence := "```" + for strings.Contains(text, fence) { + fence += "`" + } + return fence + "\n" + text + "\n" + fence +} diff --git a/core/output/timeline_test.go b/core/output/timeline_test.go index b92aec35..baac2462 100644 --- a/core/output/timeline_test.go +++ b/core/output/timeline_test.go @@ -1,18 +1,23 @@ package output import ( + "encoding/json" "strings" "testing" + "time" + + "github.com/chainreactors/aiscan/pkg/aop" + xcommand "github.com/chainreactors/aiscan/pkg/aop/x/command" ) func TestParseLineReadsNativeAOPEnvelope(t *testing.T) { - raw := []byte(`{"type":"text","ts":"2026-07-20T00:00:00Z","session_id":"session-1","agent":"aiscan","data":{"content":"hello","role":"assistant"}}`) + raw := []byte(`{"type":"message","ts":"2026-07-20T00:00:00Z","session_id":"session-1","agent":"aiscan","data":{"message_id":"m-1","role":"assistant","parts":[{"type":"text","text":"hello"}]}}`) entry, ok := parseLine(raw) if !ok { t.Fatal("native AOP envelope was not parsed") } - if _, ok := entry.Data.(*AOPTimelineEntry); !ok { + if _, ok := entry.Data.(*aop.Event); !ok { t.Fatalf("entry data type = %T", entry.Data) } if markdown := BuildTimelineMarkdown([]TimelineEntry{entry}); !strings.Contains(markdown, "hello") { @@ -20,6 +25,50 @@ func TestParseLineReadsNativeAOPEnvelope(t *testing.T) { } } +func TestTimelineRendersStructuredToolResult(t *testing.T) { + data, _ := json.Marshal(aop.ToolResultData{ + ToolCallID: "call-1", ToolName: "scan", + Content: aop.ToolResultContent{Content: "three ports", Images: []aop.ImageSource{{MediaType: "image/png", Base64: "eA=="}}}, + }) + event := aop.Event{ + Type: aop.TypeToolResult, TS: "2026-07-20T00:00:00Z", SessionID: "session-1", TurnID: "turn-1", Agent: "aiscan", Data: data, + } + markdown := BuildTimelineMarkdown([]TimelineEntry{{Timestamp: mustTimelineTime(t, event.TS), Type: event.Type, Data: &event}}) + if !strings.Contains(markdown, "three ports") { + t.Fatalf("timeline markdown = %q", markdown) + } +} + +func TestTimelineFormatsPreformattedCommandAtPresentationBoundary(t *testing.T) { + data, _ := json.Marshal(aop.MessageData{ + MessageID: "command-1", Role: "assistant", Parts: []aop.MessagePart{{Type: aop.PartText, Text: "one\ntwo"}}, + }) + event := aop.Event{ + Type: aop.TypeMessage, TS: "2026-07-20T00:00:00Z", SessionID: "session-1", Agent: "aiscan", Data: data, + } + _ = xcommand.SetDetail(&event, xcommand.Detail{Line: "/status", Presentation: "preformatted"}) + markdown := BuildTimelineMarkdown([]TimelineEntry{{Timestamp: mustTimelineTime(t, event.TS), Type: event.Type, Data: &event}}) + if !strings.Contains(markdown, "```\none\ntwo\n```") { + t.Fatalf("timeline markdown = %q", markdown) + } +} + +func mustTimelineTime(t *testing.T, value string) time.Time { + t.Helper() + parsed, err := time.Parse(time.RFC3339Nano, value) + if err != nil { + t.Fatal(err) + } + return parsed +} + +func TestParseLineRejectsLegacyAgentRecord(t *testing.T) { + record := NewRecord(RecordType("agent"), map[string]any{"type": "message_end"}) + if _, ok := parseLine(record.Marshal()); ok { + t.Fatal("legacy agent record should not be accepted") + } +} + func TestParseLineRejectsLegacyAOPRecordPrefix(t *testing.T) { record := NewRecord(RecordType("aop.text"), map[string]any{"content": "legacy"}) if _, ok := parseLine(record.Marshal()); ok { diff --git a/core/runner/runner.go b/core/runner/runner.go index 8063d5ff..a9ac8c3c 100644 --- a/core/runner/runner.go +++ b/core/runner/runner.go @@ -18,9 +18,9 @@ import ( "github.com/chainreactors/aiscan/pkg/aop" cmdpkg "github.com/chainreactors/aiscan/pkg/commands" "github.com/chainreactors/aiscan/pkg/telemetry" - "github.com/chainreactors/aiscan/pkg/tools/toolargs" "github.com/chainreactors/aiscan/pkg/tui" "github.com/chainreactors/aiscan/skills" + "github.com/chainreactors/aiscan/tools/toolargs" ioaclient "github.com/chainreactors/ioa/client" "github.com/chainreactors/ioa/protocols" ) @@ -45,10 +45,11 @@ type AgentRuntime struct { cancel context.CancelFunc mu sync.RWMutex sessions map[string]*sessionState - turnIDs map[string]struct{} + runs map[string]*Run requestSeq uint64 closeOnce sync.Once wg sync.WaitGroup + operations sync.WaitGroup ptyManager *tmuxpkg.Manager replMode REPLMode maxPending int @@ -88,7 +89,7 @@ func NewAgentRuntime(ctx context.Context, option *cfg.Option, logger telemetry.L ctx: runtimeCtx, cancel: runtimeCancel, sessions: make(map[string]*sessionState), - turnIDs: make(map[string]struct{}), + runs: make(map[string]*Run), } if rc != nil { rt.replMode = rc.REPLMode @@ -317,6 +318,7 @@ func (rt *AgentRuntime) Close() { _ = rt.CloseSession(context.Background(), id, SessionCloseRuntime) } rt.wg.Wait() + rt.operations.Wait() if rt.cleanup != nil { rt.cleanup() } diff --git a/core/runner/runtime_protocol.go b/core/runner/runtime_protocol.go new file mode 100644 index 00000000..aeb9a4ec --- /dev/null +++ b/core/runner/runtime_protocol.go @@ -0,0 +1,109 @@ +package runner + +import ( + "context" + "encoding/json" + "fmt" + "strings" + + "github.com/chainreactors/aiscan/pkg/webproto" +) + +func RuntimeCommandSpecs() []webproto.CommandSpec { + return []webproto.CommandSpec{ + {Name: "/status", Description: "Show Runtime session and provider status"}, + {Name: "/clear", Description: "Clear the current Agent context"}, + {Name: "/compact", Usage: "/compact [focus]", Description: "Compact the current Agent context"}, + } +} + +// HandleProtocol handles the transport-neutral Agent Runtime control frames. +// The caller owns framing and I/O; AgentRuntime owns all Session and Run state. +func (rt *AgentRuntime) HandleProtocol(ctx context.Context, msg webproto.Message, send func(webproto.Message)) bool { + if rt == nil || send == nil { + return false + } + sendError := func(turnID, taskID string, err error) { + payload, _ := json.Marshal(webproto.ErrorPayload{Message: err.Error()}) + send(webproto.Message{Type: webproto.TypeError, TurnID: turnID, TaskID: taskID, Payload: payload}) + } + + switch msg.Type { + case webproto.TypeSessionOpen: + var payload webproto.SessionOpenPayload + if err := json.Unmarshal(msg.Payload, &payload); err != nil { + sendError("", "", err) + return true + } + session, err := rt.EnsureSession(SessionOptions{ + ID: payload.SessionID, ParentSessionID: payload.ParentSessionID, ParentToolCallID: payload.ParentToolCallID, + }) + if err != nil { + sendError("", "", err) + return true + } + encoded, _ := json.Marshal(webproto.SessionLifecyclePayload{SessionID: session.ID()}) + send(webproto.Message{Type: webproto.TypeSessionOpened, Payload: encoded}) + return true + + case webproto.TypeSessionClose: + var payload webproto.SessionLifecyclePayload + if err := json.Unmarshal(msg.Payload, &payload); err != nil { + sendError("", "", err) + return true + } + reason := SessionCloseReason(payload.Reason) + if err := rt.CloseSession(ctx, payload.SessionID, reason); err != nil { + sendError("", "", err) + return true + } + encoded, _ := json.Marshal(webproto.SessionLifecyclePayload{SessionID: payload.SessionID, Reason: string(reason)}) + send(webproto.Message{Type: webproto.TypeSessionClosed, Payload: encoded}) + return true + + case webproto.TypeRun: + if strings.TrimSpace(msg.TurnID) == "" { + sendError("", "", fmt.Errorf("run turn_id is required")) + return true + } + var payload webproto.RunPayload + if err := json.Unmarshal(msg.Payload, &payload); err != nil { + sendError(msg.TurnID, "", err) + return true + } + _, err := rt.RunSession(ctx, payload.SessionID, RunInput{ + TurnID: msg.TurnID, Parts: payload.Parts, NoEcho: payload.NoEcho, MaxTurns: payload.MaxTurns, + EvalCriteria: payload.EvalCriteria, EvalMaxRounds: payload.EvalMaxRounds, Continue: payload.Continue, + }) + if err != nil { + sendError(msg.TurnID, "", err) + } + return true + + case webproto.TypeRunCancel: + if err := rt.CancelRun(msg.TurnID); err != nil { + sendError(msg.TurnID, "", err) + } + return true + + case webproto.TypeCommand: + var payload webproto.CommandPayload + if err := json.Unmarshal(msg.Payload, &payload); err != nil { + sendError("", msg.TaskID, err) + return true + } + rt.operations.Add(1) + go func() { + defer rt.operations.Done() + result, err := rt.CommandSession(ctx, payload.SessionID, payload.Line) + if err != nil { + sendError("", msg.TaskID, err) + return + } + encoded, _ := json.Marshal(result) + send(webproto.Message{Type: webproto.TypeCommandResult, TaskID: msg.TaskID, Payload: encoded}) + }() + return true + } + return false +} diff --git a/core/runner/runtime_protocol_test.go b/core/runner/runtime_protocol_test.go new file mode 100644 index 00000000..096af634 --- /dev/null +++ b/core/runner/runtime_protocol_test.go @@ -0,0 +1,64 @@ +package runner + +import ( + "context" + "encoding/json" + "strings" + "testing" + + "github.com/chainreactors/aiscan/pkg/webproto" +) + +func TestProtocolErrorsKeepDistinctCorrelationIDs(t *testing.T) { + rt := newBareRuntime(t, nil, nil) + + var runError webproto.Message + if !rt.HandleProtocol(context.Background(), webproto.Message{ + Type: webproto.TypeRun, TurnID: "turn-1", Payload: json.RawMessage(`{`), + }, func(message webproto.Message) { runError = message }) { + t.Fatal("run frame was not handled") + } + if runError.Type != webproto.TypeError || runError.TurnID != "turn-1" || runError.TaskID != "" { + t.Fatalf("run error correlation = %+v", runError) + } + + var commandError webproto.Message + if !rt.HandleProtocol(context.Background(), webproto.Message{ + Type: webproto.TypeCommand, TaskID: "command-1", Payload: json.RawMessage(`{`), + }, func(message webproto.Message) { commandError = message }) { + t.Fatal("command frame was not handled") + } + if commandError.Type != webproto.TypeError || commandError.TaskID != "command-1" || commandError.TurnID != "" { + t.Fatalf("command error correlation = %+v", commandError) + } +} + +func TestProtocolRequiresTurnID(t *testing.T) { + rt := newBareRuntime(t, nil, nil) + var response webproto.Message + rt.HandleProtocol(context.Background(), webproto.Message{ + Type: webproto.TypeRun, Payload: webproto.MustJSON(webproto.RunPayload{SessionID: "session-1"}), + }, func(message webproto.Message) { response = message }) + var payload webproto.ErrorPayload + _ = json.Unmarshal(response.Payload, &payload) + if response.Type != webproto.TypeError || !strings.Contains(payload.Message, "turn_id is required") { + t.Fatalf("response = %+v payload=%+v", response, payload) + } +} + +func TestProtocolSessionOpenIsIdempotent(t *testing.T) { + rt := newBareRuntime(t, nil, nil) + request := webproto.Message{ + Type: webproto.TypeSessionOpen, + Payload: webproto.MustJSON(webproto.SessionOpenPayload{SessionID: "session-1"}), + } + for i := 0; i < 2; i++ { + var response webproto.Message + if !rt.HandleProtocol(context.Background(), request, func(message webproto.Message) { response = message }) { + t.Fatal("session.open was not handled") + } + if response.Type != webproto.TypeSessionOpened { + t.Fatalf("open %d response = %+v", i, response) + } + } +} diff --git a/core/runner/runtime_semantics_test.go b/core/runner/runtime_semantics_test.go index 2b407599..e2b1c4e3 100644 --- a/core/runner/runtime_semantics_test.go +++ b/core/runner/runtime_semantics_test.go @@ -11,6 +11,7 @@ import ( "github.com/chainreactors/aiscan/pkg/agent" "github.com/chainreactors/aiscan/pkg/agent/inbox" "github.com/chainreactors/aiscan/pkg/aop" + xcommand "github.com/chainreactors/aiscan/pkg/aop/x/command" "github.com/chainreactors/aiscan/pkg/commands" "github.com/chainreactors/aiscan/pkg/telemetry" ) @@ -72,18 +73,21 @@ func TestSessionRunHasOneReliableTurnLifecycle(t *testing.T) { t.Fatal(err) } - var replay []aop.Event - for event := range run.Events(context.Background()) { - replay = append(replay, event) + var turnEvents []aop.Event + for _, event := range all { + if event.TurnID != "turn-1" { + continue + } + turnEvents = append(turnEvents, event) if event.SessionID != "session-1" || event.TurnID != "turn-1" { t.Fatalf("run event identity = %+v", event) } } - if len(replay) < 2 || replay[0].Type != aop.TypeTurnStart || replay[len(replay)-1].Type != aop.TypeTurnEnd { - t.Fatalf("run replay = %+v", replay) + if len(turnEvents) < 2 || turnEvents[0].Type != aop.TypeTurnStart || turnEvents[len(turnEvents)-1].Type != aop.TypeTurnEnd { + t.Fatalf("turn events = %+v", turnEvents) } starts, ends := 0, 0 - for _, event := range replay { + for _, event := range turnEvents { if event.Type == aop.TypeTurnStart { starts++ } @@ -174,6 +178,10 @@ func TestCommandAddsAOPHistoryWithoutChangingTranscript(t *testing.T) { if commandEvent.Type != aop.TypeMessage || commandEvent.TurnID != "" { t.Fatalf("command AOP event = %+v", commandEvent) } + detail, ok, err := xcommand.GetDetail(commandEvent) + if err != nil || !ok || detail.Line != "!printf COMMAND_OK" || detail.Presentation != CommandPresentationPreformatted { + t.Fatalf("command extension = %+v ok=%v err=%v", detail, ok, err) + } after := session.MessagesSnapshot() if len(after) != len(before) { t.Fatalf("command changed transcript: before=%d after=%d", len(before), len(after)) @@ -183,6 +191,8 @@ func TestCommandAddsAOPHistoryWithoutChangingTranscript(t *testing.T) { func TestActiveRunSteersAsyncInputWithoutSecondLifecycle(t *testing.T) { provider := &runtimeSemanticProvider{started: make(chan struct{}), release: make(chan struct{})} rt := newBareRuntime(t, nil, provider) + var events []aop.Event + rt.Subscribe(func(event aop.Event) { events = append(events, event) }) session, err := rt.OpenSession(context.Background(), SessionOptions{ID: "session-1"}) if err != nil { t.Fatal(err) @@ -207,7 +217,10 @@ func TestActiveRunSteersAsyncInputWithoutSecondLifecycle(t *testing.T) { t.Fatalf("provider calls = %d, want 2 inside one Run", provider.callCount()) } starts, ends := 0, 0 - for event := range run.Events(context.Background()) { + for _, event := range events { + if event.TurnID != "turn-1" { + continue + } if event.Type == aop.TypeTurnStart { starts++ } diff --git a/core/runner/runtime_session.go b/core/runner/runtime_session.go index ac67be05..747f17ec 100644 --- a/core/runner/runtime_session.go +++ b/core/runner/runtime_session.go @@ -1,7 +1,6 @@ package runner import ( - "bytes" "context" "encoding/json" "errors" @@ -11,11 +10,11 @@ import ( "time" "github.com/chainreactors/aiscan/core/eventbus" - outputpkg "github.com/chainreactors/aiscan/core/output" "github.com/chainreactors/aiscan/pkg/agent" "github.com/chainreactors/aiscan/pkg/agent/evaluator" inboxpkg "github.com/chainreactors/aiscan/pkg/agent/inbox" "github.com/chainreactors/aiscan/pkg/aop" + xcommand "github.com/chainreactors/aiscan/pkg/aop/x/command" "github.com/chainreactors/aiscan/pkg/commands" "github.com/chainreactors/aiscan/pkg/telemetry" "github.com/chainreactors/aiscan/pkg/tui" @@ -60,18 +59,24 @@ type RunResult struct { } type CommandResult struct { - Parts []aop.MessagePart - Metadata map[string]any + Command string `json:"command"` + Presentation string `json:"presentation,omitempty"` + Parts []aop.MessagePart `json:"parts,omitempty"` } +const ( + CommandPresentationPlain = "plain" + CommandPresentationPreformatted = "preformatted" +) + type Session struct { state *sessionState } type Run struct { turnID string - log *runEventLog done chan struct{} + cancel context.CancelFunc mu sync.Mutex result RunResult err error @@ -84,15 +89,6 @@ func (r *Run) TurnID() string { return r.turnID } -func (r *Run) Events(ctx context.Context) <-chan aop.Event { - if r == nil || r.log == nil { - ch := make(chan aop.Event) - close(ch) - return ch - } - return r.log.events(ctx) -} - func (r *Run) Wait() (RunResult, error) { if r == nil { return RunResult{}, fmt.Errorf("run is nil") @@ -122,78 +118,6 @@ type commandOutcome struct { err error } -type runEventLog struct { - mu sync.Mutex - eventsLog []aop.Event - notify chan struct{} - closed bool -} - -func newRunEventLog() *runEventLog { - return &runEventLog{notify: make(chan struct{})} -} - -func (l *runEventLog) append(event aop.Event) { - l.mu.Lock() - if l.closed { - l.mu.Unlock() - return - } - l.eventsLog = append(l.eventsLog, event) - close(l.notify) - l.notify = make(chan struct{}) - l.mu.Unlock() -} - -func (l *runEventLog) close() { - l.mu.Lock() - if !l.closed { - l.closed = true - close(l.notify) - } - l.mu.Unlock() -} - -func (l *runEventLog) events(ctx context.Context) <-chan aop.Event { - if ctx == nil { - ctx = context.Background() - } - out := make(chan aop.Event) - go func() { - defer close(out) - index := 0 - for { - l.mu.Lock() - var event aop.Event - hasEvent := index < len(l.eventsLog) - if hasEvent { - event = l.eventsLog[index] - index++ - } - closed := l.closed - notify := l.notify - l.mu.Unlock() - if hasEvent { - select { - case out <- event: - case <-ctx.Done(): - return - } - continue - } - if closed { - return - } - select { - case <-notify: - case <-ctx.Done(): - return - } - } - }() - return out -} - type sessionEmitter struct { bus *eventbus.Bus[aop.Event] mu sync.Mutex @@ -225,17 +149,6 @@ type turnEmitter struct { turnID string agentName string emitter *sessionEmitter - log *runEventLog -} - -func (e *turnEmitter) observe(event aop.Event) { - if event.SessionID != e.sessionID || event.TurnID != e.turnID { - return - } - e.log.append(event) - if event.Type == aop.TypeTurnEnd { - e.log.close() - } } func (e *turnEmitter) start() { @@ -271,36 +184,99 @@ func (s *commandSession) execute(ctx context.Context, input string) commandOutco if line == "/continue" || strings.HasPrefix(line, "/followup ") || strings.HasPrefix(line, "/skill:") { return commandOutcome{err: fmt.Errorf("%s requires a Run", line)} } - - var stdout, stderr bytes.Buffer ctx = commands.ContextWithInbox(ctx, s.state.inbox) ctx = agent.ContextWithLoopScheduler(ctx, s.state.scheduler) - option := s.state.runtime.option - if option != nil { - copy := *option - copy.NoColor = true - option = © - } - console := tui.NewAgentConsoleWithWriters(ctx, option, s.state.runtime.consoleAppInfo(), s.state.agent, &stdout, &stderr) - console.SetEvalCriteria(s.evalCriteria) - _, err := console.ExecuteLineAndWait(line) - s.evalCriteria = console.EvalCriteria() - out := strings.TrimRight(outputpkg.StripANSI(stdout.String()), " \t\r\n") - errOut := strings.TrimRight(outputpkg.StripANSI(stderr.String()), " \t\r\n") + + if strings.HasPrefix(line, "!") { + return s.executeBash(ctx, line, strings.TrimSpace(strings.TrimPrefix(line, "!"))) + } + args, err := commands.SplitCommandLine(line) if err != nil { - if errOut != "" { - err = fmt.Errorf("%s: %w", errOut, err) - } return commandOutcome{err: err} } - if out == "" { - out = errOut - } else if errOut != "" { - out = strings.TrimRight(out+"\n"+errOut, " \t\r\n") + if len(args) == 0 { + return commandOutcome{err: fmt.Errorf("command line is required")} } - result := CommandResult{Metadata: map[string]any{"command": line}} - if out != "" { - result.Parts = []aop.MessagePart{{Type: aop.PartText, Text: out}} + name := args[0] + values := args[1:] + switch name { + case "/help": + return commandText(line, CommandPresentationPreformatted, + "Runtime commands:\n /status\n /clear\n /compact [focus]\n /eval [criteria|off]\n /loop [interval prompt|list|stop name]\n !") + case "/status": + provider, model, _ := s.state.runtime.providerSnapshot() + providerName := "not configured" + if provider != nil { + providerName = provider.Name() + } + return commandText(line, CommandPresentationPreformatted, fmt.Sprintf( + "Session: %s\nAgent: %s\nProvider: %s\nModel: %s\nMessages: %d", + s.state.id, s.state.agentName, providerName, model, len(s.state.agent.MessagesSnapshot()))) + case "/clear": + s.state.agent.Reset() + return commandText(line, CommandPresentationPlain, "Context cleared.") + case "/compact": + if len(s.state.agent.MessagesSnapshot()) < 4 { + return commandText(line, CommandPresentationPlain, "Nothing to compact (too few messages).") + } + result, err := s.state.agent.Compact(ctx, agent.CompactConfig{CustomInstructions: strings.TrimSpace(strings.Join(values, " "))}) + if err != nil { + return commandOutcome{err: err} + } + return commandText(line, CommandPresentationPlain, fmt.Sprintf( + "Compacted: ~%d -> ~%d tokens (%d messages kept)", result.TokensBefore, result.TokensAfter, result.KeptMessages)) + case "/eval", "/goal": + criteria := strings.TrimSpace(strings.Join(values, " ")) + switch criteria { + case "": + if s.evalCriteria == "" { + return commandText(line, CommandPresentationPlain, "Goal evaluation: off") + } + return commandText(line, CommandPresentationPlain, "Goal evaluation: on\n criteria: "+s.evalCriteria) + case "off": + s.evalCriteria = "" + return commandText(line, CommandPresentationPlain, "Goal evaluation disabled.") + default: + s.evalCriteria = criteria + return commandText(line, CommandPresentationPlain, "Goal evaluation enabled: "+criteria) + } + case "/loop": + command := "loop" + if len(values) == 0 { + command += " list" + } else { + command += " " + strings.Join(values, " ") + } + return s.executeBash(ctx, line, command) + default: + return commandOutcome{err: fmt.Errorf("command %q is not a Runtime command", name)} + } +} + +func (s *commandSession) executeBash(ctx context.Context, line, command string) commandOutcome { + if command == "" { + return commandOutcome{err: fmt.Errorf("command is required after !")} + } + registry := s.state.runtime.app.Commands + if registry == nil { + return commandOutcome{err: fmt.Errorf("command registry is not available")} + } + bash, ok := registry.GetTool("bash") + if !ok { + return commandOutcome{err: fmt.Errorf("bash tool is not registered")} + } + payload, _ := json.Marshal(commands.BashArgs{Command: command}) + result, err := bash.Execute(ctx, string(payload)) + if err != nil { + return commandOutcome{err: err} + } + return commandText(line, CommandPresentationPreformatted, strings.TrimRight(result.Text(), " \t\r\n")) +} + +func commandText(line, presentation, text string) commandOutcome { + result := CommandResult{Command: line, Presentation: presentation} + if text != "" { + result.Parts = []aop.MessagePart{{Type: aop.PartText, Text: text}} } return commandOutcome{result: result} } @@ -365,17 +341,19 @@ func (m *sessionMailbox) RegisterProducer(name string) *inboxpkg.ProducerHandle func (m *sessionMailbox) ActiveProducers() int { return m.base.ActiveProducers() } type sessionState struct { - runtime *AgentRuntime - id string - agentName string - agent *agent.Agent - inbox *sessionMailbox - scheduler *agent.LoopScheduler - commands *commandSession - ctx context.Context - cancel context.CancelFunc - ops chan *sessionOperation - done chan struct{} + runtime *AgentRuntime + id string + agentName string + parentSessionID string + parentToolCallID string + agent *agent.Agent + inbox *sessionMailbox + scheduler *agent.LoopScheduler + commands *commandSession + ctx context.Context + cancel context.CancelFunc + ops chan *sessionOperation + done chan struct{} mu sync.Mutex pending int @@ -431,7 +409,9 @@ func (rt *AgentRuntime) OpenSession(ctx context.Context, options SessionOptions) ag.LoadMessages(rt.resumeMessages) } state := &sessionState{ - runtime: rt, id: id, agentName: agentName, agent: ag, inbox: mailbox, + runtime: rt, id: id, agentName: agentName, + parentSessionID: options.ParentSessionID, parentToolCallID: options.ParentToolCallID, + agent: ag, inbox: mailbox, scheduler: scheduler, ctx: sessionCtx, cancel: cancel, ops: make(chan *sessionOperation, rt.pendingLimit()), done: make(chan struct{}), } @@ -456,6 +436,50 @@ func (rt *AgentRuntime) OpenSession(ctx context.Context, options SessionOptions) return public, nil } +// EnsureSession returns an existing Runtime-owned Session or opens it with the +// Runtime lifetime. It is idempotent so a transport reconnect can safely +// announce the same logical Session again. +func (rt *AgentRuntime) EnsureSession(options SessionOptions) (*Session, error) { + if rt == nil { + return nil, fmt.Errorf("agent runtime is not configured") + } + id := strings.TrimSpace(options.ID) + if id != "" { + rt.mu.RLock() + state := rt.sessions[id] + rt.mu.RUnlock() + if state != nil { + return ensuredSession(state, options) + } + } + session, err := rt.OpenSession(rt.ctx, options) + if err == nil || id == "" { + return session, err + } + // Concurrent reconnects may both observe the Session as absent. The strict + // OpenSession call admits one; the loser re-reads and validates that Session. + rt.mu.RLock() + state := rt.sessions[id] + rt.mu.RUnlock() + if state == nil { + return nil, err + } + return ensuredSession(state, options) +} + +func ensuredSession(state *sessionState, options SessionOptions) (*Session, error) { + if options.ParentSessionID != "" && options.ParentSessionID != state.parentSessionID { + return nil, fmt.Errorf("session %q parent_session_id conflicts with open session", state.id) + } + if options.ParentToolCallID != "" && options.ParentToolCallID != state.parentToolCallID { + return nil, fmt.Errorf("session %q parent_tool_call_id conflicts with open session", state.id) + } + if options.AgentName != "" && options.AgentName != state.agentName { + return nil, fmt.Errorf("session %q agent name conflicts with open session", state.id) + } + return &Session{state: state}, nil +} + func (rt *AgentRuntime) CloseSession(ctx context.Context, sessionID string, reason SessionCloseReason) error { if rt == nil { return fmt.Errorf("agent runtime is not configured") @@ -497,6 +521,58 @@ func (rt *AgentRuntime) Subscribe(fn func(aop.Event)) func() { return rt.bus.Subscribe(fn) } +func (rt *AgentRuntime) session(sessionID string) (*Session, error) { + if rt == nil { + return nil, fmt.Errorf("agent runtime is not configured") + } + rt.mu.RLock() + state := rt.sessions[strings.TrimSpace(sessionID)] + rt.mu.RUnlock() + if state == nil { + return nil, fmt.Errorf("session %q is not open", sessionID) + } + return &Session{state: state}, nil +} + +func (rt *AgentRuntime) RunSession(ctx context.Context, sessionID string, input RunInput) (*Run, error) { + session, err := rt.session(sessionID) + if err != nil { + return nil, err + } + return session.Run(ctx, input) +} + +func (rt *AgentRuntime) CommandSession(ctx context.Context, sessionID, line string) (CommandResult, error) { + session, err := rt.session(sessionID) + if err != nil { + return CommandResult{}, err + } + return session.Command(ctx, line) +} + +func (rt *AgentRuntime) CancelRun(turnID string) error { + if rt == nil { + return fmt.Errorf("agent runtime is not configured") + } + turnID = strings.TrimSpace(turnID) + rt.mu.RLock() + run := rt.runs[turnID] + rt.mu.RUnlock() + if run == nil { + return fmt.Errorf("turn %q is not active", turnID) + } + run.cancel() + return nil +} + +// WaitOperations waits for all Runs and asynchronous control operations that +// were admitted before the call. Transports use it to drain before shutdown. +func (rt *AgentRuntime) WaitOperations() { + if rt != nil { + rt.operations.Wait() + } +} + func (s *Session) Run(ctx context.Context, input RunInput) (*Run, error) { if s == nil || s.state == nil { return nil, fmt.Errorf("session is not configured") @@ -549,21 +625,24 @@ func (s *sessionState) startRun(ctx context.Context, input RunInput) (*Run, erro if turnID == "" { turnID = s.runtime.nextRuntimeID("turn") } + if ctx == nil { + ctx = context.Background() + } + runCtx, runCancel := context.WithCancel(ctx) + run := &Run{turnID: turnID, done: make(chan struct{}), cancel: runCancel} s.runtime.mu.Lock() - if _, exists := s.runtime.turnIDs[turnID]; exists { + if _, exists := s.runtime.runs[turnID]; exists { s.runtime.mu.Unlock() + runCancel() return nil, fmt.Errorf("turn %q already exists", turnID) } - s.runtime.turnIDs[turnID] = struct{}{} + s.runtime.runs[turnID] = run + s.runtime.operations.Add(1) s.runtime.mu.Unlock() - log := newRunEventLog() - run := &Run{turnID: turnID, log: log, done: make(chan struct{})} - emitter := &turnEmitter{sessionID: s.id, turnID: turnID, agentName: s.agentName, emitter: s.runtime.sessionEvents, log: log} - unsubscribe := s.runtime.Subscribe(emitter.observe) + emitter := &turnEmitter{sessionID: s.id, turnID: turnID, agentName: s.agentName, emitter: s.runtime.sessionEvents} op := &sessionOperation{ execute: func(runCtx context.Context) { - defer s.runtime.releaseTurnID(turnID) - defer unsubscribe() + defer s.runtime.releaseRun(run) s.inbox.setActive(true) emitter.start() result, runErr := s.executeRun(runCtx, turnID, input) @@ -585,8 +664,7 @@ func (s *sessionState) startRun(ctx context.Context, input RunInput) (*Run, erro run.finish(runResult, runErr) }, reject: func(err error) { - defer s.runtime.releaseTurnID(turnID) - defer unsubscribe() + defer s.runtime.releaseRun(run) result := RunResult{Stop: agent.StopReasonCanceled} if !errors.Is(err, context.Canceled) { result.Stop = agent.StopReasonError @@ -596,9 +674,8 @@ func (s *sessionState) startRun(ctx context.Context, input RunInput) (*Run, erro run.finish(result, err) }, } - if err := s.admit(ctx, op); err != nil { - unsubscribe() - s.runtime.releaseTurnID(turnID) + if err := s.admit(runCtx, op); err != nil { + s.runtime.releaseRun(run) return nil, err } return run, nil @@ -726,7 +803,9 @@ func (s *sessionState) emitCommandResult(result CommandResult) { raw, _ := json.Marshal(aop.MessageData{ MessageID: s.runtime.nextRuntimeID("command"), Role: "assistant", Parts: result.Parts, }) - s.runtime.sessionEvents.emit(aop.Event{Type: aop.TypeMessage, SessionID: s.id, Agent: s.agentName, Data: raw}) + event := aop.Event{Type: aop.TypeMessage, SessionID: s.id, Agent: s.agentName, Data: raw} + _ = xcommand.SetDetail(&event, xcommand.Detail{Line: result.Command, Presentation: result.Presentation}) + s.runtime.sessionEvents.emit(event) } func (rt *AgentRuntime) pendingLimit() int { @@ -762,10 +841,17 @@ func (rt *AgentRuntime) nextRuntimeID(prefix string) string { return id } -func (rt *AgentRuntime) releaseTurnID(turnID string) { +func (rt *AgentRuntime) releaseRun(run *Run) { + if run == nil { + return + } + run.cancel() rt.mu.Lock() - delete(rt.turnIDs, turnID) + if rt.runs[run.turnID] == run { + delete(rt.runs, run.turnID) + } rt.mu.Unlock() + rt.operations.Done() } func (rt *AgentRuntime) providerSnapshot() (agent.Provider, string, telemetry.Logger) { diff --git a/core/runner/runtime_session_isolation_test.go b/core/runner/runtime_session_isolation_test.go index 7d82d66b..14b328d1 100644 --- a/core/runner/runtime_session_isolation_test.go +++ b/core/runner/runtime_session_isolation_test.go @@ -26,7 +26,7 @@ func newBareRuntime(t *testing.T, reg *commands.CommandRegistry, provider agent. kernelBus.Subscribe(events.emit) rt := &AgentRuntime{ app: &App{Commands: reg}, option: &cfg.Option{}, ctx: ctx, cancel: cancel, - sessions: make(map[string]*sessionState), turnIDs: make(map[string]struct{}), + sessions: make(map[string]*sessionState), runs: make(map[string]*Run), bus: publicBus, kernelBus: kernelBus, sessionEvents: events, config: agent.Config{Provider: provider, Tools: reg, Bus: kernelBus, Logger: telemetry.NopLogger()}, } diff --git a/core/runner/stdio.go b/core/runner/stdio.go index 4fc34aba..aa7c4516 100644 --- a/core/runner/stdio.go +++ b/core/runner/stdio.go @@ -49,17 +49,12 @@ type stdioHost struct { enc *json.Encoder encErr error - rt *AgentRuntime - mu sync.Mutex - sessions map[string]*Session - runs map[string]context.CancelFunc - wg sync.WaitGroup + rt *AgentRuntime } func newStdioHost(ctx context.Context, option *cfg.Option, logger telemetry.Logger, output io.Writer) *stdioHost { return &stdioHost{ ctx: ctx, option: option, logger: logger, enc: json.NewEncoder(output), - sessions: make(map[string]*Session), runs: make(map[string]context.CancelFunc), } } @@ -102,11 +97,6 @@ func (h *stdioHost) emitError(turnID string, err error) { _ = h.emit(webproto.Message{Type: webproto.TypeError, TurnID: turnID, Payload: payload}) } -func (h *stdioHost) emitTaskError(taskID string, err error) { - payload, _ := json.Marshal(webproto.ErrorPayload{Message: err.Error()}) - _ = h.emit(webproto.Message{Type: webproto.TypeError, TaskID: taskID, Payload: payload}) -} - func (h *stdioHost) err() error { h.encMu.Lock() defer h.encMu.Unlock() @@ -122,120 +112,13 @@ func (h *stdioHost) accept(line string) { h.emitError("", fmt.Errorf("decode frame: %w", err)) return } - switch message.Type { - case webproto.TypeSessionOpen: - var payload webproto.SessionOpenPayload - if err := json.Unmarshal(message.Payload, &payload); err != nil { - h.emitError("", err) - return - } - session, err := h.rt.OpenSession(h.ctx, SessionOptions{ - ID: payload.SessionID, ParentSessionID: payload.ParentSessionID, ParentToolCallID: payload.ParentToolCallID, - }) - if err != nil { - h.emitError("", err) - return - } - h.mu.Lock() - h.sessions[session.ID()] = session - h.mu.Unlock() - opened, _ := json.Marshal(webproto.SessionLifecyclePayload{SessionID: session.ID()}) - _ = h.emit(webproto.Message{Type: webproto.TypeSessionOpened, Payload: opened}) - - case webproto.TypeSessionClose: - var payload webproto.SessionLifecyclePayload - if err := json.Unmarshal(message.Payload, &payload); err != nil { - h.emitError("", err) - return - } - reason := SessionCloseReason(payload.Reason) - if err := h.rt.CloseSession(h.ctx, payload.SessionID, reason); err != nil { - h.emitError("", err) - return - } - h.mu.Lock() - delete(h.sessions, payload.SessionID) - h.mu.Unlock() - closed, _ := json.Marshal(webproto.SessionLifecyclePayload{SessionID: payload.SessionID, Reason: string(reason)}) - _ = h.emit(webproto.Message{Type: webproto.TypeSessionClosed, Payload: closed}) - - case webproto.TypeRun: - var payload webproto.RunPayload - if err := json.Unmarshal(message.Payload, &payload); err != nil { - h.emitError(message.TurnID, err) - return - } - h.mu.Lock() - session := h.sessions[payload.SessionID] - h.mu.Unlock() - if session == nil { - h.emitError(message.TurnID, fmt.Errorf("session %q is not open", payload.SessionID)) - return - } - runCtx, cancel := context.WithCancel(h.ctx) - run, err := session.Run(runCtx, RunInput{ - TurnID: message.TurnID, Parts: payload.Parts, NoEcho: payload.NoEcho, MaxTurns: payload.MaxTurns, - EvalCriteria: payload.EvalCriteria, EvalMaxRounds: payload.EvalMaxRounds, - }) - if err != nil { - cancel() - h.emitError(message.TurnID, err) - return - } - turnID := run.TurnID() - h.mu.Lock() - h.runs[turnID] = cancel - h.mu.Unlock() - h.wg.Add(1) - go func() { - defer h.wg.Done() - defer cancel() - _, _ = run.Wait() - h.mu.Lock() - delete(h.runs, turnID) - h.mu.Unlock() - }() - - case webproto.TypeRunCancel: - h.mu.Lock() - cancel := h.runs[message.TurnID] - h.mu.Unlock() - if cancel == nil { - h.emitError(message.TurnID, fmt.Errorf("turn %q is not active", message.TurnID)) - return - } - cancel() - - case webproto.TypeCommand: - var payload webproto.CommandPayload - if err := json.Unmarshal(message.Payload, &payload); err != nil { - h.emitTaskError(message.TaskID, err) - return - } - h.mu.Lock() - session := h.sessions[payload.SessionID] - h.mu.Unlock() - if session == nil { - h.emitTaskError(message.TaskID, fmt.Errorf("session %q is not open", payload.SessionID)) - return - } - h.wg.Add(1) - go func() { - defer h.wg.Done() - result, err := session.Command(h.ctx, payload.Line) - if err != nil { - h.emitTaskError(message.TaskID, err) - return - } - encoded, _ := json.Marshal(webproto.CommandResultPayload{ - SessionID: payload.SessionID, Parts: result.Parts, Metadata: result.Metadata, - }) - _ = h.emit(webproto.Message{Type: webproto.TypeCommandResult, TaskID: message.TaskID, Payload: encoded}) - }() - - default: + if h.rt == nil || !h.rt.HandleProtocol(h.ctx, message, func(response webproto.Message) { _ = h.emit(response) }) { h.emitError(message.TurnID, fmt.Errorf("unsupported frame type %q", message.Type)) } } -func (h *stdioHost) drain() { h.wg.Wait() } +func (h *stdioHost) drain() { + if h.rt != nil { + h.rt.WaitOperations() + } +} diff --git a/core/runner/stdio_concurrency_test.go b/core/runner/stdio_concurrency_test.go index df453250..d8729d6c 100644 --- a/core/runner/stdio_concurrency_test.go +++ b/core/runner/stdio_concurrency_test.go @@ -68,8 +68,6 @@ func newStdioTestSession(t *testing.T, h *stdioHost, output *bytes.Buffer, id st if h.rt == nil || h.rt.ctx == nil { initialized := newRuntimeStdioHost(t, output, prov) h.rt = initialized.rt - h.sessions = initialized.sessions - h.runs = initialized.runs } h.accept(openSessionLine(t, id)) } diff --git a/core/runner/subagent_handoff.go b/core/runner/subagent_handoff.go index 6343921e..6650676d 100644 --- a/core/runner/subagent_handoff.go +++ b/core/runner/subagent_handoff.go @@ -16,16 +16,6 @@ import ( "github.com/chainreactors/ioa/protocols" ) -// subscribeIOAHandoff records the two subagent lifecycle boundaries as native -// IOA handoff messages by observing the agent AOP bus: a child session.start -// carrying a delegation extension is the delegation, and the matching child -// session.end is the return. The return references the delegation message so -// other IOA implementations can reconstruct the thread without aiscan-specific -// APIs. -func subscribeIOAHandoff(bus *eventbus.Bus[aop.Event], client protocols.ClientAPI, spaceName string, logger telemetry.Logger) { - _ = subscribeIOAHandoffContext(context.Background(), bus, client, spaceName, logger) -} - func subscribeIOAHandoffContext(ctx context.Context, bus *eventbus.Bus[aop.Event], client protocols.ClientAPI, spaceName string, logger telemetry.Logger) func() { if bus == nil || client == nil || spaceName == "" { return func() {} diff --git a/core/runner/subagent_handoff_test.go b/core/runner/subagent_handoff_test.go index 65b20fd3..77adc5e5 100644 --- a/core/runner/subagent_handoff_test.go +++ b/core/runner/subagent_handoff_test.go @@ -72,7 +72,8 @@ func handoffEvent(t *testing.T, typ, sessionID, agentName string, data any) aop. func TestIOAHandoffFromAOPBus(t *testing.T) { client := &handoffClient{} bus := eventbus.New[aop.Event]() - subscribeIOAHandoff(bus, client, "test", nil) + cancel := subscribeIOAHandoffContext(context.Background(), bus, client, "test", nil) + defer cancel() start := handoffEvent(t, aop.TypeSessionStart, "child-session", "worker", aop.SessionStartData{ Model: "test-model", @@ -136,7 +137,8 @@ func TestIOAHandoffFromAOPBus(t *testing.T) { func TestIOAHandoffFailedRun(t *testing.T) { client := &handoffClient{} bus := eventbus.New[aop.Event]() - subscribeIOAHandoff(bus, client, "test", nil) + cancel := subscribeIOAHandoffContext(context.Background(), bus, client, "test", nil) + defer cancel() start := handoffEvent(t, aop.TypeSessionStart, "child-session", "worker", aop.SessionStartData{ ParentSessionID: "parent-session", @@ -165,7 +167,8 @@ func TestIOAHandoffFailedRun(t *testing.T) { func TestIOAHandoffIgnoresNonDelegationSessions(t *testing.T) { client := &handoffClient{} bus := eventbus.New[aop.Event]() - subscribeIOAHandoff(bus, client, "test", nil) + cancel := subscribeIOAHandoffContext(context.Background(), bus, client, "test", nil) + defer cancel() bus.Emit(handoffEvent(t, aop.TypeSessionStart, "root-session", "aiscan", aop.SessionStartData{Model: "test-model"})) bus.Emit(handoffEvent(t, aop.TypeTurnEnd, "root-session", "aiscan", aop.TurnEndData{Stop: "completed"})) diff --git a/docs/development.md b/docs/development.md index 49b61d5e..2ed17424 100644 --- a/docs/development.md +++ b/docs/development.md @@ -1,5 +1,7 @@ # Aiscan 扩展开发手册 +> 新增结构化原生工具时,优先阅读 [`tools/README.md`](../tools/README.md)。最小实现只需要 `core/tool.Tool` 的四个方法和一次 `RegisterTool`;本页主要说明需要 Bash、扫描引擎或其他 Runtime 依赖的 Pseudo-Command/Factory 路径。 + Aiscan 提供两种对 AI 零侵入的扩展机制,开发者无需修改 agent 核心代码即可为 AI 增加新能力: | 扩展方式 | 实现方式 | 侵入程度 | 适用场景 | @@ -155,11 +157,11 @@ type Deps struct { ```go // cmd/aiscan/imports.go import ( - _ "github.com/chainreactors/aiscan/pkg/tools" // scanner 组 - _ "github.com/chainreactors/aiscan/pkg/tools/arsenal" // arsenal 组 - _ "github.com/chainreactors/aiscan/pkg/tools/ioa" // ioa 组 - _ "github.com/chainreactors/aiscan/pkg/tools/proxy" // proxy 组 - _ "github.com/chainreactors/aiscan/pkg/tools/search" // search 组 + _ "github.com/chainreactors/aiscan/tools" // scanner 组 + _ "github.com/chainreactors/aiscan/tools/arsenal" // arsenal 组 + _ "github.com/chainreactors/aiscan/tools/ioa" // ioa 组 + _ "github.com/chainreactors/aiscan/tools/proxy" // proxy 组 + _ "github.com/chainreactors/aiscan/tools/search" // search 组 ) ``` @@ -170,12 +172,12 @@ import ( **步骤 1:创建包目录** ``` -pkg/tools/whatweb/ +tools/whatweb/ ├── whatweb.go # 命令实现 └── register.go # 工厂注册 ``` -**步骤 2:实现 Command 接口** — `pkg/tools/whatweb/whatweb.go` +**步骤 2:实现 Command 接口** — `tools/whatweb/whatweb.go` ```go package whatweb @@ -259,7 +261,7 @@ func (c *Command) Execute(ctx context.Context, args []string) error { } ``` -**步骤 3:注册工厂** — `pkg/tools/whatweb/register.go` +**步骤 3:注册工厂** — `tools/whatweb/register.go` ```go package whatweb @@ -289,7 +291,7 @@ func init() { ```go import ( // ...existing imports... - _ "github.com/chainreactors/aiscan/pkg/tools/whatweb" + _ "github.com/chainreactors/aiscan/tools/whatweb" ) ``` diff --git a/docs/mechanisms.md b/docs/mechanisms.md index 6b375acf..a86cfbc6 100644 --- a/docs/mechanisms.md +++ b/docs/mechanisms.md @@ -217,27 +217,19 @@ agent 端的 skill 命令和 `!bash` 从浏览器也能用。 --- -## 12. completeAssistantRun 始终广播 +## 12. Agent 生命周期统一由 AOP 驱动 -**问题**: 旧 `persistAssistantMessage` 在 content 为空时跳过广播和持久化。tool-only turn 或 eval 命中轮次上限时 UI 卡在 streaming indicator。 +**问题**: 旧 Web 路径通过 `completeAssistantRun` 合成终止消息,并另外持久化中间轮次。它与 Runtime 已产生的 AOP message/turn 生命周期重复,tool-only turn 还需要额外的空消息规则才能释放 UI 状态。 -**机制**: 新 `completeAssistantRun` **始终广播** terminal message event,但只在有文本时持久化。空回复不留空行,UI 正常释放 composer。 +**机制**: Runtime 产生的 typed AOP event 是 Agent 消息、工具调用和 turn 状态的唯一语义来源。Web 层直接转发和持久化这些事件,不再合成第二套 assistant 完成事件,也不再为中间轮次维护独立的聊天事件协议。 -**文件**: `pkg/web/service.go` +scan、agent joined、session cleared 等产品事件保留独立的 `DomainEvent`,不携带 Agent 的 role/content/message ID 字段。 ---- - -## 13. message_end 中间轮持久化 - -**问题**: 多轮对话中只有最终聚合回复被持久化(`completeAssistantRun`),中间每轮的 assistant 文本只在 SSE 流中出现,页面刷新后消失。 - -**机制**: `persistRuntimeChatEvent` 新增 `ChatEventMessageEnd` case。每轮非空的 finalized text 存为 assistant message,带 turn 元数据。`buildTimelineFromMessages` 按 turn 归到正确的气泡。streaming partials (message_start/message_delta) 不持久化。 - -**文件**: `pkg/web/service.go` +**文件**: `core/runner/`, `pkg/aop/`, `pkg/web/service.go` --- -## 14. TUI 渲染改进 +## 13. TUI 渲染改进 ### CJK 感知宽度 @@ -255,15 +247,15 @@ agent 端的 skill 命令和 `!bash` 从浏览器也能用。 `redactIOAURL` 剥离 `http://@host/ioa` 中的 userinfo,防止 token 泄露到终端/截图。 -### fenceTerminalOutput +### 命令展示边界 -REPL 多行输出(box-drawing panel)在 web chat 中包裹 code fence,让前端以 monospace `
` 渲染。单行输出保持 prose。fence 长度自适应避免与内容中的 backtick 冲突。
+跨界面 Runtime 命令通过 typed AOP command detail 标记 `presentation: preformatted`。Web timeline 在最终展示边界生成自适应 Markdown code fence;Runtime、Session 和 transport 不再处理 Markdown 或终端格式。
 
-**文件**: `pkg/tui/banner.go`, `pkg/tui/commands.go`, `pkg/tui/ioa.go`, `pkg/webagent/agent.go`
+**文件**: `pkg/tui/banner.go`, `pkg/tui/commands.go`, `pkg/tui/ioa.go`, `pkg/aop/x/command/command.go`, `core/output/timeline.go`
 
 ---
 
-## 15. 环境变量优先级修正
+## 14. 环境变量优先级修正
 
 旧逻辑中 provider-scoped env(如 `ANTHROPIC_MODEL`)和 aiscan 自有 env(`AISCAN_MODEL`)在 `else if` 链中平级。hub 启动的 agent 继承 hub 环境后,Settings UI 配置的 model 被环境变量覆盖。
 
diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go
index 0a1f1332..744420e9 100644
--- a/pkg/agent/loop.go
+++ b/pkg/agent/loop.go
@@ -535,14 +535,8 @@ func runToolCall(ctx context.Context, cfg Config, assistantMsg ChatMessage, tc T
 // eventContent returns the AOP tool.result payload: a plain string, or the
 // {content, images} variant when the tool returned images.
 func (e toolExecution) eventContent() any {
-	if e.fullResult != nil && e.fullResult.HasImages() {
-		trc := aop.ToolResultContent{Content: e.eventResultText()}
-		for _, block := range e.fullResult.Content {
-			if block.Type == "image" {
-				trc.Images = append(trc.Images, aop.ImageSource{Base64: block.Base64Data, MediaType: block.MimeType})
-			}
-		}
-		return trc
+	if e.fullResult != nil {
+		return aop.ToolResultContentFromResult(*e.fullResult, e.eventResultText())
 	}
 	return e.eventResultText()
 }
diff --git a/pkg/agent/probe/conn.go b/pkg/agent/probe/conn.go
index 9f17a76b..e941f34c 100644
--- a/pkg/agent/probe/conn.go
+++ b/pkg/agent/probe/conn.go
@@ -19,7 +19,7 @@ import (
 	ioaclient "github.com/chainreactors/ioa/client"
 	"github.com/chainreactors/sdk/pkg/cyberhub"
 
-	"github.com/chainreactors/aiscan/pkg/tools/search"
+	"github.com/chainreactors/aiscan/tools/search"
 )
 
 // ConnCheck is the outcome of probing one external dependency. A single
diff --git a/pkg/aop/tool_result.go b/pkg/aop/tool_result.go
new file mode 100644
index 00000000..93ad9d0a
--- /dev/null
+++ b/pkg/aop/tool_result.go
@@ -0,0 +1,60 @@
+package aop
+
+import (
+	"fmt"
+	"time"
+
+	"github.com/chainreactors/aiscan/core/tool"
+)
+
+// ToolResultContentFromResult converts the canonical tool Result blocks to the
+// AOP content variant without flattening images or changing the supplied text.
+func ToolResultContentFromResult(result tool.Result, text string) any {
+	if !result.HasImages() {
+		return text
+	}
+	content := ToolResultContent{Content: text}
+	for _, block := range result.Content {
+		if block.Type == "image" {
+			content.Images = append(content.Images, ImageSource{Base64: block.Base64Data, MediaType: block.MimeType})
+		}
+	}
+	return content
+}
+
+// ToolResultDataFromResult is the single conversion used by Agent-internal and
+// direct remote tool execution.
+func ToolResultDataFromResult(call ToolCallData, result tool.Result, execErr error, duration time.Duration) ToolResultData {
+	text := result.Text()
+	if execErr != nil {
+		text = execErr.Error()
+	}
+	return ToolResultData{
+		ToolCallID: call.ToolCallID,
+		ToolName:   call.ToolName,
+		Content:    ToolResultContentFromResult(result, text),
+		Details:    result.Details,
+		Terminate:  result.Terminate,
+		IsError:    execErr != nil || result.IsError,
+		DurationMs: int(duration.Milliseconds()),
+	}
+}
+
+// ToolResultText reads both in-memory and JSON-decoded structured content.
+func ToolResultText(content any) string {
+	switch value := content.(type) {
+	case string:
+		return value
+	case ToolResultContent:
+		return value.Content
+	case *ToolResultContent:
+		if value != nil {
+			return value.Content
+		}
+	case map[string]any:
+		if text, ok := value["content"].(string); ok {
+			return text
+		}
+	}
+	return fmt.Sprint(content)
+}
diff --git a/pkg/aop/tool_result_test.go b/pkg/aop/tool_result_test.go
new file mode 100644
index 00000000..3f213dd0
--- /dev/null
+++ b/pkg/aop/tool_result_test.go
@@ -0,0 +1,34 @@
+package aop
+
+import (
+	"errors"
+	"testing"
+	"time"
+
+	"github.com/chainreactors/aiscan/core/tool"
+)
+
+func TestToolResultDataFromResultPreservesStructuredContent(t *testing.T) {
+	result := tool.Result{
+		Content: []tool.ContentBlock{
+			tool.TextBlock("done"),
+			tool.ImageBlock("image/png", "aGVsbG8="),
+		},
+		Details: map[string]any{"ports": 3}, Terminate: true,
+	}
+	data := ToolResultDataFromResult(ToolCallData{ToolCallID: "call-1", ToolName: "scan"}, result, nil, 12*time.Millisecond)
+	if data.ToolCallID != "call-1" || data.ToolName != "scan" || data.DurationMs != 12 || !data.Terminate || data.IsError {
+		t.Fatalf("data = %+v", data)
+	}
+	content, ok := data.Content.(ToolResultContent)
+	if !ok || content.Content != "done" || len(content.Images) != 1 || content.Images[0].MediaType != "image/png" {
+		t.Fatalf("content = %#v", data.Content)
+	}
+}
+
+func TestToolResultDataFromResultUsesExecutionError(t *testing.T) {
+	data := ToolResultDataFromResult(ToolCallData{ToolCallID: "call-1"}, tool.TextResult("partial"), errors.New("failed"), 0)
+	if !data.IsError || ToolResultText(data.Content) != "failed" {
+		t.Fatalf("data = %+v", data)
+	}
+}
diff --git a/pkg/aop/x/command/command.go b/pkg/aop/x/command/command.go
new file mode 100644
index 00000000..123965de
--- /dev/null
+++ b/pkg/aop/x/command/command.go
@@ -0,0 +1,13 @@
+package command
+
+import "github.com/chainreactors/aiscan/pkg/aop"
+
+const NS = "command"
+
+type Detail struct {
+	Line         string `json:"line"`
+	Presentation string `json:"presentation,omitempty"`
+}
+
+func GetDetail(event aop.Event) (Detail, bool, error) { return aop.Ext[Detail](event, NS) }
+func SetDetail(event *aop.Event, value Detail) error  { return aop.SetExt(event, NS, value) }
diff --git a/pkg/tui/commands.go b/pkg/tui/commands.go
index ff781628..925b72b1 100644
--- a/pkg/tui/commands.go
+++ b/pkg/tui/commands.go
@@ -77,7 +77,7 @@ func SkillCommands(s *Session) []Command {
 		}
 		sk := skill
 		cmds = append(cmds, Command{
-			Name:        "/" + sk.Name,
+			Name:        "/skill:" + sk.Name,
 			Description: sk.Description,
 			Args:        ArgsOptional,
 			Run: func(ctx context.Context, s *Session, args []string) error {
diff --git a/pkg/tui/console.go b/pkg/tui/console.go
index 6ecede85..0dc558e5 100644
--- a/pkg/tui/console.go
+++ b/pkg/tui/console.go
@@ -443,6 +443,13 @@ func (r *AgentConsole) handleRuntimeInputLine(line string) (bool, error) {
 			return r.appInfo.Run(ctx, prompt, false)
 		})
 	}
+	if runtimeTUICommand(text) {
+		args, err := AgentConsoleArgsForLine(text)
+		if err != nil {
+			return false, err
+		}
+		return false, r.executeArgs(r.ctx, args)
+	}
 	if strings.HasPrefix(text, "!") {
 		return false, r.appInfo.Command(r.ctx, text)
 	}
@@ -455,6 +462,19 @@ func (r *AgentConsole) handleRuntimeInputLine(line string) (bool, error) {
 	return false, r.appInfo.Command(r.ctx, text)
 }
 
+func runtimeTUICommand(line string) bool {
+	name := strings.Fields(strings.TrimSpace(line))
+	if len(name) == 0 {
+		return false
+	}
+	switch name[0] {
+	case "/help", "/resume", "/provider", "/model", "/spaces", "/messages", "/context", "/nodes":
+		return true
+	default:
+		return false
+	}
+}
+
 func (r *AgentConsole) promptString() string {
 	return agentPromptString(r.ensureOutput())
 }
diff --git a/pkg/tui/remote_console.go b/pkg/tui/remote_console.go
index 17ba1c57..dd8193f1 100644
--- a/pkg/tui/remote_console.go
+++ b/pkg/tui/remote_console.go
@@ -13,13 +13,13 @@ import (
 	rlterm "github.com/chainreactors/tui/readline/terminal"
 )
 
-// AgentEventSubscriber connects a console-local renderer to the runtime AOP
+// AOPEventSubscriber connects a console-local renderer to the runtime AOP
 // bus and returns an unsubscribe function owned by that console attachment.
-type AgentEventSubscriber func(func(aop.Event)) func()
+type AOPEventSubscriber func(func(aop.Event)) func()
 
 // RunRemoteAgentConsoleWithControl adapts a byte-stream terminal while keeping
 // event rendering scoped to the attached agent session.
-func RunRemoteAgentConsoleWithControl(ctx context.Context, option *cfg.Option, appInfo AppInfo, session *agent.Agent, input io.Reader, output io.Writer, control *rlterm.StreamControl, subscribers ...AgentEventSubscriber) error {
+func RunRemoteAgentConsoleWithControl(ctx context.Context, option *cfg.Option, appInfo AppInfo, session *agent.Agent, input io.Reader, output io.Writer, control *rlterm.StreamControl, subscribers ...AOPEventSubscriber) error {
 	if control == nil {
 		control = rlterm.NewControl(true, 80, 24)
 	}
@@ -30,7 +30,7 @@ func RunRemoteAgentConsoleWithControl(ctx context.Context, option *cfg.Option, a
 // RunAgentConsoleWithTerminal creates the renderer and readline console for an
 // explicit terminal. Local callers pass the process terminal directly so
 // control sequences are never buffered and replayed through a PTY.
-func RunAgentConsoleWithTerminal(ctx context.Context, option *cfg.Option, appInfo AppInfo, session *agent.Agent, terminal *rlterm.Terminal, subscribers ...AgentEventSubscriber) error {
+func RunAgentConsoleWithTerminal(ctx context.Context, option *cfg.Option, appInfo AppInfo, session *agent.Agent, terminal *rlterm.Terminal, subscribers ...AOPEventSubscriber) error {
 	if terminal == nil {
 		return fmt.Errorf("terminal is nil")
 	}
@@ -43,7 +43,7 @@ func RunAgentConsoleWithTerminal(ctx context.Context, option *cfg.Option, appInf
 
 // subscribeAgentOutput filters the shared runtime bus by session ID so a
 // remote or local REPL cannot render sibling/subagent events accidentally.
-func subscribeAgentOutput(output *AgentOutput, session *agent.Agent, subscribers ...AgentEventSubscriber) func() {
+func subscribeAgentOutput(output *AgentOutput, session *agent.Agent, subscribers ...AOPEventSubscriber) func() {
 	if output == nil || session == nil || len(subscribers) == 0 || subscribers[0] == nil {
 		return func() {}
 	}
diff --git a/pkg/web/agents.go b/pkg/web/agents.go
index 9217bec9..b1c0bfcc 100644
--- a/pkg/web/agents.go
+++ b/pkg/web/agents.go
@@ -5,7 +5,6 @@ import (
 	"encoding/json"
 	"fmt"
 	"net/http"
-	"strings"
 	"sync"
 	"sync/atomic"
 	"time"
@@ -98,7 +97,7 @@ func (a *remoteAgent) commandSpecs() []webproto.CommandSpec {
 // SessionLookup resolves a task ID to its owning chat session.
 type SessionLookup interface {
 	TaskSession(taskID string) (sessionID string, ok bool)
-	BroadcastChatEvent(sessionID string, event ChatEvent)
+	BroadcastDomainEvent(sessionID string, event DomainEvent)
 	BroadcastAOPEvent(sessionID string, event aop.Event)
 }
 
@@ -265,31 +264,51 @@ func (p *AgentPool) PickChat() *remoteAgent {
 	return fallback
 }
 
-// DispatchToolCall sends a structured Command to a tool-capable node and
-// returns a channel for the result. taskID correlates this non-Run RPC and its
-// progress telemetry.
+// DispatchToolCall sends a canonical AOP tool.call to a tool-capable node.
+// The task completes only on the matching AOP tool.result.
 func (p *AgentPool) DispatchToolCall(agentID, taskID string, call aop.ToolCallData) (<-chan taskResult, error) {
-	payload, err := json.Marshal(webproto.CommandPayload{SessionID: taskID, ToolCall: &call})
-	if err != nil {
-		return nil, fmt.Errorf("marshal command: %w", err)
+	a := p.get(agentID)
+	if a == nil {
+		return nil, fmt.Errorf("agent %s not connected", agentID)
 	}
-	if a := p.get(agentID); a != nil {
-		a.mu.Lock()
-		if a.toolCalls == nil {
-			a.toolCalls = map[string]struct{}{}
+	call.ToolCallID = taskID
+	sessionID := taskID
+	if p.sessions != nil {
+		if sid, ok := p.sessions.TaskSession(taskID); ok {
+			sessionID = sid
 		}
-		a.toolCalls[taskID] = struct{}{}
-		a.mu.Unlock()
 	}
-	ch, err := p.dispatchMessage(agentID, taskID, webproto.Message{Type: webproto.TypeCommand, TaskID: taskID, Payload: payload})
+	agentName := a.name
+	if agentName == "" {
+		agentName = a.id
+	}
+	data, err := json.Marshal(call)
 	if err != nil {
-		if a := p.get(agentID); a != nil {
-			a.mu.Lock()
-			delete(a.toolCalls, taskID)
-			a.mu.Unlock()
-		}
+		return nil, fmt.Errorf("marshal tool.call: %w", err)
+	}
+	event := aop.Event{
+		Type: aop.TypeToolCall, TS: time.Now().UTC().Format(time.RFC3339Nano),
+		SessionID: sessionID, TurnID: taskID, Agent: agentName, Data: data,
+	}
+	payload, _ := json.Marshal(event)
+	a.mu.Lock()
+	if a.toolCalls == nil {
+		a.toolCalls = map[string]struct{}{}
+	}
+	a.toolCalls[taskID] = struct{}{}
+	a.mu.Unlock()
+	ch, err := p.dispatchMessage(agentID, taskID, webproto.Message{
+		Type: webproto.TypeAOP, TaskID: taskID, TurnID: taskID, Payload: payload,
+	})
+	if err != nil {
+		a.mu.Lock()
+		delete(a.toolCalls, taskID)
+		a.mu.Unlock()
 		return nil, err
 	}
+	if p.sessions != nil && sessionID != taskID {
+		p.sessions.BroadcastAOPEvent(sessionID, event)
+	}
 	return ch, nil
 }
 
@@ -809,8 +828,8 @@ func (p *AgentPool) handleAgentMessage(a *remoteAgent, msg webproto.Message) {
 			Type: "progress",
 			Data: mustJSON(map[string]string{"scan_id": msg.TaskID, "data": data}),
 		})
-		p.forwardToSession(a, msg.TaskID, ChatEvent{
-			Type:   ChatEventScanProgress,
+		p.forwardToSession(a, msg.TaskID, DomainEvent{
+			Type:   DomainEventScanProgress,
 			ScanID: msg.TaskID,
 			Data:   data,
 		})
@@ -878,25 +897,10 @@ func (p *AgentPool) handleAgentMessage(a *remoteAgent, msg webproto.Message) {
 		if ok && ch != nil {
 			result := taskResult{Result: msg.Payload}
 			if isToolCall {
-				var command webproto.CommandResultPayload
-				if err := json.Unmarshal(msg.Payload, &command); err != nil {
-					result.Err = "decode command.result: " + err.Error()
-				} else {
-					result.Output = commandPartsText(command.Parts)
-					if isError, _ := command.Metadata["is_error"].(bool); isError {
-						result.Err, result.Output = result.Output, ""
-					}
-					if details := command.Metadata["details"]; details != nil {
-						result.Result, _ = json.Marshal(details)
-					}
-				}
+				result = taskResult{Err: "direct tool task returned command.result; expected AOP tool.result"}
 			}
 			ch <- result
 			close(ch)
-			if isToolCall {
-				p.recordScanResultStats(a, result.Result)
-				p.persistResultRecords(a, msg.TaskID, result.Result)
-			}
 		}
 
 	// complete/error are the terminal envelopes of the file RPCs only; agent
@@ -972,7 +976,7 @@ func (p *AgentPool) recordScanResultStats(a *remoteAgent, payload json.RawMessag
 	a.mu.Unlock()
 }
 
-func (p *AgentPool) forwardToSession(a *remoteAgent, taskID string, event ChatEvent) {
+func (p *AgentPool) forwardToSession(a *remoteAgent, taskID string, event DomainEvent) {
 	if p.sessions == nil || taskID == "" {
 		return
 	}
@@ -986,7 +990,7 @@ func (p *AgentPool) forwardToSession(a *remoteAgent, taskID string, event ChatEv
 	if event.AgentName == "" {
 		event.AgentName = a.name
 	}
-	p.sessions.BroadcastChatEvent(sid, event)
+	p.sessions.BroadcastDomainEvent(sid, event)
 }
 
 func (p *AgentPool) forwardAOPEvent(a *remoteAgent, msg webproto.Message) {
@@ -1000,7 +1004,11 @@ func (p *AgentPool) forwardAOPEvent(a *remoteAgent, msg webproto.Message) {
 	// Session-topic broadcast is optional (scans dispatched outside chat have
 	// no chat session); task convergence below is not.
 	if p.sessions != nil {
-		if sid, ok := p.sessions.TaskSession(msg.TurnID); ok {
+		correlationID := msg.TurnID
+		if msg.TaskID != "" {
+			correlationID = msg.TaskID
+		}
+		if sid, ok := p.sessions.TaskSession(correlationID); ok {
 			p.sessions.BroadcastAOPEvent(sid, aopEv)
 		} else if aopEv.SessionID != "" {
 			p.sessions.BroadcastAOPEvent(aopEv.SessionID, aopEv)
@@ -1045,7 +1053,7 @@ func (p *AgentPool) convergeTaskOnToolResult(a *remoteAgent, taskID string, ev a
 		close(ch)
 		return
 	}
-	res := taskResult{Output: toolResultText(d.Content), Turn: turn}
+	res := taskResult{Output: aop.ToolResultText(d.Content), Turn: turn}
 	if d.IsError {
 		res.Err = res.Output
 		res.Output = ""
@@ -1061,30 +1069,6 @@ func (p *AgentPool) convergeTaskOnToolResult(a *remoteAgent, taskID string, ev a
 	p.persistResultRecords(a, taskID, details)
 }
 
-// toolResultText flattens tool.result content: a plain string, or the text of
-// a structured ToolResultContent (text plus images).
-func toolResultText(content any) string {
-	switch c := content.(type) {
-	case string:
-		return c
-	case map[string]any:
-		if text, ok := c["content"].(string); ok {
-			return text
-		}
-	}
-	return ""
-}
-
-func commandPartsText(parts []aop.MessagePart) string {
-	var values []string
-	for _, part := range parts {
-		if part.Type == aop.PartText && part.Text != "" {
-			values = append(values, part.Text)
-		}
-	}
-	return strings.Join(values, "\n")
-}
-
 // convergeTaskOnSessionEnd closes a chat task when the ROOT agent session
 // ends: this terminal event drives task cleanup; child (derived sub-agent)
 // session ends and mid-run AOP error events are not terminal. Idempotent —
diff --git a/pkg/web/agents_test.go b/pkg/web/agents_test.go
index ce9128d3..6da788dc 100644
--- a/pkg/web/agents_test.go
+++ b/pkg/web/agents_test.go
@@ -235,17 +235,20 @@ func TestWSDispatchAndComplete(t *testing.T) {
 
 	var cmd WSMessage
 	conn.ReadJSON(&cmd)
-	if cmd.Type != webproto.TypeCommand {
+	if cmd.Type != webproto.TypeAOP || cmd.TaskID != "task-1" {
 		t.Fatalf("unexpected: %+v", cmd)
 	}
-	var command webproto.CommandPayload
-	if err := json.Unmarshal(cmd.Payload, &command); err != nil {
+	var callEvent aop.Event
+	if err := json.Unmarshal(cmd.Payload, &callEvent); err != nil {
 		t.Fatal(err)
 	}
-	if command.ToolCall == nil || command.SessionID != "task-1" {
-		t.Fatalf("unexpected command: %+v", command)
+	if callEvent.Type != aop.TypeToolCall || callEvent.TurnID != "task-1" {
+		t.Fatalf("unexpected tool.call event: %+v", callEvent)
+	}
+	call, err := aop.DecodeData[aop.ToolCallData](callEvent)
+	if err != nil {
+		t.Fatal(err)
 	}
-	call := *command.ToolCall
 	args, _ := call.Args.(map[string]any)
 	if call.ToolName != "bash" || args["command"] != "scan -i 1.2.3.4" {
 		t.Fatalf("unexpected tool.call data: %+v", call)
@@ -262,11 +265,14 @@ func TestWSDispatchAndComplete(t *testing.T) {
 		t.Fatal("timeout")
 	}
 
-	resultPayload, _ := json.Marshal(webproto.CommandResultPayload{
-		Parts:    []aop.MessagePart{{Type: aop.PartText, Text: "done"}},
-		Metadata: map[string]any{"tool_call_id": "task-1", "tool_name": "bash", "details": map[string]int{"ports": 3}},
+	resultData, _ := json.Marshal(aop.ToolResultData{
+		ToolCallID: "task-1", ToolName: "bash", Content: "done", Details: map[string]int{"ports": 3},
 	})
-	conn.WriteJSON(WSMessage{Type: webproto.TypeCommandResult, TaskID: "task-1", Payload: resultPayload})
+	resultEvent := callEvent
+	resultEvent.Type = aop.TypeToolResult
+	resultEvent.TS = time.Now().UTC().Format(time.RFC3339Nano)
+	resultEvent.Data = resultData
+	conn.WriteJSON(WSMessage{Type: webproto.TypeAOP, TaskID: "task-1", TurnID: "task-1", Payload: webproto.MustJSON(resultEvent)})
 	select {
 	case res := <-resultCh:
 		if res.Err != "" || res.Output != "done" {
diff --git a/pkg/web/eval_forward_test.go b/pkg/web/eval_forward_test.go
index a9bfac84..78ca8d11 100644
--- a/pkg/web/eval_forward_test.go
+++ b/pkg/web/eval_forward_test.go
@@ -11,12 +11,12 @@ import (
 
 type evalSink struct {
 	sid        string
-	chatEvents []ChatEvent
+	chatEvents []DomainEvent
 	aopEvents  []aop.Event
 }
 
 func (s *evalSink) TaskSession(string) (string, bool) { return s.sid, true }
-func (s *evalSink) BroadcastChatEvent(_ string, event ChatEvent) {
+func (s *evalSink) BroadcastDomainEvent(_ string, event DomainEvent) {
 	s.chatEvents = append(s.chatEvents, event)
 }
 func (s *evalSink) BroadcastAOPEvent(_ string, event aop.Event) {
diff --git a/pkg/web/service.go b/pkg/web/service.go
index aeff029d..368e25fc 100644
--- a/pkg/web/service.go
+++ b/pkg/web/service.go
@@ -22,9 +22,9 @@ import (
 	xcompact "github.com/chainreactors/aiscan/pkg/aop/x/compact"
 	xeval "github.com/chainreactors/aiscan/pkg/aop/x/eval"
 	"github.com/chainreactors/aiscan/pkg/commands"
-	scantool "github.com/chainreactors/aiscan/pkg/tools/scan"
 	"github.com/chainreactors/aiscan/pkg/tui"
 	"github.com/chainreactors/aiscan/pkg/webproto"
+	scantool "github.com/chainreactors/aiscan/tools/scan"
 )
 
 // hubCommands are the 3 commands that run on the web hub, not the agent.
@@ -1131,15 +1131,15 @@ func (s *Service) GetAOPEvents(ctx context.Context, sessionID string) ([]aop.Eve
 	return s.store.ListAOPEvents(ctx, sessionID, 10000)
 }
 
-func (s *Service) BroadcastChatEvent(sessionID string, event ChatEvent) {
+func (s *Service) BroadcastDomainEvent(sessionID string, event DomainEvent) {
 	event.SessionID = sessionID
 	if !event.Transient {
-		s.persistRuntimeChatEvent(sessionID, event)
+		s.persistRuntimeDomainEvent(sessionID, event)
 	}
 	s.hub.Broadcast(sessionTopic(sessionID), HubEvent{
 		Type:     event.Type,
 		Data:     mustJSON(event),
-		Reliable: isTerminalChatEvent(event.Type),
+		Reliable: isTerminalDomainEvent(event.Type),
 	})
 }
 
@@ -1199,13 +1199,13 @@ func isReliableAOPEvent(event aop.Event) bool {
 	return false
 }
 
-// isTerminalChatEvent classifies terminal platform events. Agent run lifecycle
+// isTerminalDomainEvent classifies terminal platform events. Agent run lifecycle
 // (including hub-originated failures) is carried exclusively by AOP.
-func isTerminalChatEvent(t string) bool {
-	return t == ChatEventScanComplete
+func isTerminalDomainEvent(t string) bool {
+	return t == DomainEventScanComplete
 }
 
-func (s *Service) persistRuntimeChatEvent(sessionID string, event ChatEvent) {
+func (s *Service) persistRuntimeDomainEvent(sessionID string, event DomainEvent) {
 	if s == nil || s.store == nil || sessionID == "" {
 		return
 	}
@@ -1221,12 +1221,9 @@ func (s *Service) persistRuntimeChatEvent(sessionID string, event ChatEvent) {
 	metadata := map[string]any{
 		"event_type": event.Type,
 	}
-	if event.Turn > 0 {
-		metadata["turn"] = event.Turn
-	}
 
 	switch event.Type {
-	case ChatEventScanComplete:
+	case DomainEventScanComplete:
 		// Persist a lightweight marker so the inline scan card survives a reload /
 		// session switch. The heavy Result payload is NOT stored here — it stays
 		// reloadable via the session_scans link (getScan), and the client fills the
@@ -1333,8 +1330,17 @@ func (s *Service) dispatchUserMessage(sessionID string, msg *ChatMessage, opts w
 		case "exit", "quit":
 			s.closeRemoteSession(sessionID)
 			return
-		case "continue", "followup":
-			// These are Runs: the adapter normalizes their prompt semantics.
+		case "continue":
+			s.handleAgentRun(sessionID, webproto.RunPayload{
+				SessionID: sessionID, Continue: true, NoEcho: true,
+				MaxTurns: opts.PersistMaxTurns, EvalCriteria: opts.EvalCriteria, EvalMaxRounds: opts.EvalMaxRounds,
+			})
+			return
+		case "followup":
+			followup := *msg
+			followup.Content = strings.TrimSpace(args)
+			s.handleChatMessage(sessionID, &followup, opts)
+			return
 		default:
 			if !strings.HasPrefix(content, "/skill:") {
 				s.handleAgentCommand(sessionID, content)
@@ -1356,7 +1362,7 @@ func (s *Service) handleClearCommand(sessionID string, opts webproto.GoalExt) {
 	_ = s.store.ClearMessages(context.Background(), sessionID)
 	// Transient: a live-only signal to connected clients — the cleared state is
 	// already durable in the store, so a reconnecting client re-derives it on load.
-	s.BroadcastChatEvent(sessionID, ChatEvent{Type: ChatEventSessionCleared, Transient: true})
+	s.BroadcastDomainEvent(sessionID, DomainEvent{Type: DomainEventSessionCleared, Transient: true})
 	if s.sessionAgent(sessionID) != nil {
 		s.handleAgentCommand(sessionID, "/clear")
 	}
@@ -1477,8 +1483,8 @@ func (s *Service) handleScanCommand(sessionID, args string) {
 
 	s.registerSessionTask(job.ID, sessionID, "")
 
-	s.BroadcastChatEvent(sessionID, ChatEvent{
-		Type:   ChatEventScanStarted,
+	s.BroadcastDomainEvent(sessionID, DomainEvent{
+		Type:   DomainEventScanStarted,
 		ScanID: job.ID,
 		Data:   fmt.Sprintf("Scan started: %s (%s)", target, mode),
 	})
@@ -1524,6 +1530,16 @@ func (s *Service) sessionAgent(sessionID string) *remoteAgent {
 }
 
 func (s *Service) handleChatMessage(sessionID string, msg *ChatMessage, opts webproto.GoalExt) {
+	run := webproto.RunPayload{
+		SessionID: sessionID,
+		Parts:     []aop.MessagePart{{Type: aop.PartText, Text: strings.TrimSpace(msg.Content)}},
+		NoEcho:    true, MaxTurns: opts.PersistMaxTurns,
+		EvalCriteria: opts.EvalCriteria, EvalMaxRounds: opts.EvalMaxRounds,
+	}
+	s.handleAgentRun(sessionID, run)
+}
+
+func (s *Service) handleAgentRun(sessionID string, run webproto.RunPayload) {
 	agent := s.sessionAgent(sessionID)
 	if agent == nil {
 		s.broadcastSystemMessage(sessionID, SysAgentNotConnected,
@@ -1534,18 +1550,12 @@ func (s *Service) handleChatMessage(sessionID string, msg *ChatMessage, opts web
 	taskID := generateID()
 	s.registerSessionTask(taskID, sessionID, agent.id)
 
-	s.BroadcastChatEvent(sessionID, ChatEvent{
-		Type:      ChatEventAgentJoined,
+	s.BroadcastDomainEvent(sessionID, DomainEvent{
+		Type:      DomainEventAgentJoined,
 		AgentID:   agent.id,
 		AgentName: agent.name,
 	})
 
-	run := webproto.RunPayload{
-		SessionID: sessionID,
-		Parts:     []aop.MessagePart{{Type: aop.PartText, Text: strings.TrimSpace(msg.Content)}},
-		NoEcho:    true, MaxTurns: opts.PersistMaxTurns,
-		EvalCriteria: opts.EvalCriteria, EvalMaxRounds: opts.EvalMaxRounds,
-	}
 	resultCh, err := s.agents.DispatchRun(agent.id, taskID, run)
 	if err != nil {
 		s.finishSessionTask(taskID)
@@ -1643,8 +1653,8 @@ func (s *Service) broadcastScanComplete(scanID string, result *output.Result) {
 	if s.finishSessionTask(scanID) {
 		return
 	}
-	s.BroadcastChatEvent(sid, ChatEvent{
-		Type:   ChatEventScanComplete,
+	s.BroadcastDomainEvent(sid, DomainEvent{
+		Type:   DomainEventScanComplete,
 		ScanID: scanID,
 		Result: result,
 	})
diff --git a/pkg/web/sse.go b/pkg/web/sse.go
index 5ba83ca1..49737eb2 100644
--- a/pkg/web/sse.go
+++ b/pkg/web/sse.go
@@ -18,7 +18,7 @@ type HubEvent struct {
 	Data json.RawMessage
 	// Reliable marks a terminal event that Broadcast must not drop under
 	// backpressure: on a full buffer it evicts the oldest queued event to seat
-	// one, rather than shedding it like a token delta. See isTerminalChatEvent
+	// one, rather than shedding it like a token delta. See isTerminalDomainEvent
 	// for which events qualify and why a lost one strands the UI.
 	Reliable bool
 }
diff --git a/pkg/web/sse_test.go b/pkg/web/sse_test.go
index 790c7f27..c14efd4e 100644
--- a/pkg/web/sse_test.go
+++ b/pkg/web/sse_test.go
@@ -56,15 +56,15 @@ func TestHubBroadcastReliableSurvivesBackpressure(t *testing.T) {
 	}
 }
 
-// isTerminalChatEvent is the only test of the reliability classification: the
+// isTerminalDomainEvent is the only test of the reliability classification: the
 // run-ending platform signal must qualify, or the stuck-cursor bug returns.
 // Agent lifecycle terminals are AOP events and covered by isReliableAOPEvent.
-func TestIsTerminalChatEvent(t *testing.T) {
-	if !isTerminalChatEvent(ChatEventScanComplete) {
-		t.Errorf("%q should be terminal (reliable)", ChatEventScanComplete)
+func TestIsTerminalDomainEvent(t *testing.T) {
+	if !isTerminalDomainEvent(DomainEventScanComplete) {
+		t.Errorf("%q should be terminal (reliable)", DomainEventScanComplete)
 	}
-	for _, ty := range []string{ChatEventScanStarted, ChatEventScanProgress, ChatEventAgentJoined} {
-		if isTerminalChatEvent(ty) {
+	for _, ty := range []string{DomainEventScanStarted, DomainEventScanProgress, DomainEventAgentJoined} {
+		if isTerminalDomainEvent(ty) {
 			t.Errorf("%q should not be terminal", ty)
 		}
 	}
@@ -143,8 +143,8 @@ func TestScanCompletePersistsMarkerMetadata(t *testing.T) {
 	// A completed scan must leave a durable marker so its inline card survives a
 	// timeline rebuild (reload / session switch). The heavy Result is intentionally
 	// not stored — only the scan_id, which the client re-hydrates via scan_ids.
-	svc.BroadcastChatEvent("sess-scan", ChatEvent{
-		Type:   ChatEventScanComplete,
+	svc.BroadcastDomainEvent("sess-scan", DomainEvent{
+		Type:   DomainEventScanComplete,
 		ScanID: "scan-123",
 	})
 
@@ -159,12 +159,12 @@ func TestScanCompletePersistsMarkerMetadata(t *testing.T) {
 	if err := json.Unmarshal(msgs[0].Metadata, &metadata); err != nil {
 		t.Fatalf("metadata json: %v", err)
 	}
-	if metadata["event_type"] != ChatEventScanComplete || metadata["scan_id"] != "scan-123" {
+	if metadata["event_type"] != DomainEventScanComplete || metadata["scan_id"] != "scan-123" {
 		t.Fatalf("scan marker metadata = %#v", metadata)
 	}
 
 	// A marker with no scan id is meaningless — it must not create a phantom row.
-	svc.BroadcastChatEvent("sess-scan-empty", ChatEvent{Type: ChatEventScanComplete})
+	svc.BroadcastDomainEvent("sess-scan-empty", DomainEvent{Type: DomainEventScanComplete})
 	empty, _ := store.ListMessages(context.Background(), "sess-scan-empty", 100)
 	if len(empty) != 0 {
 		t.Fatalf("empty-scanID persisted messages = %d, want 0", len(empty))
diff --git a/pkg/web/types.go b/pkg/web/types.go
index 8658aa9c..5643c3c5 100644
--- a/pkg/web/types.go
+++ b/pkg/web/types.go
@@ -194,11 +194,11 @@ type ChatMessage struct {
 }
 
 const (
-	ChatEventScanStarted    = "scan_started"
-	ChatEventScanProgress   = "scan_progress"
-	ChatEventScanComplete   = "scan_complete"
-	ChatEventAgentJoined    = "agent_joined"
-	ChatEventSessionCleared = "session_cleared"
+	DomainEventScanStarted    = "scan_started"
+	DomainEventScanProgress   = "scan_progress"
+	DomainEventScanComplete   = "scan_complete"
+	DomainEventAgentJoined    = "agent_joined"
+	DomainEventSessionCleared = "session_cleared"
 )
 
 // System message codes. A backend-generated system message carries a stable
@@ -214,15 +214,11 @@ const (
 	SysAgentNotConnected = "agent_not_connected"
 )
 
-type ChatEvent struct {
+type DomainEvent struct {
 	Type      string         `json:"type"`
 	SessionID string         `json:"session_id"`
-	MessageID string         `json:"message_id,omitempty"`
-	Role      string         `json:"role,omitempty"`
 	AgentID   string         `json:"agent_id,omitempty"`
 	AgentName string         `json:"agent_name,omitempty"`
-	Turn      int            `json:"turn,omitempty"`
-	Content   string         `json:"content,omitempty"`
 	ScanID    string         `json:"scan_id,omitempty"`
 	Result    *output.Result `json:"result,omitempty"`
 	Data      string         `json:"data,omitempty"`
diff --git a/pkg/webagent/agent.go b/pkg/webagent/agent.go
index 18d9f4cd..142c920d 100644
--- a/pkg/webagent/agent.go
+++ b/pkg/webagent/agent.go
@@ -8,14 +8,12 @@ import (
 	"os"
 	"path/filepath"
 	"strings"
-	"sync"
 
 	cfg "github.com/chainreactors/aiscan/core/config"
 	"github.com/chainreactors/aiscan/core/runner"
 	"github.com/chainreactors/aiscan/pkg/agent"
 	"github.com/chainreactors/aiscan/pkg/aop"
 	"github.com/chainreactors/aiscan/pkg/telemetry"
-	"github.com/chainreactors/aiscan/pkg/tui"
 	"github.com/chainreactors/aiscan/pkg/webproto"
 	"github.com/chainreactors/ioa/protocols"
 	"github.com/chainreactors/utils/pty"
@@ -60,7 +58,6 @@ func RunWebSocket(ctx context.Context, option *cfg.Option, logger telemetry.Logg
 	chatHandler := &chatAgentHandler{
 		rt:        rt,
 		serverURL: option.WebURL,
-		sessions:  make(map[string]*runner.Session),
 		app:       application,
 		option:    option,
 		logger:    logger,
@@ -107,12 +104,11 @@ func RunWebSocket(ctx context.Context, option *cfg.Option, logger telemetry.Logg
 		return nil
 	}
 
-	startup, err := rt.OpenSession(ctx, runner.SessionOptions{ID: "startup"})
+	_, err = rt.EnsureSession(runner.SessionOptions{ID: "startup"})
 	if err != nil {
 		return err
 	}
-	chatHandler.sessions["startup"] = startup
-	run, err := startup.Run(ctx, runner.RunInput{TurnID: "startup", Parts: []aop.MessagePart{{Type: aop.PartText, Text: task}}})
+	run, err := rt.RunSession(ctx, "startup", runner.RunInput{TurnID: "startup", Parts: []aop.MessagePart{{Type: aop.PartText, Text: task}}})
 	if err == nil {
 		_, err = run.Wait()
 	}
@@ -129,123 +125,13 @@ func RunWebSocket(ctx context.Context, option *cfg.Option, logger telemetry.Logg
 type chatAgentHandler struct {
 	rt        *runner.AgentRuntime
 	serverURL string
-	mu        sync.Mutex
-	sessions  map[string]*runner.Session
 	app       *runner.App
 	option    *cfg.Option
 	logger    telemetry.Logger
 }
 
-func (h *chatAgentHandler) HandleSessionOpen(ctx context.Context, msg webproto.Message, send func(webproto.Message)) {
-	var payload webproto.SessionOpenPayload
-	if err := json.Unmarshal(msg.Payload, &payload); err != nil {
-		sendProtocolError(send, "", "", err)
-		return
-	}
-	session, err := h.rt.OpenSession(ctx, runner.SessionOptions{
-		ID: payload.SessionID, ParentSessionID: payload.ParentSessionID, ParentToolCallID: payload.ParentToolCallID,
-	})
-	if err != nil {
-		sendProtocolError(send, "", "", err)
-		return
-	}
-	sessionID := session.ID()
-	h.mu.Lock()
-	h.sessions[sessionID] = session
-	h.mu.Unlock()
-	encoded, _ := json.Marshal(webproto.SessionLifecyclePayload{SessionID: sessionID})
-	send(webproto.Message{Type: webproto.TypeSessionOpened, Payload: encoded})
-}
-
-func (h *chatAgentHandler) HandleSessionClose(ctx context.Context, msg webproto.Message, send func(webproto.Message)) {
-	var payload webproto.SessionLifecyclePayload
-	if err := json.Unmarshal(msg.Payload, &payload); err != nil {
-		sendProtocolError(send, "", "", err)
-		return
-	}
-	reason := runner.SessionCloseReason(payload.Reason)
-	if err := h.rt.CloseSession(ctx, payload.SessionID, reason); err != nil {
-		sendProtocolError(send, "", "", err)
-		return
-	}
-	h.mu.Lock()
-	delete(h.sessions, payload.SessionID)
-	h.mu.Unlock()
-	encoded, _ := json.Marshal(webproto.SessionLifecyclePayload{SessionID: payload.SessionID, Reason: payload.Reason})
-	send(webproto.Message{Type: webproto.TypeSessionClosed, Payload: encoded})
-}
-
-func (h *chatAgentHandler) HandleRun(ctx context.Context, msg webproto.Message, send func(webproto.Message)) func() {
-	var payload webproto.RunPayload
-	if err := json.Unmarshal(msg.Payload, &payload); err != nil {
-		return func() { sendProtocolError(send, msg.TurnID, "", err) }
-	}
-	h.mu.Lock()
-	session := h.sessions[payload.SessionID]
-	h.mu.Unlock()
-	if session == nil {
-		return func() {
-			sendProtocolError(send, msg.TurnID, "", fmt.Errorf("session %q is not open", payload.SessionID))
-		}
-	}
-	input := runner.RunInput{
-		TurnID: msg.TurnID, Parts: payload.Parts, NoEcho: payload.NoEcho, MaxTurns: payload.MaxTurns,
-		EvalCriteria: payload.EvalCriteria, EvalMaxRounds: payload.EvalMaxRounds,
-	}
-	prompt := strings.TrimSpace(partsText(payload.Parts))
-	if prompt == "/continue" {
-		input.Continue = true
-		input.Parts = nil
-	} else if strings.HasPrefix(prompt, "/followup ") {
-		input.Parts = []aop.MessagePart{{Type: aop.PartText, Text: strings.TrimSpace(strings.TrimPrefix(prompt, "/followup "))}}
-	}
-	run, err := session.Run(ctx, input)
-	if err != nil {
-		return func() { sendProtocolError(send, msg.TurnID, "", err) }
-	}
-	return func() { _, _ = run.Wait() }
-}
-
-func (h *chatAgentHandler) HandleCommand(ctx context.Context, msg webproto.Message, send func(webproto.Message)) {
-	var payload webproto.CommandPayload
-	if err := json.Unmarshal(msg.Payload, &payload); err != nil {
-		sendProtocolError(send, "", msg.TaskID, err)
-		return
-	}
-	h.mu.Lock()
-	session := h.sessions[payload.SessionID]
-	h.mu.Unlock()
-	if session == nil {
-		sendProtocolError(send, "", msg.TaskID, fmt.Errorf("session %q is not open", payload.SessionID))
-		return
-	}
-	result, err := session.Command(ctx, payload.Line)
-	if err != nil {
-		sendProtocolError(send, "", msg.TaskID, err)
-		return
-	}
-	for i := range result.Parts {
-		if result.Parts[i].Type == aop.PartText {
-			result.Parts[i].Text = fenceTerminalOutput(result.Parts[i].Text)
-		}
-	}
-	encoded, _ := json.Marshal(webproto.CommandResultPayload{SessionID: payload.SessionID, Parts: result.Parts, Metadata: result.Metadata})
-	send(webproto.Message{Type: webproto.TypeCommandResult, TaskID: msg.TaskID, Payload: encoded})
-}
-
-func sendProtocolError(send func(webproto.Message), turnID, taskID string, err error) {
-	payload, _ := json.Marshal(webproto.ErrorPayload{Message: err.Error()})
-	send(webproto.Message{Type: webproto.TypeError, TurnID: turnID, TaskID: taskID, Payload: payload})
-}
-
-func partsText(parts []aop.MessagePart) string {
-	var values []string
-	for _, part := range parts {
-		if part.Type == aop.PartText && part.Text != "" {
-			values = append(values, part.Text)
-		}
-	}
-	return strings.Join(values, "\n")
+func (h *chatAgentHandler) HandleProtocol(ctx context.Context, msg webproto.Message, send func(webproto.Message)) bool {
+	return h.rt != nil && h.rt.HandleProtocol(ctx, msg, send)
 }
 
 func (h *chatAgentHandler) HandleUpload(msg webproto.Message, send func(webproto.Message)) {
@@ -352,33 +238,6 @@ func handleFileUpload(msg webproto.Message, send func(webproto.Message)) {
 	})
 }
 
-// ---------------------------------------------------------------------------
-// REPL helpers
-// ---------------------------------------------------------------------------
-
-// fenceTerminalOutput wraps multi-line REPL/`!` command output in a Markdown
-// code fence. runChatREPLLine runs the same TUI console the interactive REPL
-// uses, whose panels (/status, /provider, /nodes ...) are drawn with box-drawing
-// characters and column padding that only line up in a fixed-width,
-// newline-preserving context. The web chat renders replies as Markdown prose,
-// which collapses single newlines to spaces and uses a proportional font -- so an
-// unfenced panel flattens into one mangled line. A fence makes the frontend
-// render it verbatim in a monospace 
. Single-line output (short status
-// confirmations like "Provider ready: ...") is left as prose.
-func fenceTerminalOutput(s string) string {
-	if !strings.Contains(s, "\n") {
-		return s
-	}
-	// Opening fence must be longer than any backtick run inside the payload
-	// (a `!cat` of a Markdown file could contain ```); grow it until it can't
-	// collide. Panel output never contains backticks, so this is just insurance.
-	fence := "```"
-	for strings.Contains(s, fence) {
-		fence += "`"
-	}
-	return fence + "\n" + s + "\n" + fence
-}
-
 // ---------------------------------------------------------------------------
 // Identity and command catalog (agent-specific, needs runner.AgentRuntime)
 // ---------------------------------------------------------------------------
@@ -388,9 +247,7 @@ func fenceTerminalOutput(s string) string {
 // non-internal) skill. The hub merges it with its hub-scope commands to build
 // the web "/" menu and /help, so the menu reflects what this agent can run.
 func agentCommandCatalog(app *runner.App) []webproto.CommandSpec {
-	// Build a zero-value console to extract command metadata without a live session.
-	r := &tui.AgentConsole{}
-	specs := tui.WebMenuSpecs(r.StaticCommands())
+	specs := runner.RuntimeCommandSpecs()
 	if app == nil || app.Skills == nil {
 		return specs
 	}
@@ -399,7 +256,7 @@ func agentCommandCatalog(app *runner.App) []webproto.CommandSpec {
 			continue
 		}
 		specs = append(specs, webproto.CommandSpec{
-			Name:        "/" + strings.TrimPrefix(strings.TrimSpace(sk.Name), "/"),
+			Name:        "/skill:" + strings.TrimPrefix(strings.TrimSpace(sk.Name), "/"),
 			Description: sk.Description,
 		})
 	}
diff --git a/pkg/webagent/agent_test.go b/pkg/webagent/agent_test.go
index 1e25ce05..defcdf1d 100644
--- a/pkg/webagent/agent_test.go
+++ b/pkg/webagent/agent_test.go
@@ -16,7 +16,6 @@ import (
 	cfg "github.com/chainreactors/aiscan/core/config"
 	"github.com/chainreactors/aiscan/core/eventbus"
 	"github.com/chainreactors/aiscan/core/output"
-	"github.com/chainreactors/aiscan/core/runner"
 	"github.com/chainreactors/aiscan/pkg/aop"
 	"github.com/chainreactors/aiscan/pkg/commands"
 	"github.com/chainreactors/aiscan/pkg/webproto"
@@ -41,27 +40,6 @@ func TestWebNodeRefUsesWebIdentity(t *testing.T) {
 	}
 }
 
-func TestRunAndCommandErrorsKeepDistinctCorrelationIDs(t *testing.T) {
-	h := &chatAgentHandler{sessions: make(map[string]*runner.Session)}
-
-	var runError webproto.Message
-	wait := h.HandleRun(context.Background(), webproto.Message{
-		Type: webproto.TypeRun, TurnID: "turn-1", Payload: json.RawMessage(`{`),
-	}, func(message webproto.Message) { runError = message })
-	wait()
-	if runError.Type != webproto.TypeError || runError.TurnID != "turn-1" || runError.TaskID != "" {
-		t.Fatalf("run error correlation = %+v", runError)
-	}
-
-	var commandError webproto.Message
-	h.HandleCommand(context.Background(), webproto.Message{
-		Type: webproto.TypeCommand, TaskID: "command-1", Payload: json.RawMessage(`{`),
-	}, func(message webproto.Message) { commandError = message })
-	if commandError.Type != webproto.TypeError || commandError.TaskID != "command-1" || commandError.TurnID != "" {
-		t.Fatalf("command error correlation = %+v", commandError)
-	}
-}
-
 func connectForTest(ctx context.Context, serverURL, name string, reg *commands.CommandRegistry, bus *eventbus.Bus[aop.Event]) error {
 	if _, ok := reg.GetTool("bash"); !ok {
 		bash := commands.NewBashTool(".", 5)
@@ -129,12 +107,12 @@ func TestRunConnectionScopesTelemetryToActiveTask(t *testing.T) {
 		registeredOnce.Do(func() { close(registered) })
 
 		call := aop.ToolCallData{
-			ToolCallID: "call-1",
+			ToolCallID: "task-1",
 			ToolName:   "bash",
 			Args:       map[string]any{"command": `echo "hello world"`},
 		}
-		payload, _ := json.Marshal(webproto.CommandPayload{SessionID: "task-1", ToolCall: &call})
-		if err := conn.WriteJSON(webproto.Message{Type: webproto.TypeCommand, TaskID: "task-1", Payload: payload}); err != nil {
+		payload, _ := json.Marshal(toolEvent(t, call))
+		if err := conn.WriteJSON(webproto.Message{Type: webproto.TypeAOP, TaskID: "task-1", TurnID: "task-1", Payload: payload}); err != nil {
 			t.Errorf("tool.call write: %v", err)
 			return
 		}
@@ -144,7 +122,7 @@ func TestRunConnectionScopesTelemetryToActiveTask(t *testing.T) {
 				return
 			}
 			messages <- msg
-			if msg.Type == webproto.TypeCommandResult {
+			if msg.Type == webproto.TypeAOP {
 				return
 			}
 		}
@@ -187,8 +165,11 @@ func TestRunConnectionScopesTelemetryToActiveTask(t *testing.T) {
 						seenOutput = true
 					}
 				}
-			case webproto.TypeCommandResult:
-				seenResult = true
+			case webproto.TypeAOP:
+				var event aop.Event
+				if json.Unmarshal(msg.Payload, &event) == nil && event.Type == aop.TypeToolResult {
+					seenResult = true
+				}
 			}
 		case <-deadline:
 			t.Fatal("timeout waiting for web agent messages")
@@ -529,28 +510,3 @@ func frameHasSessionActivity(frame pty.Frame, sessionID string) bool {
 	}
 	return false
 }
-
-func TestFenceTerminalOutput(t *testing.T) {
-	// Single-line status stays prose — no fence.
-	if got := fenceTerminalOutput("Provider ready: anthropic / glm-5.2"); strings.Contains(got, "```") {
-		t.Errorf("single-line output should not be fenced, got %q", got)
-	}
-	// Multi-line panel (box art) gets fenced so the web renders it monospace.
-	panel := "╭────╮\n│ providers │\n╰────╯"
-	got := fenceTerminalOutput(panel)
-	if !strings.HasPrefix(got, "```\n") || !strings.HasSuffix(got, "\n```") {
-		t.Errorf("multi-line panel should be wrapped in a code fence, got %q", got)
-	}
-	if !strings.Contains(got, panel) {
-		t.Errorf("fenced output should preserve the panel verbatim, got %q", got)
-	}
-	// A payload containing a triple-backtick run grows the fence so it can't collide.
-	got = fenceTerminalOutput("line1\n```\nline2")
-	if !strings.HasPrefix(got, "````\n") {
-		t.Errorf("fence must be longer than an inner backtick run, got %q", got)
-	}
-	// Empty stays empty.
-	if got := fenceTerminalOutput(""); got != "" {
-		t.Errorf("empty input should stay empty, got %q", got)
-	}
-}
diff --git a/pkg/webagent/aop_tool.go b/pkg/webagent/aop_tool.go
index a54c37b7..75511a33 100644
--- a/pkg/webagent/aop_tool.go
+++ b/pkg/webagent/aop_tool.go
@@ -4,6 +4,7 @@ import (
 	"bytes"
 	"context"
 	"encoding/json"
+	"fmt"
 	"strings"
 	"time"
 
@@ -32,48 +33,49 @@ type foregroundTool interface {
 	RunForegroundTool(context.Context, string, commands.BashExecOptions) (tool.Result, error)
 }
 
-// HandleToolCommand executes one structured direct Command and returns a
-// command.result frame. It does not create a Session or Turn.
-func HandleToolCommand(ctx context.Context, msg webproto.Message, call aop.ToolCallData, executor aopToolExecutor, dataBus *eventbus.Bus[output.ToolDataEvent], send func(webproto.Message)) {
+// HandleToolCallEvent executes one direct AOP tool.call and returns its
+// terminal tool.result through the same AOP envelope.
+func HandleToolCallEvent(ctx context.Context, msg webproto.Message, event aop.Event, executor aopToolExecutor, dataBus *eventbus.Bus[output.ToolDataEvent], send func(webproto.Message)) {
+	sendError := func(err error) {
+		payload, _ := json.Marshal(webproto.ErrorPayload{Message: err.Error()})
+		send(webproto.Message{Type: webproto.TypeError, TaskID: msg.TaskID, Payload: payload})
+	}
+	if !event.Valid() {
+		sendError(fmt.Errorf("invalid inbound AOP event"))
+		return
+	}
+	if event.Type != aop.TypeToolCall {
+		sendError(fmt.Errorf("unsupported inbound AOP event %q", event.Type))
+		return
+	}
+	call, err := aop.DecodeData[aop.ToolCallData](event)
+	if err != nil {
+		sendError(fmt.Errorf("decode tool.call: %w", err))
+		return
+	}
+	if msg.TaskID == "" || call.ToolCallID != msg.TaskID {
+		sendError(fmt.Errorf("tool.call correlation requires task_id == tool_call_id"))
+		return
+	}
+	if strings.TrimSpace(call.ToolName) == "" {
+		sendError(fmt.Errorf("tool.call tool_name is required"))
+		return
+	}
 	if call.WorkDir != "" {
 		ctx = tool.ContextWithInvocation(ctx, tool.Invocation{WorkDir: call.WorkDir})
 	}
 	callID := msg.TaskID
-	if callID == "" {
-		callID = call.ToolCallID
-	}
 	ctx = output.ContextWithCallID(ctx, callID)
 
 	started := time.Now()
 	result, execErr := executeCall(ctx, executor, call, dataBus, callID)
-	metadata := map[string]any{
-		"tool_call_id": call.ToolCallID,
-		"tool_name":    call.ToolName,
-		"duration_ms":  int(time.Since(started).Milliseconds()),
-	}
-	var parts []aop.MessagePart
-	if execErr != nil {
-		parts = []aop.MessagePart{{Type: aop.PartText, Text: execErr.Error()}}
-		metadata["is_error"] = true
-	} else {
-		if result.Text() != "" {
-			parts = append(parts, aop.MessagePart{Type: aop.PartText, Text: result.Text()})
-		}
-		if result.HasImages() {
-			for _, block := range result.Content {
-				if block.Type == "image" {
-					parts = append(parts, aop.MessagePart{Type: aop.PartImage, Image: &aop.ImageSource{Base64: block.Base64Data, MediaType: block.MimeType}})
-				}
-			}
-		}
-		if result.Details != nil {
-			metadata["details"] = result.Details
-		}
-		metadata["terminate"] = result.Terminate
-		metadata["is_error"] = result.IsError
-	}
-	payload, _ := json.Marshal(webproto.CommandResultPayload{Parts: parts, Metadata: metadata})
-	send(webproto.Message{Type: webproto.TypeCommandResult, TaskID: callID, Payload: payload})
+	data := aop.ToolResultDataFromResult(call, result, execErr, time.Since(started))
+	raw, _ := json.Marshal(data)
+	event.Type = aop.TypeToolResult
+	event.TS = time.Now().UTC().Format(time.RFC3339Nano)
+	event.Data = raw
+	payload, _ := json.Marshal(event)
+	send(webproto.Message{Type: webproto.TypeAOP, TaskID: callID, TurnID: event.TurnID, Payload: payload})
 }
 
 // executeCall runs the tool call. Tools with foreground capability stream
diff --git a/pkg/webagent/aop_tool_test.go b/pkg/webagent/aop_tool_test.go
index 85212406..dee00af7 100644
--- a/pkg/webagent/aop_tool_test.go
+++ b/pkg/webagent/aop_tool_test.go
@@ -3,6 +3,7 @@ package webagent
 import (
 	"context"
 	"encoding/json"
+	"strings"
 	"testing"
 	"time"
 
@@ -28,33 +29,62 @@ func toolCommand(toolCallID, toolName string, args map[string]any) aop.ToolCallD
 	}
 }
 
-func decodeCommandResult(t *testing.T, msg webproto.Message) webproto.CommandResultPayload {
+func toolEvent(t *testing.T, call aop.ToolCallData) aop.Event {
 	t.Helper()
-	if msg.Type != webproto.TypeCommandResult {
+	data, err := json.Marshal(call)
+	if err != nil {
+		t.Fatal(err)
+	}
+	return aop.Event{
+		Type: aop.TypeToolCall, TS: time.Now().UTC().Format(time.RFC3339Nano),
+		SessionID: "session-1", TurnID: call.ToolCallID, Agent: "worker", Data: data,
+	}
+}
+
+func decodeToolResult(t *testing.T, msg webproto.Message) aop.ToolResultData {
+	t.Helper()
+	if msg.Type != webproto.TypeAOP {
 		t.Fatalf("result envelope = %+v", msg)
 	}
-	var result webproto.CommandResultPayload
-	if err := json.Unmarshal(msg.Payload, &result); err != nil {
+	var event aop.Event
+	if err := json.Unmarshal(msg.Payload, &event); err != nil {
+		t.Fatal(err)
+	}
+	if event.Type != aop.TypeToolResult {
+		t.Fatalf("result event = %+v", event)
+	}
+	result, err := aop.DecodeData[aop.ToolResultData](event)
+	if err != nil {
 		t.Fatal(err)
 	}
 	return result
 }
 
-func TestHandleToolCommand(t *testing.T) {
+func TestHandleToolCallEvent(t *testing.T) {
 	var got webproto.Message
-	HandleToolCommand(context.Background(), webproto.Message{Type: webproto.TypeCommand, TaskID: "call-1"},
-		toolCommand("call-1", "echo", map[string]any{"value": "hello"}),
+	call := toolCommand("call-1", "echo", map[string]any{"value": "hello"})
+	HandleToolCallEvent(context.Background(), webproto.Message{Type: webproto.TypeAOP, TaskID: "call-1"}, toolEvent(t, call),
 		aopTestExecutor{}, nil, func(msg webproto.Message) { got = msg })
 
 	if got.TaskID != "call-1" {
 		t.Fatalf("result envelope = %+v", got)
 	}
-	result := decodeCommandResult(t, got)
-	if result.Metadata["tool_call_id"] != "call-1" || result.Metadata["tool_name"] != "echo" || len(result.Parts) != 1 {
+	result := decodeToolResult(t, got)
+	if result.ToolCallID != "call-1" || result.ToolName != "echo" || !strings.Contains(aop.ToolResultText(result.Content), "echo") {
 		t.Fatalf("result data = %+v", result)
 	}
 }
 
+func TestHandleToolCallEventRejectsMismatchedCorrelation(t *testing.T) {
+	var got webproto.Message
+	call := toolCommand("call-1", "echo", map[string]any{"value": "hello"})
+	HandleToolCallEvent(context.Background(), webproto.Message{Type: webproto.TypeAOP, TaskID: "other"}, toolEvent(t, call),
+		aopTestExecutor{}, nil, func(msg webproto.Message) { got = msg })
+	if got.Type != webproto.TypeError || got.TaskID != "other" {
+		t.Fatalf("error envelope = %+v", got)
+	}
+}
+
 type recordingBash struct {
 	command string
 	options commands.BashExecOptions
@@ -79,11 +109,11 @@ func (b *recordingBash) RunForegroundTool(_ context.Context, command string, opt
 	return result, nil
 }
 
-// TestHandleToolCommandForeground verifies that a foreground-capable tool is
+// TestHandleToolCallEventForeground verifies that a foreground-capable tool is
 // run via RunForegroundTool, that output lines stream as tool.data progress
 // events correlated by the call session id, and that the tool.result carries
 // the text content plus structured Details.
-func TestHandleToolCommandForeground(t *testing.T) {
+func TestHandleToolCallEventForeground(t *testing.T) {
 	reg := commands.NewRegistry()
 	bash := &recordingBash{}
 	reg.RegisterTool(bash)
@@ -97,8 +127,8 @@ func TestHandleToolCommandForeground(t *testing.T) {
 	})
 
 	var got webproto.Message
-	HandleToolCommand(context.Background(), webproto.Message{Type: webproto.TypeCommand, TaskID: "task-1"},
-		toolCommand("call-1", "bash", map[string]any{"command": "echo test", "timeout": 7}),
+	call := toolCommand("task-1", "bash", map[string]any{"command": "echo test", "timeout": 7})
+	HandleToolCallEvent(context.Background(), webproto.Message{Type: webproto.TypeAOP, TaskID: "task-1"}, toolEvent(t, call),
 		reg, dataBus, func(msg webproto.Message) { got = msg })
 
 	if bash.command != "echo test" || bash.options.Timeout != 7*time.Second {
@@ -108,11 +138,11 @@ func TestHandleToolCommandForeground(t *testing.T) {
 		t.Fatalf("progress events = %+v", progress)
 	}
 
-	result := decodeCommandResult(t, got)
-	if isError, _ := result.Metadata["is_error"].(bool); isError || len(result.Parts) == 0 || result.Parts[0].Text != "streamed" {
+	result := decodeToolResult(t, got)
+	if result.IsError || aop.ToolResultText(result.Content) != "streamed" {
 		t.Fatalf("result data = %+v", result)
 	}
-	details, _ := json.Marshal(result.Metadata["details"])
+	details, _ := json.Marshal(result.Details)
 	var structured output.Result
 	if err := json.Unmarshal(details, &structured); err != nil {
 		t.Fatalf("decode structured details: %v", err)
diff --git a/pkg/webagent/connection.go b/pkg/webagent/connection.go
index aa184c8f..071d96e1 100644
--- a/pkg/webagent/connection.go
+++ b/pkg/webagent/connection.go
@@ -57,10 +57,7 @@ type connectionConfig struct {
 // Implementations live in webagent or other packages that have access to the
 // agent runtime and provider.
 type chatHandler interface {
-	HandleSessionOpen(ctx context.Context, msg webproto.Message, send func(webproto.Message))
-	HandleSessionClose(ctx context.Context, msg webproto.Message, send func(webproto.Message))
-	HandleRun(ctx context.Context, msg webproto.Message, send func(webproto.Message)) func()
-	HandleCommand(ctx context.Context, msg webproto.Message, send func(webproto.Message))
+	HandleProtocol(ctx context.Context, msg webproto.Message, send func(webproto.Message)) bool
 
 	// HandleUpload processes a file upload message.
 	HandleUpload(msg webproto.Message, send func(webproto.Message))
@@ -224,7 +221,6 @@ func connectOnce(ctx context.Context, cc connectionConfig, logger telemetry.Logg
 
 	var mu sync.Mutex
 	execTasks := make(map[string]context.CancelFunc) // active tool.call tasks
-	turnCancels := make(map[string]context.CancelFunc)
 
 	// Tool telemetry: scanner tool.data and normalized tool.sco events ride the
 	// same connection, correlated to the calling task by call ID.
@@ -299,57 +295,36 @@ func connectOnce(ctx context.Context, cc connectionConfig, logger telemetry.Logg
 		}
 
 		switch msg.Type {
-		case webproto.TypeSessionOpen:
+		case webproto.TypeSessionOpen, webproto.TypeSessionClose, webproto.TypeRun, webproto.TypeRunCancel:
 			if cc.Chat != nil {
-				cc.Chat.HandleSessionOpen(connectionCtx, msg, send)
+				cc.Chat.HandleProtocol(connectionCtx, msg, send)
 			}
 
-		case webproto.TypeSessionClose:
+		case webproto.TypeCommand:
 			if cc.Chat != nil {
-				cc.Chat.HandleSessionClose(connectionCtx, msg, send)
+				cc.Chat.HandleProtocol(connectionCtx, msg, send)
 			}
 
-		case webproto.TypeRun:
-			if cc.Chat == nil || msg.TurnID == "" {
+		case webproto.TypeAOP:
+			var event aop.Event
+			if err := json.Unmarshal(msg.Payload, &event); err != nil {
+				payload, _ := json.Marshal(webproto.ErrorPayload{Message: "decode AOP: " + err.Error()})
+				send(webproto.Message{Type: webproto.TypeError, TaskID: msg.TaskID, Payload: payload})
 				continue
 			}
-			runCtx, runCancel := context.WithCancel(connectionCtx)
+			taskCtx, cancel := context.WithCancel(connectionCtx)
 			mu.Lock()
-			turnCancels[msg.TurnID] = runCancel
+			execTasks[msg.TaskID] = cancel
 			mu.Unlock()
-			wait := cc.Chat.HandleRun(runCtx, msg, send)
-			go func(turnID string) {
-				defer runCancel()
+			go func(m webproto.Message, event aop.Event) {
+				defer cancel()
 				defer func() {
 					mu.Lock()
-					delete(turnCancels, turnID)
+					delete(execTasks, m.TaskID)
 					mu.Unlock()
 				}()
-				wait()
-			}(msg.TurnID)
-
-		case webproto.TypeCommand:
-			var command webproto.CommandPayload
-			if json.Unmarshal(msg.Payload, &command) != nil {
-				continue
-			}
-			if command.ToolCall != nil {
-				taskCtx, cancel := context.WithCancel(connectionCtx)
-				mu.Lock()
-				execTasks[msg.TaskID] = cancel
-				mu.Unlock()
-				go func(m webproto.Message, call aop.ToolCallData) {
-					defer cancel()
-					defer func() {
-						mu.Lock()
-						delete(execTasks, m.TaskID)
-						mu.Unlock()
-					}()
-					HandleToolCommand(taskCtx, m, call, cc.Registry, cc.DataBus, send)
-				}(msg, *command.ToolCall)
-			} else if cc.Chat != nil {
-				go cc.Chat.HandleCommand(connectionCtx, msg, send)
-			}
+				HandleToolCallEvent(taskCtx, m, event, cc.Registry, cc.DataBus, send)
+			}(msg, event)
 
 		case "upload":
 			if cc.Chat != nil {
@@ -392,14 +367,6 @@ func connectOnce(ctx context.Context, cc connectionConfig, logger telemetry.Logg
 				go cc.Chat.HandleConfigReload(cc.ServerURL, send)
 			}
 
-		case webproto.TypeRunCancel:
-			mu.Lock()
-			cancel := turnCancels[msg.TurnID]
-			mu.Unlock()
-			if cancel != nil {
-				cancel()
-			}
-
 		case "cancel":
 			mu.Lock()
 			if cancel, ok := execTasks[msg.TaskID]; ok {
diff --git a/pkg/webagent/connection_lifecycle_test.go b/pkg/webagent/connection_lifecycle_test.go
index f14d5b4b..2d908215 100644
--- a/pkg/webagent/connection_lifecycle_test.go
+++ b/pkg/webagent/connection_lifecycle_test.go
@@ -22,19 +22,16 @@ type disconnectChatHandler struct {
 	once     sync.Once
 }
 
-func (h *disconnectChatHandler) HandleRun(ctx context.Context, _ webproto.Message, _ func(webproto.Message)) func() {
+func (h *disconnectChatHandler) HandleProtocol(ctx context.Context, msg webproto.Message, _ func(webproto.Message)) bool {
+	if msg.Type != webproto.TypeRun {
+		return false
+	}
 	h.once.Do(func() { close(h.started) })
-	return func() {
+	go func() {
 		<-ctx.Done()
 		close(h.canceled)
-	}
-}
-
-func (*disconnectChatHandler) HandleSessionOpen(context.Context, webproto.Message, func(webproto.Message)) {
-}
-func (*disconnectChatHandler) HandleSessionClose(context.Context, webproto.Message, func(webproto.Message)) {
-}
-func (*disconnectChatHandler) HandleCommand(context.Context, webproto.Message, func(webproto.Message)) {
+	}()
+	return true
 }
 func (*disconnectChatHandler) HandleUpload(webproto.Message, func(webproto.Message)) {}
 func (*disconnectChatHandler) HandleConfigReload(string, func(webproto.Message))     {}
diff --git a/pkg/webagent/toolnode_test.go b/pkg/webagent/toolnode_test.go
index 7d988167..7a7cb120 100644
--- a/pkg/webagent/toolnode_test.go
+++ b/pkg/webagent/toolnode_test.go
@@ -29,7 +29,7 @@ type hubScript struct {
 	t *testing.T
 
 	registered chan webproto.RegisterPayload
-	toolResult chan webproto.CommandResultPayload
+	toolResult chan aop.ToolResultData
 	progress   chan string
 	fileData   chan []byte
 	toolData   chan webproto.Message
@@ -39,7 +39,7 @@ func newHubScript(t *testing.T) *hubScript {
 	return &hubScript{
 		t:          t,
 		registered: make(chan webproto.RegisterPayload, 1),
-		toolResult: make(chan webproto.CommandResultPayload, 1),
+		toolResult: make(chan aop.ToolResultData, 1),
 		progress:   make(chan string, 16),
 		fileData:   make(chan []byte, 1),
 		toolData:   make(chan webproto.Message, 4),
@@ -81,10 +81,15 @@ func (h *hubScript) serveHTTP(w http.ResponseWriter, r *http.Request) {
 			return
 		}
 		switch msg.Type {
-		case webproto.TypeCommandResult:
-			var result webproto.CommandResultPayload
-			if err := json.Unmarshal(msg.Payload, &result); err != nil {
-				h.t.Errorf("command.result: %v", err)
+		case webproto.TypeAOP:
+			var event aop.Event
+			if err := json.Unmarshal(msg.Payload, &event); err != nil || event.Type != aop.TypeToolResult {
+				h.t.Errorf("tool.result: event=%+v err=%v", event, err)
+				return
+			}
+			result, err := aop.DecodeData[aop.ToolResultData](event)
+			if err != nil {
+				h.t.Errorf("tool.result data: %v", err)
 				return
 			}
 			h.toolResult <- result
@@ -113,12 +118,13 @@ func (h *hubScript) serveHTTP(w http.ResponseWriter, r *http.Request) {
 // drive issues the server→runner calls once the connection is live.
 func (h *hubScript) drive(conn *websocket.Conn) {
 	call := aop.ToolCallData{
-		ToolCallID: "call-1",
+		ToolCallID: "exec-1",
 		ToolName:   "bash",
 		Args:       map[string]any{"command": "echo hello"},
 	}
-	payload, _ := json.Marshal(webproto.CommandPayload{SessionID: "exec-1", ToolCall: &call})
-	if err := conn.WriteJSON(webproto.Message{Type: webproto.TypeCommand, TaskID: "exec-1", Payload: payload}); err != nil {
+	event := toolEvent(h.t, call)
+	payload, _ := json.Marshal(event)
+	if err := conn.WriteJSON(webproto.Message{Type: webproto.TypeAOP, TaskID: "exec-1", TurnID: "exec-1", Payload: payload}); err != nil {
 		return
 	}
 }
@@ -190,9 +196,9 @@ func TestRunToolNodeWireInterop(t *testing.T) {
 	if line != "streamed" {
 		t.Fatalf("progress line = %q", line)
 	}
-	result := wait(t, hub.toolResult, "command.result")
-	if isError, _ := result.Metadata["is_error"].(bool); isError || result.Metadata["tool_call_id"] != "call-1" || result.Metadata["tool_name"] != "bash" {
-		t.Fatalf("command.result = %+v", result)
+	result := wait(t, hub.toolResult, "tool.result")
+	if result.IsError || result.ToolCallID != "exec-1" || result.ToolName != "bash" {
+		t.Fatalf("tool.result = %+v", result)
 	}
 
 	// tool.data rides the same connection, correlated by call ID.
diff --git a/pkg/webproto/message.go b/pkg/webproto/message.go
index ee7688e6..e1225fb2 100644
--- a/pkg/webproto/message.go
+++ b/pkg/webproto/message.go
@@ -46,6 +46,7 @@ type SessionLifecyclePayload struct {
 type RunPayload struct {
 	SessionID     string            `json:"session_id"`
 	Parts         []aop.MessagePart `json:"parts"`
+	Continue      bool              `json:"continue,omitempty"`
 	NoEcho        bool              `json:"no_echo,omitempty"`
 	MaxTurns      int               `json:"max_turns,omitempty"`
 	EvalCriteria  string            `json:"eval_criteria,omitempty"`
@@ -53,15 +54,8 @@ type RunPayload struct {
 }
 
 type CommandPayload struct {
-	SessionID string            `json:"session_id"`
-	Line      string            `json:"line"`
-	ToolCall  *aop.ToolCallData `json:"tool_call,omitempty"`
-}
-
-type CommandResultPayload struct {
-	SessionID string            `json:"session_id"`
-	Parts     []aop.MessagePart `json:"parts,omitempty"`
-	Metadata  map[string]any    `json:"metadata,omitempty"`
+	SessionID string `json:"session_id"`
+	Line      string `json:"line"`
 }
 
 type ErrorPayload struct {
diff --git a/tools/README.md b/tools/README.md
new file mode 100644
index 00000000..af871aba
--- /dev/null
+++ b/tools/README.md
@@ -0,0 +1,102 @@
+# Extending aiscan with a tool
+
+Use a native tool when the model needs one structured capability. A tool only
+implements `core/tool.Tool`; it does not need a plugin manager, lifecycle
+container, global hook, or factory.
+
+## Minimal implementation
+
+Create `tools/echo/echo.go`:
+
+```go
+package echo
+
+import (
+	"context"
+
+	"github.com/chainreactors/aiscan/core/tool"
+	"github.com/chainreactors/aiscan/pkg/commands"
+)
+
+type Args struct {
+	Text string `json:"text" jsonschema:"description=Text to return"`
+}
+
+type Tool struct{}
+
+func (Tool) Name() string        { return "echo" }
+func (Tool) Description() string { return "Return text unchanged." }
+func (Tool) Definition() tool.Definition {
+	return tool.Def("echo", "Return text unchanged.", Args{})
+}
+
+func (Tool) Execute(ctx context.Context, arguments string) (tool.Result, error) {
+	args, err := tool.ParseArgs[Args](arguments)
+	if err != nil {
+		return tool.Result{}, err
+	}
+	if err := ctx.Err(); err != nil {
+		return tool.Result{}, err
+	}
+	return tool.TextResult(args.Text), nil
+}
+
+func Register(reg *commands.CommandRegistry) {
+	reg.RegisterTool(Tool{})
+}
+```
+
+Call `echo.Register(reg)` from the application composition point that should
+expose the tool. Keep registration explicit when the tool has no Runtime
+dependencies.
+
+## Minimal test
+
+```go
+package echo
+
+import (
+	"context"
+	"testing"
+
+	"github.com/chainreactors/aiscan/pkg/commands"
+)
+
+func TestEcho(t *testing.T) {
+	reg := commands.NewRegistry()
+	Register(reg)
+
+	result, err := reg.ExecuteTool(context.Background(), "echo", `{"text":"hello"}`)
+	if err != nil {
+		t.Fatal(err)
+	}
+	if result.Text() != "hello" {
+		t.Fatalf("result = %q", result.Text())
+	}
+}
+```
+
+This test proves schema registration, argument decoding, dispatch, and result
+conversion without starting an Agent or transport.
+
+## Result rules
+
+- Use `tool.TextResult` for normal text.
+- Use `tool.ErrorResult` for a tool-level failure the model should observe.
+- Return a Go `error` when execution itself failed.
+- Add `tool.ImageBlock` only when the result contains an image.
+- Put machine-readable domain output in `Result.Details`; do not encode it into
+  an extra transport payload.
+- Honor `ctx` for cancellation and deadlines.
+
+## When a factory is justified
+
+Use `commands.RegisterFactory` only when construction needs shared Runtime
+dependencies such as the scanner engine set, IOA client, provider, data bus, or
+working directory, or when an `init` registration must be activated by several
+binaries. The factory should only construct the tool and call `RegisterTool`.
+
+Do not add a new abstraction until at least two tools need the same behavior.
+
+Pseudo-commands exposed through the `bash` tool are documented separately in
+[`docs/development.md`](../docs/development.md).
diff --git a/pkg/tools/arsenal/arsenal_tool.go b/tools/arsenal/arsenal_tool.go
similarity index 100%
rename from pkg/tools/arsenal/arsenal_tool.go
rename to tools/arsenal/arsenal_tool.go
diff --git a/pkg/tools/arsenal/arsenal_tool_test.go b/tools/arsenal/arsenal_tool_test.go
similarity index 100%
rename from pkg/tools/arsenal/arsenal_tool_test.go
rename to tools/arsenal/arsenal_tool_test.go
diff --git a/pkg/tools/arsenal/register.go b/tools/arsenal/register.go
similarity index 100%
rename from pkg/tools/arsenal/register.go
rename to tools/arsenal/register.go
diff --git a/pkg/tools/functional_integration_full_test.go b/tools/functional_integration_full_test.go
similarity index 92%
rename from pkg/tools/functional_integration_full_test.go
rename to tools/functional_integration_full_test.go
index 6cd0e694..e84ab212 100644
--- a/pkg/tools/functional_integration_full_test.go
+++ b/tools/functional_integration_full_test.go
@@ -13,8 +13,8 @@ import (
 	"github.com/chainreactors/aiscan/core/output"
 	"github.com/chainreactors/aiscan/pkg/commands"
 	"github.com/chainreactors/aiscan/pkg/telemetry"
-	_ "github.com/chainreactors/aiscan/pkg/tools/katana"
-	"github.com/chainreactors/aiscan/pkg/tools/scan/engine"
+	_ "github.com/chainreactors/aiscan/tools/katana"
+	"github.com/chainreactors/aiscan/tools/scan/engine"
 )
 
 func TestFullScannerPublicIntegration(t *testing.T) {
diff --git a/pkg/tools/functional_integration_test.go b/tools/functional_integration_test.go
similarity index 94%
rename from pkg/tools/functional_integration_test.go
rename to tools/functional_integration_test.go
index 19556641..3a415a16 100644
--- a/pkg/tools/functional_integration_test.go
+++ b/tools/functional_integration_test.go
@@ -15,10 +15,10 @@ import (
 	"github.com/chainreactors/aiscan/core/resources"
 	"github.com/chainreactors/aiscan/pkg/commands"
 	"github.com/chainreactors/aiscan/pkg/telemetry"
-	_ "github.com/chainreactors/aiscan/pkg/tools/gogo"
-	_ "github.com/chainreactors/aiscan/pkg/tools/neutron"
-	"github.com/chainreactors/aiscan/pkg/tools/scan/engine"
-	_ "github.com/chainreactors/aiscan/pkg/tools/spray"
+	_ "github.com/chainreactors/aiscan/tools/gogo"
+	_ "github.com/chainreactors/aiscan/tools/neutron"
+	"github.com/chainreactors/aiscan/tools/scan/engine"
+	_ "github.com/chainreactors/aiscan/tools/spray"
 	"github.com/chainreactors/utils/parsers"
 )
 
diff --git a/pkg/tools/functional_norace_test.go b/tools/functional_norace_test.go
similarity index 100%
rename from pkg/tools/functional_norace_test.go
rename to tools/functional_norace_test.go
diff --git a/pkg/tools/functional_race_test.go b/tools/functional_race_test.go
similarity index 100%
rename from pkg/tools/functional_race_test.go
rename to tools/functional_race_test.go
diff --git a/pkg/tools/functional_regression_full_test.go b/tools/functional_regression_full_test.go
similarity index 94%
rename from pkg/tools/functional_regression_full_test.go
rename to tools/functional_regression_full_test.go
index 5c4aba66..d1457a96 100644
--- a/pkg/tools/functional_regression_full_test.go
+++ b/tools/functional_regression_full_test.go
@@ -13,9 +13,9 @@ import (
 	"github.com/chainreactors/aiscan/core/output"
 	"github.com/chainreactors/aiscan/pkg/commands"
 	"github.com/chainreactors/aiscan/pkg/telemetry"
-	_ "github.com/chainreactors/aiscan/pkg/tools/katana"
-	passivecmd "github.com/chainreactors/aiscan/pkg/tools/passive"
-	"github.com/chainreactors/aiscan/pkg/tools/scan/engine"
+	_ "github.com/chainreactors/aiscan/tools/katana"
+	passivecmd "github.com/chainreactors/aiscan/tools/passive"
+	"github.com/chainreactors/aiscan/tools/scan/engine"
 	"github.com/projectdiscovery/uncover/sources"
 )
 
diff --git a/pkg/tools/functional_regression_test.go b/tools/functional_regression_test.go
similarity index 97%
rename from pkg/tools/functional_regression_test.go
rename to tools/functional_regression_test.go
index 2a9fbc0d..3547584c 100644
--- a/pkg/tools/functional_regression_test.go
+++ b/tools/functional_regression_test.go
@@ -21,12 +21,12 @@ import (
 	"github.com/chainreactors/aiscan/core/resources"
 	"github.com/chainreactors/aiscan/pkg/commands"
 	"github.com/chainreactors/aiscan/pkg/telemetry"
-	_ "github.com/chainreactors/aiscan/pkg/tools/gogo"
-	_ "github.com/chainreactors/aiscan/pkg/tools/neutron"
-	_ "github.com/chainreactors/aiscan/pkg/tools/proton"
-	"github.com/chainreactors/aiscan/pkg/tools/scan/engine"
-	_ "github.com/chainreactors/aiscan/pkg/tools/spray"
-	_ "github.com/chainreactors/aiscan/pkg/tools/zombie"
+	_ "github.com/chainreactors/aiscan/tools/gogo"
+	_ "github.com/chainreactors/aiscan/tools/neutron"
+	_ "github.com/chainreactors/aiscan/tools/proton"
+	"github.com/chainreactors/aiscan/tools/scan/engine"
+	_ "github.com/chainreactors/aiscan/tools/spray"
+	_ "github.com/chainreactors/aiscan/tools/zombie"
 	"github.com/chainreactors/utils/parsers"
 )
 
diff --git a/pkg/tools/functional_testkit_test.go b/tools/functional_testkit_test.go
similarity index 100%
rename from pkg/tools/functional_testkit_test.go
rename to tools/functional_testkit_test.go
diff --git a/pkg/tools/gogo/gogo.go b/tools/gogo/gogo.go
similarity index 98%
rename from pkg/tools/gogo/gogo.go
rename to tools/gogo/gogo.go
index 5687ab39..71b42381 100644
--- a/pkg/tools/gogo/gogo.go
+++ b/tools/gogo/gogo.go
@@ -11,7 +11,7 @@ import (
 	"github.com/chainreactors/aiscan/core/output"
 	"github.com/chainreactors/aiscan/pkg/commands"
 	"github.com/chainreactors/aiscan/pkg/telemetry"
-	"github.com/chainreactors/aiscan/pkg/tools/toolargs"
+	"github.com/chainreactors/aiscan/tools/toolargs"
 	gogocore "github.com/chainreactors/gogo/v2/core"
 	"github.com/chainreactors/sdk/gogo"
 	"github.com/chainreactors/utils/parsers"
diff --git a/pkg/tools/gogo/gogo_test.go b/tools/gogo/gogo_test.go
similarity index 100%
rename from pkg/tools/gogo/gogo_test.go
rename to tools/gogo/gogo_test.go
diff --git a/pkg/tools/gogo/register.go b/tools/gogo/register.go
similarity index 93%
rename from pkg/tools/gogo/register.go
rename to tools/gogo/register.go
index afebce61..65eb87a7 100644
--- a/pkg/tools/gogo/register.go
+++ b/tools/gogo/register.go
@@ -3,7 +3,7 @@ package gogo
 import (
 	cfg "github.com/chainreactors/aiscan/core/config"
 	"github.com/chainreactors/aiscan/pkg/commands"
-	"github.com/chainreactors/aiscan/pkg/tools/scan/engine"
+	"github.com/chainreactors/aiscan/tools/scan/engine"
 )
 
 func init() {
diff --git a/pkg/tools/ioa/commands.go b/tools/ioa/commands.go
similarity index 100%
rename from pkg/tools/ioa/commands.go
rename to tools/ioa/commands.go
diff --git a/pkg/tools/ioa/commands_test.go b/tools/ioa/commands_test.go
similarity index 99%
rename from pkg/tools/ioa/commands_test.go
rename to tools/ioa/commands_test.go
index 5c9ab8df..b027edb0 100644
--- a/pkg/tools/ioa/commands_test.go
+++ b/tools/ioa/commands_test.go
@@ -489,7 +489,7 @@ func TestDefaultSpaceSkipsJoin(t *testing.T) {
 // Run with:
 //
 //	LIVE_TEST_API_KEY=sk-xxx \
-//	go test -v -run TestLLMIOAToolUsage ./pkg/tools/ioa/ -timeout 120s
+//	go test -v -run TestLLMIOAToolUsage ./tools/ioa/ -timeout 120s
 func TestLLMIOAToolUsage(t *testing.T) {
 	apiKey := os.Getenv("LIVE_TEST_API_KEY")
 	if apiKey == "" {
diff --git a/pkg/tools/ioa/register.go b/tools/ioa/register.go
similarity index 100%
rename from pkg/tools/ioa/register.go
rename to tools/ioa/register.go
diff --git a/pkg/tools/katana/katana.go b/tools/katana/katana.go
similarity index 99%
rename from pkg/tools/katana/katana.go
rename to tools/katana/katana.go
index cf66e080..e52d2e06 100644
--- a/pkg/tools/katana/katana.go
+++ b/tools/katana/katana.go
@@ -15,7 +15,7 @@ import (
 	"github.com/chainreactors/aiscan/core/output"
 	"github.com/chainreactors/aiscan/pkg/commands"
 	"github.com/chainreactors/aiscan/pkg/telemetry"
-	"github.com/chainreactors/aiscan/pkg/tools/toolargs"
+	"github.com/chainreactors/aiscan/tools/toolargs"
 	"github.com/projectdiscovery/goflags"
 	"github.com/projectdiscovery/gologger"
 	"github.com/projectdiscovery/gologger/levels"
diff --git a/pkg/tools/katana/register.go b/tools/katana/register.go
similarity index 100%
rename from pkg/tools/katana/register.go
rename to tools/katana/register.go
diff --git a/pkg/tools/neutron/neutron.go b/tools/neutron/neutron.go
similarity index 99%
rename from pkg/tools/neutron/neutron.go
rename to tools/neutron/neutron.go
index ad0632d4..ca0ac53e 100644
--- a/pkg/tools/neutron/neutron.go
+++ b/tools/neutron/neutron.go
@@ -16,8 +16,8 @@ import (
 	"github.com/chainreactors/aiscan/core/output"
 	"github.com/chainreactors/aiscan/pkg/commands"
 	"github.com/chainreactors/aiscan/pkg/telemetry"
-	scanengine "github.com/chainreactors/aiscan/pkg/tools/scan/engine"
-	"github.com/chainreactors/aiscan/pkg/tools/toolargs"
+	scanengine "github.com/chainreactors/aiscan/tools/scan/engine"
+	"github.com/chainreactors/aiscan/tools/toolargs"
 	"github.com/chainreactors/neutron/templates"
 	sdkneutron "github.com/chainreactors/sdk/neutron"
 	"github.com/chainreactors/sdk/pkg/association"
diff --git a/pkg/tools/neutron/neutron_test.go b/tools/neutron/neutron_test.go
similarity index 100%
rename from pkg/tools/neutron/neutron_test.go
rename to tools/neutron/neutron_test.go
diff --git a/pkg/tools/neutron/register.go b/tools/neutron/register.go
similarity index 93%
rename from pkg/tools/neutron/register.go
rename to tools/neutron/register.go
index 9ba4251e..9a83bc4a 100644
--- a/pkg/tools/neutron/register.go
+++ b/tools/neutron/register.go
@@ -3,7 +3,7 @@ package neutron
 import (
 	cfg "github.com/chainreactors/aiscan/core/config"
 	"github.com/chainreactors/aiscan/pkg/commands"
-	"github.com/chainreactors/aiscan/pkg/tools/scan/engine"
+	"github.com/chainreactors/aiscan/tools/scan/engine"
 )
 
 func init() {
diff --git a/pkg/tools/neutron/sdk_stage.go b/tools/neutron/sdk_stage.go
similarity index 99%
rename from pkg/tools/neutron/sdk_stage.go
rename to tools/neutron/sdk_stage.go
index e21685d0..e9832d44 100644
--- a/pkg/tools/neutron/sdk_stage.go
+++ b/tools/neutron/sdk_stage.go
@@ -9,7 +9,7 @@ import (
 	"time"
 
 	"github.com/chainreactors/aiscan/pkg/telemetry"
-	scanengine "github.com/chainreactors/aiscan/pkg/tools/scan/engine"
+	scanengine "github.com/chainreactors/aiscan/tools/scan/engine"
 	"github.com/chainreactors/neutron/common"
 	"github.com/chainreactors/neutron/templates"
 	sdkneutron "github.com/chainreactors/sdk/neutron"
diff --git a/pkg/tools/passive/passive.go b/tools/passive/passive.go
similarity index 99%
rename from pkg/tools/passive/passive.go
rename to tools/passive/passive.go
index dc503f28..6a1f5f73 100644
--- a/pkg/tools/passive/passive.go
+++ b/tools/passive/passive.go
@@ -16,7 +16,7 @@ import (
 
 	"github.com/chainreactors/aiscan/pkg/commands"
 	"github.com/chainreactors/aiscan/pkg/telemetry"
-	"github.com/chainreactors/aiscan/pkg/tools/scan/engine"
+	"github.com/chainreactors/aiscan/tools/scan/engine"
 	"github.com/projectdiscovery/uncover/sources"
 )
 
diff --git a/pkg/tools/passive/passive_test.go b/tools/passive/passive_test.go
similarity index 98%
rename from pkg/tools/passive/passive_test.go
rename to tools/passive/passive_test.go
index b4726f76..be2df63f 100644
--- a/pkg/tools/passive/passive_test.go
+++ b/tools/passive/passive_test.go
@@ -7,7 +7,7 @@ import (
 	"reflect"
 	"testing"
 
-	"github.com/chainreactors/aiscan/pkg/tools/scan/engine"
+	"github.com/chainreactors/aiscan/tools/scan/engine"
 	"github.com/projectdiscovery/uncover/sources"
 )
 
diff --git a/pkg/tools/passive/register.go b/tools/passive/register.go
similarity index 94%
rename from pkg/tools/passive/register.go
rename to tools/passive/register.go
index ecdc26a7..e4b6f275 100644
--- a/pkg/tools/passive/register.go
+++ b/tools/passive/register.go
@@ -5,7 +5,7 @@ package passive
 import (
 	"github.com/chainreactors/aiscan/core/config"
 	"github.com/chainreactors/aiscan/pkg/commands"
-	"github.com/chainreactors/aiscan/pkg/tools/scan/engine"
+	"github.com/chainreactors/aiscan/tools/scan/engine"
 )
 
 func init() {
diff --git a/pkg/tools/playwright/advanced.go b/tools/playwright/advanced.go
similarity index 100%
rename from pkg/tools/playwright/advanced.go
rename to tools/playwright/advanced.go
diff --git a/pkg/tools/playwright/autofill.go b/tools/playwright/autofill.go
similarity index 100%
rename from pkg/tools/playwright/autofill.go
rename to tools/playwright/autofill.go
diff --git a/pkg/tools/playwright/browser.go b/tools/playwright/browser.go
similarity index 100%
rename from pkg/tools/playwright/browser.go
rename to tools/playwright/browser.go
diff --git a/pkg/tools/playwright/browser_test.go b/tools/playwright/browser_test.go
similarity index 100%
rename from pkg/tools/playwright/browser_test.go
rename to tools/playwright/browser_test.go
diff --git a/pkg/tools/playwright/dialog.go b/tools/playwright/dialog.go
similarity index 100%
rename from pkg/tools/playwright/dialog.go
rename to tools/playwright/dialog.go
diff --git a/pkg/tools/playwright/discover.go b/tools/playwright/discover.go
similarity index 100%
rename from pkg/tools/playwright/discover.go
rename to tools/playwright/discover.go
diff --git a/pkg/tools/playwright/headless.go b/tools/playwright/headless.go
similarity index 100%
rename from pkg/tools/playwright/headless.go
rename to tools/playwright/headless.go
diff --git a/pkg/tools/playwright/interact.go b/tools/playwright/interact.go
similarity index 100%
rename from pkg/tools/playwright/interact.go
rename to tools/playwright/interact.go
diff --git a/pkg/tools/playwright/navigation.go b/tools/playwright/navigation.go
similarity index 100%
rename from pkg/tools/playwright/navigation.go
rename to tools/playwright/navigation.go
diff --git a/pkg/tools/playwright/recorder.go b/tools/playwright/recorder.go
similarity index 100%
rename from pkg/tools/playwright/recorder.go
rename to tools/playwright/recorder.go
diff --git a/pkg/tools/playwright/recorder_test.go b/tools/playwright/recorder_test.go
similarity index 100%
rename from pkg/tools/playwright/recorder_test.go
rename to tools/playwright/recorder_test.go
diff --git a/pkg/tools/playwright/register.go b/tools/playwright/register.go
similarity index 100%
rename from pkg/tools/playwright/register.go
rename to tools/playwright/register.go
diff --git a/pkg/tools/playwright/session.go b/tools/playwright/session.go
similarity index 100%
rename from pkg/tools/playwright/session.go
rename to tools/playwright/session.go
diff --git a/pkg/tools/playwright/storage.go b/tools/playwright/storage.go
similarity index 100%
rename from pkg/tools/playwright/storage.go
rename to tools/playwright/storage.go
diff --git a/pkg/tools/playwright/storage_test.go b/tools/playwright/storage_test.go
similarity index 100%
rename from pkg/tools/playwright/storage_test.go
rename to tools/playwright/storage_test.go
diff --git a/pkg/tools/playwright/tabs.go b/tools/playwright/tabs.go
similarity index 100%
rename from pkg/tools/playwright/tabs.go
rename to tools/playwright/tabs.go
diff --git a/pkg/tools/playwright/testharness/.gitignore b/tools/playwright/testharness/.gitignore
similarity index 100%
rename from pkg/tools/playwright/testharness/.gitignore
rename to tools/playwright/testharness/.gitignore
diff --git a/pkg/tools/playwright/testharness/conftest.py b/tools/playwright/testharness/conftest.py
similarity index 100%
rename from pkg/tools/playwright/testharness/conftest.py
rename to tools/playwright/testharness/conftest.py
diff --git a/pkg/tools/playwright/testharness/fixtures/dynamic.html b/tools/playwright/testharness/fixtures/dynamic.html
similarity index 100%
rename from pkg/tools/playwright/testharness/fixtures/dynamic.html
rename to tools/playwright/testharness/fixtures/dynamic.html
diff --git a/pkg/tools/playwright/testharness/fixtures/forms.html b/tools/playwright/testharness/fixtures/forms.html
similarity index 100%
rename from pkg/tools/playwright/testharness/fixtures/forms.html
rename to tools/playwright/testharness/fixtures/forms.html
diff --git a/pkg/tools/playwright/testharness/fixtures/headless-extract.yaml b/tools/playwright/testharness/fixtures/headless-extract.yaml
similarity index 100%
rename from pkg/tools/playwright/testharness/fixtures/headless-extract.yaml
rename to tools/playwright/testharness/fixtures/headless-extract.yaml
diff --git a/pkg/tools/playwright/testharness/fixtures/headless-template.yaml b/tools/playwright/testharness/fixtures/headless-template.yaml
similarity index 100%
rename from pkg/tools/playwright/testharness/fixtures/headless-template.yaml
rename to tools/playwright/testharness/fixtures/headless-template.yaml
diff --git a/pkg/tools/playwright/testharness/fixtures/login.html b/tools/playwright/testharness/fixtures/login.html
similarity index 100%
rename from pkg/tools/playwright/testharness/fixtures/login.html
rename to tools/playwright/testharness/fixtures/login.html
diff --git a/pkg/tools/playwright/testharness/fixtures/navigation.html b/tools/playwright/testharness/fixtures/navigation.html
similarity index 100%
rename from pkg/tools/playwright/testharness/fixtures/navigation.html
rename to tools/playwright/testharness/fixtures/navigation.html
diff --git a/pkg/tools/playwright/testharness/fixtures/page2.html b/tools/playwright/testharness/fixtures/page2.html
similarity index 100%
rename from pkg/tools/playwright/testharness/fixtures/page2.html
rename to tools/playwright/testharness/fixtures/page2.html
diff --git a/pkg/tools/playwright/testharness/pw_driver.go b/tools/playwright/testharness/pw_driver.go
similarity index 91%
rename from pkg/tools/playwright/testharness/pw_driver.go
rename to tools/playwright/testharness/pw_driver.go
index 62fcc368..fc66e3f9 100644
--- a/pkg/tools/playwright/testharness/pw_driver.go
+++ b/tools/playwright/testharness/pw_driver.go
@@ -4,7 +4,7 @@
 // It reads JSON-line commands from stdin and writes JSON-line responses to stdout.
 // The Command instance (and its sessions) persist across calls.
 //
-// Build: go build -tags browser -o pw_driver ./pkg/tools/playwright/testharness/pw_driver.go
+// Build: go build -tags browser -o pw_driver ./tools/playwright/testharness/pw_driver.go
 //
 // Protocol:
 //
@@ -24,7 +24,7 @@ import (
 	"os/signal"
 
 	"github.com/chainreactors/aiscan/pkg/commands"
-	"github.com/chainreactors/aiscan/pkg/tools/playwright"
+	"github.com/chainreactors/aiscan/tools/playwright"
 )
 
 type request struct {
diff --git a/pkg/tools/playwright/testharness/test_cli_parity.py b/tools/playwright/testharness/test_cli_parity.py
similarity index 100%
rename from pkg/tools/playwright/testharness/test_cli_parity.py
rename to tools/playwright/testharness/test_cli_parity.py
diff --git a/pkg/tools/playwright/testharness/test_dispatch.py b/tools/playwright/testharness/test_dispatch.py
similarity index 100%
rename from pkg/tools/playwright/testharness/test_dispatch.py
rename to tools/playwright/testharness/test_dispatch.py
diff --git a/pkg/tools/playwright/testharness/test_extraction.py b/tools/playwright/testharness/test_extraction.py
similarity index 100%
rename from pkg/tools/playwright/testharness/test_extraction.py
rename to tools/playwright/testharness/test_extraction.py
diff --git a/pkg/tools/playwright/testharness/test_files.py b/tools/playwright/testharness/test_files.py
similarity index 100%
rename from pkg/tools/playwright/testharness/test_files.py
rename to tools/playwright/testharness/test_files.py
diff --git a/pkg/tools/playwright/testharness/test_focus.py b/tools/playwright/testharness/test_focus.py
similarity index 100%
rename from pkg/tools/playwright/testharness/test_focus.py
rename to tools/playwright/testharness/test_focus.py
diff --git a/pkg/tools/playwright/testharness/test_headers.py b/tools/playwright/testharness/test_headers.py
similarity index 100%
rename from pkg/tools/playwright/testharness/test_headers.py
rename to tools/playwright/testharness/test_headers.py
diff --git a/pkg/tools/playwright/testharness/test_headless_template.py b/tools/playwright/testharness/test_headless_template.py
similarity index 100%
rename from pkg/tools/playwright/testharness/test_headless_template.py
rename to tools/playwright/testharness/test_headless_template.py
diff --git a/pkg/tools/playwright/testharness/test_interaction.py b/tools/playwright/testharness/test_interaction.py
similarity index 100%
rename from pkg/tools/playwright/testharness/test_interaction.py
rename to tools/playwright/testharness/test_interaction.py
diff --git a/pkg/tools/playwright/testharness/test_navigation.py b/tools/playwright/testharness/test_navigation.py
similarity index 100%
rename from pkg/tools/playwright/testharness/test_navigation.py
rename to tools/playwright/testharness/test_navigation.py
diff --git a/pkg/tools/playwright/testharness/test_route.py b/tools/playwright/testharness/test_route.py
similarity index 100%
rename from pkg/tools/playwright/testharness/test_route.py
rename to tools/playwright/testharness/test_route.py
diff --git a/pkg/tools/playwright/testharness/test_viewport.py b/tools/playwright/testharness/test_viewport.py
similarity index 100%
rename from pkg/tools/playwright/testharness/test_viewport.py
rename to tools/playwright/testharness/test_viewport.py
diff --git a/pkg/tools/playwright/testharness/test_wait.py b/tools/playwright/testharness/test_wait.py
similarity index 100%
rename from pkg/tools/playwright/testharness/test_wait.py
rename to tools/playwright/testharness/test_wait.py
diff --git a/pkg/tools/proton/command.go b/tools/proton/command.go
similarity index 99%
rename from pkg/tools/proton/command.go
rename to tools/proton/command.go
index 0ebe3612..af7adf37 100644
--- a/pkg/tools/proton/command.go
+++ b/tools/proton/command.go
@@ -19,7 +19,7 @@ import (
 	"github.com/chainreactors/aiscan/core/output"
 	"github.com/chainreactors/aiscan/pkg/commands"
 	"github.com/chainreactors/aiscan/pkg/telemetry"
-	"github.com/chainreactors/aiscan/pkg/tools/toolargs"
+	"github.com/chainreactors/aiscan/tools/toolargs"
 	"github.com/chainreactors/neutron/operators"
 	"github.com/chainreactors/neutron/protocols"
 	"github.com/chainreactors/proton/proton/file"
diff --git a/pkg/tools/proton/command_test.go b/tools/proton/command_test.go
similarity index 99%
rename from pkg/tools/proton/command_test.go
rename to tools/proton/command_test.go
index 3ad30f11..090df25d 100644
--- a/pkg/tools/proton/command_test.go
+++ b/tools/proton/command_test.go
@@ -11,7 +11,7 @@ import (
 
 	"github.com/chainreactors/aiscan/core/resources"
 	"github.com/chainreactors/aiscan/pkg/commands"
-	protoncmd "github.com/chainreactors/aiscan/pkg/tools/proton"
+	protoncmd "github.com/chainreactors/aiscan/tools/proton"
 )
 
 // ---------------------------------------------------------------------------
diff --git a/pkg/tools/proton/register.go b/tools/proton/register.go
similarity index 100%
rename from pkg/tools/proton/register.go
rename to tools/proton/register.go
diff --git a/pkg/tools/proton/register_test.go b/tools/proton/register_test.go
similarity index 100%
rename from pkg/tools/proton/register_test.go
rename to tools/proton/register_test.go
diff --git a/pkg/tools/proxy/command.go b/tools/proxy/command.go
similarity index 100%
rename from pkg/tools/proxy/command.go
rename to tools/proxy/command.go
diff --git a/pkg/tools/proxy/command_test.go b/tools/proxy/command_test.go
similarity index 100%
rename from pkg/tools/proxy/command_test.go
rename to tools/proxy/command_test.go
diff --git a/pkg/tools/proxy/mitm.go b/tools/proxy/mitm.go
similarity index 100%
rename from pkg/tools/proxy/mitm.go
rename to tools/proxy/mitm.go
diff --git a/pkg/tools/proxy/mitm_test.go b/tools/proxy/mitm_test.go
similarity index 100%
rename from pkg/tools/proxy/mitm_test.go
rename to tools/proxy/mitm_test.go
diff --git a/pkg/tools/proxy/race_norace_test.go b/tools/proxy/race_norace_test.go
similarity index 100%
rename from pkg/tools/proxy/race_norace_test.go
rename to tools/proxy/race_norace_test.go
diff --git a/pkg/tools/proxy/race_test.go b/tools/proxy/race_test.go
similarity index 100%
rename from pkg/tools/proxy/race_test.go
rename to tools/proxy/race_test.go
diff --git a/pkg/tools/proxy/register_command.go b/tools/proxy/register_command.go
similarity index 100%
rename from pkg/tools/proxy/register_command.go
rename to tools/proxy/register_command.go
diff --git a/pkg/tools/proxy/state.go b/tools/proxy/state.go
similarity index 100%
rename from pkg/tools/proxy/state.go
rename to tools/proxy/state.go
diff --git a/pkg/tools/proxy/state_test.go b/tools/proxy/state_test.go
similarity index 100%
rename from pkg/tools/proxy/state_test.go
rename to tools/proxy/state_test.go
diff --git a/pkg/tools/register_command.go b/tools/register_command.go
similarity index 89%
rename from pkg/tools/register_command.go
rename to tools/register_command.go
index 2507b4c4..33bf7f10 100644
--- a/pkg/tools/register_command.go
+++ b/tools/register_command.go
@@ -3,8 +3,8 @@ package tools
 import (
 	cfg "github.com/chainreactors/aiscan/core/config"
 	"github.com/chainreactors/aiscan/pkg/commands"
-	"github.com/chainreactors/aiscan/pkg/tools/scan"
-	"github.com/chainreactors/aiscan/pkg/tools/scan/engine"
+	"github.com/chainreactors/aiscan/tools/scan"
+	"github.com/chainreactors/aiscan/tools/scan/engine"
 )
 
 func init() {
diff --git a/pkg/tools/register_command_full_test.go b/tools/register_command_full_test.go
similarity index 93%
rename from pkg/tools/register_command_full_test.go
rename to tools/register_command_full_test.go
index 9ab600c0..962e00ea 100644
--- a/pkg/tools/register_command_full_test.go
+++ b/tools/register_command_full_test.go
@@ -5,7 +5,7 @@ package tools
 import (
 	"testing"
 
-	"github.com/chainreactors/aiscan/pkg/tools/scan/engine"
+	"github.com/chainreactors/aiscan/tools/scan/engine"
 	"github.com/chainreactors/sdk/gogo"
 	"github.com/chainreactors/sdk/spray"
 )
diff --git a/pkg/tools/register_command_integration_test.go b/tools/register_command_integration_test.go
similarity index 93%
rename from pkg/tools/register_command_integration_test.go
rename to tools/register_command_integration_test.go
index 88ac149f..4f31e33e 100644
--- a/pkg/tools/register_command_integration_test.go
+++ b/tools/register_command_integration_test.go
@@ -1,7 +1,7 @@
 //go:build full && integration
 
 //	Run with: AISCAN_INTEGRATION=1 FOFA_EMAIL=... FOFA_KEY=... \
-//	  go test -tags 'full integration' ./pkg/tools/... -run TestIntegration -v
+//	  go test -tags 'full integration' ./tools/... -run TestIntegration -v
 package tools
 
 import (
@@ -15,8 +15,8 @@ import (
 
 	"github.com/chainreactors/aiscan/pkg/commands"
 	"github.com/chainreactors/aiscan/pkg/telemetry"
-	passivecmd "github.com/chainreactors/aiscan/pkg/tools/passive"
-	"github.com/chainreactors/aiscan/pkg/tools/scan/engine"
+	passivecmd "github.com/chainreactors/aiscan/tools/passive"
+	"github.com/chainreactors/aiscan/tools/scan/engine"
 )
 
 func passiveExecString(t *testing.T, cmd *passivecmd.Command, ctx context.Context, args []string) string {
diff --git a/pkg/tools/register_command_test.go b/tools/register_command_test.go
similarity index 95%
rename from pkg/tools/register_command_test.go
rename to tools/register_command_test.go
index bca2d107..5c6ad7d4 100644
--- a/pkg/tools/register_command_test.go
+++ b/tools/register_command_test.go
@@ -13,12 +13,12 @@ import (
 
 	"github.com/chainreactors/aiscan/core/resources"
 	"github.com/chainreactors/aiscan/pkg/commands"
-	"github.com/chainreactors/aiscan/pkg/tools/gogo"
-	"github.com/chainreactors/aiscan/pkg/tools/neutron"
-	"github.com/chainreactors/aiscan/pkg/tools/scan/engine"
-	_ "github.com/chainreactors/aiscan/pkg/tools/search"
-	"github.com/chainreactors/aiscan/pkg/tools/spray"
-	"github.com/chainreactors/aiscan/pkg/tools/zombie"
+	"github.com/chainreactors/aiscan/tools/gogo"
+	"github.com/chainreactors/aiscan/tools/neutron"
+	"github.com/chainreactors/aiscan/tools/scan/engine"
+	_ "github.com/chainreactors/aiscan/tools/search"
+	"github.com/chainreactors/aiscan/tools/spray"
+	"github.com/chainreactors/aiscan/tools/zombie"
 	fingerslib "github.com/chainreactors/fingers/fingers"
 	neutronhttp "github.com/chainreactors/neutron/protocols/http"
 	"github.com/chainreactors/proxyclient"
diff --git a/pkg/tools/scan/adapter.go b/tools/scan/adapter.go
similarity index 99%
rename from pkg/tools/scan/adapter.go
rename to tools/scan/adapter.go
index 4abb30c6..c5a56a6a 100644
--- a/pkg/tools/scan/adapter.go
+++ b/tools/scan/adapter.go
@@ -6,11 +6,11 @@ import (
 	"net/url"
 	"strings"
 
-	"github.com/chainreactors/aiscan/pkg/tools/scan/engine"
-	"github.com/chainreactors/utils/parsers"
+	"github.com/chainreactors/aiscan/tools/scan/engine"
 	sdktypes "github.com/chainreactors/sdk/pkg/types"
 	sdkzombie "github.com/chainreactors/sdk/zombie"
 	"github.com/chainreactors/utils"
+	"github.com/chainreactors/utils/parsers"
 	zombiepkg "github.com/chainreactors/zombie/pkg"
 )
 
diff --git a/pkg/tools/scan/aggregate.go b/tools/scan/aggregate.go
similarity index 100%
rename from pkg/tools/scan/aggregate.go
rename to tools/scan/aggregate.go
diff --git a/pkg/tools/scan/aggregate_test.go b/tools/scan/aggregate_test.go
similarity index 100%
rename from pkg/tools/scan/aggregate_test.go
rename to tools/scan/aggregate_test.go
diff --git a/pkg/tools/scan/bridge.go b/tools/scan/bridge.go
similarity index 96%
rename from pkg/tools/scan/bridge.go
rename to tools/scan/bridge.go
index b883b728..92b6a3bb 100644
--- a/pkg/tools/scan/bridge.go
+++ b/tools/scan/bridge.go
@@ -6,7 +6,7 @@ import (
 	"io"
 
 	"github.com/chainreactors/aiscan/core/eventbus"
-	"github.com/chainreactors/aiscan/pkg/tools/scan/pipeline"
+	"github.com/chainreactors/aiscan/tools/scan/pipeline"
 )
 
 type pipelineEvent struct {
diff --git a/pkg/tools/scan/capability.go b/tools/scan/capability.go
similarity index 98%
rename from pkg/tools/scan/capability.go
rename to tools/scan/capability.go
index c188986d..f18f54e1 100644
--- a/pkg/tools/scan/capability.go
+++ b/tools/scan/capability.go
@@ -3,8 +3,8 @@ package scan
 import (
 	"context"
 
-	"github.com/chainreactors/aiscan/pkg/tools/scan/engine"
-	"github.com/chainreactors/aiscan/pkg/tools/scan/pipeline"
+	"github.com/chainreactors/aiscan/tools/scan/engine"
+	"github.com/chainreactors/aiscan/tools/scan/pipeline"
 )
 
 const (
diff --git a/pkg/tools/scan/capability_katana.go b/tools/scan/capability_katana.go
similarity index 87%
rename from pkg/tools/scan/capability_katana.go
rename to tools/scan/capability_katana.go
index f6497b6f..74699eb0 100644
--- a/pkg/tools/scan/capability_katana.go
+++ b/tools/scan/capability_katana.go
@@ -16,7 +16,7 @@ import (
 	katanatypes "github.com/projectdiscovery/katana/pkg/types"
 	"github.com/projectdiscovery/katana/pkg/utils/queue"
 
-	"github.com/chainreactors/aiscan/pkg/tools/scan/pipeline"
+	"github.com/chainreactors/aiscan/tools/scan/pipeline"
 )
 
 const (
@@ -87,17 +87,17 @@ func runKatanaCrawl(ctx context.Context, c *Command, e event, depth int, jsMode
 	seen := make(map[string]struct{})
 
 	options := &katanatypes.Options{
-		MaxDepth:              depth,
-		FieldScope:            "rdn",
-		BodyReadSize:          math.MaxInt,
-		RateLimit:             150,
-		Strategy:              queue.DepthFirst.String(),
-		Silent:                true,
-		ScrapeJSResponses:     jsMode,
+		MaxDepth:               depth,
+		FieldScope:             "rdn",
+		BodyReadSize:           math.MaxInt,
+		RateLimit:              150,
+		Strategy:               queue.DepthFirst.String(),
+		Silent:                 true,
+		ScrapeJSResponses:      jsMode,
 		ScrapeJSLuiceResponses: jsMode,
-		Timeout:               10,
-		Concurrency:           10,
-		Parallelism:           10,
+		Timeout:                10,
+		Concurrency:            10,
+		Parallelism:            10,
 		OnResult: func(r katanaoutput.Result) {
 			if r.Request == nil || r.Request.URL == "" {
 				return
@@ -177,6 +177,6 @@ func sameRootDomain(rawURL, rdn string) bool {
 
 type silentWriter struct{}
 
-func (w *silentWriter) Close() error                      { return nil }
-func (w *silentWriter) Write(_ *katanaoutput.Result) error { return nil }
+func (w *silentWriter) Close() error                         { return nil }
+func (w *silentWriter) Write(_ *katanaoutput.Result) error   { return nil }
 func (w *silentWriter) WriteErr(_ *katanaoutput.Error) error { return nil }
diff --git a/pkg/tools/scan/capability_katana_stub.go b/tools/scan/capability_katana_stub.go
similarity index 100%
rename from pkg/tools/scan/capability_katana_stub.go
rename to tools/scan/capability_katana_stub.go
diff --git a/pkg/tools/scan/capability_katana_test.go b/tools/scan/capability_katana_test.go
similarity index 100%
rename from pkg/tools/scan/capability_katana_test.go
rename to tools/scan/capability_katana_test.go
diff --git a/pkg/tools/scan/collector.go b/tools/scan/collector.go
similarity index 99%
rename from pkg/tools/scan/collector.go
rename to tools/scan/collector.go
index 303692c4..f8854e9b 100644
--- a/pkg/tools/scan/collector.go
+++ b/tools/scan/collector.go
@@ -8,7 +8,7 @@ import (
 	"time"
 
 	"github.com/chainreactors/aiscan/core/output"
-	"github.com/chainreactors/aiscan/pkg/tools/scan/pipeline"
+	"github.com/chainreactors/aiscan/tools/scan/pipeline"
 	sdktypes "github.com/chainreactors/sdk/pkg/types"
 	"github.com/chainreactors/utils"
 	"github.com/chainreactors/utils/parsers"
diff --git a/pkg/tools/scan/command.go b/tools/scan/command.go
similarity index 98%
rename from pkg/tools/scan/command.go
rename to tools/scan/command.go
index 02a8837d..c405df38 100644
--- a/pkg/tools/scan/command.go
+++ b/tools/scan/command.go
@@ -13,9 +13,9 @@ import (
 	"github.com/chainreactors/aiscan/pkg/aop"
 	"github.com/chainreactors/aiscan/pkg/commands"
 	"github.com/chainreactors/aiscan/pkg/telemetry"
-	"github.com/chainreactors/aiscan/pkg/tools/scan/engine"
-	"github.com/chainreactors/aiscan/pkg/tools/scan/pipeline"
-	"github.com/chainreactors/aiscan/pkg/tools/toolargs"
+	"github.com/chainreactors/aiscan/tools/scan/engine"
+	"github.com/chainreactors/aiscan/tools/scan/pipeline"
+	"github.com/chainreactors/aiscan/tools/toolargs"
 	goflags "github.com/jessevdk/go-flags"
 )
 
diff --git a/pkg/tools/scan/command_test.go b/tools/scan/command_test.go
similarity index 99%
rename from pkg/tools/scan/command_test.go
rename to tools/scan/command_test.go
index 548b6c51..5bf8967a 100644
--- a/pkg/tools/scan/command_test.go
+++ b/tools/scan/command_test.go
@@ -18,8 +18,8 @@ import (
 	"github.com/chainreactors/aiscan/core/output"
 	"github.com/chainreactors/aiscan/pkg/commands"
 	"github.com/chainreactors/aiscan/pkg/telemetry"
-	"github.com/chainreactors/aiscan/pkg/tools/scan/engine"
-	"github.com/chainreactors/aiscan/pkg/tools/scan/pipeline"
+	"github.com/chainreactors/aiscan/tools/scan/engine"
+	"github.com/chainreactors/aiscan/tools/scan/pipeline"
 	"github.com/chainreactors/fingers/common"
 	"github.com/chainreactors/logs"
 	"github.com/chainreactors/neutron/operators"
diff --git a/pkg/tools/scan/data_bus_test.go b/tools/scan/data_bus_test.go
similarity index 95%
rename from pkg/tools/scan/data_bus_test.go
rename to tools/scan/data_bus_test.go
index 03903ed9..1a0b2e77 100644
--- a/pkg/tools/scan/data_bus_test.go
+++ b/tools/scan/data_bus_test.go
@@ -6,7 +6,7 @@ import (
 
 	"github.com/chainreactors/aiscan/core/eventbus"
 	"github.com/chainreactors/aiscan/core/output"
-	"github.com/chainreactors/aiscan/pkg/tools/scan/engine"
+	"github.com/chainreactors/aiscan/tools/scan/engine"
 	"github.com/chainreactors/utils/parsers"
 )
 
diff --git a/pkg/tools/scan/engine/gogo.go b/tools/scan/engine/gogo.go
similarity index 100%
rename from pkg/tools/scan/engine/gogo.go
rename to tools/scan/engine/gogo.go
diff --git a/pkg/tools/scan/engine/gogo_test.go b/tools/scan/engine/gogo_test.go
similarity index 100%
rename from pkg/tools/scan/engine/gogo_test.go
rename to tools/scan/engine/gogo_test.go
diff --git a/pkg/tools/scan/engine/neutron.go b/tools/scan/engine/neutron.go
similarity index 100%
rename from pkg/tools/scan/engine/neutron.go
rename to tools/scan/engine/neutron.go
diff --git a/pkg/tools/scan/engine/race_norace_test.go b/tools/scan/engine/race_norace_test.go
similarity index 100%
rename from pkg/tools/scan/engine/race_norace_test.go
rename to tools/scan/engine/race_norace_test.go
diff --git a/pkg/tools/scan/engine/race_test.go b/tools/scan/engine/race_test.go
similarity index 100%
rename from pkg/tools/scan/engine/race_test.go
rename to tools/scan/engine/race_test.go
diff --git a/pkg/tools/scan/engine/set.go b/tools/scan/engine/set.go
similarity index 100%
rename from pkg/tools/scan/engine/set.go
rename to tools/scan/engine/set.go
diff --git a/pkg/tools/scan/engine/set_test.go b/tools/scan/engine/set_test.go
similarity index 100%
rename from pkg/tools/scan/engine/set_test.go
rename to tools/scan/engine/set_test.go
diff --git a/pkg/tools/scan/engine/set_uncover_recon.go b/tools/scan/engine/set_uncover_recon.go
similarity index 100%
rename from pkg/tools/scan/engine/set_uncover_recon.go
rename to tools/scan/engine/set_uncover_recon.go
diff --git a/pkg/tools/scan/engine/set_uncover_stub.go b/tools/scan/engine/set_uncover_stub.go
similarity index 100%
rename from pkg/tools/scan/engine/set_uncover_stub.go
rename to tools/scan/engine/set_uncover_stub.go
diff --git a/pkg/tools/scan/engine/spray.go b/tools/scan/engine/spray.go
similarity index 100%
rename from pkg/tools/scan/engine/spray.go
rename to tools/scan/engine/spray.go
diff --git a/pkg/tools/scan/engine/spray_test.go b/tools/scan/engine/spray_test.go
similarity index 100%
rename from pkg/tools/scan/engine/spray_test.go
rename to tools/scan/engine/spray_test.go
diff --git a/pkg/tools/scan/engine/uncover.go b/tools/scan/engine/uncover.go
similarity index 100%
rename from pkg/tools/scan/engine/uncover.go
rename to tools/scan/engine/uncover.go
diff --git a/pkg/tools/scan/engine/uncover_agents.go b/tools/scan/engine/uncover_agents.go
similarity index 100%
rename from pkg/tools/scan/engine/uncover_agents.go
rename to tools/scan/engine/uncover_agents.go
diff --git a/pkg/tools/scan/engine/uncover_stub.go b/tools/scan/engine/uncover_stub.go
similarity index 100%
rename from pkg/tools/scan/engine/uncover_stub.go
rename to tools/scan/engine/uncover_stub.go
diff --git a/pkg/tools/scan/engine/uncover_test.go b/tools/scan/engine/uncover_test.go
similarity index 100%
rename from pkg/tools/scan/engine/uncover_test.go
rename to tools/scan/engine/uncover_test.go
diff --git a/pkg/tools/scan/engine/zombie.go b/tools/scan/engine/zombie.go
similarity index 100%
rename from pkg/tools/scan/engine/zombie.go
rename to tools/scan/engine/zombie.go
diff --git a/pkg/tools/scan/event.go b/tools/scan/event.go
similarity index 100%
rename from pkg/tools/scan/event.go
rename to tools/scan/event.go
diff --git a/pkg/tools/scan/http_auth.go b/tools/scan/http_auth.go
similarity index 100%
rename from pkg/tools/scan/http_auth.go
rename to tools/scan/http_auth.go
diff --git a/pkg/tools/scan/input.go b/tools/scan/input.go
similarity index 100%
rename from pkg/tools/scan/input.go
rename to tools/scan/input.go
diff --git a/pkg/tools/scan/intent.go b/tools/scan/intent.go
similarity index 100%
rename from pkg/tools/scan/intent.go
rename to tools/scan/intent.go
diff --git a/pkg/tools/scan/jsonl_writer.go b/tools/scan/jsonl_writer.go
similarity index 97%
rename from pkg/tools/scan/jsonl_writer.go
rename to tools/scan/jsonl_writer.go
index b80c73ea..b634d555 100644
--- a/pkg/tools/scan/jsonl_writer.go
+++ b/tools/scan/jsonl_writer.go
@@ -7,7 +7,7 @@ import (
 	"github.com/chainreactors/aiscan/core/eventbus"
 	"github.com/chainreactors/aiscan/core/output"
 	"github.com/chainreactors/aiscan/pkg/aop"
-	"github.com/chainreactors/aiscan/pkg/tools/scan/pipeline"
+	"github.com/chainreactors/aiscan/tools/scan/pipeline"
 )
 
 type scanJSONLWriter struct {
diff --git a/pkg/tools/scan/options.go b/tools/scan/options.go
similarity index 100%
rename from pkg/tools/scan/options.go
rename to tools/scan/options.go
diff --git a/pkg/tools/scan/output.go b/tools/scan/output.go
similarity index 100%
rename from pkg/tools/scan/output.go
rename to tools/scan/output.go
diff --git a/pkg/tools/scan/pipeline/pipeline.go b/tools/scan/pipeline/pipeline.go
similarity index 100%
rename from pkg/tools/scan/pipeline/pipeline.go
rename to tools/scan/pipeline/pipeline.go
diff --git a/pkg/tools/scan/pipeline/pipeline_test.go b/tools/scan/pipeline/pipeline_test.go
similarity index 100%
rename from pkg/tools/scan/pipeline/pipeline_test.go
rename to tools/scan/pipeline/pipeline_test.go
diff --git a/pkg/tools/scan/report.go b/tools/scan/report.go
similarity index 100%
rename from pkg/tools/scan/report.go
rename to tools/scan/report.go
diff --git a/pkg/tools/scan/report_json.go b/tools/scan/report_json.go
similarity index 100%
rename from pkg/tools/scan/report_json.go
rename to tools/scan/report_json.go
diff --git a/pkg/tools/scan/report_plain.go b/tools/scan/report_plain.go
similarity index 100%
rename from pkg/tools/scan/report_plain.go
rename to tools/scan/report_plain.go
diff --git a/pkg/tools/scan/scan_options.go b/tools/scan/scan_options.go
similarity index 100%
rename from pkg/tools/scan/scan_options.go
rename to tools/scan/scan_options.go
diff --git a/pkg/tools/scan/sco.go b/tools/scan/sco.go
similarity index 100%
rename from pkg/tools/scan/sco.go
rename to tools/scan/sco.go
diff --git a/pkg/tools/scan/sco_stub.go b/tools/scan/sco_stub.go
similarity index 100%
rename from pkg/tools/scan/sco_stub.go
rename to tools/scan/sco_stub.go
diff --git a/pkg/tools/scan/sco_test.go b/tools/scan/sco_test.go
similarity index 97%
rename from pkg/tools/scan/sco_test.go
rename to tools/scan/sco_test.go
index 9518e1b1..82c63893 100644
--- a/pkg/tools/scan/sco_test.go
+++ b/tools/scan/sco_test.go
@@ -7,7 +7,7 @@ import (
 	"testing"
 
 	"github.com/chainreactors/aiscan/core/eventbus"
-	"github.com/chainreactors/aiscan/pkg/tools/scan/pipeline"
+	"github.com/chainreactors/aiscan/tools/scan/pipeline"
 	"github.com/chainreactors/utils/parsers"
 )
 
diff --git a/pkg/tools/scan/structured.go b/tools/scan/structured.go
similarity index 100%
rename from pkg/tools/scan/structured.go
rename to tools/scan/structured.go
diff --git a/pkg/tools/scan/target.go b/tools/scan/target.go
similarity index 100%
rename from pkg/tools/scan/target.go
rename to tools/scan/target.go
diff --git a/pkg/tools/scan/verify.go b/tools/scan/verify.go
similarity index 100%
rename from pkg/tools/scan/verify.go
rename to tools/scan/verify.go
diff --git a/pkg/tools/search/cyberhub.go b/tools/search/cyberhub.go
similarity index 100%
rename from pkg/tools/search/cyberhub.go
rename to tools/search/cyberhub.go
diff --git a/pkg/tools/search/cyberhub_test.go b/tools/search/cyberhub_test.go
similarity index 100%
rename from pkg/tools/search/cyberhub_test.go
rename to tools/search/cyberhub_test.go
diff --git a/pkg/tools/search/fetch.go b/tools/search/fetch.go
similarity index 100%
rename from pkg/tools/search/fetch.go
rename to tools/search/fetch.go
diff --git a/pkg/tools/search/fetch_test.go b/tools/search/fetch_test.go
similarity index 100%
rename from pkg/tools/search/fetch_test.go
rename to tools/search/fetch_test.go
diff --git a/pkg/tools/search/register.go b/tools/search/register.go
similarity index 95%
rename from pkg/tools/search/register.go
rename to tools/search/register.go
index 7c183290..5d9a4817 100644
--- a/pkg/tools/search/register.go
+++ b/tools/search/register.go
@@ -4,7 +4,7 @@ import (
 	"github.com/chainreactors/aiscan/core/resources"
 	"github.com/chainreactors/aiscan/pkg/agent/provider"
 	"github.com/chainreactors/aiscan/pkg/commands"
-	"github.com/chainreactors/aiscan/pkg/tools/scan/engine"
+	"github.com/chainreactors/aiscan/tools/scan/engine"
 	"github.com/chainreactors/sdk/pkg/association"
 )
 
diff --git a/pkg/tools/search/tavily.go b/tools/search/tavily.go
similarity index 100%
rename from pkg/tools/search/tavily.go
rename to tools/search/tavily.go
diff --git a/pkg/tools/search/tavily_test.go b/tools/search/tavily_test.go
similarity index 100%
rename from pkg/tools/search/tavily_test.go
rename to tools/search/tavily_test.go
diff --git a/pkg/tools/search/websearch.go b/tools/search/websearch.go
similarity index 100%
rename from pkg/tools/search/websearch.go
rename to tools/search/websearch.go
diff --git a/pkg/tools/search/websearch_tool.go b/tools/search/websearch_tool.go
similarity index 100%
rename from pkg/tools/search/websearch_tool.go
rename to tools/search/websearch_tool.go
diff --git a/pkg/tools/spray/register.go b/tools/spray/register.go
similarity index 93%
rename from pkg/tools/spray/register.go
rename to tools/spray/register.go
index 4c0c14ac..3489fdd2 100644
--- a/pkg/tools/spray/register.go
+++ b/tools/spray/register.go
@@ -3,7 +3,7 @@ package spray
 import (
 	cfg "github.com/chainreactors/aiscan/core/config"
 	"github.com/chainreactors/aiscan/pkg/commands"
-	"github.com/chainreactors/aiscan/pkg/tools/scan/engine"
+	"github.com/chainreactors/aiscan/tools/scan/engine"
 )
 
 func init() {
diff --git a/pkg/tools/spray/spray.go b/tools/spray/spray.go
similarity index 98%
rename from pkg/tools/spray/spray.go
rename to tools/spray/spray.go
index cf28ee5f..69e0035c 100644
--- a/pkg/tools/spray/spray.go
+++ b/tools/spray/spray.go
@@ -11,7 +11,7 @@ import (
 	"github.com/chainreactors/aiscan/core/output"
 	"github.com/chainreactors/aiscan/pkg/commands"
 	"github.com/chainreactors/aiscan/pkg/telemetry"
-	"github.com/chainreactors/aiscan/pkg/tools/toolargs"
+	"github.com/chainreactors/aiscan/tools/toolargs"
 	"github.com/chainreactors/sdk/spray"
 	spraycore "github.com/chainreactors/spray/core"
 	"github.com/chainreactors/utils/parsers"
diff --git a/pkg/tools/spray/spray_test.go b/tools/spray/spray_test.go
similarity index 100%
rename from pkg/tools/spray/spray_test.go
rename to tools/spray/spray_test.go
diff --git a/pkg/tools/toolargs/base.go b/tools/toolargs/base.go
similarity index 100%
rename from pkg/tools/toolargs/base.go
rename to tools/toolargs/base.go
diff --git a/pkg/tools/toolargs/flags.go b/tools/toolargs/flags.go
similarity index 100%
rename from pkg/tools/toolargs/flags.go
rename to tools/toolargs/flags.go
diff --git a/pkg/tools/toolargs/help.go b/tools/toolargs/help.go
similarity index 100%
rename from pkg/tools/toolargs/help.go
rename to tools/toolargs/help.go
diff --git a/pkg/tools/toolargs/normalize.go b/tools/toolargs/normalize.go
similarity index 100%
rename from pkg/tools/toolargs/normalize.go
rename to tools/toolargs/normalize.go
diff --git a/pkg/tools/toolargs/resolve.go b/tools/toolargs/resolve.go
similarity index 100%
rename from pkg/tools/toolargs/resolve.go
rename to tools/toolargs/resolve.go
diff --git a/pkg/tools/zombie/register.go b/tools/zombie/register.go
similarity index 92%
rename from pkg/tools/zombie/register.go
rename to tools/zombie/register.go
index 722d7023..8b78f30b 100644
--- a/pkg/tools/zombie/register.go
+++ b/tools/zombie/register.go
@@ -3,7 +3,7 @@ package zombie
 import (
 	cfg "github.com/chainreactors/aiscan/core/config"
 	"github.com/chainreactors/aiscan/pkg/commands"
-	"github.com/chainreactors/aiscan/pkg/tools/scan/engine"
+	"github.com/chainreactors/aiscan/tools/scan/engine"
 )
 
 func init() {
diff --git a/pkg/tools/zombie/zombie.go b/tools/zombie/zombie.go
similarity index 97%
rename from pkg/tools/zombie/zombie.go
rename to tools/zombie/zombie.go
index 5975a5a0..6d3e0966 100644
--- a/pkg/tools/zombie/zombie.go
+++ b/tools/zombie/zombie.go
@@ -10,7 +10,7 @@ import (
 	"github.com/chainreactors/aiscan/core/output"
 	"github.com/chainreactors/aiscan/pkg/commands"
 	"github.com/chainreactors/aiscan/pkg/telemetry"
-	"github.com/chainreactors/aiscan/pkg/tools/toolargs"
+	"github.com/chainreactors/aiscan/tools/toolargs"
 	sdkzombie "github.com/chainreactors/sdk/zombie"
 	zombiecore "github.com/chainreactors/zombie/core"
 )
diff --git a/pkg/tools/zombie/zombie_test.go b/tools/zombie/zombie_test.go
similarity index 100%
rename from pkg/tools/zombie/zombie_test.go
rename to tools/zombie/zombie_test.go
diff --git a/web/frontend/src/api.ts b/web/frontend/src/api.ts
index 6e7821c4..9f2136a4 100644
--- a/web/frontend/src/api.ts
+++ b/web/frontend/src/api.ts
@@ -662,19 +662,15 @@ export interface ChatMessage {
   created_at: string
 }
 
-export type ChatEventType =
+export type DomainEventType =
   | 'scan_started' | 'scan_progress' | 'scan_complete'
   | 'agent_joined' | 'session_cleared'
 
-export interface ChatEvent {
-  type: ChatEventType
+export interface DomainEvent {
+  type: DomainEventType
   session_id: string
-  message_id?: string
-  role?: ChatMessage['role']
   agent_id?: string
   agent_name?: string
-  turn?: number
-  content?: string
   scan_id?: string
   result?: ScanResult
   data?: string
@@ -784,14 +780,14 @@ export async function fetchScanReport(scanID: string, lang: string): Promise void,
+  onEvent: (event: DomainEvent) => void,
   onReconnect?: () => void,
   onAOP?: (event: AOPEvent) => void,
   onOpen?: () => void,
 ): () => void {
-  const eventTypes: ChatEventType[] = [
+  const eventTypes: DomainEventType[] = [
     'scan_started', 'scan_progress', 'scan_complete',
     'agent_joined', 'session_cleared',
   ]
@@ -804,7 +800,7 @@ export function subscribeChatEvents(
         const parsed = JSON.parse(data)
         onEvent({ ...parsed, type })
       } catch {
-        onEvent({ type, session_id: sessionID, data } as ChatEvent)
+        onEvent({ type, session_id: sessionID, data } as DomainEvent)
       }
     }
   }
diff --git a/web/frontend/src/components/ChatPanel.tsx b/web/frontend/src/components/ChatPanel.tsx
index 633a1070..bba50c86 100644
--- a/web/frontend/src/components/ChatPanel.tsx
+++ b/web/frontend/src/components/ChatPanel.tsx
@@ -116,6 +116,28 @@ function eventText(event: AOPEvent): string {
     .join('\n')
 }
 
+function markdownCodeFence(text: string): string {
+  let fence = '```'
+  while (text.includes(fence)) fence += '`'
+  return `${fence}\n${text}\n${fence}`
+}
+
+function presentAOPEvent(event: AOPEvent): AOPEvent {
+  if (event.type !== 'message') return event
+  const command = event.ext?.command as { presentation?: string } | undefined
+  if (command?.presentation !== 'preformatted') return event
+  const data = event.data as { parts?: Array<{ type?: string; text?: string }> }
+  return {
+    ...event,
+    data: {
+      ...data,
+      parts: (data.parts ?? []).map((part) => (
+        part.type === 'text' && part.text ? { ...part, text: markdownCodeFence(part.text) } : part
+      )),
+    },
+  }
+}
+
 function extensionBlock(event: AOPEvent): Record {
   for (const value of Object.values(event.ext ?? {})) {
     if (value && typeof value === 'object') return value as Record
@@ -145,7 +167,7 @@ function reduceConversationAOP(
 
   const childIDs = new Set(childStarts.keys())
   const topLevel = reduceAOPToTimeline(
-    events.filter((event) => !childIDs.has(event.session_id)),
+    events.filter((event) => !childIDs.has(event.session_id)).map(presentAOPEvent),
     { streaming, lifecycle: 'errors' },
   ) as ViewerTimelineItem[]
 
@@ -170,7 +192,7 @@ function reduceConversationAOP(
           ? 'canceled'
           : 'completed'
     const timestamp = Date.parse(start.ts)
-    const items = reduceAOPToTimeline(childEvents, {
+    const items = reduceAOPToTimeline(childEvents.map(presentAOPEvent), {
       streaming: streaming && !end,
       lifecycle: 'errors',
     }).filter((item) => item.kind !== 'divider' || item.variant === 'warning') as ViewerTimelineItem[]
diff --git a/web/frontend/src/hooks/useChatSession.ts b/web/frontend/src/hooks/useChatSession.ts
index db228f63..640dbba8 100644
--- a/web/frontend/src/hooks/useChatSession.ts
+++ b/web/frontend/src/hooks/useChatSession.ts
@@ -10,10 +10,10 @@ import {
   listChatMessages,
   listChatSessions,
   sendChatMessage,
-  subscribeChatEvents,
+  subscribeDomainEvents,
   getScan,
 } from '../api'
-import type { AgentInfo, AOPEvent, ChatEvent, ChatMessage, ChatSession, ScanResult } from '../api'
+import type { AgentInfo, AOPEvent, DomainEvent, ChatMessage, ChatSession, ScanResult } from '../api'
 import {
   isRootPath,
   parseRoute,
@@ -242,7 +242,7 @@ export function useChatSession() {
     setPendingResponse(false)
   }
 
-  function handleChatEvent(event: ChatEvent) {
+  function handleDomainEvent(event: DomainEvent) {
     const now = Date.now()
 
     switch (event.type) {
@@ -460,9 +460,9 @@ export function useChatSession() {
     } catch {}
 
     if (activation !== activationRef.current) return
-    unsubRef.current = subscribeChatEvents(
+    unsubRef.current = subscribeDomainEvents(
       id,
-      handleChatEvent,
+      handleDomainEvent,
       () => reconcileAfterReconnect(id),
       handleAOPEvent,
       () => {