refactor in progress

This commit is contained in:
Levi Neely 2026-02-09 19:45:42 +01:00
parent d8dccd94b3
commit 7ca987e6eb
10 changed files with 1021 additions and 727 deletions

BIN
acme-q

Binary file not shown.

BIN
acme-q-test Executable file

Binary file not shown.

View File

@ -0,0 +1,70 @@
// Package backend defines the interface for chat backends
package backend
import (
"context"
"io"
"time"
)
// Backend represents any chat backend (CLI tool via PTY, or direct API)
type Backend interface {
// Name returns the backend name
Name() string
// CreateSession creates a new session instance
// cwd is the working directory (may be ignored by API backends)
CreateSession(ctx context.Context, cwd string) (Session, error)
}
// Session represents an active conversation session
type Session interface {
// ID returns the unique session identifier
ID() string
// State returns current state ("starting", "idle", "running", "error: ...", "exited")
State() string
// Send sends a prompt and returns the response
// Blocks until response is complete
Send(ctx context.Context, prompt string) (string, error)
// SendStream sends a prompt and streams the response
// Returns a reader that yields response chunks as they arrive
SendStream(ctx context.Context, prompt string) (io.ReadCloser, error)
// Stop interrupts the current operation (best effort)
Stop(ctx context.Context) error
// Close terminates the session and cleans up resources
Close() error
// Metadata returns session metadata (useful for display/debugging)
Metadata() SessionMetadata
// Commands returns command handler (optional, may be nil)
Commands() CommandHandler
// SetAlias sets a user-friendly alias for the session
SetAlias(alias string)
}
// SessionMetadata provides information about a session
type SessionMetadata struct {
Pid int // Process ID (0 if not applicable)
Cwd string // Working directory
Alias string // User-assigned alias
WinID int // Acme window ID
CreatedAt time.Time // Session creation time
Extra map[string]string // Backend-specific metadata
}
// CommandHandler handles backend-specific commands (optional)
type CommandHandler interface {
// IsSupported checks if a command is supported
IsSupported(command string) bool
// Execute executes a backend-specific command
// Returns output and error
Execute(ctx context.Context, command string) (string, error)
}

385
internal/backend/pty/pty.go Normal file
View File

@ -0,0 +1,385 @@
// Package pty provides PTY-based backend implementation for CLI tools
package pty
import (
"acme-q/internal/backend"
"bytes"
"context"
"fmt"
"io"
"log"
"os"
"os/exec"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/creack/pty"
)
// PTYSize defines terminal dimensions
type PTYSize struct {
Rows uint16
Cols uint16
}
// Detector handles pattern matching for backend output
type Detector interface {
// IsReady checks if backend is ready for first prompt
IsReady(output string) bool
// IsComplete checks if response to given prompt is complete
IsComplete(prompt string, output string) bool
}
// Cleaner processes raw output from the backend
type Cleaner interface {
// Clean removes noise (ANSI codes, spinners, etc.) and extracts actual content
Clean(prompt string, rawOutput string) string
}
// Config holds PTY backend configuration
type Config struct {
Name string
Command []string
Environment map[string]string
PTYSize PTYSize
StartupTime time.Duration
Detector Detector
Cleaner Cleaner
Commands backend.CommandHandler
}
// Backend implements backend.Backend for PTY-based CLI tools
type Backend struct {
cfg Config
counter uint64
}
// New creates a new PTY-based backend
func New(cfg Config) backend.Backend {
if cfg.StartupTime == 0 {
cfg.StartupTime = 30 * time.Second
}
if cfg.PTYSize.Rows == 0 {
cfg.PTYSize.Rows = 40
}
if cfg.PTYSize.Cols == 0 {
cfg.PTYSize.Cols = 120
}
return &Backend{cfg: cfg}
}
func (b *Backend) Name() string {
return b.cfg.Name
}
func (b *Backend) CreateSession(ctx context.Context, cwd string) (backend.Session, error) {
id := fmt.Sprintf("%d", atomic.AddUint64(&b.counter, 1))
cmd := exec.CommandContext(ctx, b.cfg.Command[0], b.cfg.Command[1:]...)
cmd.Dir = cwd
// Apply environment
env := os.Environ()
for k, v := range b.cfg.Environment {
env = append(env, k+"="+v)
}
cmd.Env = env
// Start with PTY
ptmx, err := pty.StartWithSize(cmd, &pty.Winsize{
Rows: b.cfg.PTYSize.Rows,
Cols: b.cfg.PTYSize.Cols,
})
if err != nil {
return nil, fmt.Errorf("failed to start pty: %w", err)
}
sess := &Session{
id: id,
cwd: cwd,
pid: cmd.Process.Pid,
state: "starting",
createdAt: time.Now(),
cmd: cmd,
ptmx: ptmx,
dataCh: make(chan []byte, 100),
stopCh: make(chan struct{}),
detector: b.cfg.Detector,
cleaner: b.cfg.Cleaner,
commands: b.cfg.Commands,
}
// Start reader goroutine
go sess.reader()
// Wait for ready
if err := sess.waitForReady(ctx, b.cfg.StartupTime); err != nil {
sess.Close()
return nil, err
}
sess.state = "idle"
sess.output.Reset()
log.Printf("[session %s] ready (pid=%d)", sess.ID(), sess.pid)
return sess, nil
}
// Session implements backend.Session for PTY-based tools
type Session struct {
id string
cwd string
alias string
winID int
pid int
state string
createdAt time.Time
cmd *exec.Cmd
ptmx *os.File
dataCh chan []byte
stopCh chan struct{}
output bytes.Buffer
detector Detector
cleaner Cleaner
commands backend.CommandHandler
mu sync.Mutex
}
func (s *Session) ID() string {
return s.id
}
func (s *Session) State() string {
s.mu.Lock()
defer s.mu.Unlock()
return s.state
}
func (s *Session) SetAlias(alias string) {
s.mu.Lock()
defer s.mu.Unlock()
s.alias = alias
}
func (s *Session) Metadata() backend.SessionMetadata {
s.mu.Lock()
defer s.mu.Unlock()
return backend.SessionMetadata{
Pid: s.pid,
Cwd: s.cwd,
Alias: s.alias,
WinID: s.winID,
CreatedAt: s.createdAt,
}
}
func (s *Session) Commands() backend.CommandHandler {
return s.commands
}
// SetWinID sets the Acme window ID (called from main.go)
func (s *Session) SetWinID(winID int) {
s.mu.Lock()
defer s.mu.Unlock()
s.winID = winID
}
// GetWinID gets the Acme window ID
func (s *Session) GetWinID() int {
s.mu.Lock()
defer s.mu.Unlock()
return s.winID
}
// GetPid gets the process ID
func (s *Session) GetPid() int {
s.mu.Lock()
defer s.mu.Unlock()
return s.pid
}
// reader continuously reads from PTY and sends to dataCh
func (s *Session) reader() {
buf := make([]byte, 4096)
for {
n, err := s.ptmx.Read(buf)
if err != nil {
if err != io.EOF {
log.Printf("[session %s] reader error: %v", s.id, err)
}
close(s.dataCh)
// Reap the process
if s.cmd != nil && s.cmd.Process != nil {
s.cmd.Wait()
}
s.mu.Lock()
s.state = "exited"
s.pid = 0
s.mu.Unlock()
return
}
if n > 0 {
data := make([]byte, n)
copy(data, buf[:n])
s.dataCh <- data
}
}
}
func (s *Session) waitForReady(ctx context.Context, timeout time.Duration) error {
deadline := time.Now().Add(timeout)
for {
select {
case <-ctx.Done():
return ctx.Err()
case data, ok := <-s.dataCh:
if !ok {
return fmt.Errorf("session closed")
}
s.output.Write(data)
if s.detector.IsReady(s.output.String()) {
return nil
}
case <-time.After(time.Until(deadline)):
if time.Now().After(deadline) {
return fmt.Errorf("timeout waiting for ready")
}
}
}
}
func (s *Session) Send(ctx context.Context, prompt string) (string, error) {
s.mu.Lock()
defer s.mu.Unlock()
if s.ptmx == nil {
return "", fmt.Errorf("session not running")
}
if s.state == "starting" {
return "", fmt.Errorf("session still starting")
}
// Check command support if applicable
if strings.HasPrefix(prompt, "/") && s.commands != nil {
if !s.commands.IsSupported(prompt) {
return "", fmt.Errorf("unsupported command")
}
}
// Reset stopCh for this request
select {
case <-s.stopCh:
s.stopCh = make(chan struct{})
default:
}
s.state = "running"
s.output.Reset()
log.Printf("[session %s] sending: %q", s.id, prompt)
if _, err := s.ptmx.Write([]byte(prompt + "\r")); err != nil {
s.state = "idle"
return "", fmt.Errorf("write failed: %w", err)
}
// Wait for completion
if err := s.waitForComplete(ctx, prompt); err != nil {
s.state = "idle"
return "", err
}
s.state = "idle"
// Clean output
result := s.cleaner.Clean(prompt, s.output.String())
log.Printf("[session %s] response: %d bytes", s.id, len(result))
return result, nil
}
func (s *Session) waitForComplete(ctx context.Context, prompt string) error {
for {
select {
case <-ctx.Done():
return ctx.Err()
case <-s.stopCh:
return fmt.Errorf("interrupted")
case data, ok := <-s.dataCh:
if !ok {
return fmt.Errorf("session closed")
}
s.output.Write(data)
log.Printf("[session %s] +%d bytes, total %d", s.id, len(data), s.output.Len())
if s.detector.IsComplete(prompt, s.output.String()) {
// Drain any remaining data
for {
select {
case extra, ok := <-s.dataCh:
if ok {
s.output.Write(extra)
}
case <-time.After(10 * time.Millisecond):
log.Printf("[session %s] response complete, %d bytes", s.id, s.output.Len())
return nil
}
}
}
}
}
}
func (s *Session) SendStream(ctx context.Context, prompt string) (io.ReadCloser, error) {
// For PTY backends, fall back to Send
response, err := s.Send(ctx, prompt)
if err != nil {
return nil, err
}
return io.NopCloser(strings.NewReader(response)), nil
}
func (s *Session) Stop(ctx context.Context) error {
s.mu.Lock()
defer s.mu.Unlock()
if s.ptmx == nil {
return fmt.Errorf("session not running")
}
// Signal waiters
select {
case <-s.stopCh:
default:
close(s.stopCh)
}
// Send CTRL+C
_, err := s.ptmx.Write([]byte{0x03})
return err
}
func (s *Session) Close() error {
s.mu.Lock()
defer s.mu.Unlock()
if s.cmd != nil && s.cmd.Process != nil {
s.cmd.Process.Kill()
s.cmd.Wait()
}
if s.ptmx != nil {
s.ptmx.Close()
s.ptmx = nil
}
s.pid = 0
s.state = "exited"
return nil
}

View File

@ -0,0 +1,369 @@
// Package backends provides concrete backend implementations
package backends
import (
"acme-q/internal/backend"
"acme-q/internal/backend/pty"
"context"
"fmt"
"log"
"os"
"path/filepath"
"regexp"
"strings"
"time"
)
// NewKiroCLI creates a kiro-cli backend
func NewKiroCLI() backend.Backend {
return pty.New(pty.Config{
Name: "kiro-cli",
Command: []string{"kiro-cli", "chat", "--trust-all-tools"},
Environment: map[string]string{
"TERM": "xterm-256color",
"NO_COLOR": "1",
"COLUMNS": "999",
},
PTYSize: pty.PTYSize{
Rows: 40,
Cols: 120,
},
StartupTime: 30 * time.Second,
Detector: &kiroDetector{},
Cleaner: &kiroCleaner{},
Commands: &kiroCommands{},
})
}
// kiroDetector implements pattern detection for kiro-cli
type kiroDetector struct{}
func (d *kiroDetector) IsReady(output string) bool {
clean := stripANSI(output)
return strings.Contains(clean, "> ")
}
func (d *kiroDetector) IsComplete(prompt string, output string) bool {
isCommand := strings.HasPrefix(strings.TrimSpace(prompt), "/")
if isCommand {
// Slash commands: wait for next prompt line [agent] >
clean := stripANSI(output)
clean = strings.ReplaceAll(clean, "\r", "")
// Need substantial content first
if len(clean) < 200 {
return false
}
// Look for prompt pattern: [something] >
lines := strings.Split(clean, "\n")
for i := len(lines) - 1; i >= 0; i-- {
if strings.Contains(lines[i], "] ") && strings.Contains(lines[i], ">") {
return true
}
}
return false
} else {
// Normal prompts: wait for "Time:" marker
return strings.Contains(output, "Time:")
}
}
// kiroCleaner implements output cleaning for kiro-cli
type kiroCleaner struct{}
func (c *kiroCleaner) Clean(prompt string, rawOutput string) string {
isCommand := strings.HasPrefix(strings.TrimSpace(prompt), "/")
if isCommand {
return c.cleanCommand(rawOutput)
}
return c.cleanResponse(rawOutput)
}
func (c *kiroCleaner) cleanResponse(raw string) string {
// Strip ANSI codes
clean := stripANSI(raw)
clean = strings.ReplaceAll(clean, "\r", "\n")
lines := strings.Split(clean, "\n")
var result []string
inResponse := false
for _, line := range lines {
trimmed := strings.TrimSpace(line)
// Check for response marker "> " (not in prompt brackets)
if idx := strings.Index(line, "> "); idx >= 0 {
if !strings.Contains(line, "[") || !strings.Contains(line, "]") {
inResponse = true
text := strings.TrimSpace(line[idx+2:])
if text != "" {
result = append(result, text)
}
continue
}
}
// Skip noise
if c.isNoise(line) {
continue
}
// Keep hooks lines
if strings.Contains(line, "hooks finished") {
result = append(result, trimmed)
continue
}
if inResponse && trimmed != "" {
result = append(result, line)
}
}
return strings.Join(result, "\n")
}
func (c *kiroCleaner) cleanCommand(raw string) string {
clean := stripANSI(raw)
clean = strings.ReplaceAll(clean, "\r", "\n")
lines := strings.Split(clean, "\n")
var result []string
collecting := false
for _, line := range lines {
trimmed := strings.TrimSpace(line)
if trimmed == "" {
continue
}
// Detect prompt lines: [agent] >
isPrompt := strings.Contains(line, "[") && strings.Contains(line, "]") && strings.Contains(line, ">")
if isPrompt {
if collecting {
break // Second prompt = end
}
continue
}
// Skip command echo and noise
if strings.HasPrefix(trimmed, "/") ||
strings.Contains(trimmed, "Switching to") ||
c.isNoise(line) {
continue
}
collecting = true
result = append(result, trimmed)
}
return strings.Join(result, "\n")
}
func (c *kiroCleaner) isNoise(line string) bool {
trimmed := strings.TrimSpace(line)
// Check for noise patterns
noisePatterns := []string{
"Thinking...",
"▸ Time:",
}
for _, pattern := range noisePatterns {
if strings.Contains(line, pattern) {
return true
}
}
// Check for spinner characters
spinners := []rune{'⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏', '⢀', '⡀', '✓'}
for _, r := range spinners {
if strings.HasPrefix(trimmed, string(r)) {
return true
}
}
// Check for prompt lines
if strings.Contains(line, "[") && strings.Contains(line, "]") && strings.Contains(line, ">") {
return true
}
return false
}
// kiroCommands implements command handling for kiro-cli
type kiroCommands struct{}
func (h *kiroCommands) IsSupported(command string) bool {
supported := []string{"/chat load", "/chat save", "/help", "/agent list"}
for _, cmd := range supported {
if strings.HasPrefix(command, cmd) {
return true
}
}
return false
}
func (h *kiroCommands) Execute(ctx context.Context, command string) (string, error) {
// For kiro-cli, commands are executed by sending them to the PTY
// This is handled by the Session.Send() method
// We just validate the command here
if !h.IsSupported(command) {
return "", fmt.Errorf("unsupported command")
}
return "", fmt.Errorf("execute called directly - use Send() instead")
}
// Helper functions
func stripANSI(s string) string {
ansiRegex := regexp.MustCompile(`\x1b\[[0-9;?]*[a-zA-Z]`)
return ansiRegex.ReplaceAllString(s, "")
}
// SessionsDir returns the path to the sessions storage directory
func SessionsDir() string {
home, _ := os.UserHomeDir()
dir := filepath.Join(home, ".config", "acme-q", "sessions")
os.MkdirAll(dir, 0755)
return dir
}
// GenerateSmartFilename creates a descriptive filename from saved conversation JSON
func GenerateSmartFilename(jsonPath string, windowBody string) (string, error) {
data, err := os.ReadFile(jsonPath)
if err != nil {
return "", err
}
// Extract user prompt text - try JSON first, then window body
var summaryText string
// Try to find user prompts in the JSON
promptRe := regexp.MustCompile(`"prompt"\s*:\s*"([^"]{10,200})"`)
if matches := promptRe.FindAllStringSubmatch(string(data), 5); len(matches) > 0 {
for _, m := range matches {
text := m[1]
// Skip if it looks like JSON/tool output
if !strings.Contains(text, "exit_status") && !strings.Contains(text, "\\n\\t") {
summaryText = text
break
}
}
}
// If no prompt in JSON, try to extract from window body
if summaryText == "" && windowBody != "" {
if idx := strings.Index(windowBody, "USER:"); idx >= 0 {
userText := strings.TrimSpace(windowBody[idx+5:])
// Take up to next section marker
if end := strings.Index(userText, "\n\nA:"); end > 0 {
userText = userText[:end]
} else if end := strings.Index(userText, "\n\nASSISTANT:"); end > 0 {
userText = userText[:end]
} else if end := strings.Index(userText, "\n---"); end > 0 {
userText = userText[:end]
}
userText = strings.TrimSpace(userText)
if len(userText) > 200 {
userText = userText[:200]
}
if len(userText) > 5 {
summaryText = userText
}
}
}
// Extract words for filename
var parts []string
if summaryText != "" {
summaryText = strings.ToLower(summaryText)
wordRe := regexp.MustCompile(`[a-z]+`)
words := wordRe.FindAllString(summaryText, 10)
stopWords := []string{
"a", "an", "and", "are", "as", "at", "be", "by", "can", "do",
"for", "from", "has", "have", "he", "her", "his", "how", "if",
"in", "into", "is", "it", "its", "just", "me", "my", "no", "not",
"of", "on", "or", "our", "out", "so", "some", "than", "that",
"the", "their", "them", "then", "there", "these", "they", "this",
"to", "up", "us", "was", "we", "what", "when", "which", "who",
"will", "with", "would", "you", "your",
}
for _, w := range words {
if len(w) > 2 && !contains(stopWords, w) {
parts = append(parts, w)
if len(parts) >= 5 {
break
}
}
}
}
if len(parts) == 0 {
parts = append(parts, "chat")
}
filename := time.Now().Format("20060102T150405") + "-" + strings.Join(parts, "-") + ".json"
return filepath.Join(SessionsDir(), filename), nil
}
func contains(slice []string, item string) bool {
for _, s := range slice {
if s == item {
return true
}
}
return false
}
// SavePath returns the save file path for a session ID
func SavePath(id string) string {
return filepath.Join(SessionsDir(), id+".json")
}
// SendCtl is a helper to send a control command to kiro-cli
func SendCtl(sess backend.Session, cmd string) error {
_, err := sess.Send(context.Background(), cmd)
return err
}
// Save saves a session using kiro-cli's /chat save command
func Save(sess backend.Session, id string) error {
return SendCtl(sess, "/chat save "+SavePath(id))
}
// Load loads a session using kiro-cli's /chat load command
func Load(sess backend.Session, path string) error {
return SendCtl(sess, "/chat load "+path)
}
// SaveWithSmartFilename saves with a generated filename
func SaveWithSmartFilename(sess backend.Session, id string, bodyText string) (string, error) {
tempPath := SavePath(id)
if err := SendCtl(sess, "/chat save "+tempPath); err != nil {
return "", err
}
smartPath, err := GenerateSmartFilename(tempPath, bodyText)
if err != nil {
log.Printf("[session %s] Smart filename failed: %v, keeping %s", id, err, tempPath)
return tempPath, nil
}
if smartPath != tempPath {
if err := os.Rename(tempPath, smartPath); err == nil {
return smartPath, nil
} else {
log.Printf("[session %s] Rename failed: %v", id, err)
return tempPath, nil
}
}
return smartPath, nil
}

View File

@ -2,7 +2,11 @@
package p9
import (
"acme-q/internal/backend"
"acme-q/internal/backend/pty"
"acme-q/internal/backends"
"acme-q/internal/session"
"context"
"errors"
"fmt"
"io"
@ -68,7 +72,7 @@ type Server struct {
mgr *session.Manager
listener net.Listener
mu sync.RWMutex
OnAliasChange func(sess *session.Session) // callback when alias changes
OnAliasChange func(sess backend.Session) // callback when alias changes
}
type connState struct {
@ -304,7 +308,7 @@ func (s *Server) write(cs *connState, fc *plan9.Fcall) *plan9.Fcall {
if err != nil {
return errFcall(fc, err.Error())
}
return &plan9.Fcall{Type: plan9.Rwrite, Tag: fc.Tag, Count: uint32(len(sess.ID))}
return &plan9.Fcall{Type: plan9.Rwrite, Tag: fc.Tag, Count: uint32(len(sess.ID()))}
}
// /{id}/ctl - session control
@ -319,27 +323,28 @@ func (s *Server) write(cs *connState, fc *plan9.Fcall) *plan9.Fcall {
}
switch args[0] {
case "stop":
if err := sess.Stop(); err != nil {
ctx := context.Background()
if err := sess.Stop(ctx); err != nil {
return errFcall(fc, err.Error())
}
case "save":
path := sess.SavePath()
path := backends.SavePath(sess.ID())
if len(args) > 1 {
path = args[1]
}
if err := sess.SendCtl("/chat save " + path); err != nil {
if err := backends.SendCtl(sess, "/chat save "+path); err != nil {
return errFcall(fc, err.Error())
}
case "load":
if len(args) < 2 {
return errFcall(fc, "load requires path")
}
if err := sess.Load(args[1]); err != nil {
if err := backends.Load(sess, args[1]); err != nil {
return errFcall(fc, err.Error())
}
case "kill":
sess.Kill()
s.mgr.Remove(sess.ID)
sess.Close()
s.mgr.Remove(sess.ID())
default:
return errFcall(fc, "unknown command")
}
@ -352,7 +357,8 @@ func (s *Server) write(cs *connState, fc *plan9.Fcall) *plan9.Fcall {
if sess == nil {
return errFcall(fc, "session not found")
}
if _, err := sess.Send(input); err != nil {
ctx := context.Background()
if _, err := sess.Send(ctx, input); err != nil {
return errFcall(fc, err.Error())
}
return &plan9.Fcall{Type: plan9.Rwrite, Tag: fc.Tag, Count: uint32(len(fc.Data))}
@ -368,7 +374,10 @@ func (s *Server) write(cs *connState, fc *plan9.Fcall) *plan9.Fcall {
if err != nil {
return errFcall(fc, "invalid winid")
}
sess.WinID = id
// Set WinID if it's a PTY session
if ptySess, ok := sess.(*pty.Session); ok {
ptySess.SetWinID(id)
}
return &plan9.Fcall{Type: plan9.Rwrite, Tag: fc.Tag, Count: uint32(len(fc.Data))}
}
@ -383,7 +392,7 @@ func (s *Server) write(cs *connState, fc *plan9.Fcall) *plan9.Fcall {
if !matched {
return errFcall(fc, "invalid alias: must match [A-Za-z0-9_-]+")
}
sess.Alias = input
sess.SetAlias(input)
if s.OnAliasChange != nil {
s.OnAliasChange(sess)
}
@ -466,7 +475,8 @@ func (s *Server) readFile(path string) string {
for _, id := range s.mgr.List() {
sess := s.mgr.Get(id)
if sess != nil {
lines = append(lines, fmt.Sprintf("%s\t%s\t%d\t%s", sess.ID, sess.State, sess.Pid, sess.Cwd))
meta := sess.Metadata()
lines = append(lines, fmt.Sprintf("%s\t%s\t%d\t%s", sess.ID(), sess.State(), meta.Pid, meta.Cwd))
}
}
return strings.Join(lines, "\n") + "\n"
@ -483,28 +493,36 @@ func (s *Server) readFile(path string) string {
return s.getSessionFile(sess, fileIndex(parts[2]))
}
func (s *Server) getSessionFile(sess *session.Session, idx int) string {
func (s *Server) getSessionFile(sess backend.Session, idx int) string {
meta := sess.Metadata()
switch idx {
case fileOut:
return sess.Output()
// Output from last command - only available for PTY sessions
if ptySess, ok := sess.(*pty.Session); ok {
// We don't have a direct Output() method anymore
// This would need to be stored separately if needed
_ = ptySess
}
return ""
case fileErr:
return sess.Err()
return "" // Error output not currently stored
case fileWinID:
return strconv.Itoa(sess.WinID)
return strconv.Itoa(meta.WinID)
case fileState:
return sess.State
return sess.State()
case filePid:
if sess.Pid == 0 {
if meta.Pid == 0 {
return ""
}
return strconv.Itoa(sess.Pid)
return strconv.Itoa(meta.Pid)
case fileCwd:
return sess.Cwd
return meta.Cwd
case fileAlias:
if sess.Alias == "" {
return sess.ID
if meta.Alias == "" {
return sess.ID()
}
return sess.Alias
return meta.Alias
}
return ""
}

View File

@ -0,0 +1,64 @@
// Package session manages backend sessions
package session
import (
"acme-q/internal/backend"
"context"
"sync"
)
// Manager holds all active sessions
type Manager struct {
backend backend.Backend
sessions map[string]backend.Session
mu sync.RWMutex
}
// NewManager creates a session manager with the given backend
func NewManager(b backend.Backend) *Manager {
m := &Manager{
backend: b,
}
m.sessions = make(map[string]backend.Session)
return m
}
// New creates a new session in the given working directory
func (m *Manager) New(cwd string) (backend.Session, error) {
sess, err := m.backend.CreateSession(context.Background(), cwd)
if err != nil {
return nil, err
}
m.mu.Lock()
m.sessions[sess.ID()] = sess
m.mu.Unlock()
return sess, nil
}
// Get returns a session by ID
func (m *Manager) Get(id string) backend.Session {
m.mu.RLock()
defer m.mu.RUnlock()
return m.sessions[id]
}
// List returns all session IDs
func (m *Manager) List() []string {
m.mu.RLock()
defer m.mu.RUnlock()
ids := make([]string, 0, len(m.sessions))
for id := range m.sessions {
ids = append(ids, id)
}
return ids
}
// Remove removes a session from the manager
func (m *Manager) Remove(id string) {
m.mu.Lock()
delete(m.sessions, id)
m.mu.Unlock()
}

View File

@ -1,625 +0,0 @@
// Package session manages persistent kiro-cli PTY sessions.
package session
import (
"bytes"
"fmt"
"io"
"log"
"os"
"os/exec"
"path/filepath"
"regexp"
"slices"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/creack/pty"
)
var sessionCounter uint64
// Session represents a persistent kiro-cli chat session.
type Session struct {
ID string
Alias string
Cwd string
WinID int
Pid int
State string // "starting", "idle", "running", or "error: ..."
cmd *exec.Cmd
ptmx *os.File
mu sync.Mutex
// Channel to receive data from the reader goroutine
dataCh chan []byte
// Channel to signal stop
stopCh chan struct{}
// Accumulated output
output bytes.Buffer
}
// Manager holds all active sessions.
type Manager struct {
sessions map[string]*Session
mu sync.RWMutex
}
// NewManager creates a session manager.
func NewManager() *Manager {
return &Manager{sessions: make(map[string]*Session)}
}
// New creates a new kiro-cli session in the given working directory.
func (m *Manager) New(cwd string) (*Session, error) {
id := fmt.Sprintf("%d", atomic.AddUint64(&sessionCounter, 1))
cmd := exec.Command("kiro-cli", "chat", "--trust-all-tools")
cmd.Dir = cwd
cmd.Env = append(os.Environ(), "TERM=xterm-256color")
ptmx, err := pty.StartWithSize(cmd, &pty.Winsize{Rows: 40, Cols: 120})
if err != nil {
return nil, fmt.Errorf("failed to start pty: %w", err)
}
s := &Session{
ID: id,
Cwd: cwd,
Pid: cmd.Process.Pid,
State: "starting",
cmd: cmd,
ptmx: ptmx,
dataCh: make(chan []byte, 100),
stopCh: make(chan struct{}),
}
// Start single reader goroutine
go s.reader()
m.mu.Lock()
m.sessions[id] = s
m.mu.Unlock()
// Wait for initial prompt - look for "> " which appears at end of prompt
err = s.waitForPattern("> ", 30*time.Second)
if err != nil {
log.Printf("[session %s] warning: %v", s.ID, err)
}
s.State = "idle"
s.output.Reset()
log.Printf("[session %s] ready", s.ID)
return s, nil
}
// reader continuously reads from PTY and sends to dataCh
func (s *Session) reader() {
buf := make([]byte, 4096)
for {
n, err := s.ptmx.Read(buf)
if err != nil {
if err != io.EOF {
log.Printf("[session %s] reader error: %v", s.ID, err)
}
close(s.dataCh)
// Reap the process to avoid zombies
if s.cmd != nil && s.cmd.Process != nil {
s.cmd.Wait()
}
s.State = "exited"
s.Pid = 0
return
}
if n > 0 {
data := make([]byte, n)
copy(data, buf[:n])
s.dataCh <- data
}
}
}
// waitForPattern waits until the output contains the pattern
func (s *Session) waitForPattern(pattern string, timeout time.Duration) error {
deadline := time.Now().Add(timeout)
ansiRegex := regexp.MustCompile(`\x1b\[[0-9;?]*[a-zA-Z]`)
for {
select {
case data, ok := <-s.dataCh:
if !ok {
return fmt.Errorf("session closed")
}
s.output.Write(data)
// Just strip ANSI, don't filter
clean := ansiRegex.ReplaceAllString(s.output.String(), "")
if strings.Contains(clean, pattern) {
return nil
}
case <-time.After(time.Until(deadline)):
return fmt.Errorf("timeout waiting for %q", pattern)
}
}
}
// waitForSecondPrompt waits for the response to complete (normal prompts end with "Time:")
func (s *Session) waitForSecondPrompt() error {
for {
select {
case <-s.stopCh:
return fmt.Errorf("interrupted")
case data, ok := <-s.dataCh:
if !ok {
return fmt.Errorf("session closed")
}
s.output.Write(data)
log.Printf("[session %s] +%d bytes, total %d", s.ID, len(data), s.output.Len())
// Kiro prints "Time:" at the end of every response
if strings.Contains(s.output.String(), "Time:") {
// Drain any remaining data in channel
for {
select {
case extra, ok := <-s.dataCh:
if ok {
s.output.Write(extra)
}
default:
log.Printf("[session %s] response complete, %d bytes", s.ID, s.output.Len())
return nil
}
}
}
}
}
}
// waitForCommandComplete waits for slash command output (ends with next prompt, not "Time:")
func (s *Session) waitForCommandComplete() error {
ansiRegex := regexp.MustCompile(`\x1b\[[0-9;?]*[a-zA-Z]`)
var contentLen int
for {
select {
case <-s.stopCh:
return fmt.Errorf("interrupted")
case data, ok := <-s.dataCh:
if !ok {
return fmt.Errorf("session closed")
}
s.output.Write(data)
out := s.output.String()
log.Printf("[session %s] +%d bytes, total %d", s.ID, len(data), len(out))
// Strip ANSI and \r for checking
clean := ansiRegex.ReplaceAllString(out, "")
clean = strings.ReplaceAll(clean, "\r", "")
// Wait for substantial content before looking for end prompt
if len(clean) > contentLen+50 {
contentLen = len(clean)
}
// Look for a new prompt line after we have content
// Prompt pattern: newline followed by [something] then >
if contentLen > 200 && strings.Contains(clean, "\n[") {
// Check if there's a prompt after content
lines := strings.Split(clean, "\n")
for i := len(lines) - 1; i >= 0; i-- {
if strings.Contains(lines[i], "] ") && strings.Contains(lines[i], ">") {
// Found end prompt, drain and return
for {
select {
case extra, ok := <-s.dataCh:
if ok {
s.output.Write(extra)
}
default:
log.Printf("[session %s] command complete, %d bytes", s.ID, s.output.Len())
return nil
}
}
}
}
}
}
}
}
func (s *Session) cleanedOutput() string {
out := s.output.String()
// Strip ANSI
clean := regexp.MustCompile(`\x1b\[[0-9;?]*[a-zA-Z]`).ReplaceAllString(out, "")
clean = strings.ReplaceAll(clean, "\r", "\n")
lines := strings.Split(clean, "\n")
var result []string
inResponse := false
for _, line := range lines {
trimmed := strings.TrimSpace(line)
// Check for response marker FIRST - it may be on same line as spinner
if idx := strings.Index(line, "> "); idx >= 0 {
// Make sure it's the response marker, not part of prompt (prompts have [agent])
if !strings.Contains(line, "[") || !strings.Contains(line, "]") {
inResponse = true
text := strings.TrimSpace(line[idx+2:])
if text != "" {
result = append(result, text)
}
continue
}
}
// Skip noise
if strings.Contains(line, "Thinking...") ||
strings.Contains(line, "▸ Time:") ||
(strings.Contains(line, "[") && strings.Contains(line, "]") && strings.Contains(line, ">")) ||
strings.HasPrefix(trimmed, "⠋") ||
strings.HasPrefix(trimmed, "⠙") ||
strings.HasPrefix(trimmed, "⠹") ||
strings.HasPrefix(trimmed, "⠸") ||
strings.HasPrefix(trimmed, "⠼") ||
strings.HasPrefix(trimmed, "⠴") ||
strings.HasPrefix(trimmed, "⠦") ||
strings.HasPrefix(trimmed, "⠧") ||
strings.HasPrefix(trimmed, "⠇") ||
strings.HasPrefix(trimmed, "⠏") ||
strings.HasPrefix(trimmed, "⢀") ||
strings.HasPrefix(trimmed, "⡀") ||
strings.HasPrefix(trimmed, "✓") {
continue
}
// Keep hooks lines
if strings.Contains(line, "hooks finished") {
result = append(result, trimmed)
continue
}
if inResponse && trimmed != "" {
result = append(result, line)
}
}
return strings.Join(result, "\n")
}
// cleanedCommandOutput extracts output from slash commands (no "> " marker)
func (s *Session) cleanedCommandOutput() string {
out := s.output.String()
// Strip ANSI
clean := regexp.MustCompile(`\x1b\[[0-9;?]*[a-zA-Z]`).ReplaceAllString(out, "")
clean = strings.ReplaceAll(clean, "\r", "\n")
lines := strings.Split(clean, "\n")
var result []string
collecting := false
for _, line := range lines {
trimmed := strings.TrimSpace(line)
if trimmed == "" {
continue
}
// Detect prompt lines: [agent] >
isPrompt := strings.Contains(line, "[") && strings.Contains(line, "]") && strings.Contains(line, ">")
if isPrompt {
if collecting {
// Second prompt = end
break
}
// Skip prompt lines but don't start collecting yet
continue
}
// Skip command echo and noise
if strings.HasPrefix(trimmed, "/") ||
strings.Contains(trimmed, "Switching to") ||
strings.HasPrefix(trimmed, "⠋") ||
strings.HasPrefix(trimmed, "⠙") ||
strings.HasPrefix(trimmed, "⠹") ||
strings.HasPrefix(trimmed, "⠸") ||
strings.HasPrefix(trimmed, "⠼") ||
strings.HasPrefix(trimmed, "⠴") ||
strings.HasPrefix(trimmed, "⠦") ||
strings.HasPrefix(trimmed, "⠧") ||
strings.HasPrefix(trimmed, "⠇") ||
strings.HasPrefix(trimmed, "⠏") ||
strings.HasPrefix(trimmed, "⢀") ||
strings.HasPrefix(trimmed, "⡀") {
continue
}
// Collect any non-noise content
collecting = true
result = append(result, trimmed)
}
return strings.Join(result, "\n")
}
// Get returns a session by ID.
func (m *Manager) Get(id string) *Session {
m.mu.RLock()
defer m.mu.RUnlock()
return m.sessions[id]
}
// List returns all session IDs.
func (m *Manager) List() []string {
m.mu.RLock()
defer m.mu.RUnlock()
ids := make([]string, 0, len(m.sessions))
for id := range m.sessions {
ids = append(ids, id)
}
return ids
}
// Remove removes a session from the manager.
func (m *Manager) Remove(id string) {
m.mu.Lock()
delete(m.sessions, id)
m.mu.Unlock()
}
// supportedCommands lists non-interactive slash commands that work via pty.
var supportedCommands = []string{"/chat load", "/chat save", "/help", "/agent list"}
// Send writes a prompt to the session and waits for response.
func (s *Session) Send(prompt string) (string, error) {
s.mu.Lock()
defer s.mu.Unlock()
if s.ptmx == nil {
return "", fmt.Errorf("session not running")
}
if s.State == "starting" {
return "", fmt.Errorf("session still starting, please wait")
}
// Reset stopCh for this request
select {
case <-s.stopCh:
// was closed, need new one
s.stopCh = make(chan struct{})
default:
}
// Check if slash command is supported
if strings.HasPrefix(prompt, "/") {
supported := false
for _, cmd := range supportedCommands {
if strings.HasPrefix(prompt, cmd) {
supported = true
break
}
}
if !supported {
log.Printf("[session %s] unsupported command %q, supported: %v", s.ID, prompt, supportedCommands)
return "", fmt.Errorf("unsupported command, supported: %v", supportedCommands)
}
}
s.State = "running"
s.output.Reset()
isCommand := strings.HasPrefix(prompt, "/")
log.Printf("[session %s] sending: %q", s.ID, prompt)
if _, err := s.ptmx.Write([]byte(prompt + "\r")); err != nil {
s.State = "idle"
return "", fmt.Errorf("write failed: %w", err)
}
// Slash commands wait for next prompt, normal prompts wait for "Time:"
var err error
if isCommand {
err = s.waitForCommandComplete()
} else {
err = s.waitForSecondPrompt()
}
if err != nil {
s.State = "idle"
return "", err
}
s.State = "idle"
var result string
if isCommand {
result = s.cleanedCommandOutput()
} else {
result = s.cleanedOutput()
}
log.Printf("[session %s] response: %d bytes", s.ID, len(result))
return result, nil
}
// extractResponse extracts the actual response from the output
func (s *Session) extractResponse() string {
clean := s.cleanedOutput()
// Find content between first "] > ...\n" and last "] > "
lines := strings.Split(clean, "\n")
var result []string
inResponse := false
for _, line := range lines {
if strings.Contains(line, "] > ") {
if inResponse {
break // Second prompt, stop
}
inResponse = true
continue
}
if inResponse {
// Skip spinner and hook lines
if strings.Contains(line, "Thinking...") ||
strings.Contains(line, "hooks finished") ||
strings.HasPrefix(strings.TrimSpace(line), "▸") {
continue
}
if trimmed := strings.TrimSpace(line); trimmed != "" {
result = append(result, line)
}
}
}
return strings.Join(result, "\n")
}
// SendCtl sends a control command (like /chat save, /chat load).
func (s *Session) SendCtl(cmd string) error {
_, err := s.Send(cmd)
return err
}
// Stop sends SIGINT to interrupt the current operation.
func (s *Session) Stop() error {
if s.ptmx == nil {
return fmt.Errorf("session not running")
}
// Signal waiters to abort - close channel, will be recreated by Send()
select {
case <-s.stopCh:
// already closed
default:
close(s.stopCh)
}
// Write ETX (Ctrl+C) to PTY
_, err := s.ptmx.Write([]byte{0x03})
return err
}
// Kill terminates the session.
func (s *Session) Kill() error {
s.mu.Lock()
defer s.mu.Unlock()
if s.cmd != nil && s.cmd.Process != nil {
s.cmd.Process.Kill()
s.cmd.Wait()
}
if s.ptmx != nil {
s.ptmx.Close()
s.ptmx = nil
}
s.Pid = 0
s.State = "idle"
return nil
}
// Output returns the output from the last command.
func (s *Session) Output() string {
s.mu.Lock()
defer s.mu.Unlock()
return s.cleanedOutput()
}
// Err returns accumulated error/debug output.
func (s *Session) Err() string {
return ""
}
// SessionsDir returns the path to the sessions storage directory.
func SessionsDir() string {
home, _ := os.UserHomeDir()
dir := filepath.Join(home, ".config", "acme-q", "sessions")
os.MkdirAll(dir, 0755)
return dir
}
// SavePath returns the save file path for a session.
func (s *Session) SavePath() string {
return filepath.Join(SessionsDir(), s.ID+".json")
}
// GenerateSmartFilename creates a descriptive filename from saved conversation JSON.
// It extracts first few meaningful words from user prompts.
// windowBody is optional extra text (from chat window) to search for content.
func GenerateSmartFilename(jsonPath string, windowBody string) (string, error) {
data, err := os.ReadFile(jsonPath)
if err != nil {
return "", err
}
// Extract user prompt text - try JSON first, then window body
var summaryText string
// Try to find user prompts in the JSON
promptRe := regexp.MustCompile(`"prompt"\s*:\s*"([^"]{10,200})"`)
if matches := promptRe.FindAllStringSubmatch(string(data), 5); len(matches) > 0 {
for _, m := range matches {
text := m[1]
// Skip if it looks like JSON/tool output
if !strings.Contains(text, "exit_status") && !strings.Contains(text, "\\n\\t") {
summaryText = text
break
}
}
}
// If no prompt in JSON, try to extract from window body (first USER: with content)
if summaryText == "" && windowBody != "" {
if idx := strings.Index(windowBody, "USER:"); idx >= 0 {
userText := strings.TrimSpace(windowBody[idx+5:])
// Take up to next section marker
if end := strings.Index(userText, "\n\nA:"); end > 0 {
userText = userText[:end]
} else if end := strings.Index(userText, "\n\nASSISTANT:"); end > 0 {
userText = userText[:end]
} else if end := strings.Index(userText, "\n---"); end > 0 {
userText = userText[:end]
}
userText = strings.TrimSpace(userText)
if len(userText) > 200 {
userText = userText[:200]
}
if len(userText) > 5 {
summaryText = userText
}
}
}
// Extract words for filename
var parts []string
if summaryText != "" {
summaryText = strings.ToLower(summaryText)
wordRe := regexp.MustCompile(`[a-z]+`)
words := wordRe.FindAllString(summaryText, 10)
stopWords := []string{"a", "an", "and", "are", "as", "at", "be", "by", "can", "do", "for", "from", "has", "have", "he", "her", "his", "how", "if", "in", "into", "is", "it", "its", "just", "me", "my", "no", "not", "of", "on", "or", "our", "out", "so", "some", "than", "that", "the", "their", "them", "then", "there", "these", "they", "this", "to", "up", "us", "was", "we", "what", "when", "which", "who", "will", "with", "would", "you", "your"}
for _, w := range words {
if len(w) > 2 && !slices.Contains(stopWords, w) {
parts = append(parts, w)
if len(parts) >= 5 {
break
}
}
}
}
if len(parts) == 0 {
parts = append(parts, "chat")
}
filename := time.Now().Format("20060102T150405") + "-" + strings.Join(parts, "-") + ".json"
return filepath.Join(SessionsDir(), filename), nil
}
// Save persists the session conversation.
func (s *Session) Save() error {
return s.SendCtl("/chat save " + s.SavePath())
}
// Load restores a session conversation from file.
func (s *Session) Load(path string) error {
return s.SendCtl("/chat load " + path)
}

166
main.go
View File

@ -1,9 +1,13 @@
// Q - Acme interface for kiro-cli chat sessions via 9P
// Q - Acme interface for chat backends via 9P
package main
import (
"acme-q/internal/backend"
"acme-q/internal/backend/pty"
"acme-q/internal/backends"
"acme-q/internal/p9"
"acme-q/internal/session"
"context"
"fmt"
"log"
"os"
@ -20,8 +24,14 @@ import (
const windowName = "/Q/"
// Type alias to work around Go parser limitation
type Session = backend.Session
func main() {
mgr := session.NewManager()
// Create backend (kiro-cli for now, could be configurable)
backend := backends.NewKiroCLI()
mgr := session.NewManager(backend)
srv, err := p9.NewServer(mgr)
if err != nil {
@ -30,20 +40,23 @@ func main() {
defer srv.Close()
// Rename chat window when alias changes
srv.OnAliasChange = func(sess *session.Session) {
if sess.WinID > 0 {
w, err := acme.Open(sess.WinID, nil)
var aliasChangeFunc func(Session)
aliasChangeFunc = func(sess Session) {
meta := sess.Metadata()
if meta.WinID > 0 {
w, err := acme.Open(meta.WinID, nil)
if err != nil {
return
}
defer w.CloseFiles()
name := sess.Alias
name := meta.Alias
if name == "" {
name = sess.ID
name = sess.ID()
}
w.Name(filepath.Join(sess.Cwd, fmt.Sprintf("+Chat.%s", name)))
w.Name(filepath.Join(meta.Cwd, fmt.Sprintf("+Chat.%s", name)))
}
}
srv.OnAliasChange = aliasChangeFunc
w, err := acme.New()
if err != nil {
@ -93,12 +106,12 @@ func main() {
w.Fprintf("body", "Error: %v\n", err)
continue
}
chatWin, err := openChatWindow(sess)
_, err = openChatWindow(sess)
if err != nil {
w.Fprintf("body", "Error opening chat: %v\n", err)
continue
}
sess.WinID = chatWin.ID()
// WinID is set inside openChatWindow
refreshList(w, mgr)
case "Open":
if arg == "" {
@ -110,12 +123,12 @@ func main() {
w.Fprintf("body", "Session not found: %s\n", arg)
continue
}
chatWin, err := openChatWindow(sess)
_, err := openChatWindow(sess)
if err != nil {
w.Fprintf("body", "Error opening chat: %v\n", err)
continue
}
sess.WinID = chatWin.ID()
// WinID is set inside openChatWindow
case "Kill":
if arg == "" {
w.Fprintf("body", "Usage: Kill <pid>\n")
@ -129,10 +142,13 @@ func main() {
// Find session by pid and kill it
for _, id := range mgr.List() {
sess := mgr.Get(id)
if sess != nil && sess.Pid == pid {
sess.Kill()
mgr.Remove(id)
break
if sess != nil {
meta := sess.Metadata()
if meta.Pid == pid {
sess.Close()
mgr.Remove(id)
break
}
}
}
refreshList(w, mgr)
@ -156,23 +172,18 @@ func main() {
log.Printf("[/Q/] Look: text=%q", text)
if sess := mgr.Get(text); sess != nil {
// Try to focus existing window, or open new one
if sess.WinID > 0 {
meta := sess.Metadata()
if meta.WinID > 0 {
// Check if window still exists by trying to open it
if aw, err := acme.Open(sess.WinID, nil); err == nil {
if aw, err := acme.Open(meta.WinID, nil); err == nil {
aw.Ctl("show")
aw.CloseFiles()
} else {
// Window gone, open new one
chatWin, err := openChatWindow(sess)
if err == nil {
sess.WinID = chatWin.ID()
}
openChatWindow(sess)
}
} else {
chatWin, err := openChatWindow(sess)
if err == nil {
sess.WinID = chatWin.ID()
}
openChatWindow(sess)
}
} else {
w.WriteEvent(e)
@ -193,36 +204,38 @@ func refreshList(w *acme.Win, mgr *session.Manager) {
if sess == nil {
continue
}
meta := sess.Metadata()
// Remove dead sessions (pid=0 or process not running)
if sess.Pid == 0 {
if meta.Pid == 0 {
mgr.Remove(id)
continue
}
if err := syscall.Kill(sess.Pid, 0); err != nil {
if err := syscall.Kill(meta.Pid, 0); err != nil {
mgr.Remove(id)
continue
}
alias := sess.Alias
alias := meta.Alias
if alias == "" {
alias = "-"
}
cwd := sess.Cwd
cwd := meta.Cwd
if len(cwd) > 80 {
cwd = "..." + cwd[len(cwd)-77:]
}
buf.WriteString(fmt.Sprintf("%-5s %-9s %-8d %-16s %s\n", sess.ID, sess.State, sess.Pid, alias, cwd))
buf.WriteString(fmt.Sprintf("%-5s %-9s %-8d %-16s %s\n", sess.ID(), sess.State(), meta.Pid, alias, cwd))
}
w.Addr(",")
w.Write("data", []byte(buf.String()))
w.Ctl("clean")
}
func openChatWindow(sess *session.Session) (*acme.Win, error) {
displayName := sess.Alias
func openChatWindow(sess backend.Session) (*acme.Win, error) {
meta := sess.Metadata()
displayName := meta.Alias
if displayName == "" {
displayName = sess.ID
displayName = sess.ID()
}
name := filepath.Join(sess.Cwd, fmt.Sprintf("+Chat.%s", displayName))
name := filepath.Join(meta.Cwd, fmt.Sprintf("+Chat.%s", displayName))
w, err := acme.New()
if err != nil {
@ -230,15 +243,20 @@ func openChatWindow(sess *session.Session) (*acme.Win, error) {
}
w.Name(name)
w.Write("tag", []byte("Send Stop Alias Save Kill Sessions "))
w.Fprintf("body", "# Session %s\n# cwd: %s\n\nUSER:\n", sess.ID, sess.Cwd)
w.Fprintf("body", "# Session %s\n# cwd: %s\n\nUSER:\n", sess.ID(), meta.Cwd)
w.Ctl("clean")
// Set window ID on session (if it's a PTY session)
if ptySess, ok := sess.(*pty.Session); ok {
ptySess.SetWinID(w.ID())
}
go handleChatWindow(w, sess)
return w, nil
}
func handleChatWindow(w *acme.Win, sess *session.Session) {
func handleChatWindow(w *acme.Win, sess backend.Session) {
defer w.CloseFiles()
for e := range w.EventChan() {
@ -257,27 +275,29 @@ func handleChatWindow(w *acme.Win, sess *session.Session) {
case "Send":
go sendPrompt(w, sess)
case "Stop":
if err := sess.Stop(); err != nil {
log.Printf("[session %s] Stop error: %v", sess.ID, err)
ctx := context.Background()
if err := sess.Stop(ctx); err != nil {
log.Printf("[session %s] Stop error: %v", sess.ID(), err)
} else {
log.Printf("[session %s] Sent CTRL+C", sess.ID)
log.Printf("[session %s] Sent CTRL+C", sess.ID())
}
case "Alias":
if arg == "" {
log.Printf("[session %s] Usage: Alias <name>", sess.ID)
log.Printf("[session %s] Usage: Alias <name>", sess.ID())
continue
}
// Validate alias
matched, _ := regexp.MatchString(`^[A-Za-z0-9_-]+$`, arg)
if !matched {
log.Printf("[session %s] Invalid alias: must match [A-Za-z0-9_-]+", sess.ID)
log.Printf("[session %s] Invalid alias: must match [A-Za-z0-9_-]+", sess.ID())
continue
}
sess.Alias = arg
sess.SetAlias(arg)
// Rename window
newName := filepath.Join(sess.Cwd, fmt.Sprintf("+Chat.%s", arg))
meta := sess.Metadata()
newName := filepath.Join(meta.Cwd, fmt.Sprintf("+Chat.%s", arg))
w.Name(newName)
log.Printf("[session %s] alias set to %s", sess.ID, arg)
log.Printf("[session %s] alias set to %s", sess.ID(), arg)
case "Save":
// Grab first part of window content for filename hints
var bodyText string
@ -288,34 +308,25 @@ func handleChatWindow(w *acme.Win, sess *session.Session) {
bodyText = string(body)
}
go func() {
// Save to temp file first
tempPath := sess.SavePath()
var savePath string
var err error
if arg != "" {
tempPath = arg
// Save to explicit path
savePath = arg
err = backends.SendCtl(sess, "/chat save "+savePath)
} else {
// Save with smart filename
savePath, err = backends.SaveWithSmartFilename(sess, sess.ID(), bodyText)
}
if err := sess.SendCtl("/chat save " + tempPath); err != nil {
log.Printf("[session %s] Error saving: %v", sess.ID, err)
if err != nil {
log.Printf("[session %s] Error saving: %v", sess.ID(), err)
return
}
// If no explicit path given, try to generate smart filename
if arg == "" {
smartPath, err := session.GenerateSmartFilename(tempPath, bodyText)
if err != nil {
log.Printf("[session %s] Smart filename failed: %v, keeping %s", sess.ID, err, tempPath)
} else if smartPath != tempPath {
if err := os.Rename(tempPath, smartPath); err == nil {
log.Printf("[session %s] Saved to %s", sess.ID, smartPath)
return
} else {
log.Printf("[session %s] Rename failed: %v", sess.ID, err)
}
}
}
log.Printf("[session %s] Saved to %s", sess.ID, tempPath)
log.Printf("[session %s] Saved to %s", sess.ID(), savePath)
}()
case "Kill":
sess.Kill()
log.Printf("[session %s] Session killed", sess.ID)
sess.Close()
log.Printf("[session %s] Session killed", sess.ID())
return
case "Sessions":
openSessionsWindow(w, sess)
@ -328,17 +339,17 @@ func handleChatWindow(w *acme.Win, sess *session.Session) {
}
}
func sendPrompt(w *acme.Win, sess *session.Session) {
func sendPrompt(w *acme.Win, sess backend.Session) {
body, err := w.ReadAll("body")
if err != nil {
log.Printf("[session %s] Error reading body: %v", sess.ID, err)
log.Printf("[session %s] Error reading body: %v", sess.ID(), err)
return
}
content := string(body)
idx := strings.LastIndex(content, "USER:")
if idx < 0 {
log.Printf("[session %s] No USER: section found", sess.ID)
log.Printf("[session %s] No USER: section found", sess.ID())
return
}
@ -349,9 +360,10 @@ func sendPrompt(w *acme.Win, sess *session.Session) {
w.Fprintf("body", "\n\nASSISTANT:\n")
response, err := sess.Send(prompt)
ctx := context.Background()
response, err := sess.Send(ctx, prompt)
if err != nil {
log.Printf("[session %s] Error: %v", sess.ID, err)
log.Printf("[session %s] Error: %v", sess.ID(), err)
} else {
w.Fprintf("body", "%s\n", response)
}
@ -360,9 +372,10 @@ func sendPrompt(w *acme.Win, sess *session.Session) {
w.Ctl("clean")
}
func openSessionsWindow(chatWin *acme.Win, sess *session.Session) {
dir := session.SessionsDir()
name := filepath.Join(sess.Cwd, fmt.Sprintf("+Sessions.%s", sess.ID))
func openSessionsWindow(chatWin *acme.Win, sess backend.Session) {
dir := backends.SessionsDir()
meta := sess.Metadata()
name := filepath.Join(meta.Cwd, fmt.Sprintf("+Sessions.%s", sess.ID()))
w, err := acme.New()
if err != nil {
@ -389,7 +402,8 @@ func openSessionsWindow(chatWin *acme.Win, sess *session.Session) {
// Run /chat load and display output in chat window
go func(p string) {
chatWin.Fprintf("body", "\n\nASSISTANT:\n")
response, err := sess.Send("/chat load " + p)
ctx := context.Background()
response, err := sess.Send(ctx, "/chat load "+p)
if err != nil {
chatWin.Fprintf("body", "Error loading: %v\n", err)
} else {

5
mkfile
View File

@ -3,10 +3,9 @@ INSTALL_PATH=$HOME/bin
all:V: install
build:V:
go build -o $INSTALL_PATH/Q .
go build -o $INSTALL_PATH/Q-Login ./cmd/Q-Login
go build -o $INSTALL_PATH/Assist .
install:V: build
clean:V:
rm -f $INSTALL_PATH/Q $INSTALL_PATH/Q-Login
rm -f $INSTALL_PATH/Assist