@gotgenes/pi-subagents 11.3.0 → 11.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +16 -0
- package/docs/architecture/architecture.md +149 -249
- package/docs/decisions/0002-extensions-on-a-minimal-core.md +98 -0
- package/docs/plans/0257-extract-child-session-factory.md +283 -0
- package/docs/plans/0262-add-workspace-provider-seam.md +262 -0
- package/docs/retro/0256-extract-worktree-isolation.md +44 -0
- package/docs/retro/0257-extract-child-session-factory.md +31 -0
- package/docs/retro/0262-add-workspace-provider-seam.md +44 -0
- package/package.json +1 -1
- package/src/index.ts +3 -0
- package/src/lifecycle/agent-manager.ts +30 -0
- package/src/lifecycle/agent-runner.ts +14 -9
- package/src/lifecycle/agent.ts +44 -7
- package/src/lifecycle/child-lifecycle.ts +89 -0
- package/src/lifecycle/workspace.ts +47 -0
- package/src/service/service-adapter.ts +6 -0
- package/src/service/service.ts +13 -1
- package/src/lifecycle/permission-bridge.ts +0 -63
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
---
|
|
2
|
+
status: accepted
|
|
3
|
+
date: 2026-05-29
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# 0002 — Workspaces and permissions are extensions on a minimal core
|
|
7
|
+
|
|
8
|
+
## Status
|
|
9
|
+
|
|
10
|
+
Accepted.
|
|
11
|
+
Supersedes the "agent collaborator architecture" framing of Phase 16 (an abandoned exploration) and the work shipped under it: issue #256 (`WorktreeIsolation` as an `Agent` collaborator) and issue #257 (`ChildSessionFactory` extraction, parked at planning).
|
|
12
|
+
Reclaims Phase 16's original intent — "invert dependencies" — and extends it to evict worktree isolation from the core.
|
|
13
|
+
|
|
14
|
+
## Context
|
|
15
|
+
|
|
16
|
+
The core question that triggered this decision: a single-method `ChildSessionFactory` with a `create(cwd?)` method (planned for #257) looked like it wanted to be a function, and the `cwd` parameter was late-bound.
|
|
17
|
+
Pulling that thread exposed progressively more rudimentary issues.
|
|
18
|
+
|
|
19
|
+
1. `cwd` is late-bound because `WorktreeIsolation.setup()` is called lazily inside `Agent.run()`, after construction — a two-phase `construct-then-setup()` that violates design principle 8 ("Construct complete").
|
|
20
|
+
2. The worktree is *ready* only at dequeue (a concurrency slot is held and `git worktree add` has run).
|
|
21
|
+
"Construct when ready" therefore means constructing the worktree at run-start, not at spawn — which dissolves the lazy `setup()` and makes `cwd` knowable at construction.
|
|
22
|
+
3. The worktree and the child session share one lifespan: both are born at run-start and torn down at completion (the worktree's cleanup saves a branch; the session is disposed).
|
|
23
|
+
Resources with one lifetime are one resource, not sibling collaborators that `Agent` must sequence.
|
|
24
|
+
The `create(cwd?)` parameter only existed because we split one run-scoped resource (the worktree) out and made `Agent` relay its output back in.
|
|
25
|
+
4. Worktrees are not intrinsic to what makes subagents useful.
|
|
26
|
+
The maintainer never uses them (WIP-of-1, trunk-based, CI/CD).
|
|
27
|
+
Git worktree isolation is one *strategy* for answering "where does this child run, and what brackets the run?"
|
|
28
|
+
— a container, a throwaway tmpdir, or a remote sandbox are others.
|
|
29
|
+
The core needs only *a working directory and a disposal hook*; the default (the parent's cwd, no setup/teardown) is always correct.
|
|
30
|
+
5. This mirrors Phase 14, which evicted tool/extension *policy* (`disallowed_tools`, `extensions` filtering) to `@gotgenes/pi-permission-system`.
|
|
31
|
+
Worktrees are *environment* policy; they belong outside the core for the same reason.
|
|
32
|
+
|
|
33
|
+
Permissions and workspaces are orthogonal concerns that must compose as independent extensions on the core, never knowing about each other.
|
|
34
|
+
|
|
35
|
+
## Decision
|
|
36
|
+
|
|
37
|
+
pi-subagents is a minimal orchestrator: it spawns a child session derived from the parent, runs the turn loop, tracks and streams and collects the result, gates concurrency, supports resume, and **publishes its lifecycle**.
|
|
38
|
+
Everything else attaches through exactly two extension surfaces, distinguished by the direction of information flow.
|
|
39
|
+
|
|
40
|
+
### Two extension surfaces
|
|
41
|
+
|
|
42
|
+
1. **Lifecycle events (observational) — unlimited.**
|
|
43
|
+
The core emits awaited, ordered events for the child-execution lifecycle (`spawning`, `session-created` pre-`bindExtensions`, `completed`, `disposed`).
|
|
44
|
+
Any number of extensions subscribe; handlers return nothing.
|
|
45
|
+
Reactive concerns live here: permission detection, telemetry, UI, notifications.
|
|
46
|
+
Adding a reactive concern never modifies the core.
|
|
47
|
+
|
|
48
|
+
2. **Provider seams (generative) — rationed.**
|
|
49
|
+
The rare concern that must *inject* a value the core consumes synchronously registers a provider the core consults.
|
|
50
|
+
Today there is exactly one: the **workspace provider** (it returns the child's working directory plus bracketed setup/teardown).
|
|
51
|
+
A provider seam is the only place the core is "open," so the list is kept as small as possible.
|
|
52
|
+
|
|
53
|
+
### The discriminator
|
|
54
|
+
|
|
55
|
+
When deciding how a concern attaches:
|
|
56
|
+
|
|
57
|
+
- It only needs to **know** what happened → subscribe to a lifecycle event (observational, unlimited).
|
|
58
|
+
- It must **return a value the core consumes** → register a provider (generative, rationed).
|
|
59
|
+
|
|
60
|
+
Permissions are observational: the core does not enforce policy; it publishes the child's identity at the pre-bind instant so the permission extension (loaded in the child) can detect "am I a subagent?"
|
|
61
|
+
and gate tool calls at runtime.
|
|
62
|
+
Workspaces are generative: the core cannot default the cwd away when an isolation strategy is requested, so the provider hands it back.
|
|
63
|
+
|
|
64
|
+
### The governing rule: no vacant hooks
|
|
65
|
+
|
|
66
|
+
The architecture must *admit* a seam without *shipping* it until a concrete consumer exists.
|
|
67
|
+
A provider seam with no consumer is not extensibility — it is a speculative abstraction that taxes every reader, and `fallow` flags it as dead.
|
|
68
|
+
Latent extensibility (the design can host the seam additively) is the deliverable; a vacant hook is not.
|
|
69
|
+
|
|
70
|
+
### What leaves the core
|
|
71
|
+
|
|
72
|
+
- **Worktree isolation** (`worktree.ts`, `worktree-isolation.ts`, `GitWorktreeManager`, the `isolation: "worktree"` spawn mode) → a new package, `@gotgenes/pi-subagents-worktrees`, that implements the workspace provider and owns the git plumbing and the "saved to branch" result.
|
|
73
|
+
- **`permission-bridge.ts`** → retired.
|
|
74
|
+
The core stops reaching *out* to `Symbol.for("@gotgenes/pi-permission-system:service")` and instead *emits* lifecycle events the permission system subscribes to.
|
|
75
|
+
- **`isolated` / `extensions: false` / `noSkills`** → removed.
|
|
76
|
+
Deny-at-use (the in-child permission layer blocking disallowed tool calls) covers what `isolated` pretended to do for tools.
|
|
77
|
+
Prevent-load (refusing to bind an extension because of load-time side effects, cost, or true sandboxing) is genuinely generative and cannot be reduced to observation, so it is left as a *latent* (un-built) provider seam, added only if a real consumer needs it.
|
|
78
|
+
|
|
79
|
+
### What stays in the core (not policy)
|
|
80
|
+
|
|
81
|
+
- The **recursion guard** (stripping the core's own `subagent` / `get_subagent_result` / `steer_subagent` tools from children).
|
|
82
|
+
It defends the core's own invariant — a subagent must not recursively spawn — keyed off the core's own tool names.
|
|
83
|
+
With `isolated` gone, children always load the parent's resources, so the guard becomes unconditional rather than gated on `cfg.extensions`.
|
|
84
|
+
|
|
85
|
+
### Composition test
|
|
86
|
+
|
|
87
|
+
Install neither extension, only permissions, only workspaces, or both: the core is byte-for-byte identical in all four cases, and the two extensions never reference each other.
|
|
88
|
+
Permissions depend only on the core's events; workspaces depend only on the core's provider seam; the core depends on neither.
|
|
89
|
+
|
|
90
|
+
## Consequences
|
|
91
|
+
|
|
92
|
+
- The "agent collaborator architecture" Phase 16 (give `Agent` a worktree collaborator + a session factory) is abandoned.
|
|
93
|
+
#256 is superseded (worktree was placed in the wrong layer); #257 is parked (it polished a subsystem slated for eviction).
|
|
94
|
+
- A new package `@gotgenes/pi-subagents-worktrees` is introduced; the core spawn API drops `isolation` and `isolated`.
|
|
95
|
+
- `permission-bridge.ts` is removed; `@gotgenes/pi-permission-system` migrates from a published-service lookup to lifecycle-event subscription, which requires the core to emit an awaited, ordered `session-created` event before `bindExtensions()`.
|
|
96
|
+
Confirming Pi's event model supports awaited pre-bind emission is the first investigation of the reclaimed phase.
|
|
97
|
+
- Once the cwd is resolved through the provider seam rather than relayed by `Agent`, child-session creation can construct a born-complete execution and the "runner" concept dissolves — recovering the structural goal of the abandoned collaborator steps by a cleaner route.
|
|
98
|
+
- The reclaimed Phase 16 roadmap and step issues live in [`docs/architecture/architecture.md`](../architecture/architecture.md).
|
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
---
|
|
2
|
+
issue: 257
|
|
3
|
+
issue_title: "Extract ChildSessionFactory from runner"
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Extract ChildSessionFactory from runner
|
|
7
|
+
|
|
8
|
+
> Superseded — issue #257 was closed `not_planned`.
|
|
9
|
+
> Planning this extraction exposed that worktree isolation does not belong in the core; see [ADR 0002](../decisions/0002-extensions-on-a-minimal-core.md) and the reclaimed Phase 16 roadmap in [`docs/architecture/architecture.md`](../architecture/architecture.md).
|
|
10
|
+
> The structural goal is recovered by #265.
|
|
11
|
+
> This plan is retained for historical context only.
|
|
12
|
+
|
|
13
|
+
## Problem Statement
|
|
14
|
+
|
|
15
|
+
`runAgent()` in `src/lifecycle/agent-runner.ts` conflates two concerns.
|
|
16
|
+
The first is session *creation* — platform plumbing: env detection, config assembly, resource-loader construction, session-manager creation, `createSession()`, permission-bridge registration, `bindExtensions()`, and the post-bind recursion-guard tool filter.
|
|
17
|
+
The second is session *interaction* — prompting, turn tracking, soft/hard turn-limit enforcement, response collection, and abort forwarding.
|
|
18
|
+
|
|
19
|
+
This is Phase 16, Step 2 of the agent-collaborator architecture (`docs/architecture/architecture.md`).
|
|
20
|
+
The step extracts the creation concern into a narrow `ChildSessionFactory` collaborator so session creation becomes independently testable and so `permission-bridge.ts` is imported by the factory rather than the runner.
|
|
21
|
+
This is a lift-and-shift: `runAgent()` keeps its signature and delegates creation to the factory internally.
|
|
22
|
+
`Agent` is not touched — that is Step 3 (#258).
|
|
23
|
+
|
|
24
|
+
## Goals
|
|
25
|
+
|
|
26
|
+
- Define `ChildSessionFactory` (one method, `create(cwd?)`) and `ChildSessionResult` in a new module `src/lifecycle/child-session-factory.ts`.
|
|
27
|
+
- Move the session-creation block out of `runAgent()` into a `ConcreteChildSessionFactory` class bound per-agent with creation config.
|
|
28
|
+
- Move the `permission-bridge.ts` imports (`registerChildSession` / `unregisterChildSession`) and the recursion-guard helpers (`EXCLUDED_TOOL_NAMES`, `filterActiveTools`) from `agent-runner.ts` into the factory.
|
|
29
|
+
- Expose teardown as a `cleanup()` function on the result so the runner (and, in Step 3, `Agent`) never imports the permission bridge.
|
|
30
|
+
- Keep `runAgent()`'s signature `(snapshot, type, prompt, options, deps)` stable so the existing runner test suite continues to pass through delegation.
|
|
31
|
+
- Add factory-level unit tests for session creation.
|
|
32
|
+
|
|
33
|
+
This change is **not** breaking to any published API — `runAgent`, `RunnerDeps`, the IO interfaces, and the new factory types are all internal to the package.
|
|
34
|
+
|
|
35
|
+
## Non-Goals
|
|
36
|
+
|
|
37
|
+
- No changes to `Agent` (`src/lifecycle/agent.ts`), `AgentManager`, or the tools — Step 3 (#258) makes `Agent` own the session and call `factory.create()`.
|
|
38
|
+
- No `ConcreteAgentRunner.createFactory()` method yet — see the Design Overview decision below; it is added in Step 3 when `AgentManager` becomes its consumer.
|
|
39
|
+
- No removal of `runAgent`, `resumeAgent`, `RunOptions`, `RunResult`, or the runner concept — that is Step 4 (#259).
|
|
40
|
+
- No relocation of the session-creation IO interfaces (`RunnerIO`, `RunnerDeps`, `EnvironmentIO`, `SessionFactoryIO`, `CreateSessionOptions`, `ResourceLoaderOptions`, `ResourceLoaderLike`, `SessionManagerLike`) out of `agent-runner.ts` — they stay put to minimize churn; their home is revisited when the runner dissolves in Step 4.
|
|
41
|
+
- No change to `assembleSessionConfig`, `session-config.ts`, `worktree-isolation.ts`, or the permission-bridge module itself.
|
|
42
|
+
|
|
43
|
+
## Background
|
|
44
|
+
|
|
45
|
+
Relevant modules:
|
|
46
|
+
|
|
47
|
+
- `src/lifecycle/agent-runner.ts` — `runAgent()` performs creation (effectiveCwd resolution, `detectEnv`, `assembleSessionConfig`, `createResourceLoader`+`reload`, `deriveSessionDir`, `createSessionManager`+`newSession`, `createSession`, `registerChildSession`, `bindExtensions`, post-bind `filterActiveTools`) then interaction (turn-tracking subscription, `collectResponseText`, `forwardAbortSignal`, `prompt`, finally `unregisterChildSession`, build `RunResult`).
|
|
48
|
+
Holds the IO interfaces and `RunnerDeps`; `ConcreteAgentRunner.run()` delegates to `runAgent(..., this.deps)`.
|
|
49
|
+
- `src/lifecycle/permission-bridge.ts` — `registerChildSession` / `unregisterChildSession`; no-ops when pi-permission-system is absent.
|
|
50
|
+
Currently imported only by `agent-runner.ts`.
|
|
51
|
+
- `src/session/session-config.ts` — `assembleSessionConfig()` returns `SessionConfig` with `effectiveCwd`, `systemPrompt`, `toolNames`, `extensions`, `thinkingLevel`, `noSkills`, and `agentMaxTurns` (= `agentConfig.maxTurns`).
|
|
52
|
+
- `src/lifecycle/agent.ts` — `Agent.run()` calls `this._runner.run(...)`; `Agent` imports `RunResult` from the runner.
|
|
53
|
+
Unchanged in this step.
|
|
54
|
+
- `src/index.ts:136-166` — constructs `runnerDeps: RunnerDeps` and `new ConcreteAgentRunner(runnerDeps)`.
|
|
55
|
+
Unchanged.
|
|
56
|
+
|
|
57
|
+
Existing tests touching the runner:
|
|
58
|
+
|
|
59
|
+
- `test/lifecycle/agent-runner.test.ts` (313 lines) — final-output capture, `bindExtensions` ordering, cwd/agentDir wiring, AGENTS.md suppression, `sessionFile` in `RunResult`, `newSession` with `parentSession`, `defaultMaxTurns`/`graceTurns` enforcement, resume fallback, and a permission-bridge describe block (register-before-bind, unregister-on-success, unregister-on-throw, sessionDir-as-key, agentName/parentSessionId).
|
|
60
|
+
All exercise `runAgent()` directly via the `createRunnerIO()` helper and a `vi.mock("#src/lifecycle/permission-bridge")`.
|
|
61
|
+
- `test/lifecycle/agent-runner-extension-tools.test.ts` — the post-bind recursion guard (`setActiveToolsByName` ordering, EXCLUDED filtering, `extensions: false` skip).
|
|
62
|
+
- `test/lifecycle/agent-runner-settings.test.ts` — `normalizeMaxTurns` only.
|
|
63
|
+
- `test/lifecycle/concrete-agent-runner.test.ts` — `ConcreteAgentRunner.run()`/`resume()` delegation.
|
|
64
|
+
- `test/helpers/runner-io.ts` — `createRunnerIO()`, `createAgentLookup()`, `createRunnerDeps()` shared stubs.
|
|
65
|
+
|
|
66
|
+
AGENTS.md / skill constraints that apply:
|
|
67
|
+
|
|
68
|
+
- ES2024 target; Biome (not Prettier) formats; tabs (match `permission-bridge.ts`/`worktree-isolation.ts` style — new file uses tabs).
|
|
69
|
+
- Tests use `vi.hoisted(...)` for module-level mocks (the permission-bridge mock pattern already exists).
|
|
70
|
+
- fallow flags exports/members with no production consumer — drives the `createFactory` deferral decision below and the requirement that the factory have a production consumer (`runAgent`) by the end of the work.
|
|
71
|
+
- The full vitest suite must pass before publishing.
|
|
72
|
+
|
|
73
|
+
## Design Overview
|
|
74
|
+
|
|
75
|
+
### Decision model
|
|
76
|
+
|
|
77
|
+
`runAgent()` keeps its signature.
|
|
78
|
+
Internally it constructs a `ConcreteChildSessionFactory` from the creation-relevant inputs plus `deps`, calls `factory.create(options.context.cwd)` to obtain `{ session, outputFile, cleanup, agentMaxTurns }`, then runs the unchanged interaction logic.
|
|
79
|
+
The `finally` block calls `cleanup()` instead of `unregisterChildSession(sessionDir)`.
|
|
80
|
+
`RunResult.sessionFile` comes from the factory's `outputFile` instead of a second `sessionManager.getSessionFile()` call at the end (same value — `getSessionFile()` is stable after `newSession()`; the existing test asserts the constant `/sessions/child.jsonl`).
|
|
81
|
+
|
|
82
|
+
### Data shapes
|
|
83
|
+
|
|
84
|
+
```typescript
|
|
85
|
+
// src/lifecycle/child-session-factory.ts
|
|
86
|
+
import type { Model } from "@earendil-works/pi-ai";
|
|
87
|
+
import type { AgentSession } from "@earendil-works/pi-coding-agent";
|
|
88
|
+
import type { RunnerDeps } from "#src/lifecycle/agent-runner";
|
|
89
|
+
import type { ParentSnapshot } from "#src/lifecycle/parent-snapshot";
|
|
90
|
+
import type { ParentSessionInfo, SubagentType, ThinkingLevel } from "#src/types";
|
|
91
|
+
|
|
92
|
+
/** Per-agent session-creation config, bound at factory construction. */
|
|
93
|
+
export interface ChildSessionConfig {
|
|
94
|
+
snapshot: ParentSnapshot;
|
|
95
|
+
type: SubagentType;
|
|
96
|
+
model?: Model<any>;
|
|
97
|
+
isolated?: boolean;
|
|
98
|
+
thinkingLevel?: ThinkingLevel;
|
|
99
|
+
parentSession?: ParentSessionInfo;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Result of creating a configured child session. */
|
|
103
|
+
export interface ChildSessionResult {
|
|
104
|
+
session: AgentSession;
|
|
105
|
+
/** Path to the persisted session JSONL file, if persisted. */
|
|
106
|
+
outputFile?: string;
|
|
107
|
+
/** Tear down creation side effects (permission-bridge unregister). */
|
|
108
|
+
cleanup: () => void;
|
|
109
|
+
/**
|
|
110
|
+
* Per-agent configured max turns (from agentConfig.maxTurns) for the
|
|
111
|
+
* caller's turn-limit enforcement. Crosses the creation/interaction seam
|
|
112
|
+
* because it is computed during config assembly but consumed by the run loop.
|
|
113
|
+
*/
|
|
114
|
+
agentMaxTurns?: number;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Creates a configured child AgentSession. Narrow: one method. */
|
|
118
|
+
export interface ChildSessionFactory {
|
|
119
|
+
create(cwd?: string): Promise<ChildSessionResult>;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export class ConcreteChildSessionFactory implements ChildSessionFactory {
|
|
123
|
+
constructor(
|
|
124
|
+
private readonly config: ChildSessionConfig,
|
|
125
|
+
private readonly deps: RunnerDeps,
|
|
126
|
+
) {}
|
|
127
|
+
|
|
128
|
+
async create(cwd?: string): Promise<ChildSessionResult> { /* lifted creation block */ }
|
|
129
|
+
}
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
Two deliberate refinements of the issue's sketch, both forced by the lift-and-shift and documented here:
|
|
133
|
+
|
|
134
|
+
1. `ChildSessionResult` adds `agentMaxTurns?: number`.
|
|
135
|
+
The turn-limit resolution `normalizeMaxTurns(options.maxTurns ?? cfg.agentMaxTurns ?? options.defaultMaxTurns)` lives in the interaction half (`runAgent`), but `cfg.agentMaxTurns` is only known after `assembleSessionConfig`, which moves into the factory.
|
|
136
|
+
The narrowest way to carry it across the seam is a single field on the result (ISP — not the whole `SessionConfig`).
|
|
137
|
+
It remains useful in Step 3 when `Agent` owns turn enforcement.
|
|
138
|
+
2. `ChildSessionConfig` is narrow — only the six creation inputs.
|
|
139
|
+
The issue's target lists `prompt`, `maxTurns`, and `getRunConfig` as bound config, but those are interaction concerns; binding them now would violate ISP for a factory whose only job is creation.
|
|
140
|
+
They stay in `runAgent`'s `options` and migrate to the factory's config only if/when Step 3 needs them there.
|
|
141
|
+
|
|
142
|
+
### Why `ConcreteAgentRunner.createFactory()` is deferred to Step 3
|
|
143
|
+
|
|
144
|
+
The issue describes the runner gaining `createFactory(config)`.
|
|
145
|
+
Adding it in this step produces an unused class member: `runAgent()` builds the factory directly (it is a free function with `deps`, not a runner instance), and `AgentManager` — the eventual caller of `createFactory` — is not wired to it until Step 3. fallow flags unused class members.
|
|
146
|
+
Adding it now would require either a `// fallow-ignore` suppression or rewiring `ConcreteAgentRunner.run()` to take a factory, which would change `runAgent`'s signature and force a premature rewrite of the 313-line runner test file.
|
|
147
|
+
Deferring `createFactory` to Step 3 keeps this step a clean, fallow-green lift-and-shift and aligns with the architecture's "Agent is not changed yet" framing.
|
|
148
|
+
The factory still has a production consumer in this step — `runAgent` — so the new class is not dead.
|
|
149
|
+
|
|
150
|
+
### Consumer call-site sketch (Tell-Don't-Ask)
|
|
151
|
+
|
|
152
|
+
`runAgent()` after extraction (interaction only):
|
|
153
|
+
|
|
154
|
+
```typescript
|
|
155
|
+
const factory = new ConcreteChildSessionFactory(
|
|
156
|
+
{
|
|
157
|
+
snapshot,
|
|
158
|
+
type,
|
|
159
|
+
model: options.model,
|
|
160
|
+
isolated: options.isolated,
|
|
161
|
+
thinkingLevel: options.thinkingLevel,
|
|
162
|
+
parentSession: options.context.parentSession,
|
|
163
|
+
},
|
|
164
|
+
deps,
|
|
165
|
+
);
|
|
166
|
+
const { session, outputFile, cleanup, agentMaxTurns } = await factory.create(options.context.cwd);
|
|
167
|
+
|
|
168
|
+
options.onSessionCreated?.(session);
|
|
169
|
+
const maxTurns = normalizeMaxTurns(options.maxTurns ?? agentMaxTurns ?? options.defaultMaxTurns);
|
|
170
|
+
// ... turn-tracking subscription, collector, abort forwarding ...
|
|
171
|
+
try {
|
|
172
|
+
await session.prompt(effectivePrompt);
|
|
173
|
+
} finally {
|
|
174
|
+
unsubTurns();
|
|
175
|
+
collector.unsubscribe();
|
|
176
|
+
cleanupAbort();
|
|
177
|
+
cleanup(); // was: unregisterChildSession(sessionDir)
|
|
178
|
+
}
|
|
179
|
+
return { responseText, session, aborted, steered: softLimitReached, sessionFile: outputFile };
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
`runAgent` tells the factory "create me a session" and tells the result "clean up" — no reach-through, no bridge import.
|
|
183
|
+
|
|
184
|
+
### Extracted-module interaction with upstream dependencies
|
|
185
|
+
|
|
186
|
+
`ConcreteChildSessionFactory.create()` is the verbatim creation block, re-rooted onto `this.config` / `this.deps`.
|
|
187
|
+
It carries no output-argument mutation or reverse-search patterns from the original (the block already only reads from `deps.io` and returns a session).
|
|
188
|
+
The one in-place dependency it touches — `sessionManager` from `deps.io.createSessionManager` — is local to `create()`, captured in the returned `outputFile` and `cleanup` closure (which closes over `sessionDir`).
|
|
189
|
+
The upstream API (`deps.io`, `assembleSessionConfig`, the permission-bridge functions) needs no changes; nothing about the seam requires fixing an upstream gap first.
|
|
190
|
+
|
|
191
|
+
The factory reads four of `ParentSnapshot`'s fields (`cwd`, `systemPrompt`, `model`, `modelRegistry`); `parentContext` stays with `runAgent` for the prompt prefix.
|
|
192
|
+
Passing the cohesive `ParentSnapshot` value object whole is appropriate.
|
|
193
|
+
|
|
194
|
+
### Edge cases
|
|
195
|
+
|
|
196
|
+
- `cwd` omitted → `create()` falls back to `snapshot.cwd`, identical to today's `options.context.cwd ?? snapshot.cwd`.
|
|
197
|
+
- `extensions: false` → factory skips the recursion-guard filter (`setActiveToolsByName` not called), identical to today.
|
|
198
|
+
- `prompt()` throws → `runAgent`'s `finally` still calls `cleanup()`, so `unregisterChildSession` runs (existing "unregisters even when prompt throws" test preserved).
|
|
199
|
+
- pi-permission-system absent → register/unregister remain no-ops (bridge behavior unchanged).
|
|
200
|
+
|
|
201
|
+
## Module-Level Changes
|
|
202
|
+
|
|
203
|
+
- New: `src/lifecycle/child-session-factory.ts`
|
|
204
|
+
- `ChildSessionConfig`, `ChildSessionResult`, `ChildSessionFactory` interfaces.
|
|
205
|
+
- `ConcreteChildSessionFactory` class with the lifted `create(cwd?)` body.
|
|
206
|
+
- Moved here from `agent-runner.ts`: the `registerChildSession` / `unregisterChildSession` imports, the `EXCLUDED_TOOL_NAMES` constant, and the `filterActiveTools` helper.
|
|
207
|
+
- Imports (type-only) `RunnerDeps` from `agent-runner.ts` — type-only, so no runtime import cycle; the runtime arrow is one-way (`agent-runner` imports the factory class as a value).
|
|
208
|
+
- Changed: `src/lifecycle/agent-runner.ts`
|
|
209
|
+
- Remove the permission-bridge import, `EXCLUDED_TOOL_NAMES`, and `filterActiveTools`.
|
|
210
|
+
- Add `import { ConcreteChildSessionFactory } from "#src/lifecycle/child-session-factory"`.
|
|
211
|
+
- `runAgent()`: replace the creation block (effectiveCwd → post-bind filter) with `new ConcreteChildSessionFactory(...).create(options.context.cwd)`; resolve `maxTurns` from the returned `agentMaxTurns`; call `cleanup()` in the `finally`; set `RunResult.sessionFile = outputFile`.
|
|
212
|
+
- Keep `RunnerDeps`, all IO interfaces, `RunResult`, `RunOptions`, `normalizeMaxTurns`, `collectResponseText`, `getLastAssistantText`, `forwardAbortSignal`, `resumeAgent`, `getAgentConversation`, and `ConcreteAgentRunner` unchanged.
|
|
213
|
+
- Check the unused-import set after the move: `AgentSession` and `assembleSessionConfig`/`AssemblerIO` may no longer be referenced in `agent-runner.ts` once creation leaves; remove any now-dead imports (the factory imports them instead).
|
|
214
|
+
- Doc updates (`docs/architecture/architecture.md`):
|
|
215
|
+
- Lifecycle subgraph (≈ lines 54-60): add a `ChildSessionFactory` node; rewire the `AgentRunner --> SessionConfig` edge to `AgentRunner --> ChildSessionFactory --> SessionConfig` (the subscribe edges from observers stay on `AgentRunner`).
|
|
216
|
+
- Layout listing (≈ lines 270-280): add `child-session-factory.ts child session creation (env, config assembly, bind, tool filter)`; update the `agent-runner.ts` line to "turn loop, results (creation delegated to ChildSessionFactory)".
|
|
217
|
+
- Component dependency bullets (≈ lines 354-357): update the `agent-runner` bullet and add a `child-session-factory` bullet.
|
|
218
|
+
- The fallow health snapshot (dated table, ≈ line 925) is left unchanged — it is a point-in-time fallow dump regenerated at phase boundaries, not per-step.
|
|
219
|
+
- Doc update (`.pi/skills/package-pi-subagents/SKILL.md`): Lifecycle domain row — add `child-session-factory.ts`; bump the Lifecycle module count (9 → 10) and the total file count (56 → 57).
|
|
220
|
+
|
|
221
|
+
Removed/moved symbols and their consumers (grepped across `src/` and `test/`):
|
|
222
|
+
|
|
223
|
+
- `EXCLUDED_TOOL_NAMES`, `filterActiveTools` — private to `agent-runner.ts`, no other consumer; moved (not deleted) into the factory.
|
|
224
|
+
- `registerChildSession` / `unregisterChildSession` imports — only `agent-runner.ts` imported them in `src/`; the import moves to the factory.
|
|
225
|
+
The test mock `vi.mock("#src/lifecycle/permission-bridge")` is path-based and continues to intercept the factory's import unchanged.
|
|
226
|
+
- No exported symbol is removed, so no excess-property or dangling-import breakage in `src/`.
|
|
227
|
+
|
|
228
|
+
## Test Impact Analysis
|
|
229
|
+
|
|
230
|
+
1. New unit tests enabled by the extraction (`test/lifecycle/child-session-factory.test.ts`, using `createRunnerDeps()` + a session stub):
|
|
231
|
+
- register-before-`bindExtensions` ordering; register key = `sessionDir`; `agentName`/`parentSessionId` forwarded.
|
|
232
|
+
- `cleanup()` calls `unregisterChildSession(sessionDir)`.
|
|
233
|
+
- effective cwd/agentDir wiring into the loader and settings manager; AGENTS.md/CLAUDE.md/APPEND_SYSTEM suppression.
|
|
234
|
+
- `newSession` called with `parentSession`.
|
|
235
|
+
- `outputFile` = persisted session file; `agentMaxTurns` surfaced from the assembled config.
|
|
236
|
+
- post-bind recursion guard: `setActiveToolsByName` once after bind; includes extension tool when `extensions: true`; excludes `EXCLUDED_TOOL_NAMES`; `extensions: false` skips the filter (migrated from `agent-runner-extension-tools.test.ts`).
|
|
237
|
+
2. Existing tests that become redundant / can be trimmed: the pure-creation assertions in `agent-runner.test.ts` (cwd/agentDir wiring, AGENTS.md suppression, `newSession` with `parentSession`, the permission "registers before bind"/"registers with sessionDir key"/"agentName+parentSessionId" cases) duplicate the new factory tests once migrated; the `agent-runner-extension-tools.test.ts` recursion-guard block moves to the factory test.
|
|
238
|
+
These all currently pass through `runAgent → factory` delegation, so trimming is cleanup, not a correctness fix.
|
|
239
|
+
3. Existing tests that must stay (genuinely exercise the interaction layer or the delegation seam):
|
|
240
|
+
`agent-runner.test.ts` keeps final-output capture + fallback, `defaultMaxTurns`/`graceTurns`/`maxTurns`-precedence enforcement, resume fallback, "binds extensions before prompting" (the create-then-prompt ordering is `runAgent`'s orchestration), "returns `sessionFile` in `RunResult`" (verifies `runAgent` surfaces `outputFile`), and "unregisters after success"/"unregisters even when prompt throws" (verify `runAgent` calls `cleanup()`).
|
|
241
|
+
`agent-runner-settings.test.ts` (`normalizeMaxTurns`) and `concrete-agent-runner.test.ts` (`run`/`resume` delegation) are untouched.
|
|
242
|
+
|
|
243
|
+
## TDD Order
|
|
244
|
+
|
|
245
|
+
1. Add `ChildSessionFactory` with factory-level unit tests.
|
|
246
|
+
Surface: `test/lifecycle/child-session-factory.test.ts`.
|
|
247
|
+
Covers the creation behaviors and the recursion-guard cases listed in Test Impact #1.
|
|
248
|
+
Implement `src/lifecycle/child-session-factory.ts` (interfaces + `ConcreteChildSessionFactory`, with the permission-bridge import and tool-filter helpers).
|
|
249
|
+
The factory is standalone here — `runAgent` still has its own creation copy — so `pnpm fallow dead-code` will transiently flag `ConcreteChildSessionFactory` (consumed in step 2); that is expected and resolved by the next commit.
|
|
250
|
+
Commit: `test(pi-subagents): add ChildSessionFactory creation tests` then `feat(pi-subagents): add ChildSessionFactory for child session creation`.
|
|
251
|
+
2. Delegate session creation from `runAgent()` to the factory.
|
|
252
|
+
Rewire `runAgent()` to construct the factory and call `create()`; remove the creation block, the permission-bridge import, `EXCLUDED_TOOL_NAMES`, and `filterActiveTools` from `agent-runner.ts`; resolve `maxTurns` from `agentMaxTurns`; call `cleanup()` in `finally`; set `sessionFile = outputFile`.
|
|
253
|
+
Trim the now-redundant creation tests from `agent-runner.test.ts` and migrate the recursion-guard block out of `agent-runner-extension-tools.test.ts` into the factory test (Test Impact #2).
|
|
254
|
+
The factory now has a production consumer; `pnpm fallow dead-code` is clean.
|
|
255
|
+
Run `pnpm run check` immediately (the creation extraction touches the runner's import surface).
|
|
256
|
+
Commit: `refactor(pi-subagents): runAgent delegates session creation to ChildSessionFactory`.
|
|
257
|
+
3. Update the architecture doc and package skill.
|
|
258
|
+
`docs/architecture/architecture.md` (lifecycle subgraph, layout listing, component bullets) and `.pi/skills/package-pi-subagents/SKILL.md` (Lifecycle row + counts).
|
|
259
|
+
Commit: `docs(pi-subagents): reflect ChildSessionFactory extraction in architecture`.
|
|
260
|
+
|
|
261
|
+
After all steps: `pnpm run check`, `pnpm run lint`, `pnpm -r run test`, `pnpm fallow dead-code`.
|
|
262
|
+
|
|
263
|
+
## Risks and Mitigations
|
|
264
|
+
|
|
265
|
+
- Risk: a type-only import of `RunnerDeps` from `agent-runner.ts` into the factory while `agent-runner.ts` value-imports the factory looks circular.
|
|
266
|
+
Mitigation: `import type` is fully erased, so the only runtime arrow is `agent-runner → child-session-factory`; verified by `pnpm run check` after step 2.
|
|
267
|
+
- Risk: `RunResult.sessionFile` changes from a late `sessionManager.getSessionFile()` to the factory's `outputFile`.
|
|
268
|
+
Mitigation: `getSessionFile()` is stable after `newSession()`; the existing assertion (`/sessions/child.jsonl`) and the persisted-file test both pass — confirmed by the runner suite in step 2.
|
|
269
|
+
- Risk: the permission-bridge module mock stops intercepting after the import moves.
|
|
270
|
+
Mitigation: `vi.mock()` is path-based; the factory imports the same `#src/lifecycle/permission-bridge` path, so the existing mock applies to the factory's calls.
|
|
271
|
+
- Risk: trimming/migrating tests across `agent-runner.test.ts` and `agent-runner-extension-tools.test.ts` accidentally drops coverage.
|
|
272
|
+
Mitigation: every trimmed assertion has an equivalent in the new factory test; the suite is the safety net (`pnpm -r run test`).
|
|
273
|
+
- Risk: leftover dead imports in `agent-runner.ts` after the creation block leaves.
|
|
274
|
+
Mitigation: step 2 ends with `pnpm run check` + `pnpm run lint`, which flag unused imports.
|
|
275
|
+
|
|
276
|
+
## Open Questions
|
|
277
|
+
|
|
278
|
+
- Whether `ChildSessionResult.agentMaxTurns` should become a fully-resolved `maxTurns` (combining `options.maxTurns` / `defaultMaxTurns`) once Step 3 binds `getRunConfig` into the factory config.
|
|
279
|
+
Deferred: keep the raw per-agent value for now; revisit when `Agent` owns turn enforcement.
|
|
280
|
+
- Whether the session-creation IO interfaces (`RunnerIO`, `RunnerDeps`, `EnvironmentIO`, `SessionFactoryIO`, `CreateSessionOptions`, etc.) should move from `agent-runner.ts` into `child-session-factory.ts`.
|
|
281
|
+
Deferred to Step 4, when the runner dissolves and the natural home for these creation contracts is the factory module.
|
|
282
|
+
- Whether `ConcreteAgentRunner.createFactory()` lands in Step 3 (when `AgentManager` consumes it) exactly as the issue describes.
|
|
283
|
+
Deferred to Step 3 per the Design Overview rationale.
|