From 911733bd8a42495c94fa8b7aece93fdd34832df1 Mon Sep 17 00:00:00 2001 From: Levi Neely Date: Mon, 3 Aug 2026 10:37:29 +0200 Subject: [PATCH] fs: add agent-level kill, remove activeAgentID from session MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add 'kill' command to agent ctl (removes agent from session). Add Session.RemoveAgent() method. Remove activeAgentID field, Agent(), SetActiveAgent, and dead WaitChange — 'active agent' is a GUI concept, not a server one. All callers updated to iterate Agents() or use FindAgent(id). --- fs/agentfiles.go | 11 ++--- fs/lifecycle.go | 32 +++++++++---- fs/persist.go | 7 ++- fs/sessionfiles.go | 3 +- fs/types.go | 10 +++- session/session.go | 114 +++++++++++++-------------------------------- 6 files changed, 74 insertions(+), 103 deletions(-) diff --git a/fs/agentfiles.go b/fs/agentfiles.go index a853bf8..c7abe04 100644 --- a/fs/agentfiles.go +++ b/fs/agentfiles.go @@ -410,6 +410,11 @@ func handleAgentCtl(a *agent.Agent, s *Session, data []byte) error { return nil } switch cmd[0] { + case "kill": + if s.Core != nil { + s.Core.RemoveAgent(a.ID()) + PostEvent("A kill session/" + s.Name() + "/agent/" + a.ID()) + } case "stop": a.Interrupt(agent.ErrInterrupted) case "compact": @@ -426,12 +431,6 @@ func handleAgentCtl(a *agent.Agent, s *Session, data []byte) error { be.SetModel(strings.Join(cmd[1:], " ")) } } - case "agents": - if len(cmd) > 1 { - if s.Core != nil { - s.Core.SetActiveAgent(strings.Join(cmd[1:], " ")) - } - } case "name": if len(cmd) > 1 { a.SetName(strings.Join(cmd[1:], " ")) diff --git a/fs/lifecycle.go b/fs/lifecycle.go index 3732821..4f3c35e 100644 --- a/fs/lifecycle.go +++ b/fs/lifecycle.go @@ -179,9 +179,13 @@ func Rename(rs *rootState, old, new string) error { rs.mu.Unlock() return fmt.Errorf("session already exists: %s", new) } - if sess.Core != nil && sess.Core.Agent().IsRunning() { - rs.mu.Unlock() - return fmt.Errorf("cannot rename while agent is running") + if sess.Core != nil { + for _, ag := range sess.Core.Agents() { + if ag.IsRunning() { + rs.mu.Unlock() + return fmt.Errorf("cannot rename while agent is running") + } + } } sess.name = new rs.sessions[new] = sess @@ -231,8 +235,15 @@ func waitIdle(rs *rootState, poll, timeout time.Duration) { allIdle := true rs.mu.RLock() for _, sess := range rs.sessions { - if sess.Core != nil && sess.Core.Agent().State() != "idle" { - allIdle = false + if sess.Core != nil { + for _, ag := range sess.Core.Agents() { + if ag.State() != "idle" { + allIdle = false + break + } + } + } + if !allIdle { break } } @@ -252,7 +263,9 @@ func InterruptAll(root *Tree) { defer rs.mu.RUnlock() for _, sess := range rs.sessions { if sess.Core != nil { - sess.Core.Agent().Interrupt(agent.ErrInterrupted) + for _, ag := range sess.Core.Agents() { + ag.Interrupt(agent.ErrInterrupted) + } } } } @@ -617,7 +630,7 @@ func CreateAgent(rs *rootState, sessName string, args []string) error { } rs.mu.Unlock() - ag := sess.Core.Agent() + ag := sess.Core.FindAgent(uname) ag.Submit(sess.SessionCtx, "/agent "+profile) if name != "" { ag.SetName(name) @@ -638,8 +651,9 @@ func CreateAgentFromRoot(root *Tree, sessName string, args []string) (string, er rs.mu.RLock() sess := rs.sessions[sessName] rs.mu.RUnlock() - if sess == nil || sess.Core == nil { + if sess == nil || sess.Core == nil || sess.Core.AgentCount() == 0 { return "", fmt.Errorf("agent not created") } - return sess.Core.Agent().ID(), nil + agents := sess.Core.Agents() + return agents[len(agents)-1].ID(), nil } diff --git a/fs/persist.go b/fs/persist.go index dada1a6..f5efa61 100644 --- a/fs/persist.go +++ b/fs/persist.go @@ -38,14 +38,14 @@ func persistSession(rs *rootState, id string) { if !ok { return } - if sess.Core == nil || sess.Core.Agent() == nil { + if sess.Core == nil || sess.Core.AgentCount() == 0 { // Empty session — no agent to persist. Silently skip. return } dir := activeSessionsDir(rs) os.MkdirAll(dir, 0700) path := filepath.Join(dir, sess.id+".json") - ag := sess.Core.Agent() + ag := sess.Core.Agents()[0] if err := ag.SaveFull(path, sess.id, sess.Name(), sess.Core.CWD(), ""); err != nil { rs.cfg.Log.Error("persist session %s: %v", id, err) } @@ -278,7 +278,7 @@ func restoreSession(rs *rootState, ps *agent.PersistedAgent) error { sess.toolsConn = toolSrv // Restore agent display name from persisted session. - ag := sess.Core.Agent() + ag := sess.Core.FindAgent(uname) if ps.Agent != "" { ag.SetName(ps.Agent) } @@ -287,7 +287,6 @@ func restoreSession(rs *rootState, ps *agent.PersistedAgent) error { // Pre-load autoLoad tools into the remote. if cfg != nil { - ag := sess.Core.Agent() for _, tl := range cfg.AutoLoad { if err := sess.loadTool(tl, ag); err != nil { rs.cfg.Log.Error("session %s: autoLoad tool %q: %v", sessID, tl, err) diff --git a/fs/sessionfiles.go b/fs/sessionfiles.go index 33bca55..ee93d4b 100644 --- a/fs/sessionfiles.go +++ b/fs/sessionfiles.go @@ -96,7 +96,8 @@ func listSessions(ctx HandlerCtx) ([]FsNodeDecl, error) { if err := CreateAgent(rs, s.Name(), args); err != nil { return nil, fmt.Errorf("create agent: %w", err) } - return []byte(s.Core.Agent().ID() + "\n"), nil + agents := s.Core.Agents() + return []byte(agents[len(agents)-1].ID() + "\n"), nil })), // {aid} — dynamic per-agent directory diff --git a/fs/types.go b/fs/types.go index 808058f..76de7f6 100644 --- a/fs/types.go +++ b/fs/types.go @@ -123,7 +123,11 @@ func (sess *Session) CachedListModels() string { sess.modelsMu.Unlock() // Cache miss — fetch and store. - models := sess.Core.Agent().ListModels() + agents := sess.Core.Agents() + if len(agents) == 0 { + return "" + } + models := agents[0].ListModels() result := strings.Join(models, "\n") sess.modelsMu.Lock() sess.modelsCache = result @@ -217,7 +221,9 @@ func (sess *Session) Cancel() { } func (sess *Session) Interrupt() { - sess.Core.Agent().Interrupt(agent.ErrInterrupted) + for _, ag := range sess.Core.Agents() { + ag.Interrupt(agent.ErrInterrupted) + } } // --- AgentLog methods --- diff --git a/session/session.go b/session/session.go index b5affdb..d682a50 100644 --- a/session/session.go +++ b/session/session.go @@ -1,7 +1,6 @@ package session import ( - "context" "crypto/rand" "fmt" "io" @@ -43,8 +42,7 @@ type Config struct { } // Session is the concrete session type. It owns session-level state and -// delegates agent operations to its owned agents. Operations that act on -// "the agent" (Agent(), CWD, etc.) use the agent identified by immutable activeAgentID. +// delegates agent operations to its owned agents. type Session struct { id string envMu sync.RWMutex @@ -52,9 +50,8 @@ type Session struct { plan []byte prevPrompt string - agents []*agent.Agent - activeAgentID string // immutable ID of the agent returned by Agent() - log *olog.Logger + agents []*agent.Agent + log *olog.Logger sessionsDir string listHandlers map[string]func() []string remote string @@ -165,39 +162,10 @@ func New(cfg Config) *Session { }) a.agents = append(a.agents, ag) - a.activeAgentID = ag.ID() ag.SetSessionEnv(a.id) return a } -// Agent returns the currently active agent by immutable ID. -// If no active agent is set, returns the first agent (backward compatible). -func (a *Session) Agent() *agent.Agent { - if len(a.agents) == 0 { - return nil - } - if a.activeAgentID != "" { - for _, ag := range a.agents { - if ag.ID() == a.activeAgentID { - return ag - } - } - } - // Fallback: first agent or match by ID - return a.agents[0] -} - -// SetActiveAgent selects an agent by immutable ID. -func (a *Session) SetActiveAgent(id string) error { - for _, ag := range a.agents { - if ag.ID() == id { - a.activeAgentID = id - return nil - } - } - return fmt.Errorf("agent %q not found", id) -} - // FindAgent returns the agent matching the given name or ID, or nil. func (a *Session) FindAgent(nameOrID string) *agent.Agent { for _, ag := range a.agents { @@ -229,15 +197,30 @@ func (a *Session) SaveFuncs() (save func(), flush func()) { return a.saveSession, a.flushSave } -// AddAgent appends an agent to the session and sets it as the active agent. +// AddAgent appends an agent to the session. func (a *Session) AddAgent(ag *agent.Agent) { a.agents = append(a.agents, ag) - if a.activeAgentID == "" { - a.activeAgentID = ag.ID() - } ag.SetSessionEnv(a.id) } +// RemoveAgent removes an agent by ID and closes it. +// Returns false if the agent was not found. +func (a *Session) RemoveAgent(id string) bool { + idx := -1 + for i, ag := range a.agents { + if ag.ID() == id { + idx = i + break + } + } + if idx < 0 { + return false + } + a.agents[idx].Close() + a.agents = append(a.agents[:idx], a.agents[idx+1:]...) + return true +} + // Close releases resources for this session. func (a *Session) Close() { a.log.Debug("Close() session=%q", a.id) @@ -261,10 +244,10 @@ func (a *Session) SetEnv(key, value string) { } // CWD returns the current working directory for tool execution -// (from the primary agent). +// (from the first agent). func (a *Session) CWD() string { - if ag := a.Agent(); ag != nil { - if c := ag.Cwd(); c != "" { + if len(a.agents) > 0 { + if c := a.agents[0].Cwd(); c != "" { return c } } @@ -272,7 +255,7 @@ func (a *Session) CWD() string { return wd } -// SetCWD validates and sets the working directory on the primary agent. +// SetCWD validates and sets the working directory on all agents. func (a *Session) SetCWD(dir string) error { dir = paths.ExpandHome(dir) if dir != "" { @@ -280,7 +263,7 @@ func (a *Session) SetCWD(dir string) error { return fmt.Errorf("cwd: %w", err) } } - if ag := a.Agent(); ag != nil { + for _, ag := range a.agents { ag.SetCWD(dir) } return nil @@ -311,45 +294,13 @@ func (a *Session) SetSessionID(newID string) error { if _, err := os.Stat(oldTemp); err == nil { os.Rename(oldTemp, newTemp) //nolint:errcheck } - if ag := a.Agent(); ag != nil { + for _, ag := range a.agents { ag.SetSessionEnv(newID) } return nil } -// WaitChange blocks until the named field changes from current. -func (a *Session) WaitChange(ctx context.Context, field, current string) (string, bool) { - ag := a.Agent() - if ag == nil { - return "", false - } - if field == agent.WatchState { - return ag.WaitChange(ctx, field, current) - } - // Other fields — read via agent methods, use agent's change signal. - read := func() string { - switch field { - case "usage": - return ag.UsageStr() - case "ctxsz": - return ag.CtxSz() - case "cwd": - return a.CWD() - case "agent": - return ag.ID() - } - return "" - } - stop := context.AfterFunc(ctx, func() { ag.BroadcastChange() }) - defer stop() - for ctx.Err() == nil { - if v := read(); v != current { - return v, true - } - ag.WaitForChange(ctx) - } - return "", false -} + func (a *Session) activeSessionPath(id, suffix string) string { return filepath.Join(a.sessionsDir, "active", id+suffix) @@ -381,7 +332,8 @@ func (a *Session) flushSave() { a.log.Error("session save: %v", err) return } - if ag := a.Agent(); ag != nil { + if len(a.agents) > 0 { + ag := a.agents[0] if err := ag.SaveFull(path, a.id, "", a.CWD(), a.remote); err != nil { a.log.Error("session save: %v", err) } @@ -391,8 +343,8 @@ func (a *Session) flushSave() { // SaveSession writes the current session state to the given path. // Returns nil if there is no agent (empty session). func (a *Session) SaveSession(path string) error { - if ag := a.Agent(); ag != nil { - return ag.SaveFull(path, a.id, "", a.CWD(), a.remote) + if len(a.agents) > 0 { + return a.agents[0].SaveFull(path, a.id, "", a.CWD(), a.remote) } return nil }