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 [<dir>|-])
- Agent.SyncCwdToToolServer re-pushes on every (re)connect
- GUI NewAgentDialog shows '(inherit: <sessionCwd>)' 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
This commit is contained in:
Levi Neely 2026-10-07 15:14:04 +02:00
parent d5efe575ce
commit f7a52088db
17 changed files with 447 additions and 50 deletions

View File

@ -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. 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. 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()`. 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 <id> <dir>`), 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 [<dir>|-]`); the GUI Agent Settings dialog shows `(inherit: <sessionCwd>)` as the placeholder.
## Where to Start ## Where to Start

View File

@ -42,6 +42,7 @@ type Agent struct {
stateSince time.Time // when the current state began stateSince time.Time // when the current state began
reply string // last assistant response reply string // last assistant response
getCwd func() string // returns session working directory getCwd func() string // returns session working directory
cwdOverride string // per-agent cwd override (empty = inherit session cwd)
id string // agent identity (unique principal) id string // agent identity (unique principal)
parentID string // immutable ID of the agent that spawned this agent 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) 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 { func (ag *Agent) Cwd() string {
ag.stateMu.RLock()
override := ag.cwdOverride
ag.stateMu.RUnlock()
if override != "" {
return override
}
if ag.getCwd != nil { if ag.getCwd != nil {
return ag.getCwd() return ag.getCwd()
} }
@ -199,6 +207,62 @@ func (ag *Agent) Cwd() string {
return wd 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. // SetGetCwd sets the CWD getter callback.
func (ag *Agent) SetGetCwd(fn func() string) { func (ag *Agent) SetGetCwd(fn func() string) {
ag.getCwd = fn ag.getCwd = fn
@ -291,6 +355,9 @@ func (ag *Agent) SwitchProfile(name string) (*AgentConfig, error) {
ag.runtime = rt ag.runtime = rt
ag.profile = name ag.profile = name
ag.history = nil 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.save()
ag.flush() ag.flush()
ag.notifyChange() ag.notifyChange()

View File

@ -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, "profile=%s\n", a.Profile())
fmt.Fprintf(&sb, "displayName=%s\n", a.Name()) fmt.Fprintf(&sb, "displayName=%s\n", a.Name())
fmt.Fprintf(&sb, "cwd=%s\n", a.Cwd()) 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, "remote=%s\n", s.Remote)
fmt.Fprintf(&sb, "maxTokens=%d\n", p.MaxTokens) fmt.Fprintf(&sb, "maxTokens=%d\n", p.MaxTokens)
if p.Temperature != nil { if p.Temperature != nil {
@ -929,6 +931,10 @@ func buildAgentChildren(a *agent.Agent, s *session.Session) []virtfs.FsNodeDecl
if strings.TrimSpace(parts[1]) != "" { if strings.TrimSpace(parts[1]) != "" {
a.SetName(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: default:
return fmt.Errorf("unknown cfg key: %s", parts[0]) 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, " ")) a.SetName(strings.Join(args, " "))
return []byte(strings.Join(args, " ") + "\n"), nil 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 [<dir>|-]", 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 return []byte(a.Cwd() + "\n"), nil
}}, }},
{"proc", "manage background procs: proc [top|idx|term|kill|out|dismiss <pid>]", func(args []string) ([]byte, error) { {"proc", "manage background procs: proc [top|idx|term|kill|out|dismiss <pid>]", func(args []string) ([]byte, error) {

View File

@ -38,6 +38,7 @@ type PersistedAgent struct {
Profile string `json:"profile"` Profile string `json:"profile"`
Backend string `json:"backend"` Backend string `json:"backend"`
Model string `json:"model"` Model string `json:"model"`
CwdOverride string `json:"cwdOverride,omitempty"`
Peers []string `json:"peers,omitempty"` Peers []string `json:"peers,omitempty"`
Messages []backend.Message `json:"messages"` Messages []backend.Message `json:"messages"`
// Usage tracking // Usage tracking
@ -83,14 +84,15 @@ func PersistSession(name string) error {
for _, ag := range agents { for _, ag := range agents {
pa := PersistedAgent{ pa := PersistedAgent{
ID: ag.ID(), ID: ag.ID(),
Name: ag.Name(), Name: ag.Name(),
ParentID: ag.ParentID(), ParentID: ag.ParentID(),
Profile: ag.Profile(), Profile: ag.Profile(),
Backend: ag.BackendName(), Backend: ag.BackendName(),
Model: ag.ModelName(), Model: ag.ModelName(),
Peers: ag.Peers(), CwdOverride: ag.CwdOverride(),
Messages: backend.SanitizeMessages(ag.Messages()), Peers: ag.Peers(),
Messages: backend.SanitizeMessages(ag.Messages()),
} }
if usage := ag.Usage(); usage != nil { if usage := ag.Usage(); usage != nil {
pa.TotalInputTokens = usage.TotalInputTokens pa.TotalInputTokens = usage.TotalInputTokens
@ -238,6 +240,9 @@ func restoreMultiAgentSession(ps *PersistedSession) (*RestoredSession, error) {
Flush: sess.flushSave, Flush: sess.flushSave,
}) })
ag.SetName(pa.Name) ag.SetName(pa.Name)
if pa.CwdOverride != "" {
ag.SetCwdOverride(pa.CwdOverride)
}
ag.RequireMemoryWake() ag.RequireMemoryWake()
if err := sess.AddAgent(ag); err != nil { if err := sess.AddAgent(ag); err != nil {
pkgLog.Error("restore agent %s: %v", pa.ID, err) pkgLog.Error("restore agent %s: %v", pa.ID, err)
@ -255,14 +260,15 @@ func restoreMultiAgentSession(ps *PersistedSession) (*RestoredSession, error) {
// Active session — use shared buildAgent // Active session — use shared buildAgent
history := agent.RestoreHistoryFromMessages(pa.Messages) history := agent.RestoreHistoryFromMessages(pa.Messages)
ag, err := buildAgent(sess, AgentParams{ ag, err := buildAgent(sess, AgentParams{
Profile: pa.Profile, Profile: pa.Profile,
ID: pa.ID, ID: pa.ID,
Name: pa.Name, Name: pa.Name,
Backend: pa.Backend, Backend: pa.Backend,
Model: pa.Model, Model: pa.Model,
Remote: ps.Remote, Remote: ps.Remote,
ParentID: pa.ParentID, ParentID: pa.ParentID,
History: history, History: history,
CwdOverride: pa.CwdOverride,
}) })
if err != nil { if err != nil {
pkgLog.Error("restore agent %s: %v", pa.ID, err) pkgLog.Error("restore agent %s: %v", pa.ID, err)

View File

@ -384,6 +384,7 @@ type AgentParams struct {
Remote string // remote target override Remote string // remote target override
ParentID string // immutable ID of the agent that spawned this agent ParentID string // immutable ID of the agent that spawned this agent
History *agent.History // nil = fresh 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. // 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") 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) cfg, err := agent.LoadConfig(pkgAgentsDir, p.Profile, nil)
if err != nil { if err != nil {
return nil, fmt.Errorf("load agent config %q: %w", p.Profile, err) 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 // 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 // Load tools
LoadTools(cfg, infra.ToolsConn, sessID, p.ID, func(f string, a ...any) { 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 // Build runtime
env := []string{"OLLIE_SESSION_ID=" + sessID, "OLLIE_UNAME=" + p.ID} 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 // Create backend
beName := p.Backend beName := p.Backend
@ -487,6 +497,9 @@ func buildAgent(sess *Session, p AgentParams) (*agent.Agent, error) {
if p.Name != "" { if p.Name != "" {
ag.SetName(p.Name) ag.SetName(p.Name)
} }
if p.CwdOverride != "" {
ag.SetCwdOverride(p.CwdOverride)
}
return ag, nil return ag, nil
} }

View File

@ -583,6 +583,10 @@ func rebuildAgentRuntime(ag *agent.Agent, sessID string) error {
LoadTools(cfg, ag.ToolServer(), sessID, ag.ID(), func(f string, a ...any) { 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...)...) 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 return nil
} }

View File

@ -352,6 +352,18 @@ func (c *ToolsrvConn) SetCWD(dir string) error {
return err 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. // SetOnToolsChanged stores a callback that is invoked when tools change.
// In 9P model, this is handled by polling ToolRegistryRevision(). // In 9P model, this is handled by polling ToolRegistryRevision().
// The callback is stored but you must call CheckToolsChanged() periodically. // The callback is stored but you must call CheckToolsChanged() periodically.

View File

@ -27,6 +27,7 @@ type State struct {
mu sync.RWMutex mu sync.RWMutex
cwd string cwd string
agentCWD map[string]string // per-agent cwd override; falls back to cwd
env map[string]string env map[string]string
registry *registry.Registry registry *registry.Registry
yolo bool yolo bool
@ -90,6 +91,7 @@ func (p *Proc) OutputString() string {
func NewState(cwd string) *State { func NewState(cwd string) *State {
return &State{ return &State{
cwd: cwd, cwd: cwd,
agentCWD: make(map[string]string),
env: make(map[string]string), env: make(map[string]string),
procs: make(map[int]*Proc), procs: make(map[int]*Proc),
nextID: 1, nextID: 1,
@ -125,6 +127,30 @@ func (st *State) SetCWD(dir string) {
st.mu.Unlock() 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. // SetEnv sets an environment variable.
func (st *State) SetEnv(key, value string) { func (st *State) SetEnv(key, value string) {
st.mu.Lock() 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 // Look up tool
st.mu.RLock() st.mu.RLock()
reg := st.registry reg := st.registry
cwd := st.cwd cwd := st.agentCWDOrDefault(aid)
yolo := st.yolo yolo := st.yolo
envCopy := make(map[string]string) envCopy := make(map[string]string)
for k, v := range st.env { for k, v := range st.env {
@ -284,10 +315,6 @@ func (st *State) NewProc(ctx context.Context, payload string, background bool) (
} }
st.mu.RUnlock() st.mu.RUnlock()
aid := args["agent"]
if aid == "" {
return "", 0, fmt.Errorf("missing 'agent' in payload")
}
envCopy["OLLIE_UNAME"] = aid envCopy["OLLIE_UNAME"] = aid
// Get session ID from environment // Get session ID from environment
@ -728,6 +755,14 @@ func (st *State) HandleCtl(input string) error {
} }
st.SetCWD(parts[1]) st.SetCWD(parts[1])
return nil return nil
case "agentcwd":
// agentcwd <agentID> [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: default:
return fmt.Errorf("unknown command: %s", parts[0]) return fmt.Errorf("unknown command: %s", parts[0])
} }

View File

@ -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) { func TestState_ProcLifecycle(t *testing.T) {
st := NewState("/tmp") st := NewState("/tmp")

View File

@ -2,8 +2,8 @@
# acme-ollie-ensure - Ensure acme session and agent exist # acme-ollie-ensure - Ensure acme session and agent exist
# #
# One session "acme", one agent per project directory. # One session "acme", one agent per project directory.
# Agent name: parent:basename (e.g., src:ollie, ABC-123:repo) # Agent name: prefix:basename (e.g., src:ollie, md-20470:mobydick)
# If basename contains _, use prefix before first _ (e.g., ABC-123_feature -> ABC-123) # If parent dir contains _, use prefix before first _ (e.g., MD-20470_feature -> md-20470)
# #
# Usage: eval $(acme-ollie-ensure) # Usage: eval $(acme-ollie-ensure)
# Then use $OLLIE_SESSION and $OLLIE_AGENT # Then use $OLLIE_SESSION and $OLLIE_AGENT
@ -11,20 +11,20 @@
set -euo pipefail set -euo pipefail
# Compute agent name from directory path # 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() { agent_name_from_cwd() {
local cwd="$1" local cwd="$1"
local parent basename prefix local parent basename prefix
parent=$(basename "$(dirname "$cwd")") parent=$(basename "$(dirname "$cwd")")
basename=$(basename "$cwd") basename=$(basename "$cwd")
# Split basename at first underscore if present # Split parent at first underscore if present
if [[ "$basename" == *_* ]]; then if [[ "$parent" == *_* ]]; then
prefix="${basename%%_*}" prefix="${parent%%_*}"
else else
prefix="$basename" prefix="$parent"
fi fi
# Slugify: lowercase, replace non-alnum with - # 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. # Acme sets $PWD to the window's directory for executed commands.

View File

@ -2,8 +2,8 @@
# ollie-session-here: start a session in the given directory # ollie-session-here: start a session in the given directory
# Works from both Dolphin (passes %f) and terminal (uses $PWD). # Works from both Dolphin (passes %f) and terminal (uses $PWD).
# #
# Session is always "default". Agent name follows convention: parent:prefix # Session is always "default". Agent name follows convention: prefix:basename
# where prefix is basename split at first _ (e.g., src:ollie, ABC-123:repo) # where prefix is parent dir split at first _ (e.g., src:ollie, md-20470:mobydick)
# Session/agent creation is idempotent. # Session/agent creation is idempotent.
TARGET="$1" TARGET="$1"
@ -11,15 +11,16 @@ TARGET="$1"
[ -z "$TARGET" ] && TARGET="$PWD" [ -z "$TARGET" ] && TARGET="$PWD"
TARGET="$(cd "$TARGET" 2>/dev/null && pwd)" || 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")")" PARENT="$(basename "$(dirname "$TARGET")")"
BASENAME="$(basename "$TARGET")" BASENAME="$(basename "$TARGET")"
case "$BASENAME" in case "$PARENT" in
*_*) PREFIX="${BASENAME%%_*}" ;; *_*) PREFIX="${PARENT%%_*}" ;;
*) PREFIX="$BASENAME" ;; *) PREFIX="$PARENT" ;;
esac esac
# Slugify: lowercase, replace non-alnum with - # 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) # Create default session (idempotent)
printf 'name=default\ncwd=%s\n' "$TARGET" | ollie-9p rdwr session/new >/dev/null 2>&1 printf 'name=default\ncwd=%s\n' "$TARGET" | ollie-9p rdwr session/new >/dev/null 2>&1

View File

@ -5,7 +5,7 @@ import QtQuick.Layouts 1.15
Dialog { Dialog {
id: root id: root
title: "New Agent" title: editMode ? "Agent Settings" : "New Agent"
width: 420 width: 420
modal: false modal: false
x: (parent ? (parent.width - width) / 2 : 0) x: (parent ? (parent.width - width) / 2 : 0)
@ -59,14 +59,32 @@ Dialog {
// Session ID to create the agent in // Session ID to create the agent in
property string targetSessionId: "" 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() signal agentCreated()
// Open in edit mode for an existing agent.
function openForAgent(sessionId, agentId) {
editMode = true
targetSessionId = sessionId
editAgentId = agentId
open()
}
contentItem: ColumnLayout { contentItem: ColumnLayout {
spacing: 16 spacing: 16
Layout.margins: 16 Layout.margins: 16
Label { Label {
text: "Agent" text: root.editMode ? "Agent Settings" : "Agent"
font.bold: true font.bold: true
font.pointSize: theme.fontSize > 0 ? theme.fontSize + 1 : Qt.application.font.pointSize + 1 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: { Component.onCompleted: {
if (!ollie.rootBackendsLoaded) ollie.loadRootBackends() if (!ollie.rootBackendsLoaded) ollie.loadRootBackends()
if (!ollie.rootAgentsLoaded) ollie.loadRootAgents() if (!ollie.rootAgentsLoaded) ollie.loadRootAgents()
@ -159,14 +209,46 @@ Dialog {
Item { Layout.fillWidth: true } Item { Layout.fillWidth: true }
Button { Button {
id: okButton id: okButton
text: "OK" text: root.editMode ? "Save" : "OK"
onClicked: { onClicked: {
errorLabel.text = "" errorLabel.text = ""
var backend = backendCombo.currentText.trim() 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 agent = agentCombo.currentText
var agentAlias = aliasField.text.trim() 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 newAgentId = ollie.createAgent(root.targetSessionId, backend, model, agent, "", agentAlias)
var err = ollie.lastError() var err = ollie.lastError()
if (err === "") { if (err === "") {
@ -191,5 +273,50 @@ Dialog {
onOpened: { onOpened: {
errorLabel.text = "" 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 = ({})
}
} }
} }

View File

@ -15,6 +15,7 @@ Item {
signal settingsRequested() signal settingsRequested()
signal sessionSettingsRequested(string sessionName) signal sessionSettingsRequested(string sessionName)
signal newAgentRequested(string sessionId) signal newAgentRequested(string sessionId)
signal agentSettingsRequested(string sessionId, string agentId)
signal goalRequested(string sessionId, string sessionName) signal goalRequested(string sessionId, string sessionName)
signal renameFailed(string itemType, string attemptedName) signal renameFailed(string itemType, string attemptedName)
@ -188,6 +189,13 @@ Item {
Menu { Menu {
id: agentCtxMenu id: agentCtxMenu
MenuItem {
text: "Settings..."
onClicked: {
if (tree.killTargetAgentSessionId && tree.killTargetAgentId)
tree.agentSettingsRequested(tree.killTargetAgentSessionId, tree.killTargetAgentId)
}
}
MenuItem { MenuItem {
text: "Kill" text: "Kill"
onClicked: { onClicked: {

View File

@ -207,9 +207,14 @@ ApplicationWindow {
newSessionDialog.openForSession(sessionName) newSessionDialog.openForSession(sessionName)
} }
onNewAgentRequested: function(sessionId) { onNewAgentRequested: function(sessionId) {
newAgentDialog.editMode = false
newAgentDialog.editAgentId = ""
newAgentDialog.targetSessionId = sessionId newAgentDialog.targetSessionId = sessionId
newAgentDialog.open() newAgentDialog.open()
} }
onAgentSettingsRequested: function(sessionId, agentId) {
newAgentDialog.openForAgent(sessionId, agentId)
}
onGoalRequested: function(sessionId, sessionName) { onGoalRequested: function(sessionId, sessionName) {
goalDialog.sessionId = sessionId goalDialog.sessionId = sessionId
goalDialog.sessionName = sessionName goalDialog.sessionName = sessionName

View File

@ -738,6 +738,59 @@ QString Ollie9pClient::updateSessionConfig(const QString &sessionName, const QSt
return m_9p->lastError(); 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) void Ollie9pClient::setGoal(const QString &sessionName, const QString &goalText)
{ {
if (!m_9p || !m_9p->isConnected()) return; if (!m_9p || !m_9p->isConnected()) return;

View File

@ -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 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 QVariantMap readSessionConfig(const QString &sessionName) const;
Q_INVOKABLE QString updateSessionConfig(const QString &sessionName, const QString &key, const QString &value); 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 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 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); Q_INVOKABLE bool killAgent(const QString &sessionId, const QString &agentId);

View File

@ -183,21 +183,21 @@ OllieKateView::~OllieKateView()
// --- Agent Naming --- // --- Agent Naming ---
// Compute agent name from directory path // 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 // 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) QString OllieKateView::agentNameFromCwd(const QString &cwd)
{ {
QFileInfo fi(cwd); QFileInfo fi(cwd);
QString parent = QFileInfo(fi.path()).fileName(); // parent directory name QString parent = QFileInfo(fi.path()).fileName(); // parent directory name
QString basename = fi.fileName(); QString basename = fi.fileName();
// Split basename at first underscore if present // Split parent at first underscore if present
int underscoreIdx = basename.indexOf('_'); int underscoreIdx = parent.indexOf('_');
QString prefix = (underscoreIdx > 0) ? basename.left(underscoreIdx) : basename; QString prefix = (underscoreIdx > 0) ? parent.left(underscoreIdx) : parent;
// Slugify: lowercase, replace non-alnum with - // Slugify: lowercase, replace non-alnum with -
QString slug = (parent + ":" + prefix).toLower(); QString slug = (prefix + ":" + basename).toLower();
QString result; QString result;
for (QChar c : slug) { for (QChar c : slug) {
if (c.isLetterOrNumber() || c == ':' || c == '-') { if (c.isLetterOrNumber() || c == ':' || c == '-') {