From adf9f65e74883eaa122614b935b456992032ad65 Mon Sep 17 00:00:00 2001
From: jpia <6011922+jpia@users.noreply.github.com>
Date: Sat, 15 Aug 2026 18:33:39 -0500
Subject: [PATCH 1/5] feat: add support for persisting subagent conversation
histories
---
cmd/late/main.go | 25 ++-
cmd/late/main_test.go | 97 ++++++++
docs/quickstart.md | 3 +
docs/quickstart.zh-CN.md | 3 +
internal/agent/agent.go | 26 ++-
internal/agent/agent_test.go | 261 +++++++++++++++++++++-
internal/config/config.go | 18 ++
internal/config/config_test.go | 54 +++++
internal/orchestrator/base.go | 18 +-
internal/orchestrator/base_test.go | 73 ++++++
internal/session/models_test.go | 105 +++++++++
internal/session/paths.go | 44 ++++
internal/session/paths_test.go | 138 ++++++++++++
internal/session/session.go | 16 +-
internal/session/subagent_session_test.go | 69 ++++++
15 files changed, 941 insertions(+), 9 deletions(-)
create mode 100644 internal/session/paths.go
create mode 100644 internal/session/paths_test.go
create mode 100644 internal/session/subagent_session_test.go
diff --git a/cmd/late/main.go b/cmd/late/main.go
index a7fc28c..11a37bc 100644
--- a/cmd/late/main.go
+++ b/cmd/late/main.go
@@ -39,6 +39,7 @@ func main() {
enableSubagentsReq := flag.Bool("enable-subagents", true, "Enable subagent usage")
gemmaThinkingReq := flag.Bool("gemma-thinking", false, "Prepend <|think|> token to system prompt for Gemma 4 models")
subagentMaxTurns := flag.Int("subagent-max-turns", 500, "Maximum number of turns for subagents (default: 500)")
+ saveSubagentHistoriesReq := flag.Bool("save-subagent-histories", false, "Persist subagent conversation histories to disk (default: off)")
enableSqzReq := flag.Bool("enable-sqz", false, "Enable sqz context compression (if available)")
appendSystemPromptReq := flag.String("append-system-prompt", "", "Append text to the system prompt after processing")
versionReq := flag.Bool("version", false, "Show version")
@@ -174,6 +175,12 @@ func main() {
historyPath = loadedHistoryPath
}
+ // Effective session ID for this run — derived from the FINAL history path so
+ // resumed sessions keep their original ID (the sessionID var above is a fresh
+ // timestamp even on resume). Used to place subagent histories under the right
+ // per-session folder.
+ effectiveSessionID := strings.TrimSuffix(filepath.Base(historyPath), ".json")
+
// Load existing history
history, err := session.LoadHistory(historyPath)
if err != nil {
@@ -209,6 +216,15 @@ func main() {
}
}
+ // Resolve subagent history persistence opt-in (explicit CLI flag > config file).
+ saveSubagentHistoriesCLI := false
+ flag.Visit(func(f *flag.Flag) {
+ if f.Name == "save-subagent-histories" {
+ saveSubagentHistoriesCLI = true
+ }
+ })
+ saveSubagentHistories := appconfig.ResolveSaveSubagentHistories(appConfig, saveSubagentHistoriesCLI, *saveSubagentHistoriesReq)
+
// Initialize Core Components
resolvedOpenAIConfig := appconfig.ResolveOpenAISettings(appConfig)
resolvedClientConfig := client.Config{
@@ -361,7 +377,7 @@ func main() {
currentSubagentClient = subagentClient
}
- child, err := agent.NewSubagentOrchestrator(currentSubagentClient, goal, ctxFiles, agentType, enabledTools, *injectCWDReq, *gemmaThinkingReq, *subagentMaxTurns, rootAgent, p)
+ child, err := agent.NewSubagentOrchestrator(currentSubagentClient, goal, ctxFiles, agentType, enabledTools, *injectCWDReq, *gemmaThinkingReq, *subagentMaxTurns, effectiveSessionID, saveSubagentHistories, rootAgent, p)
if err != nil {
return "", err
}
@@ -547,6 +563,13 @@ func handleSessionDelete(id string) {
os.Exit(1)
}
+ // Delete the session's subagent history folder (hierarchical layout). No-op for
+ // legacy flat sessions without a folder. Non-fatal: the session itself is already
+ // gone, so don't block the success message on leftover artifacts.
+ if err := session.RemoveSessionFolder(meta.ID); err != nil {
+ fmt.Fprintf(os.Stderr, "Warning: failed to delete subagent history folder: %v\n", err)
+ }
+
fmt.Printf("Deleted session: %s\n", meta.Title)
}
diff --git a/cmd/late/main_test.go b/cmd/late/main_test.go
index 8f82cf7..41d5512 100644
--- a/cmd/late/main_test.go
+++ b/cmd/late/main_test.go
@@ -3,7 +3,13 @@ package main
import (
"context"
"encoding/json"
+ "os"
+ "path/filepath"
"testing"
+ "time"
+
+ "late/internal/client"
+ "late/internal/session"
)
type mcpToolStub struct {
@@ -29,3 +35,94 @@ func TestMCPToolEnabledSupportsBareNames(t *testing.T) {
t.Fatal("namespaced setting did not override bare-name setting")
}
}
+
+// writeTestSession creates a flat session in the injected sessions directory:
+//
/.json (history) and /.meta.json. It returns both paths.
+func writeTestSession(t *testing.T, sessionsDir, id string) (metaPath, historyPath string) {
+ t.Helper()
+
+ historyPath = filepath.Join(sessionsDir, id+".json")
+ if err := session.SaveHistory(historyPath, []client.ChatMessage{
+ {Role: "user", Content: client.TextContent("hello")},
+ }); err != nil {
+ t.Fatalf("SaveHistory(%s): %v", id, err)
+ }
+
+ if err := session.SaveSessionMeta(session.SessionMeta{
+ ID: id,
+ Title: "Test session " + id,
+ CreatedAt: time.Now(),
+ LastUpdated: time.Now(),
+ HistoryPath: historyPath,
+ MessageCount: 1,
+ }); err != nil {
+ t.Fatalf("SaveSessionMeta(%s): %v", id, err)
+ }
+
+ return filepath.Join(sessionsDir, id+".meta.json"), historyPath
+}
+
+// injectSessionDir points session.SessionDir at a temp dir for the test's duration.
+func injectSessionDir(t *testing.T) string {
+ t.Helper()
+
+ tmp := t.TempDir()
+ oldDir := session.SessionDir
+ session.SessionDir = func() (string, error) { return tmp, nil }
+ t.Cleanup(func() { session.SessionDir = oldDir })
+
+ return tmp
+}
+
+func assertFileGone(t *testing.T, path string) {
+ t.Helper()
+ if _, err := os.Stat(path); !os.IsNotExist(err) {
+ t.Fatalf("expected %s to be removed, but it still exists", path)
+ }
+}
+
+func assertFileExists(t *testing.T, path string) {
+ t.Helper()
+ if _, err := os.Stat(path); err != nil {
+ t.Fatalf("expected %s to still exist: %v", path, err)
+ }
+}
+
+func TestHandleSessionDelete_RemovesSubagentFolder(t *testing.T) {
+ tmp := injectSessionDir(t)
+
+ metaA, historyA := writeTestSession(t, tmp, "session-20250101-123456")
+ // Session A also has the hierarchical subagent history folder.
+ folderA := filepath.Join(tmp, "session-20250101-123456")
+ subagentsDir := filepath.Join(folderA, "subagents")
+ if err := os.MkdirAll(subagentsDir, 0700); err != nil {
+ t.Fatalf("creating subagent dir: %v", err)
+ }
+ if err := os.WriteFile(filepath.Join(subagentsDir, "researcher-subagent-0.json"), []byte("[]"), 0600); err != nil {
+ t.Fatalf("writing subagent history: %v", err)
+ }
+
+ metaB, historyB := writeTestSession(t, tmp, "session-20250102-999999")
+
+ handleSessionDelete("session-20250101-123456")
+
+ // Session A: meta, history, and the entire subagent folder are all gone.
+ assertFileGone(t, metaA)
+ assertFileGone(t, historyA)
+ assertFileGone(t, folderA)
+
+ // Session B is untouched.
+ assertFileExists(t, metaB)
+ assertFileExists(t, historyB)
+}
+
+func TestHandleSessionDelete_LegacyFlatSession(t *testing.T) {
+ tmp := injectSessionDir(t)
+
+ metaC, historyC := writeTestSession(t, tmp, "session-20250103-000000")
+
+ handleSessionDelete("session-20250103-000000")
+
+ assertFileGone(t, metaC)
+ assertFileGone(t, historyC)
+}
diff --git a/docs/quickstart.md b/docs/quickstart.md
index 058f11f..fa4edf0 100644
--- a/docs/quickstart.md
+++ b/docs/quickstart.md
@@ -242,6 +242,7 @@ You can also create an `.llmignore` file alongside your `.gitignore` to specific
| `--subagent-max-turns ` | Set max turns per subagent (default: 500) |
| `--append-system-prompt "..."` | Append text to the system prompt (e.g. further instructions) |
| `--enable-images` | Treat models as supporting images (for none llama.cpp servers) |
+| `--save-subagent-histories` | Persist subagent conversation histories to disk. Off by default (subagent transcripts are large); can also be enabled via `save_subagent_histories` in the config file |
## Sessions
@@ -254,6 +255,8 @@ late session load # Resume a previous session
late session delete # Delete a session
```
+By default, subagent conversations are kept in memory and discarded. With `--save-subagent-histories` (or `"save_subagent_histories": true` in the config file), each subagent's history is saved under the session folder — `~/.local/share/late/sessions//subagents/-subagent-.json` — while the parent session files stay in place. `late session delete ` removes the subagent folder along with the session.
+
## Git Worktrees
Late is designed for parallel development. You can manage Git worktrees directly to run separate agent instances in isolated environments:
diff --git a/docs/quickstart.zh-CN.md b/docs/quickstart.zh-CN.md
index f53e64f..28c0258 100644
--- a/docs/quickstart.zh-CN.md
+++ b/docs/quickstart.zh-CN.md
@@ -240,6 +240,7 @@ Late 的原生搜索工具会自动遵循你的项目 `.gitignore`,通过排
| `--subagent-max-turns ` | 设置每个子智能体的最大交互轮数 (默认:500) |
| `--append-system-prompt "..."` | 向系统提示词的末尾追加文本(例如自定义的补充说明) |
| `--enable-images` | 将模型视为支持图像(适用于非 llama.cpp 的服务器) |
+| `--save-subagent-histories` | 将子智能体的对话历史记录持久化保存到磁盘。默认关闭(子智能体转录内容较大);也可以在配置文件中通过 `save_subagent_histories` 开启 |
## 会话管理 (Sessions)
@@ -252,6 +253,8 @@ late session load # 恢复指定的历史会话
late session delete # 删除指定的会话
```
+默认情况下,子智能体的对话仅保存在内存中,结束后即被丢弃。使用 `--save-subagent-histories`(或在配置文件中设置 `"save_subagent_histories": true`)后,每个子智能体的对话历史记录都会被保存到会话文件夹下 —— `~/.local/share/late/sessions//subagents/-subagent-.json` —— 而父会话的原始文件位置保持不变。运行 `late session delete ` 删除会话时,子智能体文件夹也会随之被一并移除。
+
## Git 工作树 (Git Worktrees)
Late 是专为并行开发设计的。你可以直接管理 Git 工作树,从而在隔离的环境中运行多个独立的智能体实例:
diff --git a/internal/agent/agent.go b/internal/agent/agent.go
index 269aeb1..8293a95 100644
--- a/internal/agent/agent.go
+++ b/internal/agent/agent.go
@@ -23,6 +23,8 @@ func NewSubagentOrchestrator(
injectCWD bool,
gemmaThinking bool,
maxTurns int,
+ parentSessionID string,
+ saveSubagentHistory bool,
parent common.Orchestrator,
messenger tui.Messenger,
) (common.Orchestrator, error) {
@@ -60,8 +62,27 @@ func NewSubagentOrchestrator(
systemPrompt = "<|think|>" + systemPrompt
}
- // 2. Setup Subagent Session (Isolated History)
- sess := session.New(c, "", []client.ChatMessage{}, systemPrompt, true)
+ // Mint the child ID up-front so it can be embedded in the subagent history path.
+ var id string
+ if p, ok := parent.(*orchestrator.BaseOrchestrator); ok {
+ id = p.NextChildID(agentType)
+ } else if parent != nil {
+ // Fallback for non-BaseOrchestrator parents (test fakes only).
+ id = fmt.Sprintf("%s-subagent-%d", agentType, len(parent.Children()))
+ } else {
+ id = fmt.Sprintf("%s-subagent-0", agentType)
+ }
+
+ // 2. Setup Subagent Session (Isolated History; persisted only when opted in)
+ var subagentHistoryPath string
+ if saveSubagentHistory && parentSessionID != "" {
+ path, err := session.SubagentHistoryPath(parentSessionID, id)
+ if err != nil {
+ return nil, fmt.Errorf("failed to resolve subagent history path: %w", err)
+ }
+ subagentHistoryPath = path
+ }
+ sess := session.NewSubagentSession(c, subagentHistoryPath, []client.ChatMessage{}, systemPrompt)
// Inherit all tools from parent (including MCP tools)
if parent != nil && parent.Registry() != nil {
@@ -101,7 +122,6 @@ func NewSubagentOrchestrator(
}
// 4. Create Orchestrator
- id := fmt.Sprintf("%s-subagent-%d", agentType, len(parent.Children()))
mws := parent.Middlewares()
if messenger != nil {
diff --git a/internal/agent/agent_test.go b/internal/agent/agent_test.go
index b846378..df9157b 100644
--- a/internal/agent/agent_test.go
+++ b/internal/agent/agent_test.go
@@ -1,8 +1,11 @@
package agent
import (
+ "encoding/json"
"os"
+ "path/filepath"
"strings"
+ "sync"
"testing"
"late/internal/client"
@@ -34,6 +37,8 @@ func TestNewSubagentOrchestratorWithGemmaThinking(t *testing.T) {
false, // injectCWD
true, // gemmaThinking
100, // maxTurns
+ "", // parentSessionID
+ false, // saveSubagentHistory
parent,
nil, // messenger
)
@@ -66,6 +71,8 @@ func TestNewSubagentOrchestratorWithGemmaThinking(t *testing.T) {
false, // injectCWD
false, // gemmaThinking
100, // maxTurns
+ "", // parentSessionID
+ false, // saveSubagentHistory
parent,
nil, // messenger
)
@@ -107,9 +114,11 @@ func TestNewSubagentOrchestratorGemmaThinkingWithCWD(t *testing.T) {
[]string{},
"coder",
enabledTools,
- true, // injectCWD
- true, // gemmaThinking
- 100, // maxTurns
+ true, // injectCWD
+ true, // gemmaThinking
+ 100, // maxTurns
+ "", // parentSessionID
+ false, // saveSubagentHistory
parent,
nil, // messenger
)
@@ -165,6 +174,8 @@ func TestNewSubagentOrchestratorID(t *testing.T) {
false,
false,
100,
+ "",
+ false,
parent,
nil,
)
@@ -176,3 +187,247 @@ func TestNewSubagentOrchestratorID(t *testing.T) {
t.Errorf("Expected child ID to contain 'coder', got %s", child.ID())
}
}
+
+// TestNewSubagentOrchestrator_ConcurrentSpawn is the FR2 regression test:
+// concurrent spawns against a shared parent must never mint duplicate child IDs.
+func TestNewSubagentOrchestrator_ConcurrentSpawn(t *testing.T) {
+ cfg := client.Config{BaseURL: "http://localhost:8080"}
+ c := client.NewClient(cfg)
+
+ // Create a shared mock parent session and orchestrator
+ mockSession := session.New(c, "/tmp/mock-session.json", []client.ChatMessage{}, "mock system prompt", true)
+ parent := orchestrator.NewBaseOrchestrator("parent", mockSession, nil, 10)
+
+ const numSpawn = 16
+ var wg sync.WaitGroup
+ var mu sync.Mutex
+ ids := make([]string, 0, numSpawn)
+
+ for i := 0; i < numSpawn; i++ {
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+
+ child, err := NewSubagentOrchestrator(
+ c,
+ "goal",
+ nil,
+ "researcher",
+ map[string]bool{}, // enabledTools
+ false, // injectCWD
+ false, // gemmaThinking
+ 10, // maxTurns
+ "", // parentSessionID
+ false, // saveSubagentHistory
+ parent,
+ nil, // messenger
+ )
+ if err != nil {
+ t.Errorf("Failed to create subagent orchestrator: %v", err)
+ return
+ }
+
+ mu.Lock()
+ ids = append(ids, child.ID())
+ mu.Unlock()
+ }()
+ }
+ wg.Wait()
+
+ if len(ids) != numSpawn {
+ t.Fatalf("Expected %d spawned orchestrators, got %d", numSpawn, len(ids))
+ }
+
+ // Assert all IDs are unique (no collisions)
+ seen := make(map[string]bool, numSpawn)
+ for _, id := range ids {
+ if seen[id] {
+ t.Errorf("Duplicate child ID minted by concurrent spawns: %s", id)
+ }
+ seen[id] = true
+ }
+
+ if n := len(parent.Children()); n != numSpawn {
+ t.Errorf("Expected parent to have %d children, got %d", numSpawn, n)
+ }
+}
+
+// setSessionDirForTest points session.SessionDir at dir for the duration of
+// the test, restoring the previous value on cleanup.
+func setSessionDirForTest(t *testing.T, dir string) {
+ t.Helper()
+ oldDir := session.SessionDir
+ session.SessionDir = func() (string, error) { return dir, nil }
+ t.Cleanup(func() { session.SessionDir = oldDir })
+}
+
+// walkRegularFiles returns the paths of all regular files under root.
+func walkRegularFiles(t *testing.T, root string) []string {
+ t.Helper()
+ var files []string
+ err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
+ if err != nil {
+ return err
+ }
+ if info.Mode().IsRegular() {
+ files = append(files, path)
+ }
+ return nil
+ })
+ if err != nil {
+ t.Fatalf("Failed to walk %s: %v", root, err)
+ }
+ return files
+}
+
+// TestNewSubagentOrchestrator_PersistsWhenOptedIn verifies that with
+// saveSubagentHistory=true the subagent's initial goal message is persisted to
+// //subagents/.json, and that subagent
+// sessions never write a .meta.json sidecar.
+func TestNewSubagentOrchestrator_PersistsWhenOptedIn(t *testing.T) {
+ tmp := t.TempDir()
+ setSessionDirForTest(t, tmp)
+
+ cfg := client.Config{BaseURL: "http://localhost:8080"}
+ c := client.NewClient(cfg)
+
+ mockSession := session.New(c, "/tmp/mock-session.json", []client.ChatMessage{}, "mock system prompt", true)
+ parent := orchestrator.NewBaseOrchestrator("parent", mockSession, nil, 10)
+
+ const goal = "persist me"
+ _, err := NewSubagentOrchestrator(
+ c,
+ goal,
+ []string{},
+ "researcher",
+ map[string]bool{}, // enabledTools
+ false, // injectCWD
+ false, // gemmaThinking
+ 10, // maxTurns
+ "session-test", // parentSessionID
+ true, // saveSubagentHistory
+ parent,
+ nil, // messenger
+ )
+ if err != nil {
+ t.Fatalf("Failed to create subagent orchestrator: %v", err)
+ }
+
+ historyPath := filepath.Join(tmp, "session-test", "subagents", "researcher-subagent-0.json")
+ if _, err := os.Stat(historyPath); err != nil {
+ t.Fatalf("Expected subagent history file %s to exist: %v", historyPath, err)
+ }
+
+ data, err := os.ReadFile(historyPath)
+ if err != nil {
+ t.Fatalf("Failed to read subagent history file: %v", err)
+ }
+
+ var msgs []client.ChatMessage
+ if err := json.Unmarshal(data, &msgs); err != nil {
+ t.Fatalf("Failed to unmarshal subagent history file: %v", err)
+ }
+ if len(msgs) < 1 {
+ t.Fatalf("Expected at least 1 message in subagent history, got %d", len(msgs))
+ }
+
+ foundGoal := false
+ for _, msg := range msgs {
+ if strings.Contains(msg.Content.String(), goal) {
+ foundGoal = true
+ break
+ }
+ }
+ if !foundGoal {
+ t.Errorf("Expected a message containing goal %q, got %d messages", goal, len(msgs))
+ }
+
+ // Meta-skip invariant: subagent sessions must never write .meta.json sidecars.
+ for _, f := range walkRegularFiles(t, tmp) {
+ if strings.HasSuffix(f, ".meta.json") {
+ t.Errorf("Unexpected .meta.json file written by subagent session: %s", f)
+ }
+ }
+}
+
+// TestNewSubagentOrchestrator_InMemoryByDefault verifies that with
+// saveSubagentHistory=false the subagent session is fully in-memory: nothing
+// (not even a directory artifact) is written to the session directory.
+func TestNewSubagentOrchestrator_InMemoryByDefault(t *testing.T) {
+ tmp := t.TempDir()
+ setSessionDirForTest(t, tmp)
+
+ cfg := client.Config{BaseURL: "http://localhost:8080"}
+ c := client.NewClient(cfg)
+
+ mockSession := session.New(c, "/tmp/mock-session.json", []client.ChatMessage{}, "mock system prompt", true)
+ parent := orchestrator.NewBaseOrchestrator("parent", mockSession, nil, 10)
+
+ _, err := NewSubagentOrchestrator(
+ c,
+ "test goal",
+ []string{},
+ "researcher",
+ map[string]bool{}, // enabledTools
+ false, // injectCWD
+ false, // gemmaThinking
+ 10, // maxTurns
+ "session-test", // parentSessionID
+ false, // saveSubagentHistory
+ parent,
+ nil, // messenger
+ )
+ if err != nil {
+ t.Fatalf("Failed to create subagent orchestrator: %v", err)
+ }
+
+ for _, f := range walkRegularFiles(t, tmp) {
+ t.Errorf("Expected no regular files in session dir when saveSubagentHistory=false, found: %s", f)
+ }
+}
+
+// TestNewSubagentOrchestrator_SecondSpawnGetsNextID verifies that two spawns
+// against the same parent mint distinct child IDs, producing two separate
+// history files (no overwrite/collision).
+func TestNewSubagentOrchestrator_SecondSpawnGetsNextID(t *testing.T) {
+ tmp := t.TempDir()
+ setSessionDirForTest(t, tmp)
+
+ cfg := client.Config{BaseURL: "http://localhost:8080"}
+ c := client.NewClient(cfg)
+
+ mockSession := session.New(c, "/tmp/mock-session.json", []client.ChatMessage{}, "mock system prompt", true)
+ parent := orchestrator.NewBaseOrchestrator("parent", mockSession, nil, 10)
+
+ const goal = "persist me"
+ spawn := func() {
+ _, err := NewSubagentOrchestrator(
+ c,
+ goal,
+ []string{},
+ "researcher",
+ map[string]bool{}, // enabledTools
+ false, // injectCWD
+ false, // gemmaThinking
+ 10, // maxTurns
+ "session-test", // parentSessionID
+ true, // saveSubagentHistory
+ parent,
+ nil, // messenger
+ )
+ if err != nil {
+ t.Fatalf("Failed to create subagent orchestrator: %v", err)
+ }
+ }
+
+ spawn()
+ spawn()
+
+ subagentsDir := filepath.Join(tmp, "session-test", "subagents")
+ for _, name := range []string{"researcher-subagent-0.json", "researcher-subagent-1.json"} {
+ p := filepath.Join(subagentsDir, name)
+ if _, err := os.Stat(p); err != nil {
+ t.Errorf("Expected subagent history file %s to exist: %v", p, err)
+ }
+ }
+}
diff --git a/internal/config/config.go b/internal/config/config.go
index 79eaf93..e448d52 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -56,6 +56,11 @@ type Config struct {
LateSubagentAPIKey string `json:"late_subagent_api_key,omitempty"`
LateSubagentModel string `json:"late_subagent_model,omitempty"`
+ // SaveSubagentHistories opts in to persisting subagent conversation
+ // histories under //subagents/. Default false.
+ // Enable via config file or the --save-subagent-histories CLI flag.
+ SaveSubagentHistories bool `json:"save_subagent_histories,omitempty"`
+
// Legacy subagent fields for backward compatibility
SubagentBaseURL string `json:"subagent_base_url,omitempty"`
SubagentAPIKey string `json:"subagent_api_key,omitempty"`
@@ -218,6 +223,19 @@ func ResolveSubagentSettingsWithEnv(cfg *Config, openAI OpenAISettings, lookup E
return resolved
}
+// ResolveSaveSubagentHistories determines whether subagent history
+// persistence is enabled. Precedence: explicit CLI flag > config file.
+// There is intentionally no environment-variable override.
+func ResolveSaveSubagentHistories(cfg *Config, cliExplicit bool, cliValue bool) bool {
+ if cliExplicit {
+ return cliValue
+ }
+ if cfg != nil {
+ return cfg.SaveSubagentHistories
+ }
+ return false
+}
+
func nonEmptyEnv(lookup EnvLookup, key string) (string, bool) {
if lookup == nil {
return "", false
diff --git a/internal/config/config_test.go b/internal/config/config_test.go
index 0bcdadb..d6c323b 100644
--- a/internal/config/config_test.go
+++ b/internal/config/config_test.go
@@ -528,6 +528,60 @@ func TestResolveSubagentSettings(t *testing.T) {
}
}
+func TestResolveSaveSubagentHistories(t *testing.T) {
+ tests := []struct {
+ name string
+ cfg *Config
+ cliExplicit bool
+ cliValue bool
+ want bool
+ }{
+ {
+ name: "explicit flag on wins over config off",
+ cfg: &Config{SaveSubagentHistories: false},
+ cliExplicit: true,
+ cliValue: true,
+ want: true,
+ },
+ {
+ name: "explicit flag off wins over config on",
+ cfg: &Config{SaveSubagentHistories: true},
+ cliExplicit: true,
+ cliValue: false,
+ want: false,
+ },
+ {
+ name: "no flag uses config on",
+ cfg: &Config{SaveSubagentHistories: true},
+ cliExplicit: false,
+ cliValue: false,
+ want: true,
+ },
+ {
+ name: "no flag uses config off",
+ cfg: &Config{SaveSubagentHistories: false},
+ cliExplicit: false,
+ cliValue: false,
+ want: false,
+ },
+ {
+ name: "no flag and nil config defaults to off",
+ cfg: nil,
+ cliExplicit: false,
+ cliValue: false,
+ want: false,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ if got := ResolveSaveSubagentHistories(tt.cfg, tt.cliExplicit, tt.cliValue); got != tt.want {
+ t.Fatalf("ResolveSaveSubagentHistories() = %v, want %v", got, tt.want)
+ }
+ })
+ }
+}
+
func TestConfig_GetModelForAgent(t *testing.T) {
cfg := &Config{
Models: []ModelSetting{
diff --git a/internal/orchestrator/base.go b/internal/orchestrator/base.go
index 70e46ad..4546087 100644
--- a/internal/orchestrator/base.go
+++ b/internal/orchestrator/base.go
@@ -26,6 +26,9 @@ type BaseOrchestrator struct {
parent common.Orchestrator
children []common.Orchestrator
+ // childSeq is a monotonic counter for minting child IDs; guarded by mu
+ childSeq int
+
// Running state tracker
isRunning bool
pendingMsgs []client.ChatMessage
@@ -440,7 +443,9 @@ func (o *BaseOrchestrator) Registry() *common.ToolRegistry {
func (o *BaseOrchestrator) Children() []common.Orchestrator {
o.mu.RLock()
defer o.mu.RUnlock()
- return o.children
+ out := make([]common.Orchestrator, len(o.children))
+ copy(out, o.children)
+ return out
}
func (o *BaseOrchestrator) Parent() common.Orchestrator {
@@ -469,6 +474,17 @@ func (o *BaseOrchestrator) Rewind(index int) error {
return nil
}
+// NextChildID atomically mints the next child ID under o.mu. The counter is
+// monotonic and independent of len(children), so concurrent spawns can never
+// produce duplicate IDs.
+func (o *BaseOrchestrator) NextChildID(agentType string) string {
+ o.mu.Lock()
+ defer o.mu.Unlock()
+ id := fmt.Sprintf("%s-subagent-%d", agentType, o.childSeq)
+ o.childSeq++
+ return id
+}
+
func (o *BaseOrchestrator) AddChild(child common.Orchestrator) {
o.mu.Lock()
o.children = append(o.children, child)
diff --git a/internal/orchestrator/base_test.go b/internal/orchestrator/base_test.go
index 59a76af..c049bde 100644
--- a/internal/orchestrator/base_test.go
+++ b/internal/orchestrator/base_test.go
@@ -2,11 +2,13 @@ package orchestrator
import (
"context"
+ "fmt"
"late/internal/client"
"late/internal/common"
"late/internal/session"
"os"
"path/filepath"
+ "sync"
"testing"
)
@@ -133,3 +135,74 @@ func TestBaseOrchestrator_ResetStartsNewConversation(t *testing.T) {
t.Fatalf("saving the new conversation changed original history: %#v", preserved)
}
}
+
+func TestNextChildID_FormatAndMonotonic(t *testing.T) {
+ o := NewBaseOrchestrator("parent", session.New(nil, "", nil, "", false), nil, 0)
+
+ var got []string
+ for i := 0; i < 3; i++ {
+ got = append(got, o.NextChildID("researcher"))
+ }
+ got = append(got, o.NextChildID("coder"))
+
+ want := []string{
+ "researcher-subagent-0",
+ "researcher-subagent-1",
+ "researcher-subagent-2",
+ // The counter is global per parent, NOT per type.
+ "coder-subagent-3",
+ }
+
+ if len(got) != len(want) {
+ t.Fatalf("got %d IDs, want %d", len(got), len(want))
+ }
+ for i := range want {
+ if got[i] != want[i] {
+ t.Errorf("ID %d = %q, want %q", i, got[i], want[i])
+ }
+ }
+}
+
+func TestNextChildID_Concurrent(t *testing.T) {
+ o := NewBaseOrchestrator("parent", session.New(nil, "", nil, "", false), nil, 0)
+
+ const goroutines = 64
+ const perGoroutine = 16
+
+ var mu sync.Mutex
+ ids := make([]string, 0, goroutines*perGoroutine)
+
+ var wg sync.WaitGroup
+ for g := 0; g < goroutines; g++ {
+ wg.Add(1)
+ go func(g int) {
+ defer wg.Done()
+ for i := 0; i < perGoroutine; i++ {
+ id := o.NextChildID("researcher")
+ mu.Lock()
+ ids = append(ids, id)
+ mu.Unlock()
+ }
+ // Interleave lightweight child registration to ensure concurrent
+ // AddChild calls don't interfere with ID minting.
+ o.AddChild(NewBaseOrchestrator(fmt.Sprintf("dummy-%d", g), nil, nil, 0))
+ }(g)
+ }
+ wg.Wait()
+
+ if len(ids) != goroutines*perGoroutine {
+ t.Fatalf("minted %d IDs, want %d", len(ids), goroutines*perGoroutine)
+ }
+
+ seen := make(map[string]struct{}, len(ids))
+ for _, id := range ids {
+ if _, dup := seen[id]; dup {
+ t.Fatalf("duplicate ID minted: %q", id)
+ }
+ seen[id] = struct{}{}
+ }
+
+ if n := len(o.Children()); n != goroutines {
+ t.Errorf("Children() length = %d, want %d", n, goroutines)
+ }
+}
diff --git a/internal/session/models_test.go b/internal/session/models_test.go
index b0d17bf..b14e042 100644
--- a/internal/session/models_test.go
+++ b/internal/session/models_test.go
@@ -125,3 +125,108 @@ func TestGetLatestSession(t *testing.T) {
}
}
+// setupSubagentFolderFixture swaps SessionDir to a fresh temp dir containing:
+// - a legacy flat session (history file + meta file) with ID "session-20250101-123456"
+// - the hierarchical subagent artifacts of another session: a directory
+// "session-20250102-999999/subagents/" holding subagent history files,
+// intentionally WITHOUT a meta file of its own
+//
+// SessionDir is restored when the test finishes.
+func setupSubagentFolderFixture(t *testing.T) {
+ t.Helper()
+
+ tmpDir := t.TempDir()
+
+ // Mock SessionDir
+ oldSessionDir := SessionDir
+ SessionDir = func() (string, error) {
+ return tmpDir, nil
+ }
+ t.Cleanup(func() { SessionDir = oldSessionDir })
+
+ // Legacy session: flat history file + matching meta
+ const legacyID = "session-20250101-123456"
+ historyPath := filepath.Join(tmpDir, legacyID+".json")
+ history := []client.ChatMessage{{Role: "user", Content: client.TextContent("Hello")}}
+ if err := SaveHistory(historyPath, history); err != nil {
+ t.Fatalf("Failed to save history: %v", err)
+ }
+
+ meta := SessionMeta{
+ ID: legacyID,
+ Title: "Legacy Session",
+ CreatedAt: time.Now().Add(-1 * time.Hour),
+ LastUpdated: time.Now(),
+ HistoryPath: historyPath,
+ }
+ if err := SaveSessionMeta(meta); err != nil {
+ t.Fatalf("Failed to save meta: %v", err)
+ }
+
+ // Hierarchical subagent artifacts of another session (no meta file)
+ subagentsDir := filepath.Join(tmpDir, "session-20250102-999999", "subagents")
+ if err := os.MkdirAll(subagentsDir, 0700); err != nil {
+ t.Fatalf("Failed to create subagents dir: %v", err)
+ }
+ for _, name := range []string{"coder-subagent-0.json", "researcher-subagent-1.json"} {
+ if err := os.WriteFile(filepath.Join(subagentsDir, name), []byte("[]"), 0600); err != nil {
+ t.Fatalf("Failed to write subagent history %s: %v", name, err)
+ }
+ }
+}
+
+func TestListSessions_IgnoresSubagentFolders(t *testing.T) {
+ setupSubagentFolderFixture(t)
+
+ metas, err := ListSessions()
+ if err != nil {
+ t.Fatalf("Expected no error from ListSessions, got %v", err)
+ }
+ if len(metas) != 1 {
+ t.Fatalf("Expected exactly 1 session, got %d: %v", len(metas), metas)
+ }
+ if metas[0].ID != "session-20250101-123456" {
+ t.Errorf("Expected session ID 'session-20250101-123456', got %q", metas[0].ID)
+ }
+
+ latest, err := GetLatestSession()
+ if err != nil {
+ t.Fatalf("Expected no error from GetLatestSession, got %v", err)
+ }
+ if latest == nil || latest.ID != "session-20250101-123456" {
+ t.Errorf("Expected latest session 'session-20250101-123456', got %v", latest)
+ }
+}
+
+func TestLoadSessionMeta_IgnoresSubagentFolders(t *testing.T) {
+ setupSubagentFolderFixture(t)
+
+ // Exact match still works
+ exact, err := LoadSessionMeta("session-20250101-123456")
+ if err != nil || exact == nil {
+ t.Fatalf("Failed to load meta exactly: %v", err)
+ }
+ if exact.ID != "session-20250101-123456" {
+ t.Errorf("Expected loaded ID 'session-20250101-123456', got %q", exact.ID)
+ }
+
+ // Prefix matching only the legacy session: the "session-20250102-999999"
+ // directory must be skipped by the prefix scan, introducing no new match or ambiguity
+ byPrefix, err := LoadSessionMeta("session-2025")
+ if err != nil || byPrefix == nil {
+ t.Fatalf("Failed to load meta by prefix: %v", err)
+ }
+ if byPrefix.ID != "session-20250101-123456" {
+ t.Errorf("Expected loaded prefix ID 'session-20250101-123456', got %q", byPrefix.ID)
+ }
+
+ // Nonexistent ID behaves as before: (nil, nil)
+ notFound, err := LoadSessionMeta("nonexistent")
+ if err != nil {
+ t.Fatalf("Expected no error for nonexistent session, got %v", err)
+ }
+ if notFound != nil {
+ t.Errorf("Expected nil meta for nonexistent session, got %v", notFound)
+ }
+}
+
diff --git a/internal/session/paths.go b/internal/session/paths.go
new file mode 100644
index 0000000..0cec2fa
--- /dev/null
+++ b/internal/session/paths.go
@@ -0,0 +1,44 @@
+package session
+
+import (
+ "fmt"
+ "os"
+ "path/filepath"
+ "strings"
+)
+
+// SubagentHistoryDir returns the directory holding a session's subagent
+// histories: //subagents. It is created lazily by
+// SaveHistory (MkdirAll 0700), so this helper does not create it.
+func SubagentHistoryDir(sessionID string) (string, error) {
+ sessionsDir, err := SessionDir()
+ if err != nil {
+ return "", err
+ }
+ return filepath.Join(sessionsDir, sessionID, "subagents"), nil
+}
+
+// SubagentHistoryPath returns the full path of a subagent history file:
+// //subagents/.json.
+func SubagentHistoryPath(parentSessionID, childID string) (string, error) {
+ dir, err := SubagentHistoryDir(parentSessionID)
+ if err != nil {
+ return "", err
+ }
+ return filepath.Join(dir, childID+".json"), nil
+}
+
+// RemoveSessionFolder deletes a session's folder (containing its subagent
+// histories) if it exists. Flat files .json / .meta.json are
+// distinct names and are never touched. Returns nil when the folder does
+// not exist (legacy sessions) or for IDs containing path separators.
+func RemoveSessionFolder(sessionID string) error {
+ if sessionID == "" || strings.ContainsAny(sessionID, "/\\") {
+ return nil
+ }
+ sessionsDir, err := SessionDir()
+ if err != nil {
+ return fmt.Errorf("failed to get session directory: %w", err)
+ }
+ return os.RemoveAll(filepath.Join(sessionsDir, sessionID))
+}
diff --git a/internal/session/paths_test.go b/internal/session/paths_test.go
new file mode 100644
index 0000000..18f46a8
--- /dev/null
+++ b/internal/session/paths_test.go
@@ -0,0 +1,138 @@
+package session
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+)
+
+func TestSubagentHistoryPath(t *testing.T) {
+ tmpDir, err := os.MkdirTemp("", "late-session-paths-test-*")
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer os.RemoveAll(tmpDir)
+
+ // Mock SessionDir
+ oldSessionDir := SessionDir
+ SessionDir = func() (string, error) {
+ return tmpDir, nil
+ }
+ defer func() { SessionDir = oldSessionDir }()
+
+ const sessionID = "session-20250101-123456"
+ const childID = "researcher-subagent-0"
+
+ wantDir := filepath.Join(tmpDir, sessionID, "subagents")
+ gotDir, err := SubagentHistoryDir(sessionID)
+ if err != nil {
+ t.Fatalf("SubagentHistoryDir returned error: %v", err)
+ }
+ if gotDir != wantDir {
+ t.Errorf("Expected SubagentHistoryDir %q, got %q", wantDir, gotDir)
+ }
+
+ wantPath := filepath.Join(wantDir, childID+".json")
+ gotPath, err := SubagentHistoryPath(sessionID, childID)
+ if err != nil {
+ t.Fatalf("SubagentHistoryPath returned error: %v", err)
+ }
+ if gotPath != wantPath {
+ t.Errorf("Expected SubagentHistoryPath %q, got %q", wantPath, gotPath)
+ }
+
+ // Neither helper must pre-create directories.
+ if _, err := os.Stat(filepath.Join(tmpDir, sessionID)); !os.IsNotExist(err) {
+ t.Errorf("Expected no pre-created %q folder, got stat err=%v", sessionID, err)
+ }
+}
+
+func TestRemoveSessionFolder_RemovesFolderKeepsFlatFiles(t *testing.T) {
+ tmpDir, err := os.MkdirTemp("", "late-session-rmtest-*")
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer os.RemoveAll(tmpDir)
+
+ // Mock SessionDir
+ oldSessionDir := SessionDir
+ SessionDir = func() (string, error) {
+ return tmpDir, nil
+ }
+ defer func() { SessionDir = oldSessionDir }()
+
+ const sessionID = "session-X"
+ subagentsDir := filepath.Join(tmpDir, sessionID, "subagents")
+ if err := os.MkdirAll(subagentsDir, 0700); err != nil {
+ t.Fatal(err)
+ }
+ childPath := filepath.Join(subagentsDir, "coder-subagent-0.json")
+ if err := os.WriteFile(childPath, []byte("[]"), 0600); err != nil {
+ t.Fatal(err)
+ }
+
+ flatJSON := filepath.Join(tmpDir, sessionID+".json")
+ flatMeta := filepath.Join(tmpDir, sessionID+".meta.json")
+ if err := os.WriteFile(flatJSON, []byte("[]"), 0600); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(flatMeta, []byte("{}"), 0600); err != nil {
+ t.Fatal(err)
+ }
+
+ if err := RemoveSessionFolder(sessionID); err != nil {
+ t.Fatalf("RemoveSessionFolder returned error: %v", err)
+ }
+
+ if _, err := os.Stat(filepath.Join(tmpDir, sessionID)); !os.IsNotExist(err) {
+ t.Errorf("Expected %q folder to be removed, got stat err=%v", sessionID, err)
+ }
+ if _, err := os.Stat(childPath); !os.IsNotExist(err) {
+ t.Errorf("Expected subagent history %q to be removed, got stat err=%v", childPath, err)
+ }
+ if _, err := os.Stat(flatJSON); err != nil {
+ t.Errorf("Expected flat file %q to survive, got stat err=%v", flatJSON, err)
+ }
+ if _, err := os.Stat(flatMeta); err != nil {
+ t.Errorf("Expected flat file %q to survive, got stat err=%v", flatMeta, err)
+ }
+}
+
+func TestRemoveSessionFolder_NoopForMissingAndUnsafeIDs(t *testing.T) {
+ tmpDir, err := os.MkdirTemp("", "late-session-noop-*")
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer os.RemoveAll(tmpDir)
+
+ // Mock SessionDir
+ oldSessionDir := SessionDir
+ SessionDir = func() (string, error) {
+ return tmpDir, nil
+ }
+ defer func() { SessionDir = oldSessionDir }()
+
+ // Never-created ID: no error, nothing deleted.
+ if err := RemoveSessionFolder("session-does-not-exist"); err != nil {
+ t.Errorf("Expected nil error for missing session ID, got %v", err)
+ }
+
+ // Unsafe IDs: no error, nothing deleted, no panic.
+ for _, unsafeID := range []string{"", "../evil", "a/b", "..\\windows-evil"} {
+ if err := RemoveSessionFolder(unsafeID); err != nil {
+ t.Errorf("Expected nil error for unsafe session ID %q, got %v", unsafeID, err)
+ }
+ if _, err := os.Stat(filepath.Join(tmpDir, "..", "evil")); !os.IsNotExist(err) {
+ t.Errorf("Expected no deletion outside temp dir for %q, got stat err=%v", unsafeID, err)
+ }
+ }
+
+ // The temp dir itself must still exist with an empty listing.
+ entries, err := os.ReadDir(tmpDir)
+ if err != nil {
+ t.Fatalf("Failed to read temp dir: %v", err)
+ }
+ if len(entries) != 0 {
+ t.Errorf("Expected temp dir to remain empty, found %d entries: %v", len(entries), entries)
+ }
+}
diff --git a/internal/session/session.go b/internal/session/session.go
index 4cd4f27..80d256d 100644
--- a/internal/session/session.go
+++ b/internal/session/session.go
@@ -21,6 +21,7 @@ type Session struct {
History []client.ChatMessage
systemPrompt string
useTools bool
+ skipMetadata bool // when true, no top-level .meta.json sidecar is written (subagents)
Registry *tool.Registry
}
@@ -35,6 +36,16 @@ func New(c *client.Client, historyPath string, history []client.ChatMessage, sys
}
}
+// NewSubagentSession creates a session for a subagent. History is persisted
+// to historyPath when non-empty (in-memory otherwise), but the session never
+// writes a top-level .meta.json sidecar, keeping the shared sessions
+// directory free of subagent entries.
+func NewSubagentSession(c *client.Client, historyPath string, history []client.ChatMessage, systemPrompt string) *Session {
+ s := New(c, historyPath, history, systemPrompt, true)
+ s.skipMetadata = true
+ return s
+}
+
// ExecuteTool executes a tool call and returns the response as a string.
func (s *Session) ExecuteTool(ctx context.Context, tc client.ToolCall) (string, error) {
// First check registry
@@ -283,6 +294,9 @@ func (s *Session) GenerateSessionMeta() SessionMeta {
// UpdateSessionMetadata updates the session metadata file
func (s *Session) UpdateSessionMetadata() error {
+ if s.skipMetadata {
+ return nil
+ }
meta := s.GenerateSessionMeta()
return SaveSessionMeta(meta)
}
@@ -326,7 +340,7 @@ func (s *Session) saveAndNotify() error {
return nil
}
if s.HistoryPath == "" {
- return nil // Skip saving if no path provided (e.g., subagents)
+ return nil // Skip saving if no path provided (e.g., in-memory sessions, subagents without history opt-in)
}
if err := SaveHistory(s.HistoryPath, s.History); err != nil {
return err
diff --git a/internal/session/subagent_session_test.go b/internal/session/subagent_session_test.go
new file mode 100644
index 0000000..a8fd7f8
--- /dev/null
+++ b/internal/session/subagent_session_test.go
@@ -0,0 +1,69 @@
+package session
+
+import (
+ "encoding/json"
+ "late/internal/client"
+ "os"
+ "path/filepath"
+ "testing"
+)
+
+func TestSubagentSession_SavesHistoryWithoutMeta(t *testing.T) {
+ tmpDir := t.TempDir()
+
+ // Mock SessionDir
+ oldSessionDir := SessionDir
+ SessionDir = func() (string, error) {
+ return tmpDir, nil
+ }
+ t.Cleanup(func() { SessionDir = oldSessionDir })
+
+ historyPath := filepath.Join(tmpDir, "session-test", "subagents", "coder-subagent-0.json")
+ s := NewSubagentSession(nil, historyPath, nil, "sp")
+
+ if err := s.AddUserMessage("hello"); err != nil {
+ t.Fatalf("AddUserMessage returned error: %v", err)
+ }
+
+ // History must be persisted to the nested subagent path.
+ data, err := os.ReadFile(historyPath)
+ if err != nil {
+ t.Fatalf("Expected subagent history file %q to exist, got: %v", historyPath, err)
+ }
+ var history []client.ChatMessage
+ if err := json.Unmarshal(data, &history); err != nil {
+ t.Fatalf("Failed to unmarshal subagent history: %v", err)
+ }
+ if len(history) == 0 {
+ t.Errorf("Expected non-empty subagent history, got 0 messages")
+ }
+
+ // No top-level .meta.json sidecar must be written for subagents.
+ metaPath := filepath.Join(tmpDir, "coder-subagent-0.meta.json")
+ if _, err := os.Stat(metaPath); !os.IsNotExist(err) {
+ t.Errorf("Expected no top-level meta file %q, got stat err=%v", metaPath, err)
+ }
+}
+
+func TestRegularSession_StillWritesMeta(t *testing.T) {
+ tmpDir := t.TempDir()
+
+ // Mock SessionDir
+ oldSessionDir := SessionDir
+ SessionDir = func() (string, error) {
+ return tmpDir, nil
+ }
+ t.Cleanup(func() { SessionDir = oldSessionDir })
+
+ historyPath := filepath.Join(tmpDir, "session-test.json")
+ s := New(nil, historyPath, nil, "sp", true)
+
+ if err := s.AddUserMessage("hello"); err != nil {
+ t.Fatalf("AddUserMessage returned error: %v", err)
+ }
+
+ metaPath := filepath.Join(tmpDir, "session-test.meta.json")
+ if _, err := os.Stat(metaPath); err != nil {
+ t.Errorf("Expected top-level meta file %q to exist, got stat err=%v", metaPath, err)
+ }
+}
From 6e1eb93ec71aad6040fb951721806ce1b500ec97 Mon Sep 17 00:00:00 2001
From: jpia <6011922+jpia@users.noreply.github.com>
Date: Sat, 15 Aug 2026 23:50:14 -0500
Subject: [PATCH 2/5] fix(session): reject unsafe path elements in subagent
history helpers
---
internal/session/paths.go | 24 +++++++++++--
internal/session/paths_test.go | 63 +++++++++++++++++++++++++++++++++-
2 files changed, 84 insertions(+), 3 deletions(-)
diff --git a/internal/session/paths.go b/internal/session/paths.go
index 0cec2fa..316254c 100644
--- a/internal/session/paths.go
+++ b/internal/session/paths.go
@@ -7,10 +7,23 @@ import (
"strings"
)
+// isValidPathElement reports whether id is a safe single path element
+// under the sessions directory: non-empty, not "." or "..", and free of
+// path separators (/ and \) and NUL bytes.
+func isValidPathElement(id string) bool {
+ if id == "" || id == "." || id == ".." {
+ return false
+ }
+ return !strings.ContainsAny(id, "/\\") && !strings.ContainsRune(id, 0)
+}
+
// SubagentHistoryDir returns the directory holding a session's subagent
// histories: //subagents. It is created lazily by
// SaveHistory (MkdirAll 0700), so this helper does not create it.
func SubagentHistoryDir(sessionID string) (string, error) {
+ if !isValidPathElement(sessionID) {
+ return "", fmt.Errorf("invalid session ID: %q", sessionID)
+ }
sessionsDir, err := SessionDir()
if err != nil {
return "", err
@@ -21,6 +34,12 @@ func SubagentHistoryDir(sessionID string) (string, error) {
// SubagentHistoryPath returns the full path of a subagent history file:
// //subagents/.json.
func SubagentHistoryPath(parentSessionID, childID string) (string, error) {
+ if !isValidPathElement(parentSessionID) {
+ return "", fmt.Errorf("invalid session ID: %q", parentSessionID)
+ }
+ if !isValidPathElement(childID) {
+ return "", fmt.Errorf("invalid child ID: %q", childID)
+ }
dir, err := SubagentHistoryDir(parentSessionID)
if err != nil {
return "", err
@@ -31,9 +50,10 @@ func SubagentHistoryPath(parentSessionID, childID string) (string, error) {
// RemoveSessionFolder deletes a session's folder (containing its subagent
// histories) if it exists. Flat files .json / .meta.json are
// distinct names and are never touched. Returns nil when the folder does
-// not exist (legacy sessions) or for IDs containing path separators.
+// not exist (legacy sessions) or for unsafe IDs (empty, ".", "..", path
+// separators, NUL bytes).
func RemoveSessionFolder(sessionID string) error {
- if sessionID == "" || strings.ContainsAny(sessionID, "/\\") {
+ if !isValidPathElement(sessionID) {
return nil
}
sessionsDir, err := SessionDir()
diff --git a/internal/session/paths_test.go b/internal/session/paths_test.go
index 18f46a8..5684ef8 100644
--- a/internal/session/paths_test.go
+++ b/internal/session/paths_test.go
@@ -118,7 +118,7 @@ func TestRemoveSessionFolder_NoopForMissingAndUnsafeIDs(t *testing.T) {
}
// Unsafe IDs: no error, nothing deleted, no panic.
- for _, unsafeID := range []string{"", "../evil", "a/b", "..\\windows-evil"} {
+ for _, unsafeID := range []string{"", ".", "..", "../evil", "a/b", "..\\windows-evil"} {
if err := RemoveSessionFolder(unsafeID); err != nil {
t.Errorf("Expected nil error for unsafe session ID %q, got %v", unsafeID, err)
}
@@ -136,3 +136,64 @@ func TestRemoveSessionFolder_NoopForMissingAndUnsafeIDs(t *testing.T) {
t.Errorf("Expected temp dir to remain empty, found %d entries: %v", len(entries), entries)
}
}
+
+func TestSubagentHistoryPathRejectsUnsafeIDs(t *testing.T) {
+ tmpDir, err := os.MkdirTemp("", "late-session-unsafe-*")
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer os.RemoveAll(tmpDir)
+
+ // Mock SessionDir
+ oldSessionDir := SessionDir
+ SessionDir = func() (string, error) {
+ return tmpDir, nil
+ }
+ defer func() { SessionDir = oldSessionDir }()
+
+ for _, unsafeID := range []string{"", ".", "..", "../x", "a/b"} {
+ if _, err := SubagentHistoryDir(unsafeID); err == nil {
+ t.Errorf("Expected error from SubagentHistoryDir for unsafe session ID %q, got nil", unsafeID)
+ }
+ if _, err := SubagentHistoryPath(unsafeID, "coder"); err == nil {
+ t.Errorf("Expected error from SubagentHistoryPath for unsafe session ID %q, got nil", unsafeID)
+ }
+ }
+
+ if _, err := SubagentHistoryPath("session-x", ".."); err == nil {
+ t.Errorf("Expected error from SubagentHistoryPath for unsafe child ID %q, got nil", "..")
+ }
+
+ // Nothing may have been created on disk.
+ entries, err := os.ReadDir(tmpDir)
+ if err != nil {
+ t.Fatalf("Failed to read temp dir: %v", err)
+ }
+ if len(entries) != 0 {
+ t.Errorf("Expected temp dir to remain empty, found %d entries: %v", len(entries), entries)
+ }
+}
+
+func TestSubagentHistoryPathValidInputs(t *testing.T) {
+ tmpDir, err := os.MkdirTemp("", "late-session-valid-*")
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer os.RemoveAll(tmpDir)
+
+ // Mock SessionDir
+ oldSessionDir := SessionDir
+ SessionDir = func() (string, error) {
+ return tmpDir, nil
+ }
+ defer func() { SessionDir = oldSessionDir }()
+
+ gotPath, err := SubagentHistoryPath("session-x", "coder-subagent-0")
+ if err != nil {
+ t.Fatalf("SubagentHistoryPath returned error: %v", err)
+ }
+ wantPath := filepath.Join(tmpDir, "session-x", "subagents", "coder-subagent-0.json")
+ if gotPath != wantPath {
+ t.Errorf("Expected SubagentHistoryPath %q, got %q", wantPath, gotPath)
+ }
+}
From c8f1c6742b7d8d4b2dc8acf044e8b36efbed14d1 Mon Sep 17 00:00:00 2001
From: jpia <6011922+jpia@users.noreply.github.com>
Date: Sat, 15 Aug 2026 23:57:05 -0500
Subject: [PATCH 3/5] fix(cmd): fall back to in-memory subagent histories for
unsafe session IDs
---
cmd/late/main.go | 19 ++++++++++++++++--
cmd/late/main_test.go | 46 +++++++++++++++++++++++++++++++++++++++++++
2 files changed, 63 insertions(+), 2 deletions(-)
diff --git a/cmd/late/main.go b/cmd/late/main.go
index 11a37bc..9d3948c 100644
--- a/cmd/late/main.go
+++ b/cmd/late/main.go
@@ -178,8 +178,10 @@ func main() {
// Effective session ID for this run — derived from the FINAL history path so
// resumed sessions keep their original ID (the sessionID var above is a fresh
// timestamp even on resume). Used to place subagent histories under the right
- // per-session folder.
- effectiveSessionID := strings.TrimSuffix(filepath.Base(historyPath), ".json")
+ // per-session folder. The helper falls back to "" for empty or unsafe IDs,
+ // which disables subagent history persistence (in-memory fallback) instead of
+ // writing files outside the session folder.
+ effectiveSessionID := deriveEffectiveSessionID(historyPath)
// Load existing history
history, err := session.LoadHistory(historyPath)
@@ -405,6 +407,19 @@ func main() {
}
}
+// deriveEffectiveSessionID derives this run's session ID from the FINAL
+// history path so resumed sessions keep their original ID. It returns ""
+// for empty or unsafe results (a crafted meta file could claim an ID like
+// ".."), which disables subagent history persistence for the run
+// (in-memory fallback) instead of writing files outside the session folder.
+func deriveEffectiveSessionID(historyPath string) string {
+ id := strings.TrimSuffix(filepath.Base(historyPath), ".json")
+ if id == "" || id == "." || id == ".." {
+ return ""
+ }
+ return id
+}
+
func mcpToolEnabled(t tool.Tool, enabledTools map[string]bool) bool {
if enabled, exists := enabledTools[t.Name()]; exists {
return enabled
diff --git a/cmd/late/main_test.go b/cmd/late/main_test.go
index 41d5512..67e6d71 100644
--- a/cmd/late/main_test.go
+++ b/cmd/late/main_test.go
@@ -126,3 +126,49 @@ func TestHandleSessionDelete_LegacyFlatSession(t *testing.T) {
assertFileGone(t, metaC)
assertFileGone(t, historyC)
}
+
+func TestDeriveEffectiveSessionID(t *testing.T) {
+ tests := []struct {
+ name string
+ historyPath string
+ want string
+ }{
+ {
+ name: "plain session history file",
+ historyPath: "session-20260815-123456.json",
+ want: "session-20260815-123456",
+ },
+ {
+ name: "full path uses base name",
+ historyPath: "/tmp/sessions/session-abc.json",
+ want: "session-abc",
+ },
+ {
+ name: "parent directory reference",
+ historyPath: "..",
+ want: "",
+ },
+ {
+ name: "full path ending in parent directory",
+ historyPath: "/some/dir/..",
+ want: "",
+ },
+ {
+ name: "current directory reference",
+ historyPath: ".",
+ want: "",
+ },
+ {
+ name: "json suffix only",
+ historyPath: ".json",
+ want: "",
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ if got := deriveEffectiveSessionID(tt.historyPath); got != tt.want {
+ t.Errorf("deriveEffectiveSessionID(%q) = %q, want %q", tt.historyPath, got, tt.want)
+ }
+ })
+ }
+}
From 33d8ca23c3c49f4d1b86ac4a35c07b7e70dc6e5b Mon Sep 17 00:00:00 2001
From: jpia <6011922+jpia@users.noreply.github.com>
Date: Sun, 16 Aug 2026 00:05:17 -0500
Subject: [PATCH 4/5] refactor(agent): require BaseOrchestrator parent, drop
dead child-ID fallbacks
---
internal/agent/agent.go | 16 ++++++-------
internal/agent/agent_test.go | 45 ++++++++++++++++++++++++++++++++++++
2 files changed, 52 insertions(+), 9 deletions(-)
diff --git a/internal/agent/agent.go b/internal/agent/agent.go
index 8293a95..f445eac 100644
--- a/internal/agent/agent.go
+++ b/internal/agent/agent.go
@@ -62,16 +62,14 @@ func NewSubagentOrchestrator(
systemPrompt = "<|think|>" + systemPrompt
}
- // Mint the child ID up-front so it can be embedded in the subagent history path.
- var id string
- if p, ok := parent.(*orchestrator.BaseOrchestrator); ok {
- id = p.NextChildID(agentType)
- } else if parent != nil {
- // Fallback for non-BaseOrchestrator parents (test fakes only).
- id = fmt.Sprintf("%s-subagent-%d", agentType, len(parent.Children()))
- } else {
- id = fmt.Sprintf("%s-subagent-0", agentType)
+ // Mint the child ID up-front so it can be embedded in the subagent history
+ // path. The parent must be a *BaseOrchestrator: its mutex-protected counter
+ // is the only ID source that cannot collide under concurrent spawns.
+ baseParent, ok := parent.(*orchestrator.BaseOrchestrator)
+ if !ok {
+ return nil, fmt.Errorf("subagent parent must be a *orchestrator.BaseOrchestrator")
}
+ id := baseParent.NextChildID(agentType)
// 2. Setup Subagent Session (Isolated History; persisted only when opted in)
var subagentHistoryPath string
diff --git a/internal/agent/agent_test.go b/internal/agent/agent_test.go
index df9157b..cbdba61 100644
--- a/internal/agent/agent_test.go
+++ b/internal/agent/agent_test.go
@@ -350,6 +350,51 @@ func TestNewSubagentOrchestrator_PersistsWhenOptedIn(t *testing.T) {
}
}
+// TestNewSubagentOrchestrator_RejectsUnsafeParentSessionID verifies that an
+// unsafe parentSessionID (e.g. "..") is rejected at history path resolution
+// before anything is written to the session directory (defense in depth;
+// Phase 2 makes this unreachable from production).
+func TestNewSubagentOrchestrator_RejectsUnsafeParentSessionID(t *testing.T) {
+ tmp := t.TempDir()
+ setSessionDirForTest(t, tmp)
+
+ cfg := client.Config{BaseURL: "http://localhost:8080"}
+ c := client.NewClient(cfg)
+
+ mockSession := session.New(c, "/tmp/mock-session.json", []client.ChatMessage{}, "mock system prompt", true)
+ parent := orchestrator.NewBaseOrchestrator("parent", mockSession, nil, 10)
+
+ const goal = "persist me"
+ _, err := NewSubagentOrchestrator(
+ c,
+ goal,
+ []string{},
+ "coder",
+ map[string]bool{}, // enabledTools
+ false, // injectCWD
+ false, // gemmaThinking
+ 10, // maxTurns
+ "..", // parentSessionID
+ true, // saveSubagentHistory
+ parent,
+ nil, // messenger
+ )
+ if err == nil {
+ t.Fatalf("Expected error for unsafe parentSessionID, got nil")
+ }
+ if !strings.Contains(err.Error(), "failed to resolve subagent history path") {
+ t.Errorf("Expected error to contain %q, got: %v", "failed to resolve subagent history path", err)
+ }
+
+ entries, err := os.ReadDir(tmp)
+ if err != nil {
+ t.Fatalf("Failed to read temp sessions dir: %v", err)
+ }
+ if len(entries) != 0 {
+ t.Errorf("Expected zero entries in temp sessions dir, got %d: %v", len(entries), entries)
+ }
+}
+
// TestNewSubagentOrchestrator_InMemoryByDefault verifies that with
// saveSubagentHistory=false the subagent session is fully in-memory: nothing
// (not even a directory artifact) is written to the session directory.
From 686b8bad72afa343ffb901ff8f686d91e1e5b6c9 Mon Sep 17 00:00:00 2001
From: jpia <6011922+jpia@users.noreply.github.com>
Date: Sun, 16 Aug 2026 00:15:53 -0500
Subject: [PATCH 5/5] chore: warning casing, NextChildID docs, transcript
sensitivity note
---
cmd/late/main.go | 2 +-
docs/quickstart.md | 2 +-
docs/quickstart.zh-CN.md | 2 +-
internal/orchestrator/base.go | 4 +++-
4 files changed, 6 insertions(+), 4 deletions(-)
diff --git a/cmd/late/main.go b/cmd/late/main.go
index 9d3948c..587a948 100644
--- a/cmd/late/main.go
+++ b/cmd/late/main.go
@@ -582,7 +582,7 @@ func handleSessionDelete(id string) {
// legacy flat sessions without a folder. Non-fatal: the session itself is already
// gone, so don't block the success message on leftover artifacts.
if err := session.RemoveSessionFolder(meta.ID); err != nil {
- fmt.Fprintf(os.Stderr, "Warning: failed to delete subagent history folder: %v\n", err)
+ fmt.Fprintf(os.Stderr, "Warning: Failed to delete subagent history folder: %v\n", err)
}
fmt.Printf("Deleted session: %s\n", meta.Title)
diff --git a/docs/quickstart.md b/docs/quickstart.md
index fa4edf0..3be6860 100644
--- a/docs/quickstart.md
+++ b/docs/quickstart.md
@@ -255,7 +255,7 @@ late session load # Resume a previous session
late session delete # Delete a session
```
-By default, subagent conversations are kept in memory and discarded. With `--save-subagent-histories` (or `"save_subagent_histories": true` in the config file), each subagent's history is saved under the session folder — `~/.local/share/late/sessions//subagents/-subagent-.json` — while the parent session files stay in place. `late session delete ` removes the subagent folder along with the session.
+By default, subagent conversations are kept in memory and discarded. With `--save-subagent-histories` (or `"save_subagent_histories": true` in the config file), each subagent's history is saved under the session folder — `~/.local/share/late/sessions//subagents/-subagent-.json` — while the parent session files stay in place. `late session delete ` removes the subagent folder along with the session. Note that saved transcripts may contain sensitive content (file contents, tool outputs); they are written with user-only permissions.
## Git Worktrees
diff --git a/docs/quickstart.zh-CN.md b/docs/quickstart.zh-CN.md
index 28c0258..8386996 100644
--- a/docs/quickstart.zh-CN.md
+++ b/docs/quickstart.zh-CN.md
@@ -253,7 +253,7 @@ late session load # 恢复指定的历史会话
late session delete # 删除指定的会话
```
-默认情况下,子智能体的对话仅保存在内存中,结束后即被丢弃。使用 `--save-subagent-histories`(或在配置文件中设置 `"save_subagent_histories": true`)后,每个子智能体的对话历史记录都会被保存到会话文件夹下 —— `~/.local/share/late/sessions//subagents/-subagent-.json` —— 而父会话的原始文件位置保持不变。运行 `late session delete ` 删除会话时,子智能体文件夹也会随之被一并移除。
+默认情况下,子智能体的对话仅保存在内存中,结束后即被丢弃。使用 `--save-subagent-histories`(或在配置文件中设置 `"save_subagent_histories": true`)后,每个子智能体的对话历史记录都会被保存到会话文件夹下 —— `~/.local/share/late/sessions//subagents/-subagent-.json` —— 而父会话的原始文件位置保持不变。运行 `late session delete ` 删除会话时,子智能体文件夹也会随之被一并移除。请注意,保存的对话记录可能包含敏感内容(文件内容、工具输出);这些文件以仅限用户读取的权限写入。
## Git 工作树 (Git Worktrees)
diff --git a/internal/orchestrator/base.go b/internal/orchestrator/base.go
index 4546087..4bdb0c6 100644
--- a/internal/orchestrator/base.go
+++ b/internal/orchestrator/base.go
@@ -476,7 +476,9 @@ func (o *BaseOrchestrator) Rewind(index int) error {
// NextChildID atomically mints the next child ID under o.mu. The counter is
// monotonic and independent of len(children), so concurrent spawns can never
-// produce duplicate IDs.
+// produce duplicate IDs. The counter is shared across agent types (e.g.,
+// `researcher-subagent-0`, then `coder-subagent-1`), matching the legacy
+// `len(children)` numbering scheme.
func (o *BaseOrchestrator) NextChildID(agentType string) string {
o.mu.Lock()
defer o.mu.Unlock()