# copilot: don't create phantom sessions for subagent turns

Copilot fires the per-turn/session lifecycle hooks for subagent turns too, reusing the Task tool-use id (e.g. "toolu_…") as the sessionId. That spun up a second top-level Entire session which never received a matching stop (subagent-stop carries the main session id), so it stayed "active" forever and pinned its shadow branch open. After a user commit, the branch was preserved instead of cleaned up — TestSubagentCommitFlow then failed on WaitForNoShadowBranches with a leaked entire/<hash> branch.

Drop the session-lifecycle hooks (user-prompt-submitted, session-start, agent-stop, session-end) when the sessionId is a subagent tool-use id; real Copilot session ids are UUIDs so the "toolu_" prefix is an unambiguous marker. subagent-stop is left untouched (it carries the main id and drives the task-checkpoint path), so the subagent's work is still captured via the main session.

## Sessions

## Changes

- cmd/entire/cli/agent/copilotcli

- Mlifecycle.go+27  
  
  - Mlifecycle_test.go+47

```  
3 unmodified lines  
4  
5  
6  
7  
8  
9  
10  
11  
12  
13  
14  
15  
16  
17  
18  
19  
20  
21  
22  
23  
24  
25  
26  
27  
53 unmodified lines  
81  
82  
83  
84  
85  
86  
87  
88  
89  
90  
91  
92  
93  
94  
95  
96  
97  
98  
99  
100

3 unmodified lines

"context"  
	"fmt"  
	"io"  
	"strings"

"github.com/entireio/cli/cmd/entire/cli/agent"  
	"github.com/entireio/cli/cmd/entire/cli/logging"

// subagentSessionIDPrefix is the prefix Copilot uses when it reuses a Task  
// tool-use id as the sessionId on lifecycle hooks fired for a subagent turn.  
// Real Copilot session ids are UUIDs, so this prefix unambiguously marks a  
// subagent context (e.g. "toolu_bdrk_01K…" on Bedrock-backed models).  
const subagentSessionIDPrefix = "toolu_"

// isSubagentSessionID reports whether a Copilot sessionId is actually a  
// subagent's tool-use id rather than a real interactive session id.  
func isSubagentSessionID(sessionID string) bool {  
	return strings.HasPrefix(sessionID, subagentSessionIDPrefix)  
}

// Ensure CopilotCLIAgent implements HookSupport at compile time.  
var _ agent.HookSupport = (*CopilotCLIAgent)(nil)

53 unmodified lines

}  
}

// Copilot fires the per-turn/session lifecycle hooks for subagent turns too,  
// using the subagent's Task tool-use id (e.g. "toolu_…") as the sessionId.  
// Those must NOT spin up a top-level Entire session: the subagent never gets  
// a matching stop for that id, so the phantom session would stay "active"  
// forever and pin its shadow branch open after the user commits. The  
// subagent's work is still captured via the main session's subagentStop →  
// task checkpoint path, so we drop only the session-lifecycle hooks here and  
// leave subagentStop itself to run.
if hookName != HookNameSubagentStop && isSubagentSessionID(env.SessionID) {  
	logging.Debug(ctx, "copilot-cli: skipping lifecycle event for subagent session",  
		"sessionID", env.SessionID, "hook", hookName)  
	return nil, nil //nolint:nilnil // Subagent lifecycle hook — no top-level session action.  
}

switch hookName {  
case HookNameUserPromptSubmitted:  
	return c.buildUserPromptSubmitted(ctx, env), nil
}
```

func TestParseHookEvent_SubagentSession_LifecycleHooksReturnNil(t *testing.T) {  
	t.Parallel()

const subagentSessionID = "toolu_bdrk_01KTyZvJLaUtkjgvdA355rMX"  
	ag := &CopilotCLIAgent{}

lifecycleHooks := []string{  
		HookNameUserPromptSubmitted,  
		HookNameSessionStart,  
		HookNameAgentStop,  
		HookNameSessionEnd,  
	}

for _, hookName := range lifecycleHooks {  
		t.Run(hookName, func(t *testing.T) {  
			t.Parallel()  
			input := `{"timestamp":1771480081360,"cwd":"/path/to/repo","sessionId":"` + subagentSessionID + `","prompt":"hi"}`

event, err := ag.ParseHookEvent(context.Background(), hookName, strings.NewReader(input))

require.NoError(t, err)  
			require.Nil(t, event, "expected nil event for subagent-session lifecycle hook %s", hookName)  
		})  
	}  
}

// TestParseHookEvent_SubagentSession_SubagentStopStillFires verifies the  
// subagent-stop hook is NOT dropped — it always carries the main session id and  
// drives the task-checkpoint path.
func TestParseHookEvent_SubagentSession_SubagentStopStillFires(t *testing.T) {  
	t.Parallel()

ag := &CopilotCLIAgent{}  
	input := `{"timestamp":1771480085412,"cwd":"/path/to/repo","sessionId":"` + testSessionID + `"}`

event, err := ag.ParseHookEvent(context.Background(), HookNameSubagentStop, strings.NewReader(input))

require.NoError(t, err)  
	require.NotNil(t, event, "subagent-stop must still produce an event")  
	require.Equal(t, agent.SubagentEnd, event.Type)  
}

func TestParseHookEvent_PassthroughHooks_ReturnNil(t *testing.T) {  
	t.Parallel()
