initial commit

This commit is contained in:
Levi Neely 2026-02-09 17:57:42 +01:00
commit d8dccd94b3
9 changed files with 1834 additions and 0 deletions

4
.gitignore vendored Normal file
View File

@ -0,0 +1,4 @@
Q
Prompt
Q-Reset
Q-Login

BIN
acme-q Executable file

Binary file not shown.

167
cmd/Q-Service/main.go Normal file
View File

@ -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)
}
}
}

9
go.mod Normal file
View File

@ -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
)

38
go.sum Normal file
View File

@ -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=

547
internal/p9/server.go Normal file
View File

@ -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
}

625
internal/session/session.go Normal file
View File

@ -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)
}

432
main.go Normal file
View File

@ -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")
}

12
mkfile Normal file
View File

@ -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