commit d8dccd94b35c3de3c5c341b8a3ebc6d74c025563 Author: Levi Neely <141506390+lneely@users.noreply.github.com> Date: Mon Feb 9 17:57:42 2026 +0100 initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a2203be --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +Q +Prompt +Q-Reset +Q-Login diff --git a/acme-q b/acme-q new file mode 100755 index 0000000..e625f5c Binary files /dev/null and b/acme-q differ diff --git a/cmd/Q-Service/main.go b/cmd/Q-Service/main.go new file mode 100644 index 0000000..b06dc1d --- /dev/null +++ b/cmd/Q-Service/main.go @@ -0,0 +1,167 @@ +package main + +import ( + "bufio" + "database/sql" + "os" + "os/exec" + "path/filepath" + "regexp" + "strings" + "time" + + "9fans.net/go/acme" + _ "github.com/mattn/go-sqlite3" +) + +var ( + ansiRegex = regexp.MustCompile(`\x1b\[[0-9;]*m`) + conversationID string + cwd string + dbPath string +) + +func stripAnsi(s string) string { + return ansiRegex.ReplaceAllString(s, "") +} + +func loadEnvFiles() { + for _, file := range []string{"jira.env.gpg", "jenkins.env.gpg"} { + cmd := exec.Command("gpg", "--decrypt", filepath.Join(os.Getenv("HOME"), "env", file)) + cmd.Stderr = nil + if output, err := cmd.Output(); err == nil { + for _, line := range strings.Split(string(output), "\n") { + if strings.HasPrefix(line, "export ") { + if parts := strings.SplitN(line[7:], "=", 2); len(parts) == 2 { + os.Setenv(parts[0], parts[1]) + } + } + } + } + } +} + +// touchConversation updates the timestamp to make our conversation the most recent +func touchConversation() error { + if conversationID == "" { + return nil + } + db, err := sql.Open("sqlite3", dbPath) + if err != nil { + return err + } + defer db.Close() + _, err = db.Exec("UPDATE conversations_v2 SET updated_at = ? WHERE key = ? AND conversation_id = ?", + time.Now().UnixMilli(), cwd, conversationID) + return err +} + +// getLatestConversationID retrieves the most recent conversation ID for cwd +func getLatestConversationID() (string, error) { + db, err := sql.Open("sqlite3", dbPath) + if err != nil { + return "", err + } + defer db.Close() + var id string + err = db.QueryRow("SELECT conversation_id FROM conversations_v2 WHERE key = ? ORDER BY updated_at DESC LIMIT 1", cwd).Scan(&id) + return id, err +} + +func runPrompt(outputWin *acme.Win, prompt []byte) { + outputWin.Addr("$") + outputWin.Write("data", []byte("USER:\n")) + outputWin.Write("data", prompt) + outputWin.Write("data", []byte("\n\nQ:\n")) + + args := []string{"run-session", "--", "kiro-cli", "chat", "--no-interactive", "-a"} + if conversationID != "" { + touchConversation() + args = append(args, "--resume") + } + + cmd := exec.Command("superpowers", args...) + cmd.Stdin = strings.NewReader(string(prompt)) + cmd.Env = append(os.Environ(), "NO_COLOR=1", "TERM=xterm", "COLUMNS=999") + + stdout, _ := cmd.StdoutPipe() + stderr, _ := cmd.StderrPipe() + cmd.Start() + + var hasError, hasOutput bool + go func() { + scanner := bufio.NewScanner(stderr) + for scanner.Scan() { + line := stripAnsi(scanner.Text()) + if !hasError { + outputWin.Write("data", []byte("\n\nDEBUG:\n")) + hasError = true + } + outputWin.Write("data", []byte(line+"\n")) + } + }() + + scanner := bufio.NewScanner(stdout) + for scanner.Scan() { + line := stripAnsi(scanner.Text()) + if hasError && !hasOutput { + outputWin.Write("data", []byte("\nQ:\n")) + hasOutput = true + } + outputWin.Write("data", []byte(line+"\n")) + } + + cmd.Wait() + outputWin.Write("data", []byte("\n"+strings.Repeat("=", 20)+"\n\n")) + outputWin.Ctl("clean") + outputWin.Ctl("addr=$") + outputWin.Ctl("dot=addr") + outputWin.Ctl("show") + + // Capture conversation ID after first run + if conversationID == "" { + if id, err := getLatestConversationID(); err == nil { + conversationID = id + } + } +} + +func main() { + cwd, _ = os.Getwd() + dbPath = filepath.Join(os.Getenv("HOME"), ".local/share/kiro-cli/data.sqlite3") + loadEnvFiles() + + // Create prompt window + promptWin, _ := acme.New() + promptWin.Name(cwd + "/+Q-Prompt") + promptWin.Write("tag", []byte(" Send")) + promptWin.Ctl("clean") + + // Create output window + outputWin, _ := acme.New() + outputWin.Name(cwd + "/+Q-Output") + outputWin.Ctl("clean") + + // Event loop - exits when window is deleted + for evt := range promptWin.EventChan() { + if evt.C2 == 'x' || evt.C2 == 'X' { // execute + cmd := strings.TrimSpace(string(evt.Text)) + if cmd == "Send" { + promptWin.Addr(",") + prompt, _ := promptWin.ReadAll("data") + if len(strings.TrimSpace(string(prompt))) > 0 { + promptWin.Addr(",") + promptWin.Write("data", nil) + go runPrompt(outputWin, prompt) + } + } else if cmd == "Del" { + promptWin.Ctl("delete") + break + } else { + promptWin.WriteEvent(evt) + } + } else { + promptWin.WriteEvent(evt) + } + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..2e474ef --- /dev/null +++ b/go.mod @@ -0,0 +1,9 @@ +module acme-q + +go 1.21 + +require ( + 9fans.net/go v0.0.7 + github.com/creack/pty v1.1.21 + github.com/mattn/go-sqlite3 v1.14.33 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..0d5b7bd --- /dev/null +++ b/go.sum @@ -0,0 +1,38 @@ +9fans.net/go v0.0.7 h1:H5CsYJTf99C8EYAQr+uSoEJnLP/iZU8RmDuhyk30iSM= +9fans.net/go v0.0.7/go.mod h1:Rxvbbc1e+1TyGMjAvLthGTyO97t+6JMQ6ly+Lcs9Uf0= +dmitri.shuralyov.com/gpu/mtl v0.0.0-20201218220906-28db891af037/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= +github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= +github.com/creack/pty v1.1.21 h1:1/QdRyBaHHJP61QkWMXlOIBfsgdDeeKfK8SYVUWJKf0= +github.com/creack/pty v1.1.21/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4= +github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= +github.com/mattn/go-sqlite3 v1.14.33 h1:A5blZ5ulQo2AtayQ9/limgHEkFreKj1Dv226a1K73s0= +github.com/mattn/go-sqlite3 v1.14.33/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/exp v0.0.0-20190731235908-ec7cb31e5a56/go.mod h1:JhuoJpWY28nO4Vef9tZUw9qufEGTyX1+7lmHxV5q5G4= +golang.org/x/exp v0.0.0-20210405174845-4513512abef3/go.mod h1:I6l2HNBLBZEcrOoCpyKLdY2lHoRZ8lI4x60KMCQDft4= +golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= +golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= +golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE= +golang.org/x/mobile v0.0.0-20201217150744-e6ae53a27f4f/go.mod h1:skQtrUTUwhdJvXM/2KKJzY8pDgNr9I/FOMqDVRPBUS4= +golang.org/x/mobile v0.0.0-20210220033013-bdb1ca9a1e08/go.mod h1:skQtrUTUwhdJvXM/2KKJzY8pDgNr9I/FOMqDVRPBUS4= +golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY= +golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= +golang.org/x/mod v0.1.1-0.20191209134235-331c550502dd/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.3.1-0.20200828183125-ce943fd02449/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191001151750-bb3f8db39f24/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210415045647-66c3f260301c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/tools v0.0.0-20190312151545-0bb0c0a6e846/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20200117012304-6edc0a871e69/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200207183749-b753a1ba74fa/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= diff --git a/internal/p9/server.go b/internal/p9/server.go new file mode 100644 index 0000000..4fa3c54 --- /dev/null +++ b/internal/p9/server.go @@ -0,0 +1,547 @@ +// Package p9 implements the 9P filesystem for Q agent sessions. +package p9 + +import ( + "acme-q/internal/session" + "errors" + "fmt" + "io" + "net" + "os" + "path/filepath" + "regexp" + "strconv" + "strings" + "sync" + + "9fans.net/go/plan9" + "9fans.net/go/plan9/client" +) + +/* +Filesystem layout: + +agent/ + ctl (write) "new [cwd]" creates session, returns id + list (read) list sessions: "id state pid cwd" + {session-id}/ + ctl (write) "save [path]", "load path", "kill" + in (write) send prompt, blocks until response + out (read) response from last prompt + err (read) stderr/debug output + winid (r/w) acme window id + state (read) "running" or "idle" + pid (read) kiro-cli process id + cwd (read) working directory +*/ + +const ( + QTDir = plan9.QTDIR + QTFile = plan9.QTFILE +) + +// Qid paths +const ( + qidRoot = iota + qidCtl + qidList + qidSessionBase = 1000 +) + +// File indices within a session directory +const ( + fileCtl = iota + fileIn + fileOut + fileErr + fileWinID + fileState + filePid + fileCwd + fileAlias + fileCount +) + +var fileNames = []string{"ctl", "in", "out", "err", "winid", "state", "pid", "cwd", "alias"} + +type Server struct { + mgr *session.Manager + listener net.Listener + mu sync.RWMutex + OnAliasChange func(sess *session.Session) // callback when alias changes +} + +type connState struct { + fids map[uint32]*fid + mu sync.RWMutex +} + +type fid struct { + qid plan9.Qid + path string + mode uint8 + offset int64 +} + +// NewServer creates and starts the 9P server. +func NewServer(mgr *session.Manager) (*Server, error) { + ns := client.Namespace() + if ns == "" { + return nil, fmt.Errorf("no namespace") + } + + sockPath := filepath.Join(ns, "agent") + + // Remove stale socket + if _, err := os.Stat(sockPath); err == nil { + conn, err := net.Dial("unix", sockPath) + if err == nil { + conn.Close() + return nil, fmt.Errorf("agent already running") + } + os.Remove(sockPath) + } + + listener, err := net.Listen("unix", sockPath) + if err != nil { + return nil, err + } + + s := &Server{mgr: mgr, listener: listener} + go s.acceptLoop() + return s, nil +} + +func (s *Server) acceptLoop() { + for { + conn, err := s.listener.Accept() + if err != nil { + if !errors.Is(err, net.ErrClosed) { + fmt.Fprintf(os.Stderr, "accept: %v\n", err) + } + return + } + go s.serve(conn) + } +} + +func (s *Server) serve(conn net.Conn) { + defer conn.Close() + cs := &connState{fids: make(map[uint32]*fid)} + + for { + fc, err := plan9.ReadFcall(conn) + if err != nil { + if err != io.EOF { + fmt.Fprintf(os.Stderr, "read: %v\n", err) + } + return + } + + rfc := s.handle(cs, fc) + if err := plan9.WriteFcall(conn, rfc); err != nil { + fmt.Fprintf(os.Stderr, "write: %v\n", err) + return + } + } +} + +func (s *Server) handle(cs *connState, fc *plan9.Fcall) *plan9.Fcall { + switch fc.Type { + case plan9.Tversion: + return &plan9.Fcall{Type: plan9.Rversion, Tag: fc.Tag, Msize: fc.Msize, Version: "9P2000"} + case plan9.Tauth: + return errFcall(fc, "no auth required") + case plan9.Tattach: + return s.attach(cs, fc) + case plan9.Twalk: + return s.walk(cs, fc) + case plan9.Topen: + return s.open(cs, fc) + case plan9.Tread: + return s.read(cs, fc) + case plan9.Twrite: + return s.write(cs, fc) + case plan9.Tstat: + return s.stat(cs, fc) + case plan9.Tclunk: + cs.mu.Lock() + delete(cs.fids, fc.Fid) + cs.mu.Unlock() + return &plan9.Fcall{Type: plan9.Rclunk, Tag: fc.Tag} + default: + return errFcall(fc, "not supported") + } +} + +func (s *Server) attach(cs *connState, fc *plan9.Fcall) *plan9.Fcall { + cs.mu.Lock() + defer cs.mu.Unlock() + qid := plan9.Qid{Type: QTDir, Path: qidRoot} + cs.fids[fc.Fid] = &fid{qid: qid, path: "/"} + return &plan9.Fcall{Type: plan9.Rattach, Tag: fc.Tag, Qid: qid} +} + +func (s *Server) walk(cs *connState, fc *plan9.Fcall) *plan9.Fcall { + cs.mu.Lock() + defer cs.mu.Unlock() + + f, ok := cs.fids[fc.Fid] + if !ok { + return errFcall(fc, "bad fid") + } + + if len(fc.Wname) == 0 { + cs.fids[fc.Newfid] = &fid{qid: f.qid, path: f.path} + return &plan9.Fcall{Type: plan9.Rwalk, Tag: fc.Tag, Wqid: []plan9.Qid{}} + } + + path := f.path + var qids []plan9.Qid + + for _, name := range fc.Wname { + var qid plan9.Qid + var newPath string + + if path == "/" { + switch name { + case "ctl": + qid = plan9.Qid{Type: QTFile, Path: qidCtl} + newPath = "/ctl" + case "list": + qid = plan9.Qid{Type: QTFile, Path: qidList} + newPath = "/list" + default: + // Check if it's a session ID + if sess := s.mgr.Get(name); sess != nil { + qid = plan9.Qid{Type: QTDir, Path: qidSessionBase + hashID(name)} + newPath = "/" + name + } else { + return errFcall(fc, "not found") + } + } + } else if strings.Count(path, "/") == 1 && path != "/" { + // Inside a session directory + sessID := strings.TrimPrefix(path, "/") + if s.mgr.Get(sessID) == nil { + return errFcall(fc, "session not found") + } + idx := fileIndex(name) + if idx < 0 { + return errFcall(fc, "not found") + } + qid = plan9.Qid{Type: QTFile, Path: qidSessionBase + hashID(sessID)*fileCount + uint64(idx)} + newPath = path + "/" + name + } else { + return errFcall(fc, "not found") + } + + qids = append(qids, qid) + path = newPath + } + + cs.fids[fc.Newfid] = &fid{qid: qids[len(qids)-1], path: path} + return &plan9.Fcall{Type: plan9.Rwalk, Tag: fc.Tag, Wqid: qids} +} + +func (s *Server) open(cs *connState, fc *plan9.Fcall) *plan9.Fcall { + cs.mu.Lock() + defer cs.mu.Unlock() + f, ok := cs.fids[fc.Fid] + if !ok { + return errFcall(fc, "bad fid") + } + f.mode = fc.Mode + f.offset = 0 + return &plan9.Fcall{Type: plan9.Ropen, Tag: fc.Tag, Qid: f.qid} +} + +func (s *Server) read(cs *connState, fc *plan9.Fcall) *plan9.Fcall { + cs.mu.Lock() + f, ok := cs.fids[fc.Fid] + cs.mu.Unlock() + if !ok { + return errFcall(fc, "bad fid") + } + + var data []byte + + if f.qid.Type&QTDir != 0 { + data = s.readDir(f.path, fc.Offset, fc.Count) + } else { + content := s.readFile(f.path) + if fc.Offset < uint64(len(content)) { + end := min(int(fc.Offset)+int(fc.Count), len(content)) + data = []byte(content[fc.Offset:end]) + } + } + + return &plan9.Fcall{Type: plan9.Rread, Tag: fc.Tag, Count: uint32(len(data)), Data: data} +} + +func (s *Server) write(cs *connState, fc *plan9.Fcall) *plan9.Fcall { + cs.mu.Lock() + f, ok := cs.fids[fc.Fid] + cs.mu.Unlock() + if !ok { + return errFcall(fc, "bad fid") + } + + input := strings.TrimSpace(string(fc.Data)) + parts := strings.SplitN(f.path, "/", 3) + + // /ctl - create new session + if f.path == "/ctl" { + args := strings.Fields(input) + if len(args) == 0 || args[0] != "new" { + return errFcall(fc, "usage: new [cwd]") + } + cwd, _ := os.Getwd() + if len(args) > 1 { + cwd = args[1] + } + sess, err := s.mgr.New(cwd) + if err != nil { + return errFcall(fc, err.Error()) + } + return &plan9.Fcall{Type: plan9.Rwrite, Tag: fc.Tag, Count: uint32(len(sess.ID))} + } + + // /{id}/ctl - session control + if len(parts) == 3 && parts[2] == "ctl" { + sess := s.mgr.Get(parts[1]) + if sess == nil { + return errFcall(fc, "session not found") + } + args := strings.Fields(input) + if len(args) == 0 { + return errFcall(fc, "usage: stop | save [path] | load path | kill") + } + switch args[0] { + case "stop": + if err := sess.Stop(); err != nil { + return errFcall(fc, err.Error()) + } + case "save": + path := sess.SavePath() + if len(args) > 1 { + path = args[1] + } + if err := sess.SendCtl("/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 { + return errFcall(fc, err.Error()) + } + case "kill": + sess.Kill() + s.mgr.Remove(sess.ID) + default: + return errFcall(fc, "unknown command") + } + return &plan9.Fcall{Type: plan9.Rwrite, Tag: fc.Tag, Count: uint32(len(fc.Data))} + } + + // /{id}/in - send prompt + if len(parts) == 3 && parts[2] == "in" { + sess := s.mgr.Get(parts[1]) + if sess == nil { + return errFcall(fc, "session not found") + } + if _, err := sess.Send(input); err != nil { + return errFcall(fc, err.Error()) + } + return &plan9.Fcall{Type: plan9.Rwrite, Tag: fc.Tag, Count: uint32(len(fc.Data))} + } + + // /{id}/winid - set window id + if len(parts) == 3 && parts[2] == "winid" { + sess := s.mgr.Get(parts[1]) + if sess == nil { + return errFcall(fc, "session not found") + } + id, err := strconv.Atoi(input) + if err != nil { + return errFcall(fc, "invalid winid") + } + sess.WinID = id + return &plan9.Fcall{Type: plan9.Rwrite, Tag: fc.Tag, Count: uint32(len(fc.Data))} + } + + // /{id}/alias - set session alias + if len(parts) == 3 && parts[2] == "alias" { + sess := s.mgr.Get(parts[1]) + if sess == nil { + return errFcall(fc, "session not found") + } + // Validate alias: alphanumeric, hyphen, underscore only + matched, _ := regexp.MatchString(`^[A-Za-z0-9_-]+$`, input) + if !matched { + return errFcall(fc, "invalid alias: must match [A-Za-z0-9_-]+") + } + sess.Alias = input + if s.OnAliasChange != nil { + s.OnAliasChange(sess) + } + return &plan9.Fcall{Type: plan9.Rwrite, Tag: fc.Tag, Count: uint32(len(fc.Data))} + } + + return errFcall(fc, "read-only") +} + +func (s *Server) stat(cs *connState, fc *plan9.Fcall) *plan9.Fcall { + cs.mu.RLock() + f, ok := cs.fids[fc.Fid] + cs.mu.RUnlock() + if !ok { + return errFcall(fc, "bad fid") + } + + dir := s.pathToDir(f.path, f.qid) + stat, _ := dir.Bytes() + return &plan9.Fcall{Type: plan9.Rstat, Tag: fc.Tag, Stat: stat} +} + +func (s *Server) readDir(path string, offset uint64, count uint32) []byte { + var dirs []plan9.Dir + + if path == "/" { + dirs = append(dirs, plan9.Dir{ + Qid: plan9.Qid{Type: QTFile, Path: qidCtl}, Mode: 0222, Name: "ctl", + Uid: "q", Gid: "q", Muid: "q", + }) + dirs = append(dirs, plan9.Dir{ + Qid: plan9.Qid{Type: QTFile, Path: qidList}, Mode: 0444, Name: "list", + Uid: "q", Gid: "q", Muid: "q", + }) + for _, id := range s.mgr.List() { + dirs = append(dirs, plan9.Dir{ + Qid: plan9.Qid{Type: QTDir, Path: qidSessionBase + hashID(id)}, + Mode: plan9.DMDIR | 0555, Name: id, Uid: "q", Gid: "q", Muid: "q", + }) + } + } else { + sessID := strings.TrimPrefix(path, "/") + sess := s.mgr.Get(sessID) + if sess == nil { + return nil + } + for i, name := range fileNames { + mode := uint32(0444) + if name == "ctl" || name == "in" { + mode = 0222 + } else if name == "winid" || name == "alias" { + mode = 0644 + } + content := s.getSessionFile(sess, i) + dirs = append(dirs, plan9.Dir{ + Qid: plan9.Qid{Type: QTFile, Path: qidSessionBase + hashID(sessID)*fileCount + uint64(i)}, + Mode: plan9.Perm(mode), + Name: name, + Length: uint64(len(content)), + Uid: "q", Gid: "q", Muid: "q", + }) + } + } + + var data []byte + for _, d := range dirs { + b, _ := d.Bytes() + data = append(data, b...) + } + if offset >= uint64(len(data)) { + return nil + } + end := min(int(offset)+int(count), len(data)) + return data[offset:end] +} + +func (s *Server) readFile(path string) string { + if path == "/list" { + var lines []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)) + } + } + return strings.Join(lines, "\n") + "\n" + } + + parts := strings.SplitN(path, "/", 3) + if len(parts) != 3 { + return "" + } + sess := s.mgr.Get(parts[1]) + if sess == nil { + return "" + } + return s.getSessionFile(sess, fileIndex(parts[2])) +} + +func (s *Server) getSessionFile(sess *session.Session, idx int) string { + switch idx { + case fileOut: + return sess.Output() + case fileErr: + return sess.Err() + case fileWinID: + return strconv.Itoa(sess.WinID) + case fileState: + return sess.State + case filePid: + if sess.Pid == 0 { + return "" + } + return strconv.Itoa(sess.Pid) + case fileCwd: + return sess.Cwd + case fileAlias: + if sess.Alias == "" { + return sess.ID + } + return sess.Alias + } + return "" +} + +func (s *Server) pathToDir(path string, qid plan9.Qid) plan9.Dir { + name := filepath.Base(path) + if path == "/" { + name = "." + } + mode := uint32(0444) + if qid.Type&QTDir != 0 { + mode = plan9.DMDIR | 0555 + } + return plan9.Dir{Qid: qid, Mode: plan9.Perm(mode), Name: name, Uid: "q", Gid: "q", Muid: "q"} +} + +func (s *Server) Close() error { + return s.listener.Close() +} + +func errFcall(fc *plan9.Fcall, msg string) *plan9.Fcall { + return &plan9.Fcall{Type: plan9.Rerror, Tag: fc.Tag, Ename: msg} +} + +func fileIndex(name string) int { + for i, n := range fileNames { + if n == name { + return i + } + } + return -1 +} + +func hashID(id string) uint64 { + var h uint64 = 5381 + for _, c := range id { + h = ((h << 5) + h) + uint64(c) + } + return h +} diff --git a/internal/session/session.go b/internal/session/session.go new file mode 100644 index 0000000..d32507c --- /dev/null +++ b/internal/session/session.go @@ -0,0 +1,625 @@ +// 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) +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..380dfc6 --- /dev/null +++ b/main.go @@ -0,0 +1,432 @@ +// Q - Acme interface for kiro-cli chat sessions via 9P +package main + +import ( + "acme-q/internal/p9" + "acme-q/internal/session" + "fmt" + "log" + "os" + "os/exec" + "path/filepath" + "regexp" + "strconv" + "strings" + "syscall" + "time" + + "9fans.net/go/acme" +) + +const windowName = "/Q/" + +func main() { + mgr := session.NewManager() + + srv, err := p9.NewServer(mgr) + if err != nil { + log.Fatal(err) + } + 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) + if err != nil { + return + } + defer w.CloseFiles() + name := sess.Alias + if name == "" { + name = sess.ID + } + w.Name(filepath.Join(sess.Cwd, fmt.Sprintf("+Chat.%s", name))) + } + } + + w, err := acme.New() + if err != nil { + log.Fatal(err) + } + defer w.CloseFiles() + + w.Name(windowName) + w.Write("tag", []byte("New Open Kill Get Login ")) + refreshList(w, mgr) + w.Ctl("clean") + + // Periodic refresh + go func() { + for range time.Tick(5 * time.Second) { + refreshList(w, mgr) + } + }() + + // Event loop + for e := range w.EventChan() { + switch e.C2 { + case 'x', 'X': + cmd := string(e.Text) + arg := strings.TrimSpace(string(e.Arg)) + + // Handle "New /path" or "Kill " typed directly in tag + if strings.HasPrefix(cmd, "New ") { + arg = strings.TrimPrefix(cmd, "New ") + cmd = "New" + } else if strings.HasPrefix(cmd, "Kill ") { + arg = strings.TrimPrefix(cmd, "Kill ") + cmd = "Kill" + } else if strings.HasPrefix(cmd, "Open ") { + arg = strings.TrimPrefix(cmd, "Open ") + cmd = "Open" + } + + switch cmd { + case "New": + if arg == "" { + w.Fprintf("body", "Usage: New /path/to/directory\n") + continue + } + sess, err := mgr.New(arg) + if err != nil { + w.Fprintf("body", "Error: %v\n", err) + continue + } + chatWin, err := openChatWindow(sess) + if err != nil { + w.Fprintf("body", "Error opening chat: %v\n", err) + continue + } + sess.WinID = chatWin.ID() + refreshList(w, mgr) + case "Open": + if arg == "" { + w.Fprintf("body", "Usage: Open \n") + continue + } + sess := mgr.Get(arg) + if sess == nil { + w.Fprintf("body", "Session not found: %s\n", arg) + continue + } + chatWin, err := openChatWindow(sess) + if err != nil { + w.Fprintf("body", "Error opening chat: %v\n", err) + continue + } + sess.WinID = chatWin.ID() + case "Kill": + if arg == "" { + w.Fprintf("body", "Usage: Kill \n") + continue + } + pid, err := strconv.Atoi(arg) + if err != nil { + w.Fprintf("body", "Invalid pid: %s\n", arg) + continue + } + // 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 + } + } + refreshList(w, mgr) + case "Get": + refreshList(w, mgr) + case "Login": + cmd := exec.Command("firefox") + cmd.Start() + cmd = exec.Command("kitty", "-e", "q", "login") + log.Printf("starting kitty, pid will follow") + if err := cmd.Start(); err != nil { + log.Printf("kitty start error: %v", err) + } else { + log.Printf("kitty started, pid=%d", cmd.Process.Pid) + } + default: + w.WriteEvent(e) + } + case 'l', 'L': + text := strings.TrimSpace(string(e.Text)) + 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 { + // Check if window still exists by trying to open it + if aw, err := acme.Open(sess.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() + } + } + } else { + chatWin, err := openChatWindow(sess) + if err == nil { + sess.WinID = chatWin.ID() + } + } + } else { + w.WriteEvent(e) + } + default: + w.WriteEvent(e) + } + } +} + +func refreshList(w *acme.Win, mgr *session.Manager) { + var buf strings.Builder + // Header: ID=5, State=9, PID=8, Alias=16, Cwd=remaining + buf.WriteString(fmt.Sprintf("%-5s %-9s %-8s %-16s %s\n", "ID", "State", "PID", "Alias", "Cwd")) + buf.WriteString(fmt.Sprintf("%-5s %-9s %-8s %-16s %s\n", "-----", "---------", "--------", "----------------", strings.Repeat("-", 40))) + for _, id := range mgr.List() { + sess := mgr.Get(id) + if sess == nil { + continue + } + // Remove dead sessions (pid=0 or process not running) + if sess.Pid == 0 { + mgr.Remove(id) + continue + } + if err := syscall.Kill(sess.Pid, 0); err != nil { + mgr.Remove(id) + continue + } + alias := sess.Alias + if alias == "" { + alias = "-" + } + cwd := sess.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)) + } + w.Addr(",") + w.Write("data", []byte(buf.String())) + w.Ctl("clean") +} + +func openChatWindow(sess *session.Session) (*acme.Win, error) { + displayName := sess.Alias + if displayName == "" { + displayName = sess.ID + } + name := filepath.Join(sess.Cwd, fmt.Sprintf("+Chat.%s", displayName)) + + w, err := acme.New() + if err != nil { + return nil, err + } + 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.Ctl("clean") + + go handleChatWindow(w, sess) + + return w, nil +} + +func handleChatWindow(w *acme.Win, sess *session.Session) { + defer w.CloseFiles() + + for e := range w.EventChan() { + switch e.C2 { + case 'x', 'X': + cmd := string(e.Text) + arg := strings.TrimSpace(string(e.Arg)) + + // Handle "Alias " typed directly + if strings.HasPrefix(cmd, "Alias ") { + arg = strings.TrimPrefix(cmd, "Alias ") + cmd = "Alias" + } + + switch cmd { + case "Send": + go sendPrompt(w, sess) + case "Stop": + if err := sess.Stop(); err != nil { + log.Printf("[session %s] Stop error: %v", sess.ID, err) + } else { + log.Printf("[session %s] Sent CTRL+C", sess.ID) + } + case "Alias": + if arg == "" { + log.Printf("[session %s] Usage: Alias ", 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) + continue + } + sess.Alias = arg + // Rename window + newName := filepath.Join(sess.Cwd, fmt.Sprintf("+Chat.%s", arg)) + w.Name(newName) + 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 + if body, err := w.ReadAll("body"); err == nil && len(body) > 0 { + if len(body) > 500 { + body = body[:500] + } + bodyText = string(body) + } + go func() { + // Save to temp file first + tempPath := sess.SavePath() + if arg != "" { + tempPath = arg + } + if err := sess.SendCtl("/chat save " + tempPath); 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) + }() + case "Kill": + sess.Kill() + log.Printf("[session %s] Session killed", sess.ID) + return + case "Sessions": + openSessionsWindow(w, sess) + default: + w.WriteEvent(e) + } + default: + w.WriteEvent(e) + } + } +} + +func sendPrompt(w *acme.Win, sess *session.Session) { + body, err := w.ReadAll("body") + if err != nil { + 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) + return + } + + prompt := strings.TrimSpace(content[idx+5:]) + if prompt == "" { + return + } + + w.Fprintf("body", "\n\nASSISTANT:\n") + + response, err := sess.Send(prompt) + if err != nil { + log.Printf("[session %s] Error: %v", sess.ID, err) + } else { + w.Fprintf("body", "%s\n", response) + } + + w.Fprintf("body", "\n---\n\nUSER:\n") + 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)) + + w, err := acme.New() + if err != nil { + return + } + w.Name(name) + w.Write("tag", []byte("Get ")) + + entries, _ := os.ReadDir(dir) + w.Fprintf("body", "Saved sessions in %s:\n\n", dir) + for _, e := range entries { + if strings.HasSuffix(e.Name(), ".json") { + w.Fprintf("body", "%s\n", filepath.Join(dir, e.Name())) + } + } + w.Ctl("clean") + + go func() { + defer w.CloseFiles() + for e := range w.EventChan() { + if e.C2 == 'l' || e.C2 == 'L' { + path := strings.TrimSpace(string(e.Text)) + if strings.HasSuffix(path, ".json") { + // 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) + if err != nil { + chatWin.Fprintf("body", "Error loading: %v\n", err) + } else { + chatWin.Fprintf("body", "%s\n", response) + } + chatWin.Fprintf("body", "\n---\n\nUSER:\n") + chatWin.Ctl("clean") + }(path) + } + } else if e.C2 == 'x' || e.C2 == 'X' { + if string(e.Text) == "Get" { + w.Addr(",") + entries, _ := os.ReadDir(dir) + var buf strings.Builder + buf.WriteString(fmt.Sprintf("Saved sessions in %s:\n\n", dir)) + for _, ent := range entries { + if strings.HasSuffix(ent.Name(), ".json") { + buf.WriteString(filepath.Join(dir, ent.Name()) + "\n") + } + } + w.Write("data", []byte(buf.String())) + w.Ctl("clean") + } else { + w.WriteEvent(e) + } + } else { + w.WriteEvent(e) + } + } + }() +} + +func focusWindow(id int) { + w, err := acme.Open(id, nil) + if err != nil { + return + } + defer w.CloseFiles() + w.Ctl("show") +} diff --git a/mkfile b/mkfile new file mode 100644 index 0000000..dc16523 --- /dev/null +++ b/mkfile @@ -0,0 +1,12 @@ +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 + +install:V: build + +clean:V: + rm -f $INSTALL_PATH/Q $INSTALL_PATH/Q-Login