Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .claude/skills/tui-test/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ The lead agent (you) does the following:

### Phase 2: Parallel Testing

Spawn one teammate per app (max 5-6 concurrent to avoid system overload). Use `general-purpose` agent type with `bypassPermissions` mode.
Spawn one teammate per app (max 5-6 concurrent to avoid system overload). Use `general-purpose` agent type with `bypassPermissions` mode and `model: "sonnet"` (cheaper, sufficient for mechanical test execution).

Each teammate gets:
- The **Teammate Test Protocol** section below (copy it fully into the prompt)
Expand Down
16 changes: 16 additions & 0 deletions cmd/daemon.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package cmd

import (
"fmt"
"log"
"os"
"os/signal"
"path/filepath"
Expand All @@ -22,6 +23,7 @@ var (
daemonDataDirFlag string
daemonMemoryBackend bool
daemonStoppedTTLFlag string
daemonLogFileFlag string
)

var daemonCmd = &cobra.Command{
Expand All @@ -42,13 +44,27 @@ func init() {
"Use in-memory storage instead of file-based (no persistence)")
daemonCmd.Flags().StringVar(&daemonStoppedTTLFlag, "stopped-ttl", "",
"Auto-cleanup stopped sessions after duration (e.g., 5m, 1h, 24h)")
daemonCmd.Flags().StringVar(&daemonLogFileFlag, "log-file", "",
"Write daemon logs to file (default: discard)")
}

func runDaemon(cmd *cobra.Command, args []string) error {
if daemonMCPFlag {
return runMCPServer()
}

if daemonLogFileFlag != "" {
f, err := os.OpenFile(daemonLogFileFlag, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644)
if err != nil {
return fmt.Errorf("open log file: %w", err)
}
defer f.Close()
log.SetOutput(f)
log.SetFlags(log.Ldate | log.Ltime | log.Lmicroseconds)
os.Stderr = f
log.Println("daemon starting")
}

var opts []daemon.ServerOption

if daemonDataDirFlag == "" {
Expand Down
33 changes: 9 additions & 24 deletions cmd/exec.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import (

"github.com/schovi/shelli/internal/ansi"
"github.com/schovi/shelli/internal/daemon"
"github.com/schovi/shelli/internal/wait"
"github.com/spf13/cobra"
)

Expand Down Expand Up @@ -59,49 +58,35 @@ func runExec(cmd *cobra.Command, args []string) error {
return fmt.Errorf("daemon: %w", err)
}

_, startPos, err := client.Read(name, "all", 0, 0)
if err != nil {
return err
}

if err := client.Send(name, input, true); err != nil {
return err
}

var pattern string
var settleMs int

if hasWait {
pattern = execWaitFlag
settleMs = 0
} else {
pattern = ""
settleMs = execSettleFlag
}

output, pos, err := wait.ForOutput(
func() (string, int, error) { return client.Read(name, "all", 0, 0) },
wait.Config{
Pattern: pattern,
SettleMs: settleMs,
TimeoutSec: execTimeoutFlag,
StartPosition: startPos,
SizeFunc: func() (int, error) { return client.Size(name) },
},
)
result, err := client.Exec(name, daemon.ExecOptions{
Input: input,
SettleMs: settleMs,
WaitPattern: pattern,
TimeoutSec: execTimeoutFlag,
})
if err != nil {
return err
}

output := result.Output
if execStripAnsiFlag {
output = ansi.Strip(output)
}

if execJsonFlag {
out := map[string]interface{}{
"input": input,
"input": result.Input,
"output": output,
"position": pos,
"position": result.Position,
}
data, err := json.MarshalIndent(out, "", " ")
if err != nil {
Expand Down
54 changes: 54 additions & 0 deletions internal/daemon/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import (
"os"
"os/exec"
"time"

"github.com/schovi/shelli/internal/wait"
)

type Client struct{}
Expand Down Expand Up @@ -372,6 +374,56 @@ func (c *Client) Size(name string) (int, error) {
return int(sizeFloat), nil
}

type ExecOptions struct {
Input string
SettleMs int
WaitPattern string
TimeoutSec int
}

type ExecResult struct {
Input string
Output string
Position int
}

func (c *Client) Exec(name string, opts ExecOptions) (*ExecResult, error) {
_, startPos, err := c.Read(name, "all", 0, 0)
if err != nil {
return nil, err
}

if err := c.Send(name, opts.Input, true); err != nil {
return nil, err
}

settleMs := opts.SettleMs
if opts.WaitPattern == "" && settleMs == 0 {
settleMs = 500
}

timeoutSec := opts.TimeoutSec
if timeoutSec == 0 {
timeoutSec = 10
}

output, pos, err := wait.ForOutput(
func() (string, int, error) { return c.Read(name, "all", 0, 0) },
wait.Config{
Pattern: opts.WaitPattern,
SettleMs: settleMs,
TimeoutSec: timeoutSec,
StartPosition: startPos,
SizeFunc: func() (int, error) { return c.Size(name) },
},
)
if err != nil {
return nil, err
}

return &ExecResult{Input: opts.Input, Output: output, Position: pos}, nil
}

func (c *Client) send(req Request) (*Response, error) {
conn, err := net.Dial("unix", SocketPath())
if err != nil {
Expand All @@ -381,6 +433,8 @@ func (c *Client) send(req Request) (*Response, error) {

conn.SetDeadline(time.Now().Add(ClientDeadline))

req.Version = ProtocolVersion

if err := json.NewEncoder(conn).Encode(req); err != nil {
return nil, err
}
Expand Down
2 changes: 2 additions & 0 deletions internal/daemon/constants.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ package daemon
import "time"

const (
ProtocolVersion = 1

ReadBufferSize = 4096
KillGracePeriod = 100 * time.Millisecond
ClientDeadline = 30 * time.Second
Expand Down
Loading