All checks were successful
Tests / unit-tests (push) Successful in 8s
- Uklonjen --include-partial-messages (izazivao duple assistant evente) - content_block_start preskače tool_use blokove (prazni divovi) - Shift+Tab prebacuje između Code i Plan moda - Plan mod šalje instrukciju da Claude samo planira bez izmena - CSS za mode bar i plan poruke (plava boja, ⊞ prefix) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
296 lines
6.8 KiB
Go
296 lines
6.8 KiB
Go
package main
|
|
|
|
import (
|
|
"bufio"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"os/exec"
|
|
"strings"
|
|
"sync"
|
|
)
|
|
|
|
// CLIEvent represents a parsed NDJSON event from claude CLI stdout.
|
|
type CLIEvent struct {
|
|
Type string `json:"type"`
|
|
Subtype string `json:"subtype,omitempty"`
|
|
|
|
// For system init event
|
|
SessionID string `json:"session_id,omitempty"`
|
|
|
|
// For assistant message events
|
|
Message *CLIMessage `json:"message,omitempty"`
|
|
|
|
// For stream_event wrapper
|
|
Event *StreamEvent `json:"event,omitempty"`
|
|
|
|
// For result events — can be object or string, use RawMessage
|
|
RawResult json.RawMessage `json:"result,omitempty"`
|
|
Result *CLIResult `json:"-"`
|
|
|
|
// Top-level fields on result events
|
|
TotalCostUSD float64 `json:"total_cost_usd,omitempty"`
|
|
DurationMS float64 `json:"duration_ms,omitempty"`
|
|
NumTurns int `json:"num_turns,omitempty"`
|
|
}
|
|
|
|
// StreamEvent is the inner event inside a stream_event wrapper.
|
|
type StreamEvent struct {
|
|
Type string `json:"type"`
|
|
Index int `json:"index,omitempty"`
|
|
Delta *StreamDelta `json:"delta,omitempty"`
|
|
ContentBlock *ContentBlock `json:"content_block,omitempty"`
|
|
}
|
|
|
|
type ContentBlock struct {
|
|
Type string `json:"type"`
|
|
Text string `json:"text,omitempty"`
|
|
}
|
|
|
|
type StreamDelta struct {
|
|
Type string `json:"type,omitempty"`
|
|
Text string `json:"text,omitempty"`
|
|
StopReason string `json:"stop_reason,omitempty"`
|
|
}
|
|
|
|
type CLIMessage struct {
|
|
Role string `json:"role,omitempty"`
|
|
Content []CLIContent `json:"content,omitempty"`
|
|
}
|
|
|
|
type CLIContent struct {
|
|
Type string `json:"type"`
|
|
Text string `json:"text,omitempty"`
|
|
Name string `json:"name,omitempty"`
|
|
Input any `json:"input,omitempty"`
|
|
ID string `json:"id,omitempty"`
|
|
}
|
|
|
|
type CLIResult struct {
|
|
Duration float64 `json:"duration_ms,omitempty"`
|
|
NumTurns int `json:"num_turns,omitempty"`
|
|
CostUSD float64 `json:"total_cost_usd,omitempty"`
|
|
SessionID string `json:"session_id,omitempty"`
|
|
}
|
|
|
|
// CLIInputMessage is the JSON format for sending messages via stream-json input.
|
|
type CLIInputMessage struct {
|
|
Type string `json:"type"`
|
|
Message CLIInputContent `json:"message"`
|
|
}
|
|
|
|
type CLIInputContent struct {
|
|
Role string `json:"role"`
|
|
Content string `json:"content"`
|
|
}
|
|
|
|
// CLIProcess manages a running claude CLI process.
|
|
type CLIProcess struct {
|
|
cmd *exec.Cmd
|
|
stdin io.WriteCloser
|
|
stdout io.ReadCloser
|
|
stderr io.ReadCloser
|
|
|
|
Events chan CLIEvent
|
|
Errors chan error
|
|
CLISessionID string // session_id from init event, for resume
|
|
|
|
done chan struct{}
|
|
mu sync.Mutex
|
|
}
|
|
|
|
// SpawnCLI starts a new claude CLI process for the given project directory.
|
|
// Uses --input-format stream-json for multi-turn conversation support.
|
|
func SpawnCLI(projectDir string) (*CLIProcess, error) {
|
|
args := []string{
|
|
"-p",
|
|
"--input-format", "stream-json",
|
|
"--output-format", "stream-json",
|
|
"--verbose",
|
|
}
|
|
|
|
cmd := exec.Command("claude", args...)
|
|
cmd.Dir = projectDir
|
|
|
|
// Filter all Claude-related env vars to prevent nested session detection
|
|
env := filterEnvMulti(os.Environ(), []string{
|
|
"CLAUDECODE",
|
|
"CLAUDE_CODE_ENTRYPOINT",
|
|
})
|
|
cmd.Env = env
|
|
|
|
stdin, err := cmd.StdinPipe()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("stdin pipe: %w", err)
|
|
}
|
|
|
|
stdout, err := cmd.StdoutPipe()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("stdout pipe: %w", err)
|
|
}
|
|
|
|
stderr, err := cmd.StderrPipe()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("stderr pipe: %w", err)
|
|
}
|
|
|
|
if err := cmd.Start(); err != nil {
|
|
return nil, fmt.Errorf("start claude: %w", err)
|
|
}
|
|
|
|
cp := &CLIProcess{
|
|
cmd: cmd,
|
|
stdin: stdin,
|
|
stdout: stdout,
|
|
stderr: stderr,
|
|
Events: make(chan CLIEvent, 100),
|
|
Errors: make(chan error, 10),
|
|
done: make(chan struct{}),
|
|
}
|
|
|
|
go cp.readOutput()
|
|
go cp.readErrors()
|
|
|
|
return cp, nil
|
|
}
|
|
|
|
// Send writes a user message to the claude CLI process stdin as stream-json.
|
|
func (cp *CLIProcess) Send(message string) error {
|
|
cp.mu.Lock()
|
|
defer cp.mu.Unlock()
|
|
|
|
msg := CLIInputMessage{
|
|
Type: "user",
|
|
Message: CLIInputContent{
|
|
Role: "user",
|
|
Content: strings.TrimSpace(message),
|
|
},
|
|
}
|
|
|
|
data, err := json.Marshal(msg)
|
|
if err != nil {
|
|
return fmt.Errorf("marshal message: %w", err)
|
|
}
|
|
|
|
_, err = cp.stdin.Write(append(data, '\n'))
|
|
return err
|
|
}
|
|
|
|
// Close terminates the claude CLI process.
|
|
func (cp *CLIProcess) Close() error {
|
|
cp.mu.Lock()
|
|
defer cp.mu.Unlock()
|
|
|
|
cp.stdin.Close()
|
|
if cp.cmd.Process != nil {
|
|
return cp.cmd.Process.Kill()
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Done returns a channel that's closed when the process exits.
|
|
func (cp *CLIProcess) Done() <-chan struct{} {
|
|
return cp.done
|
|
}
|
|
|
|
func (cp *CLIProcess) readOutput() {
|
|
defer close(cp.done)
|
|
defer close(cp.Events)
|
|
|
|
scanner := bufio.NewScanner(cp.stdout)
|
|
scanner.Buffer(make([]byte, 0, 1024*1024), 1024*1024) // 1MB buffer
|
|
|
|
for scanner.Scan() {
|
|
line := scanner.Text()
|
|
if line == "" {
|
|
continue
|
|
}
|
|
|
|
var event CLIEvent
|
|
if err := json.Unmarshal([]byte(line), &event); err != nil {
|
|
cp.Errors <- fmt.Errorf("parse event: %w (line: %s)", err, truncate(line, 200))
|
|
continue
|
|
}
|
|
|
|
// Parse result field — can be string or object
|
|
if event.Type == "result" {
|
|
if len(event.RawResult) > 0 {
|
|
var result CLIResult
|
|
if err := json.Unmarshal(event.RawResult, &result); err == nil {
|
|
event.Result = &result
|
|
}
|
|
}
|
|
// Always build Result from top-level fields (they're always present on result events)
|
|
if event.Result == nil {
|
|
event.Result = &CLIResult{}
|
|
}
|
|
if event.TotalCostUSD > 0 {
|
|
event.Result.CostUSD = event.TotalCostUSD
|
|
}
|
|
if event.DurationMS > 0 {
|
|
event.Result.Duration = event.DurationMS
|
|
}
|
|
if event.NumTurns > 0 {
|
|
event.Result.NumTurns = event.NumTurns
|
|
}
|
|
}
|
|
|
|
// Capture session_id from init event
|
|
if event.Type == "system" && event.Subtype == "init" && event.SessionID != "" {
|
|
cp.CLISessionID = event.SessionID
|
|
}
|
|
|
|
cp.Events <- event
|
|
}
|
|
|
|
if err := scanner.Err(); err != nil {
|
|
cp.Errors <- fmt.Errorf("scanner: %w", err)
|
|
}
|
|
}
|
|
|
|
func (cp *CLIProcess) readErrors() {
|
|
scanner := bufio.NewScanner(cp.stderr)
|
|
for scanner.Scan() {
|
|
line := scanner.Text()
|
|
if line != "" {
|
|
cp.Errors <- fmt.Errorf("stderr: %s", line)
|
|
}
|
|
}
|
|
}
|
|
|
|
// filterEnvMulti returns a copy of env with all named variables removed.
|
|
func filterEnvMulti(env []string, names []string) []string {
|
|
prefixes := make([]string, len(names))
|
|
for i, n := range names {
|
|
prefixes[i] = n + "="
|
|
}
|
|
|
|
result := make([]string, 0, len(env))
|
|
for _, e := range env {
|
|
skip := false
|
|
for _, p := range prefixes {
|
|
if strings.HasPrefix(e, p) {
|
|
skip = true
|
|
break
|
|
}
|
|
}
|
|
if !skip {
|
|
result = append(result, e)
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
// filterEnv returns a copy of env with the named variable removed.
|
|
func filterEnv(env []string, name string) []string {
|
|
return filterEnvMulti(env, []string{name})
|
|
}
|
|
|
|
func truncate(s string, n int) string {
|
|
if len(s) <= n {
|
|
return s
|
|
}
|
|
return s[:n] + "..."
|
|
}
|