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:
parent
d5efe575ce
commit
f7a52088db
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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])
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 = ({})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 == '-') {
|
||||
|
|
|
|||
Loading…
Reference in New Issue