From f7a52088db517e9cc5c0e99e29ce55a12fd0e90f Mon Sep 17 00:00:00 2001 From: Levi Neely Date: Wed, 7 Oct 2026 15:14:04 +0200 Subject: [PATCH] per-agent cwd override with session-level inheritance - Agent cwd is optional; empty = inherit session cwd (the common case) - Session cwd is required at creation and is the inheritance root - toolsrv maintains agentCWD map, resolved per call from agent= field - Override set via agent cfg (cwd=...) or ctl (cwd [|-]) - Agent.SyncCwdToToolServer re-pushes on every (re)connect - GUI NewAgentDialog shows '(inherit: )' as placeholder - proc_test.go covers per-agent cwd isolation - Kate and acme scripts updated for new session/agent creation flow - AGENTS.md documents the architecture --- AGENTS.md | 1 + cmd/olliesrv/internal/agent/agent.go | 69 +++++++++- cmd/olliesrv/internal/fs/spec.go | 16 ++- cmd/olliesrv/internal/session/persist.go | 38 +++--- cmd/olliesrv/internal/session/registry.go | 17 ++- cmd/olliesrv/internal/session/session.go | 4 + cmd/olliesrv/internal/toolclient/toolsrv.go | 12 ++ cmd/toolsrv/internal/server/proc.go | 45 ++++++- cmd/toolsrv/internal/server/proc_test.go | 49 +++++++ data/scripts/acme/acme-ollie-ensure | 16 +-- kde/dolphin/ollie-session-here | 15 ++- kde/gui/NewAgentDialog.qml | 135 +++++++++++++++++++- kde/gui/SessionTree.qml | 8 ++ kde/gui/main.qml | 5 + kde/gui/ollie9pclient.cpp | 53 ++++++++ kde/gui/ollie9pclient.h | 2 + kde/kate/ollie_kate.cpp | 12 +- 17 files changed, 447 insertions(+), 50 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index e700ae6..f7c6258 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -120,6 +120,7 @@ This is a standing preference, not a per-task instruction. Apply it without aski 10. **Sub-agents**: `subagent_spawn` creates a transient child session with an independent runtime and context. The child receives a one-time parent-history snapshot and returns only its final reply. Parent/child IDs are retained for tracing; concurrent children are supported. 11. **Peers**: Persistent agents in the same session can be linked via `peeradd`. Links are bidirectional. Agents communicate by writing to `peer/{name}`, which delivers to the target's prompt handler. Only declared peers can be messaged — the `peer/` directory is the access control surface. 12. **9P namespace declaration**: `cmd/olliesrv/internal/fs/spec.go` declares the olliesrv namespace. The toolsrv namespace is declared by `cmd/toolsrv/p9.go` using `cmd/toolsrv/internal/server.Spec`; process state and handlers are in `cmd/toolsrv/internal/server/`. Both use the `virtfs` EDSL and `virtfs.BuildTree()`. +13. **Working directory (default + override)**: Session cwd is **required** at session creation and is the inheritance root for every agent — the sane default so many agents can work in one directory with zero per-agent config. Agent cwd is an **optional** per-agent override (empty = inherit the session cwd). Because the per-session `toolsrv` sets `cmd.Dir` per tool call, per-agent cwd needs no extra process: `toolsrv` keeps an in-memory `agentCWD` map resolved per call from the `agent=` field (`cmd/toolsrv/internal/server/proc.go`), falling back to the session-level global cwd. The override is set only over the controlled `olliesrv`→`toolsrv` ctl channel (`agentcwd `), never embedded in a tool-call payload, so the model cannot influence where its own tools run. The map is process-local, so `Agent.SyncCwdToToolServer` re-pushes the override on every (re)connect — profile switch, resume, restore — matching how env and tools are resynced. Set/clear via the agent `cfg` (`cwd=...`) or ctl (`cwd [|-]`); the GUI Agent Settings dialog shows `(inherit: )` as the placeholder. ## Where to Start diff --git a/cmd/olliesrv/internal/agent/agent.go b/cmd/olliesrv/internal/agent/agent.go index cf6152f..a1a1fe0 100644 --- a/cmd/olliesrv/internal/agent/agent.go +++ b/cmd/olliesrv/internal/agent/agent.go @@ -42,6 +42,7 @@ type Agent struct { stateSince time.Time // when the current state began reply string // last assistant response getCwd func() string // returns session working directory + cwdOverride string // per-agent cwd override (empty = inherit session cwd) id string // agent identity (unique principal) parentID string // immutable ID of the agent that spawned this agent depth int // sub-agent depth (0=top-level, 1=sub-agent, 2=sub-sub-agent) @@ -190,8 +191,15 @@ func (ag *Agent) SetToolServer(newToolServer func() *toolclient.ToolsrvConn, con } } -// Cwd returns the agent's working directory (from session). +// Cwd returns the agent's effective working directory: the per-agent override +// if one is set, otherwise the inherited session working directory. func (ag *Agent) Cwd() string { + ag.stateMu.RLock() + override := ag.cwdOverride + ag.stateMu.RUnlock() + if override != "" { + return override + } if ag.getCwd != nil { return ag.getCwd() } @@ -199,6 +207,62 @@ func (ag *Agent) Cwd() string { return wd } +// SessionCwd returns the inherited session working directory, ignoring any +// per-agent override. +func (ag *Agent) SessionCwd() string { + if ag.getCwd != nil { + return ag.getCwd() + } + wd, _ := os.Getwd() + return wd +} + +// CwdOverride returns the per-agent cwd override, or "" if the agent inherits +// the session working directory. +func (ag *Agent) CwdOverride() string { + ag.stateMu.RLock() + defer ag.stateMu.RUnlock() + return ag.cwdOverride +} + +// SetCwdOverride sets (or, with an empty dir, clears) the per-agent working +// directory override and propagates it to the tool server so that this agent's +// tools execute in the chosen directory. Other agents in the session are +// unaffected. The effective directory is also reflected in the preamble +// environment section. +func (ag *Agent) SetCwdOverride(dir string) { + dir = strings.TrimSpace(dir) + ag.stateMu.Lock() + ag.cwdOverride = dir + ag.stateMu.Unlock() + + // Refresh the preamble environment section to reflect the effective cwd. + eff := ag.Cwd() + if ag.runtime != nil { + ag.envBlock = EnvironmentBlock(eff, runtime.GOOS, util.IsGitRepo(eff), "") + if ag.runtime.Preamble != nil { + ag.runtime.Preamble.Set(SectionEnv, ag.envBlock) + } + } + + ag.SyncCwdToToolServer() + ag.notifyChange() +} + +// SyncCwdToToolServer pushes this agent's per-agent cwd override to its current +// tool server. The toolsrv keeps per-agent overrides in process memory, so this +// must be re-run whenever the tool server connection is (re)established — on +// profile switch, resume, or respawn — otherwise the agent silently falls back +// to the session cwd. Pushing an empty override clears any stale entry. +func (ag *Agent) SyncCwdToToolServer() { + ag.stateMu.RLock() + override := ag.cwdOverride + ag.stateMu.RUnlock() + if ts := ag.ToolServer(); ts != nil && ag.id != "" { + ts.SetAgentCWD(ag.id, override) + } +} + // SetGetCwd sets the CWD getter callback. func (ag *Agent) SetGetCwd(fn func() string) { ag.getCwd = fn @@ -291,6 +355,9 @@ func (ag *Agent) SwitchProfile(name string) (*AgentConfig, error) { ag.runtime = rt ag.profile = name ag.history = nil + // The profile switch dialed a fresh tool server connection; re-apply the + // per-agent cwd override so it survives the reconnect. + ag.SyncCwdToToolServer() ag.save() ag.flush() ag.notifyChange() diff --git a/cmd/olliesrv/internal/fs/spec.go b/cmd/olliesrv/internal/fs/spec.go index ad7396e..33beb3f 100644 --- a/cmd/olliesrv/internal/fs/spec.go +++ b/cmd/olliesrv/internal/fs/spec.go @@ -899,6 +899,8 @@ func buildAgentChildren(a *agent.Agent, s *session.Session) []virtfs.FsNodeDecl fmt.Fprintf(&sb, "profile=%s\n", a.Profile()) fmt.Fprintf(&sb, "displayName=%s\n", a.Name()) fmt.Fprintf(&sb, "cwd=%s\n", a.Cwd()) + fmt.Fprintf(&sb, "sessionCwd=%s\n", a.SessionCwd()) + fmt.Fprintf(&sb, "cwdOverride=%s\n", a.CwdOverride()) fmt.Fprintf(&sb, "remote=%s\n", s.Remote) fmt.Fprintf(&sb, "maxTokens=%d\n", p.MaxTokens) if p.Temperature != nil { @@ -929,6 +931,10 @@ func buildAgentChildren(a *agent.Agent, s *session.Session) []virtfs.FsNodeDecl if strings.TrimSpace(parts[1]) != "" { a.SetName(strings.TrimSpace(parts[1])) } + case "cwd", "cwdOverride": + // Per-agent working directory override. An empty value + // clears the override so the agent inherits the session cwd. + a.SetCwdOverride(strings.TrimSpace(parts[1])) default: return fmt.Errorf("unknown cfg key: %s", parts[0]) } @@ -1114,7 +1120,15 @@ func buildAgentChildren(a *agent.Agent, s *session.Session) []virtfs.FsNodeDecl a.SetName(strings.Join(args, " ")) return []byte(strings.Join(args, " ") + "\n"), nil }}, - {"cwd", "print the working directory", func(args []string) ([]byte, error) { + {"cwd", "print, set, or clear the per-agent working directory override: cwd [|-]", func(args []string) ([]byte, error) { + if len(args) == 0 { + return []byte(a.Cwd() + "\n"), nil + } + dir := strings.TrimSpace(strings.Join(args, " ")) + if dir == "-" { + dir = "" // clear override, inherit session cwd + } + a.SetCwdOverride(dir) return []byte(a.Cwd() + "\n"), nil }}, {"proc", "manage background procs: proc [top|idx|term|kill|out|dismiss ]", func(args []string) ([]byte, error) { diff --git a/cmd/olliesrv/internal/session/persist.go b/cmd/olliesrv/internal/session/persist.go index 63c5072..631440e 100644 --- a/cmd/olliesrv/internal/session/persist.go +++ b/cmd/olliesrv/internal/session/persist.go @@ -38,6 +38,7 @@ type PersistedAgent struct { Profile string `json:"profile"` Backend string `json:"backend"` Model string `json:"model"` + CwdOverride string `json:"cwdOverride,omitempty"` Peers []string `json:"peers,omitempty"` Messages []backend.Message `json:"messages"` // Usage tracking @@ -83,14 +84,15 @@ func PersistSession(name string) error { for _, ag := range agents { pa := PersistedAgent{ - ID: ag.ID(), - Name: ag.Name(), - ParentID: ag.ParentID(), - Profile: ag.Profile(), - Backend: ag.BackendName(), - Model: ag.ModelName(), - Peers: ag.Peers(), - Messages: backend.SanitizeMessages(ag.Messages()), + ID: ag.ID(), + Name: ag.Name(), + ParentID: ag.ParentID(), + Profile: ag.Profile(), + Backend: ag.BackendName(), + Model: ag.ModelName(), + CwdOverride: ag.CwdOverride(), + Peers: ag.Peers(), + Messages: backend.SanitizeMessages(ag.Messages()), } if usage := ag.Usage(); usage != nil { pa.TotalInputTokens = usage.TotalInputTokens @@ -238,6 +240,9 @@ func restoreMultiAgentSession(ps *PersistedSession) (*RestoredSession, error) { Flush: sess.flushSave, }) ag.SetName(pa.Name) + if pa.CwdOverride != "" { + ag.SetCwdOverride(pa.CwdOverride) + } ag.RequireMemoryWake() if err := sess.AddAgent(ag); err != nil { pkgLog.Error("restore agent %s: %v", pa.ID, err) @@ -255,14 +260,15 @@ func restoreMultiAgentSession(ps *PersistedSession) (*RestoredSession, error) { // Active session — use shared buildAgent history := agent.RestoreHistoryFromMessages(pa.Messages) ag, err := buildAgent(sess, AgentParams{ - Profile: pa.Profile, - ID: pa.ID, - Name: pa.Name, - Backend: pa.Backend, - Model: pa.Model, - Remote: ps.Remote, - ParentID: pa.ParentID, - History: history, + Profile: pa.Profile, + ID: pa.ID, + Name: pa.Name, + Backend: pa.Backend, + Model: pa.Model, + Remote: ps.Remote, + ParentID: pa.ParentID, + History: history, + CwdOverride: pa.CwdOverride, }) if err != nil { pkgLog.Error("restore agent %s: %v", pa.ID, err) diff --git a/cmd/olliesrv/internal/session/registry.go b/cmd/olliesrv/internal/session/registry.go index 60233b6..d17b247 100644 --- a/cmd/olliesrv/internal/session/registry.go +++ b/cmd/olliesrv/internal/session/registry.go @@ -384,6 +384,7 @@ type AgentParams struct { Remote string // remote target override ParentID string // immutable ID of the agent that spawned this agent History *agent.History // nil = fresh agent + CwdOverride string // per-agent cwd override; empty = inherit session cwd } // buildAgent constructs a fully-wired agent within a session. @@ -397,6 +398,15 @@ func buildAgent(sess *Session, p AgentParams) (*agent.Agent, error) { return nil, fmt.Errorf("session cwd is required") } + // effectiveCwd is where this agent's tools run and what its runtime sees. + // It is the per-agent override when set, otherwise the session cwd. The + // shared tool server process stays configured with the session cwd; the + // override is applied per-agent on the tool server below. + effectiveCwd := cwd + if p.CwdOverride != "" { + effectiveCwd = util.ExpandHome(p.CwdOverride) + } + cfg, err := agent.LoadConfig(pkgAgentsDir, p.Profile, nil) if err != nil { return nil, fmt.Errorf("load agent config %q: %w", p.Profile, err) @@ -438,7 +448,7 @@ func buildAgent(sess *Session, p AgentParams) (*agent.Agent, error) { } // Build prompt layers - layers := BuildPromptLayers(cfg, cwd, sessID, p.ID, infra.Platform, infra.IsGitRepo, "") + layers := BuildPromptLayers(cfg, effectiveCwd, sessID, p.ID, infra.Platform, infra.IsGitRepo, "") // Load tools LoadTools(cfg, infra.ToolsConn, sessID, p.ID, func(f string, a ...any) { @@ -447,7 +457,7 @@ func buildAgent(sess *Session, p AgentParams) (*agent.Agent, error) { // Build runtime env := []string{"OLLIE_SESSION_ID=" + sessID, "OLLIE_UNAME=" + p.ID} - rt := agent.BuildRuntime(cfg, infra.ToolsConn, cwd, env, layers.SystemPrompt, layers.EnvBlock) + rt := agent.BuildRuntime(cfg, infra.ToolsConn, effectiveCwd, env, layers.SystemPrompt, layers.EnvBlock) // Create backend beName := p.Backend @@ -487,6 +497,9 @@ func buildAgent(sess *Session, p AgentParams) (*agent.Agent, error) { if p.Name != "" { ag.SetName(p.Name) } + if p.CwdOverride != "" { + ag.SetCwdOverride(p.CwdOverride) + } return ag, nil } diff --git a/cmd/olliesrv/internal/session/session.go b/cmd/olliesrv/internal/session/session.go index a34e82c..e89df5d 100644 --- a/cmd/olliesrv/internal/session/session.go +++ b/cmd/olliesrv/internal/session/session.go @@ -583,6 +583,10 @@ func rebuildAgentRuntime(ag *agent.Agent, sessID string) error { LoadTools(cfg, ag.ToolServer(), sessID, ag.ID(), func(f string, a ...any) { pkgLog.Error("session %s agent %s: "+f, append([]any{sessID, ag.ID()}, a...)...) }) + // Re-apply the per-agent cwd override: the fresh toolsrv process starts with + // an empty per-agent map, so without this the agent would silently fall back + // to the session cwd after a resume. + ag.SyncCwdToToolServer() return nil } diff --git a/cmd/olliesrv/internal/toolclient/toolsrv.go b/cmd/olliesrv/internal/toolclient/toolsrv.go index 2bcdc90..812f078 100644 --- a/cmd/olliesrv/internal/toolclient/toolsrv.go +++ b/cmd/olliesrv/internal/toolclient/toolsrv.go @@ -352,6 +352,18 @@ func (c *ToolsrvConn) SetCWD(dir string) error { return err } +// SetAgentCWD sets a per-agent working directory override via ctl command. +// An empty dir clears the override, falling back to the global cwd. +func (c *ToolsrvConn) SetAgentCWD(agentID, dir string) error { + fid, err := c.fsys.Open("ctl", plan9.OWRITE) + if err != nil { + return err + } + defer fid.Close() + _, err = fmt.Fprintf(fid, "agentcwd %s %s\n", agentID, dir) + return err +} + // SetOnToolsChanged stores a callback that is invoked when tools change. // In 9P model, this is handled by polling ToolRegistryRevision(). // The callback is stored but you must call CheckToolsChanged() periodically. diff --git a/cmd/toolsrv/internal/server/proc.go b/cmd/toolsrv/internal/server/proc.go index e9ec9b8..c0fa780 100644 --- a/cmd/toolsrv/internal/server/proc.go +++ b/cmd/toolsrv/internal/server/proc.go @@ -27,6 +27,7 @@ type State struct { mu sync.RWMutex cwd string + agentCWD map[string]string // per-agent cwd override; falls back to cwd env map[string]string registry *registry.Registry yolo bool @@ -90,6 +91,7 @@ func (p *Proc) OutputString() string { func NewState(cwd string) *State { return &State{ cwd: cwd, + agentCWD: make(map[string]string), env: make(map[string]string), procs: make(map[int]*Proc), nextID: 1, @@ -125,6 +127,30 @@ func (st *State) SetCWD(dir string) { st.mu.Unlock() } +// SetAgentCWD sets a per-agent working directory override. An empty dir clears +// the override, so the agent falls back to the global cwd. +func (st *State) SetAgentCWD(agentID, dir string) { + st.mu.Lock() + if st.agentCWD == nil { + st.agentCWD = make(map[string]string) + } + if strings.TrimSpace(dir) == "" { + delete(st.agentCWD, agentID) + } else { + st.agentCWD[agentID] = util.ExpandHome(dir) + } + st.mu.Unlock() +} + +// agentCWDOrDefault returns the per-agent cwd override if set, else the global +// cwd. Caller must hold at least a read lock. +func (st *State) agentCWDOrDefault(agentID string) string { + if dir := st.agentCWD[agentID]; dir != "" { + return dir + } + return st.cwd +} + // SetEnv sets an environment variable. func (st *State) SetEnv(key, value string) { st.mu.Lock() @@ -273,10 +299,15 @@ func (st *State) NewProc(ctx context.Context, payload string, background bool) ( } }() + aid := args["agent"] + if aid == "" { + return "", 0, fmt.Errorf("missing 'agent' in payload") + } + // Look up tool st.mu.RLock() reg := st.registry - cwd := st.cwd + cwd := st.agentCWDOrDefault(aid) yolo := st.yolo envCopy := make(map[string]string) for k, v := range st.env { @@ -284,10 +315,6 @@ func (st *State) NewProc(ctx context.Context, payload string, background bool) ( } st.mu.RUnlock() - aid := args["agent"] - if aid == "" { - return "", 0, fmt.Errorf("missing 'agent' in payload") - } envCopy["OLLIE_UNAME"] = aid // Get session ID from environment @@ -728,6 +755,14 @@ func (st *State) HandleCtl(input string) error { } st.SetCWD(parts[1]) return nil + case "agentcwd": + // agentcwd [dir...]; omitting dir clears the override. + if len(parts) < 2 { + return fmt.Errorf("agentcwd requires agent ID") + } + dir := strings.TrimSpace(strings.Join(parts[2:], " ")) + st.SetAgentCWD(parts[1], dir) + return nil default: return fmt.Errorf("unknown command: %s", parts[0]) } diff --git a/cmd/toolsrv/internal/server/proc_test.go b/cmd/toolsrv/internal/server/proc_test.go index 015e1b5..04318f8 100644 --- a/cmd/toolsrv/internal/server/proc_test.go +++ b/cmd/toolsrv/internal/server/proc_test.go @@ -87,6 +87,55 @@ func TestState_HandleCtl(t *testing.T) { } } +func TestState_AgentCWD(t *testing.T) { + st := NewState("/session/cwd") + + // With no override, an agent resolves to the global cwd. + if got := st.agentCWDOrDefault("a1"); got != "/session/cwd" { + t.Errorf("agentCWDOrDefault(a1) = %q, want /session/cwd", got) + } + + // Setting an override affects only that agent. + st.SetAgentCWD("a1", "/override/a1") + if got := st.agentCWDOrDefault("a1"); got != "/override/a1" { + t.Errorf("agentCWDOrDefault(a1) = %q, want /override/a1", got) + } + if got := st.agentCWDOrDefault("a2"); got != "/session/cwd" { + t.Errorf("agentCWDOrDefault(a2) = %q, want /session/cwd (unaffected)", got) + } + + // Clearing (empty dir) restores the fallback to the global cwd. + st.SetAgentCWD("a1", "") + if got := st.agentCWDOrDefault("a1"); got != "/session/cwd" { + t.Errorf("agentCWDOrDefault(a1) after clear = %q, want /session/cwd", got) + } +} + +func TestState_HandleCtl_AgentCWD(t *testing.T) { + st := NewState("/session/cwd") + + // agentcwd without an agent ID is an error. + if err := st.HandleCtl("agentcwd"); err == nil { + t.Error("HandleCtl(agentcwd) without agent ID should fail") + } + + // Set a per-agent override via ctl. + if err := st.HandleCtl("agentcwd a1 /override/a1"); err != nil { + t.Fatalf("HandleCtl(agentcwd a1 ...) failed: %v", err) + } + if got := st.agentCWDOrDefault("a1"); got != "/override/a1" { + t.Errorf("after ctl set: agentCWDOrDefault(a1) = %q, want /override/a1", got) + } + + // Omitting the directory clears the override. + if err := st.HandleCtl("agentcwd a1"); err != nil { + t.Fatalf("HandleCtl(agentcwd a1) clear failed: %v", err) + } + if got := st.agentCWDOrDefault("a1"); got != "/session/cwd" { + t.Errorf("after ctl clear: agentCWDOrDefault(a1) = %q, want /session/cwd", got) + } +} + func TestState_ProcLifecycle(t *testing.T) { st := NewState("/tmp") diff --git a/data/scripts/acme/acme-ollie-ensure b/data/scripts/acme/acme-ollie-ensure index 1067ac2..4e678b4 100755 --- a/data/scripts/acme/acme-ollie-ensure +++ b/data/scripts/acme/acme-ollie-ensure @@ -2,8 +2,8 @@ # acme-ollie-ensure - Ensure acme session and agent exist # # One session "acme", one agent per project directory. -# Agent name: parent:basename (e.g., src:ollie, ABC-123:repo) -# If basename contains _, use prefix before first _ (e.g., ABC-123_feature -> ABC-123) +# Agent name: prefix:basename (e.g., src:ollie, md-20470:mobydick) +# If parent dir contains _, use prefix before first _ (e.g., MD-20470_feature -> md-20470) # # Usage: eval $(acme-ollie-ensure) # Then use $OLLIE_SESSION and $OLLIE_AGENT @@ -11,20 +11,20 @@ set -euo pipefail # Compute agent name from directory path -# Format: parent:basename (basename split at first _ if present) +# Format: prefix:basename (parent split at first _ if present) agent_name_from_cwd() { local cwd="$1" local parent basename prefix parent=$(basename "$(dirname "$cwd")") basename=$(basename "$cwd") - # Split basename at first underscore if present - if [[ "$basename" == *_* ]]; then - prefix="${basename%%_*}" + # Split parent at first underscore if present + if [[ "$parent" == *_* ]]; then + prefix="${parent%%_*}" else - prefix="$basename" + prefix="$parent" fi # Slugify: lowercase, replace non-alnum with - - echo "${parent}:${prefix}" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9:-]/-/g' + echo "${prefix}:${basename}" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9:-]/-/g' } # Acme sets $PWD to the window's directory for executed commands. diff --git a/kde/dolphin/ollie-session-here b/kde/dolphin/ollie-session-here index a8ac0bf..643fe82 100755 --- a/kde/dolphin/ollie-session-here +++ b/kde/dolphin/ollie-session-here @@ -2,8 +2,8 @@ # ollie-session-here: start a session in the given directory # Works from both Dolphin (passes %f) and terminal (uses $PWD). # -# Session is always "default". Agent name follows convention: parent:prefix -# where prefix is basename split at first _ (e.g., src:ollie, ABC-123:repo) +# Session is always "default". Agent name follows convention: prefix:basename +# where prefix is parent dir split at first _ (e.g., src:ollie, md-20470:mobydick) # Session/agent creation is idempotent. TARGET="$1" @@ -11,15 +11,16 @@ TARGET="$1" [ -z "$TARGET" ] && TARGET="$PWD" TARGET="$(cd "$TARGET" 2>/dev/null && pwd)" || TARGET="$PWD" -# Compute agent name: parent:prefix (prefix = basename up to first _) +# Compute agent name: prefix:basename +# prefix = parent dirname, split at first _ if present PARENT="$(basename "$(dirname "$TARGET")")" BASENAME="$(basename "$TARGET")" -case "$BASENAME" in - *_*) PREFIX="${BASENAME%%_*}" ;; - *) PREFIX="$BASENAME" ;; +case "$PARENT" in + *_*) PREFIX="${PARENT%%_*}" ;; + *) PREFIX="$PARENT" ;; esac # Slugify: lowercase, replace non-alnum with - -AGENT_NAME="$(printf '%s:%s' "$PARENT" "$PREFIX" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9:-]/-/g')" +AGENT_NAME="$(printf '%s:%s' "$PREFIX" "$BASENAME" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9:-]/-/g')" # Create default session (idempotent) printf 'name=default\ncwd=%s\n' "$TARGET" | ollie-9p rdwr session/new >/dev/null 2>&1 diff --git a/kde/gui/NewAgentDialog.qml b/kde/gui/NewAgentDialog.qml index da5b4bb..0794695 100644 --- a/kde/gui/NewAgentDialog.qml +++ b/kde/gui/NewAgentDialog.qml @@ -5,7 +5,7 @@ import QtQuick.Layouts 1.15 Dialog { id: root - title: "New Agent" + title: editMode ? "Agent Settings" : "New Agent" width: 420 modal: false x: (parent ? (parent.width - width) / 2 : 0) @@ -59,14 +59,32 @@ Dialog { // Session ID to create the agent in property string targetSessionId: "" + // Mode: false = create, true = edit existing agent + property bool editMode: false + property string editAgentId: "" + + // Store original config when opened in edit mode for diffing on save. + property var _originalConfig: ({}) + + // Inherited session working directory, shown as the Directory placeholder. + property string _inheritedCwd: "" + signal agentCreated() + // Open in edit mode for an existing agent. + function openForAgent(sessionId, agentId) { + editMode = true + targetSessionId = sessionId + editAgentId = agentId + open() + } + contentItem: ColumnLayout { spacing: 16 Layout.margins: 16 Label { - text: "Agent" + text: root.editMode ? "Agent Settings" : "Agent" font.bold: true font.pointSize: theme.fontSize > 0 ? theme.fontSize + 1 : Qt.application.font.pointSize + 1 } @@ -131,6 +149,38 @@ Dialog { } } + RowLayout { + Layout.fillWidth: true + visible: root.editMode + Label { text: "Directory:"; Layout.preferredWidth: 80 } + TextField { + id: cwdField + Layout.fillWidth: true + // Empty = inherit the session working directory. The placeholder + // shows the inherited path so it is clear what "empty" means. + placeholderText: root._inheritedCwd !== "" ? ("(inherit: " + root._inheritedCwd + ")") : "(inherit session directory)" + } + Button { + text: "Browse..." + onClicked: { + var start = cwdField.text || root._inheritedCwd || "" + var folder = theme.chooseFolder("Select Agent Working Directory", start) + if (folder !== "") + cwdField.text = folder + } + } + } + + RowLayout { + Layout.fillWidth: true + visible: root.editMode && cwdField.text.trim() !== "" + Item { Layout.preferredWidth: 80 } + Button { + text: "Reset to session directory" + onClicked: cwdField.text = "" + } + } + Component.onCompleted: { if (!ollie.rootBackendsLoaded) ollie.loadRootBackends() if (!ollie.rootAgentsLoaded) ollie.loadRootAgents() @@ -159,14 +209,46 @@ Dialog { Item { Layout.fillWidth: true } Button { id: okButton - text: "OK" + text: root.editMode ? "Save" : "OK" onClicked: { errorLabel.text = "" var backend = backendCombo.currentText.trim() - var model = modelCombo.currentIndex >= 0 ? modelCombo.currentText.trim() : "" + var model = modelCombo.currentIndex >= 0 ? modelCombo.currentText.trim() : modelCombo.editText.trim() var agent = agentCombo.currentText var agentAlias = aliasField.text.trim() + if (root.editMode) { + // Edit mode: write only the changed values back via ctl. + // Profile is applied first because switching it can reset + // backend/model; backend before model for the same reason. + var cfg = root._originalConfig + if (agent !== "" && agent !== cfg.profile) { + var ep = ollie.updateAgentConfig(root.targetSessionId, root.editAgentId, "profile", agent) + if (ep) { errorLabel.text = ep; return } + } + if (backend !== "" && backend !== cfg.backend) { + var eb = ollie.updateAgentConfig(root.targetSessionId, root.editAgentId, "backend", backend) + if (eb) { errorLabel.text = eb; return } + } + if (model !== "" && model !== cfg.model) { + var em = ollie.updateAgentConfig(root.targetSessionId, root.editAgentId, "model", model) + if (em) { errorLabel.text = em; return } + } + if (agentAlias !== "" && agentAlias !== cfg.displayName) { + var en = ollie.updateAgentConfig(root.targetSessionId, root.editAgentId, "displayName", agentAlias) + if (en) { errorLabel.text = en; return } + } + // Per-agent cwd override: empty value clears it (inherit). + var cwd = cwdField.text.trim() + if (cwd !== (cfg.cwdOverride || "")) { + var ec = ollie.updateAgentConfig(root.targetSessionId, root.editAgentId, "cwd", cwd) + if (ec) { errorLabel.text = ec; return } + } + ollie.refreshSessions() + root.close() + return + } + var newAgentId = ollie.createAgent(root.targetSessionId, backend, model, agent, "", agentAlias) var err = ollie.lastError() if (err === "") { @@ -191,5 +273,50 @@ Dialog { onOpened: { errorLabel.text = "" + if (!ollie.rootBackendsLoaded) ollie.loadRootBackends() + if (!ollie.rootAgentsLoaded) ollie.loadRootAgents() + + if (editMode && targetSessionId !== "" && editAgentId !== "") { + // Read current agent config and populate fields. + var cfg = ollie.readAgentConfig(targetSessionId, editAgentId) + _originalConfig = cfg + + // Backend: select or set as editable text. + var beIdx = ollie.availableBackends.indexOf(cfg.backend || "") + if (beIdx >= 0) backendCombo.currentIndex = beIdx + backendCombo.editText = cfg.backend || "" + + // Rebuild the model list for this backend, then select the current model. + modelList.clear() + if (cfg.backend && ollie.rootModelsLoaded) { + var models = ollie.getAvailableModels(cfg.backend) + for (var i = 0; i < models.length; ++i) + modelList.append({ modelData: models[i] }) + var mIdx = models.indexOf(cfg.model || "") + modelCombo.currentIndex = mIdx + } else { + modelCombo.currentIndex = -1 + } + modelCombo.editText = cfg.model || "" + + var prIdx = ollie.availableAgents.indexOf(cfg.profile || "") + agentCombo.currentIndex = prIdx >= 0 ? prIdx : 0 + + aliasField.text = cfg.displayName || "" + + // cwd: show the override (if any) in the field; the inherited + // session cwd is shown as the placeholder. + _inheritedCwd = cfg.sessionCwd || "" + cwdField.text = cfg.cwdOverride || "" + } else { + // Create mode: reset fields. + aliasField.text = "" + agentCombo.currentIndex = 0 + modelCombo.currentIndex = -1 + modelCombo.editText = "" + cwdField.text = "" + _inheritedCwd = "" + _originalConfig = ({}) + } } } diff --git a/kde/gui/SessionTree.qml b/kde/gui/SessionTree.qml index bac1ce2..a4021e5 100644 --- a/kde/gui/SessionTree.qml +++ b/kde/gui/SessionTree.qml @@ -15,6 +15,7 @@ Item { signal settingsRequested() signal sessionSettingsRequested(string sessionName) signal newAgentRequested(string sessionId) + signal agentSettingsRequested(string sessionId, string agentId) signal goalRequested(string sessionId, string sessionName) signal renameFailed(string itemType, string attemptedName) @@ -188,6 +189,13 @@ Item { Menu { id: agentCtxMenu + MenuItem { + text: "Settings..." + onClicked: { + if (tree.killTargetAgentSessionId && tree.killTargetAgentId) + tree.agentSettingsRequested(tree.killTargetAgentSessionId, tree.killTargetAgentId) + } + } MenuItem { text: "Kill" onClicked: { diff --git a/kde/gui/main.qml b/kde/gui/main.qml index c84c254..2673450 100644 --- a/kde/gui/main.qml +++ b/kde/gui/main.qml @@ -207,9 +207,14 @@ ApplicationWindow { newSessionDialog.openForSession(sessionName) } onNewAgentRequested: function(sessionId) { + newAgentDialog.editMode = false + newAgentDialog.editAgentId = "" newAgentDialog.targetSessionId = sessionId newAgentDialog.open() } + onAgentSettingsRequested: function(sessionId, agentId) { + newAgentDialog.openForAgent(sessionId, agentId) + } onGoalRequested: function(sessionId, sessionName) { goalDialog.sessionId = sessionId goalDialog.sessionName = sessionName diff --git a/kde/gui/ollie9pclient.cpp b/kde/gui/ollie9pclient.cpp index a65df81..710a904 100644 --- a/kde/gui/ollie9pclient.cpp +++ b/kde/gui/ollie9pclient.cpp @@ -738,6 +738,59 @@ QString Ollie9pClient::updateSessionConfig(const QString &sessionName, const QSt return m_9p->lastError(); } +QVariantMap Ollie9pClient::readAgentConfig(const QString &sessionId, const QString &agentId) const +{ + QVariantMap result; + if (!m_9p || !m_9p->isConnected() || sessionId.isEmpty() || agentId.isEmpty()) return result; + + QString path = "session/" + sessionId + "/agent/" + agentId + "/cfg"; + QByteArray out = m_9p->read(path); + QString raw = QString::fromUtf8(out).trimmed(); + if (raw.isEmpty()) return result; + + const auto lines = raw.split('\n', Qt::SkipEmptyParts); + for (const auto &line : lines) { + int eq = line.indexOf('='); + if (eq > 0) { + result[line.left(eq)] = line.mid(eq + 1); + } + } + return result; +} + +QString Ollie9pClient::updateAgentConfig(const QString &sessionId, const QString &agentId, const QString &key, const QString &value) +{ + if (!m_9p || !m_9p->isConnected() || sessionId.isEmpty() || agentId.isEmpty()) return QStringLiteral("not connected"); + + // The agent cfg file only accepts a "name" or "cwd"/"cwdOverride" write; + // backend/model/profile are changed through the agent ctl verbs. Route each + // key to the right place. + if (key == "cwd" || key == "cwdOverride" || key == "displayName" || key == "name") { + // cfg-file writes: name sets display name; cwd sets the per-agent + // working-directory override (empty value clears it). + QString cfgKey = (key == "displayName" || key == "name") ? "name" : key; + QString cfgPath = "session/" + sessionId + "/agent/" + agentId + "/cfg"; + m_9p->write(cfgPath, (cfgKey + "=" + value + "\n").toUtf8()); + QString cfgErr = m_9p->lastError(); + if (!cfgErr.isEmpty()) return cfgErr; + refreshSessions(); + return QString(); + } + + QString ctlPath = "session/" + sessionId + "/agent/" + agentId + "/ctl"; + QString verb; + if (key == "backend") verb = "backend"; + else if (key == "model") verb = "model"; + else if (key == "profile") verb = "agent"; + else return QStringLiteral("unknown agent cfg key: ") + key; + + m_9p->rdwr(ctlPath, (verb + " " + value + "\n").toUtf8()); + QString err = m_9p->lastError(); + if (!err.isEmpty()) return err; + refreshSessions(); + return QString(); +} + void Ollie9pClient::setGoal(const QString &sessionName, const QString &goalText) { if (!m_9p || !m_9p->isConnected()) return; diff --git a/kde/gui/ollie9pclient.h b/kde/gui/ollie9pclient.h index 7962328..144f59e 100644 --- a/kde/gui/ollie9pclient.h +++ b/kde/gui/ollie9pclient.h @@ -88,6 +88,8 @@ public: Q_INVOKABLE QString createSession(const QString &name, const QString &remote, const QString &cwd = QString(), const QString &workflow = QString(), const QString &variant = QString(), bool yolo = false); Q_INVOKABLE QVariantMap readSessionConfig(const QString &sessionName) const; Q_INVOKABLE QString updateSessionConfig(const QString &sessionName, const QString &key, const QString &value); + Q_INVOKABLE QVariantMap readAgentConfig(const QString &sessionId, const QString &agentId) const; + Q_INVOKABLE QString updateAgentConfig(const QString &sessionId, const QString &agentId, const QString &key, const QString &value); Q_INVOKABLE void setGoal(const QString &sessionName, const QString &goalText); Q_INVOKABLE QString createAgent(const QString &sessionId, const QString &backend, const QString &model, const QString &agent, const QString &remote, const QString &agentAlias); Q_INVOKABLE bool killAgent(const QString &sessionId, const QString &agentId); diff --git a/kde/kate/ollie_kate.cpp b/kde/kate/ollie_kate.cpp index f232abb..5f4846b 100644 --- a/kde/kate/ollie_kate.cpp +++ b/kde/kate/ollie_kate.cpp @@ -183,21 +183,21 @@ OllieKateView::~OllieKateView() // --- Agent Naming --- // Compute agent name from directory path -// Format: parent:basename (basename split at first _ if present) +// Format: prefix:basename (parent split at first _ if present) // Examples: /home/user/src/ollie -> src:ollie -// /home/user/proj/ABC-123_feature/repo -> ABC-123:repo +// /home/user/proj/MD-20470_feature/mobydick -> md-20470:mobydick QString OllieKateView::agentNameFromCwd(const QString &cwd) { QFileInfo fi(cwd); QString parent = QFileInfo(fi.path()).fileName(); // parent directory name QString basename = fi.fileName(); - // Split basename at first underscore if present - int underscoreIdx = basename.indexOf('_'); - QString prefix = (underscoreIdx > 0) ? basename.left(underscoreIdx) : basename; + // Split parent at first underscore if present + int underscoreIdx = parent.indexOf('_'); + QString prefix = (underscoreIdx > 0) ? parent.left(underscoreIdx) : parent; // Slugify: lowercase, replace non-alnum with - - QString slug = (parent + ":" + prefix).toLower(); + QString slug = (prefix + ":" + basename).toLower(); QString result; for (QChar c : slug) { if (c.isLetterOrNumber() || c == ':' || c == '-') {