initial commit
This commit is contained in:
commit
d8dccd94b3
|
|
@ -0,0 +1,4 @@
|
|||
Q
|
||||
Prompt
|
||||
Q-Reset
|
||||
Q-Login
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
)
|
||||
|
|
@ -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=
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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 <pid>" 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 <session-id>\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 <pid>\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 <name>" 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 <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)
|
||||
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")
|
||||
}
|
||||
Reference in New Issue