Run Claude sessions on tasks with event streaming and restart recovery

This commit is contained in:
Ian Gulliver
2026-10-13 22:30:39 -07:00
parent 2328904131
commit 09951e64e7
16 changed files with 1630 additions and 170 deletions
+32 -7
View File
@@ -29,6 +29,7 @@ type HookMatcher struct {
type Options struct {
Cwd string
Model string
SessionID string
Resume string
ForkSession bool
GitName string
@@ -45,12 +46,15 @@ type Event struct {
}
type Block struct {
Type string `json:"type"`
Text string `json:"text"`
Name string `json:"name"`
Input json.RawMessage `json:"input"`
Content json.RawMessage `json:"content"`
IsError bool `json:"is_error"`
Type string `json:"type"`
ID string `json:"id"`
ToolUseID string `json:"tool_use_id"`
Text string `json:"text"`
Thinking string `json:"thinking"`
Name string `json:"name"`
Input json.RawMessage `json:"input"`
Content json.RawMessage `json:"content"`
IsError bool `json:"is_error"`
}
type Result struct {
@@ -138,6 +142,7 @@ func Start(ctx context.Context, opts Options) (*Session, error) {
pending: map[string]chan controlResult{},
hooks: map[string]HookFunc{},
servers: map[string]*mcp.Server{},
id: initialID(opts),
}
args, err := s.buildArgs(opts)
@@ -179,6 +184,16 @@ func Start(ctx context.Context, opts Options) (*Session, error) {
return s, nil
}
func initialID(opts Options) string {
if opts.SessionID != "" {
return opts.SessionID
}
if opts.ForkSession {
return ""
}
return opts.Resume
}
func (s *Session) buildArgs(opts Options) ([]string, error) {
settings, err := json.Marshal(map[string]any{
"attribution": map[string]any{"commit": "", "pr": ""},
@@ -190,7 +205,7 @@ func (s *Session) buildArgs(opts Options) ([]string, error) {
servers := map[string]any{}
for _, srv := range opts.MCPServers {
s.servers[srv.Name] = srv
servers[srv.Name] = map[string]any{"type": "sdk", "name": srv.Name}
servers[srv.Name] = map[string]any{"type": "sdk", "name": srv.Name, "alwaysLoad": true}
}
mcpConfig, err := json.Marshal(map[string]any{"mcpServers": servers})
@@ -212,6 +227,10 @@ func (s *Session) buildArgs(opts Options) ([]string, error) {
args = append(args, "--model", opts.Model)
}
if opts.SessionID != "" {
args = append(args, "--session-id="+opts.SessionID)
}
if opts.Resume != "" {
args = append(args, "--resume="+opts.Resume)
}
@@ -342,6 +361,12 @@ func (s *Session) Close() error {
return s.cmd.Wait()
}
func (s *Session) Kill() {
s.cmd.Process.Kill()
<-s.done
s.cmd.Wait()
}
func (s *Session) write(v any) error {
b, err := json.Marshal(v)
if err != nil {
+18 -1
View File
@@ -6,6 +6,7 @@ import (
"os"
"os/exec"
"path/filepath"
"slices"
"strings"
"sync"
"testing"
@@ -70,6 +71,9 @@ func TestCommitIdentityHooksAndReport(t *testing.T) {
if len(tools) == 0 {
t.Fatalf("PostToolUse hook never fired")
}
if slices.Contains(tools, "ToolSearch") {
t.Fatalf("byre MCP tools were deferred behind ToolSearch")
}
log := git(t, repo, "log", "-1", "--format=%an <%ae>%n%cn <%ce>%n%B")
want := gitName + " <" + gitEmail + ">\n" + gitName + " <" + gitEmail + ">\nadd hello"
@@ -119,13 +123,26 @@ func TestInterrupt(t *testing.T) {
ctx := testContext(t)
s := start(t, ctx, claude.Options{Cwd: scratchRepo(t)})
send(t, s, "Run the bash command `sleep 120` in the foreground (not in the background), then say done.")
send(t, s, "Run the bash command `ping -c 120 127.0.0.1` in the foreground (not in the background) and wait for it to finish, then say done.")
interruptedAt := time.Time{}
waitResult(t, ctx, s, func(ev claude.Event) {
if ev.Type == "user" {
blocks, err := ev.Blocks()
if err != nil {
t.Fatalf("parse user: %v", err)
}
for _, b := range blocks {
if b.Type == "tool_result" && (!b.IsError || strings.Contains(string(b.Content), "Blocked")) {
t.Fatalf("command finished or was refused instead of being interrupted: %s", b.Content)
}
}
}
if !interruptedAt.IsZero() || !hasToolUse(t, ev) {
return
}
time.Sleep(2 * time.Second)
interruptedAt = time.Now()
err := s.Interrupt(ctx)
if err != nil {
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long