Skip to content

Commit 456cc05

Browse files
committed
feat: support explicit subagent session files
1 parent 8405e55 commit 456cc05

14 files changed

Lines changed: 232 additions & 11 deletions

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Added
11+
- **`session_file` for persistent subagent session lanes.** Custom agent frontmatter and the `Agent` tool can now point a subagent at an explicit JSONL session file, e.g. `.agents/sessions/KEY.dev.jsonl`, instead of only choosing a `session_dir` and accepting a generated filename. `session_file` implies persistence, resolves relative paths from the requested agent cwd before worktree isolation is applied, supports `~` and absolute paths, opens existing valid session files for append/resume, and creates missing files at the requested path. This builds on `persist_session` / `session_dir` while making long-running issue-keyed plan/dev/review lanes stable enough for humans and orchestration tools to inspect or continue later.
12+
1013
## [0.13.0] - 2026-06-30
1114

1215
### Added

README.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ https://github.com/user-attachments/assets/8685261b-9338-4fea-8dfe-1c590d5df543
1818
- **Custom agent types** — define agents in `.pi/agents/<name>.md` with YAML frontmatter: custom system prompts, model selection, thinking levels, tool restrictions
1919
- **Mid-run steering** — inject messages into running agents to redirect their work without restarting
2020
- **Session resume** — pick up where an agent left off, preserving full conversation context
21+
- **Explicit session files** — persist subagents into stable JSONL lanes such as `.agents/sessions/KEY.dev.jsonl`, so humans and orchestration tools can inspect or continue the same role-specific session later
2122
- **Graceful turn limits** — agents get a "wrap up" warning before hard abort, producing clean partial results instead of cut-off output
2223
- **Case-insensitive agent types**`"explore"`, `"Explore"`, `"EXPLORE"` all work. Unknown types fall back to general-purpose with a note
2324
- **Fuzzy model selection** — specify models by name (`"haiku"`, `"sonnet"`) instead of full IDs, with automatic filtering to only available/configured models
@@ -220,13 +221,14 @@ All fields are optional — sensible defaults for everything.
220221
| `max_turns` | unlimited | Max agentic turns before graceful shutdown. `0` or omit for unlimited |
221222
| `persist_session` | `false` | Persist this subagent as a normal pi session instead of keeping the session in memory only. The sidechain output transcript is still written either way |
222223
| `session_dir` | pi default | Optional session directory when `persist_session: true`; omitted uses pi's normal session location, and relative paths resolve from the agent cwd |
224+
| `session_file` || Optional explicit session JSONL file. Implies persistence; relative paths resolve from the requested agent cwd before worktree isolation is applied; existing files are opened and appended/resumed instead of overwritten |
223225
| `prompt_mode` | `replace` | `replace`: body is the full system prompt (no AGENTS.md / CLAUDE.md inheritance). `append`: body appended to parent's prompt (agent acts as a "parent twin" — inherits parent's AGENTS.md / CLAUDE.md) |
224226
| `inherit_context` | `false` | Fork parent conversation into agent |
225227
| `run_in_background` | `false` | Run in background by default |
226228
| `isolated` | `false` | Hermetic specialist mode: forces `extensions: false` + `skills: false` + drops `ext:` selectors. Only built-in tools. Distinct from `isolation: worktree` (filesystem) |
227229
| `enabled` | `true` | Set to `false` to disable an agent (useful for hiding a default agent per-project) |
228230

229-
Frontmatter is authoritative. If an agent file sets `model`, `thinking`, `max_turns`, `inherit_context`, `run_in_background`, `isolated`, or `isolation`, those values are locked for that agent. `Agent` tool parameters only fill fields the agent config leaves unspecified.
231+
Frontmatter is authoritative. If an agent file sets `model`, `thinking`, `max_turns`, `inherit_context`, `run_in_background`, `isolated`, `isolation`, or `session_file`, those values are locked for that agent. `Agent` tool parameters only fill fields the agent config leaves unspecified.
230232

231233
**Forgiving `model:` resolution.** A `model:` pin is matched against pi's model registry tolerantly, so cosmetic id variations don't silently drop the agent back to the parent's model: `.` and `-` are treated as equivalent in version numbers (`claude-haiku-4.5` ≡ `claude-haiku-4-5`), a trailing `-YYYYMMDD` date stamp is optional (`anthropic/claude-haiku-4-5-20251001` matches an undated registry id and vice-versa), and a `provider/modelId` whose named provider doesn't carry that model retries the bare id against every provider. Precedence is **exact → fuzzy under the named provider → same model under any provider → unavailable**, so an exact match always wins and dated snapshots aren't conflated. If nothing resolves, the pin can't run and the agent inherits the parent model — `/agents → Agent types` flags this case as `(unavailable, fallback: inherit)` and shows the resolved target `(→ provider/id)` when resolution lands on a different provider or version than configured. (This is distinct from [Model Scope](#model-scope) enforcement, which matches the `enabledModels` allowlist by *exact* entry.)
232234

@@ -281,6 +283,7 @@ Launch a sub-agent.
281283
| `max_turns` | number | no | Max agentic turns. Omit for unlimited (default) |
282284
| `run_in_background` | boolean | no | Run without blocking |
283285
| `resume` | string | no | Agent ID to resume a previous session |
286+
| `session_file` | string | no | Explicit session JSONL file. Implies persistence; relative paths resolve from the requested agent cwd before worktree isolation is applied; existing files are opened and appended/resumed |
284287
| `isolated` | boolean | no | No extension/MCP tools |
285288
| `isolation` | `"worktree"` | no | Run in an isolated git worktree |
286289
| `inherit_context` | boolean | no | Fork parent conversation into agent |

src/agent-manager.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,8 @@ interface SpawnOptions {
7070
bypassQueue?: boolean;
7171
/** Isolation mode — "worktree" creates a temp git worktree for the agent. */
7272
isolation?: IsolationMode;
73+
/** Explicit session JSONL file. Implies persistence and resumes/appends when it exists. */
74+
sessionFile?: string;
7375
/**
7476
* Working directory for the agent (absolute path). Default: parent session
7577
* cwd. The agent's tools operate here, but .pi config (extensions, skills,
@@ -252,6 +254,8 @@ export class AgentManager {
252254
isolated: options.isolated,
253255
inheritContext: options.inheritContext,
254256
thinkingLevel: options.thinkingLevel,
257+
sessionFile: options.sessionFile,
258+
sessionFileCwd: baseCwd,
255259
// Worktree wins for the working dir (the agent must run in the copy —
256260
// which, with a custom cwd, was created from that target). Config stays
257261
// with the parent project when a caller-supplied cwd is in play; it must
@@ -379,7 +383,7 @@ export class AgentManager {
379383
while (this.queue.length > 0 && this.runningBackground < this.maxConcurrent) {
380384
const next = this.queue.shift()!;
381385
const record = this.agents.get(next.id);
382-
if (!record || record.status !== "queued") continue;
386+
if (record?.status !== "queued") continue;
383387
try {
384388
this.startAgent(next.id, record, next.args);
385389
} catch (err) {

src/agent-runner.ts

Lines changed: 30 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
* agent-runner.ts — Core execution engine: creates sessions, runs agents, collects results.
33
*/
44

5+
import { mkdirSync } from "node:fs";
56
import { homedir } from "node:os";
67
import { basename, dirname, isAbsolute, resolve } from "node:path";
78
import type { Model } from "@earendil-works/pi-ai";
@@ -206,6 +207,10 @@ export interface RunOptions {
206207
thinkingLevel?: ThinkingLevel;
207208
/** Override working directory (e.g. for worktree isolation). */
208209
cwd?: string;
210+
/** Explicit session JSONL file. Implies persistence and resumes/appends when it exists. */
211+
sessionFile?: string;
212+
/** Base directory used to resolve a relative sessionFile. Defaults to the effective cwd. */
213+
sessionFileCwd?: string;
209214
/**
210215
* Where .pi config is discovered (project extensions, skills, pi settings,
211216
* agent memory). Default: same as the working directory. The manager sets
@@ -288,11 +293,20 @@ function forwardAbortSignal(session: AgentSession, signal?: AbortSignal): () =>
288293
return () => signal.removeEventListener("abort", onAbort);
289294
}
290295

296+
function resolveConfiguredPath(path: string | undefined, cwd: string): string | undefined {
297+
if (!path) return undefined;
298+
if (path === "~") return homedir();
299+
if (path.startsWith("~/")) return resolve(homedir(), path.slice(2));
300+
if (isAbsolute(path)) return path;
301+
return resolve(cwd, path);
302+
}
303+
291304
function resolveConfiguredSessionDir(sessionDir: string | undefined, cwd: string): string | undefined {
292-
if (!sessionDir) return undefined;
293-
if (sessionDir === "~" || sessionDir.startsWith("~/")) return resolve(homedir(), sessionDir.slice(2));
294-
if (isAbsolute(sessionDir)) return sessionDir;
295-
return resolve(cwd, sessionDir);
305+
return resolveConfiguredPath(sessionDir, cwd);
306+
}
307+
308+
function resolveConfiguredSessionFile(sessionFile: string | undefined, cwd: string): string | undefined {
309+
return resolveConfiguredPath(sessionFile, cwd);
296310
}
297311

298312
export async function runAgent(
@@ -563,11 +577,19 @@ export async function runAgent(
563577
});
564578

565579
const settingsManager = SettingsManager.create(configCwd, agentDir);
566-
const configuredSessionDir = resolveConfiguredSessionDir(agentConfig?.sessionDir, effectiveCwd);
580+
const sessionFileInput = options.sessionFile ?? agentConfig?.sessionFile;
581+
const sessionConfigCwd = sessionFileInput ? (options.sessionFileCwd ?? effectiveCwd) : effectiveCwd;
582+
const configuredSessionDir = resolveConfiguredSessionDir(agentConfig?.sessionDir, sessionConfigCwd);
583+
const configuredSessionFile = resolveConfiguredSessionFile(sessionFileInput, sessionConfigCwd);
567584
const defaultSessionDir = process.env.PI_CODING_AGENT_SESSION_DIR ?? settingsManager.getSessionDir?.();
568-
const sessionManager = agentConfig?.persistSession
569-
? SessionManager.create(effectiveCwd, configuredSessionDir ?? defaultSessionDir)
570-
: SessionManager.inMemory(effectiveCwd);
585+
if (configuredSessionFile) {
586+
mkdirSync(dirname(configuredSessionFile), { recursive: true });
587+
}
588+
const sessionManager = configuredSessionFile
589+
? SessionManager.open(configuredSessionFile, configuredSessionDir ?? dirname(configuredSessionFile), effectiveCwd)
590+
: agentConfig?.persistSession
591+
? SessionManager.create(effectiveCwd, configuredSessionDir ?? defaultSessionDir)
592+
: SessionManager.inMemory(effectiveCwd);
571593

572594
const sessionOpts: Parameters<typeof createAgentSession>[0] = {
573595
cwd: effectiveCwd,

src/custom-agents.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ function loadFromDir(dir: string, agents: Map<string, AgentConfig>, source: "pro
6767
maxTurns: nonNegativeInt(fm.max_turns),
6868
persistSession: fm.persist_session != null ? fm.persist_session === true : undefined,
6969
sessionDir: str(fm.session_dir),
70+
sessionFile: str(fm.session_file),
7071
systemPrompt: body.trim(),
7172
promptMode: fm.prompt_mode === "append" ? "append" : "replace",
7273
inheritContext: fm.inherit_context != null ? fm.inherit_context === true : undefined,

src/index.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -860,6 +860,11 @@ Terse command-style prompts produce shallow, generic work.
860860
description: "Optional agent ID to resume from. Continues from previous context.",
861861
}),
862862
),
863+
session_file: Type.Optional(
864+
Type.String({
865+
description: "Optional explicit session JSONL file. Implies persistence and resumes/appends when the file exists. Relative paths resolve from the requested agent cwd before worktree isolation is applied.",
866+
}),
867+
),
863868
isolated: Type.Optional(
864869
Type.Boolean({
865870
description: "If true, agent gets no extension/MCP tools — only built-in tools.",
@@ -1033,6 +1038,7 @@ Terse command-style prompts produce shallow, generic work.
10331038
const runInBackground = resolvedConfig.runInBackground;
10341039
const isolated = resolvedConfig.isolated;
10351040
const isolation = resolvedConfig.isolation;
1041+
const sessionFile = resolvedConfig.sessionFile;
10361042

10371043
const parentModelId = ctx.model?.id;
10381044
const effectiveModelId = model?.id;
@@ -1092,6 +1098,7 @@ Terse command-style prompts produce shallow, generic work.
10921098
max_turns: effectiveMaxTurns,
10931099
isolated: isolated,
10941100
isolation: isolation,
1101+
session_file: sessionFile,
10951102
});
10961103
const next = scheduler.getNextRun(job.id);
10971104
return textResult(
@@ -1150,6 +1157,7 @@ Terse command-style prompts produce shallow, generic work.
11501157
thinkingLevel: thinking,
11511158
isBackground: true,
11521159
isolation,
1160+
sessionFile,
11531161
invocation: agentInvocation,
11541162
...bgCallbacks,
11551163
});
@@ -1276,6 +1284,7 @@ Terse command-style prompts produce shallow, generic work.
12761284
inheritContext,
12771285
thinkingLevel: thinking,
12781286
isolation,
1287+
sessionFile,
12791288
invocation: agentInvocation,
12801289
signal,
12811290
...fgCallbacks,

src/invocation-config.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ interface AgentInvocationParams {
88
inherit_context?: boolean;
99
isolated?: boolean;
1010
isolation?: IsolationMode;
11+
session_file?: string;
1112
}
1213

1314
export function resolveAgentInvocationConfig(
@@ -22,6 +23,7 @@ export function resolveAgentInvocationConfig(
2223
runInBackground: boolean;
2324
isolated: boolean;
2425
isolation?: IsolationMode;
26+
sessionFile?: string;
2527
} {
2628
return {
2729
modelInput: agentConfig?.model ?? params.model,
@@ -32,6 +34,7 @@ export function resolveAgentInvocationConfig(
3234
runInBackground: agentConfig?.runInBackground ?? params.run_in_background ?? false,
3335
isolated: agentConfig?.isolated ?? params.isolated ?? false,
3436
isolation: agentConfig?.isolation ?? params.isolation,
37+
sessionFile: agentConfig?.sessionFile ?? params.session_file,
3538
};
3639
}
3740

src/schedule.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ export interface NewJobInput {
4343
max_turns?: number;
4444
isolated?: boolean;
4545
isolation?: IsolationMode;
46+
session_file?: string;
4647
}
4748

4849
export class SubagentScheduler {
@@ -106,6 +107,7 @@ export class SubagentScheduler {
106107
max_turns: input.max_turns,
107108
isolated: input.isolated,
108109
isolation: input.isolation,
110+
session_file: input.session_file,
109111
enabled: true,
110112
createdAt: new Date().toISOString(),
111113
runCount: 0,
@@ -247,6 +249,7 @@ export class SubagentScheduler {
247249
isolated: job.isolated,
248250
thinkingLevel: job.thinking,
249251
isolation: job.isolation,
252+
sessionFile: job.session_file,
250253
});
251254
} catch (err) {
252255
const error = err instanceof Error ? err.message : String(err);

src/types.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,8 @@ export interface AgentConfig {
4545
persistSession?: boolean;
4646
/** Optional session directory used when persistSession is true. Omitted = pi's normal session location. */
4747
sessionDir?: string;
48+
/** Optional explicit session JSONL file. Implies persistence and resumes/appends when the file exists. */
49+
sessionFile?: string;
4850
systemPrompt: string;
4951
promptMode: "replace" | "append";
5052
/** Default for spawn: fork parent conversation. undefined = caller decides. */
@@ -187,6 +189,8 @@ export interface ScheduledSubagent {
187189
max_turns?: number;
188190
isolated?: boolean;
189191
isolation?: IsolationMode;
192+
/** Explicit session JSONL file. Implies persistence and resumes/appends when the file exists. */
193+
session_file?: string;
190194

191195
// state
192196
enabled: boolean;

test/agent-manager.test.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -580,6 +580,28 @@ describe("AgentManager — SpawnOptions.cwd passthrough (#96)", () => {
580580
expect(cleanupWorktree).toHaveBeenCalledWith("/", expect.anything(), "test");
581581
});
582582

583+
it("worktree isolation resolves explicit session files against the original base cwd", async () => {
584+
const { createWorktree } = await import("../src/worktree.js");
585+
vi.mocked(createWorktree).mockReturnValueOnce({
586+
path: "/wt/copy", branch: "pi-agent-x", baseSha: "abc", workPath: "/wt/copy/sub/dir",
587+
});
588+
vi.mocked(runAgent).mockClear();
589+
resolvedRun();
590+
591+
manager = new AgentManager();
592+
const id = manager.spawn(mockPi, mockCtx, "general-purpose", "test", {
593+
description: "test",
594+
isolation: "worktree",
595+
sessionFile: ".agents/sessions/KEY.dev.jsonl",
596+
});
597+
await manager.getRecord(id)!.promise;
598+
599+
const opts = vi.mocked(runAgent).mock.lastCall![3];
600+
expect(opts.cwd).toBe("/wt/copy");
601+
expect(opts.sessionFile).toBe(".agents/sessions/KEY.dev.jsonl");
602+
expect(opts.sessionFileCwd).toBe("/tmp");
603+
});
604+
583605
it("plain worktree (no cwd) keeps the historical root working dir even when workPath differs", async () => {
584606
// Parent session sitting in a repo subdirectory: workPath would point at
585607
// the copied subdir. Without SpawnOptions.cwd the agent must stay at the

0 commit comments

Comments
 (0)