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

View File

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

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, "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 [<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
}},
{"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"`
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
@ -89,6 +90,7 @@ func PersistSession(name string) error {
Profile: ag.Profile(),
Backend: ag.BackendName(),
Model: ag.ModelName(),
CwdOverride: ag.CwdOverride(),
Peers: ag.Peers(),
Messages: backend.SanitizeMessages(ag.Messages()),
}
@ -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)
@ -263,6 +268,7 @@ func restoreMultiAgentSession(ps *PersistedSession) (*RestoredSession, error) {
Remote: ps.Remote,
ParentID: pa.ParentID,
History: history,
CwdOverride: pa.CwdOverride,
})
if err != nil {
pkgLog.Error("restore agent %s: %v", pa.ID, err)

View File

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

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

View File

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

View File

@ -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 <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:
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) {
st := NewState("/tmp")

View File

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

View File

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

View File

@ -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 = ({})
}
}
}

View File

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

View File

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

View File

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

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

View File

@ -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 == '-') {