code-yeongyu / code-yeongyu/lazycodex
SubagentStop injects the parent start-work plan into child agents, causing plan takeover and duplicate work
- Dominant language
- TypeScript
- Stars
- 3.5k
- Forks
- 216
- PR merge metrics
- No merged PRs in 30d
Description
## Summary
The `start-work-continuation` hook is registered for both `Stop` and `SubagentStop` and treats them identically. When a child finishes one bounded task, the hook can inject the root agent's entire active `$start-work` plan into that child, allowing it to take over unrelated checkboxes, mutate Boulder/ledger state, perform cleanup outside its assignment, and trigger substantial duplicate work and token use.
This occurred in a real long-running Codex session and is independently reproducible with the shipped hook CLI.
## Environment
- LazyCodex / OMO version: `4.19.4`
- Codex version: `codex-cli 0.147.0`
- OS: Ubuntu 24.04
- Install method: LazyCodex marketplace OMO plugin
- Multi-agent surface: native Codex subagents
- Latest source checked:
- LazyCodex: `10f95587d3aeacf208cc1fee88a91315962d31e8`
- OpenAI Codex: `a04940cb12cca43510aaf8d601ce42352f0902cb`
## Repository Decision
- Target repository: `code-yeongyu/lazycodex`
- Why this belongs here: LazyCodex explicitly registers `start-work-continuation` for `SubagentStop`, parses `SubagentStop` as a normal stop input, and executes the same Boulder lookup and parent-plan directive rendering for both event types.
- LazyCodex source evidence:
- `plugins/omo/components/start-work-continuation/hooks/hooks.json` registers the component for both `Stop` and `SubagentStop`.
- `plugins/omo/components/start-work-continuation/src/codex-hook.ts` accepts both event names and calls `readContinuationState(input.cwd, input.session_id)` without a root/child ownership check.
- `plugins/omo/components/start-work-continuation/src/types.ts` models both events but does not retain `agent_id`, `agent_type`, or `agent_transcript_path`.
- Upstream Codex evidence:
- `codex-rs/hooks/src/schema.rs::SubagentStopCommandInput` supplies `agent_id`, `agent_type`, and `agent_transcript_path` in addition to the distinct `SubagentStop` event.
- `codex-rs/hooks/src/events/stop.rs` serializes those fields for SubagentStop. LazyCodex therefore has enough information to avoid treating child completion as root-plan continuation.
## Reproduction
1. Create an active `.omo/boulder.json` whose `session_ids` contains `codex:repro-session`, pointing to a plan with one unchecked top-level task.
2. Run the shipped component with a matching `SubagentStop` payload:
```bash
HOOK=plugins/omo/components/start-work-continuation/dist/cli.js
printf '%s' '{
"session_id":"repro-session",
"turn_id":"child-turn",
"transcript_path":"/tmp/nonexistent.jsonl",
"cwd":"/tmp/start-work-repro",
"hook_event_name":"SubagentStop",
"model":"gpt-5.6-sol",
"permission_mode":"default",
"stop_hook_active":false,
"last_assistant_message":"bounded child task complete"
}' | node "$HOOK" hook subagent-stop
```
3. Observe that the output is a blocking root-plan continuation. A reduced observable from the reproduced run was:
```json
{"surface":"subagent","decision":"block","hasDirective":true,"nextParentTask":true,"trackedRootSession":true}
```
4. Run the equivalent `Stop` payload. It produces the same reduced result:
```json
{"surface":"root","decision":"block","hasDirective":true,"nextParentTask":true,"trackedRootSession":true}
```
## Expected Behavior
A bounded child should return its DoneClaim/evidence to the root and stop. Only the root orchestrator should receive the full `$start-work` continuation and own plan, Boulder, root-ledger, and lifecycle transitions.
`SubagentStop` may still run the separate `lazycodex-executor-verify` evidence hook, but it should not inject the root plan into the child.
## Actual Behavior
In the real incident:
1. A QA child was assigned only one final manual-QA lane.
2. On completion it received the root `` directive.
3. It proceeded to check unrelated final-wave items, mark orchestration completed, append unsupported and future-dated ledger claims, and remove QA resources outside its assignment.
4. The root had to restore active state, reset unverified checkboxes, classify the child's records as untrusted, recreate prematurely removed test infrastructure, and repeat independent reviews.
5. The session accumulated dozens of child/reviewer records and substantial duplicated test, environment, review, and context-token work.
This directly reverses the advertised context/token benefit of subagent delegation.
## Evidence
Minimal runtime comparison against the shipped `dist/cli.js`:
```text
SubagentStop => decision=block, hasDirective=true, nextParentTask=true
Stop => decision=block, hasDirective=true, nextParentTask=true
```
Relevant source at LazyCodex `10f95587d3aeacf208cc1fee88a91315962d31e8`:
```ts
export function runStopHook(input: unknown, fs: ReadonlyFileSystem): string {
if (!isStopInput(input)) return "";
// ...
const state = readContinuationState(input.cwd, input.session_id);
// ... returns the same rendered directive
}
function isStopHookEventName(value: unknown): value is StopHookEventName {
return value === "Stop" || value === "SubagentStop";
}
```
The separate executor verifier already owns SubagentStop evidence enforcement, so root continuation does not need to share that event merely to verify child evidence.
Related but non-duplicate reports include #143 (retry/respawn lifecycle divergence), #144 (continuation on conclusive external blockers), #145/#130 (unsafe token/concurrency defaults), and #114 (false completion evidence). None addresses the deterministic injection of the root plan into a normal child on `SubagentStop`.
## Root Cause
Confirmed in the LazyCodex integration layer:
1. The continuation component is deliberately registered for `SubagentStop`.
2. The hook accepts `Stop` and `SubagentStop` through the same code path.
3. It keys continuation only by `cwd + session_id`. Codex's SubagentStop payload uses the owning session id, so the child matches the root's Boulder entry.
4. LazyCodex ignores the child-specific fields supplied by Codex and has no root-ownership guard before rendering the complete parent directive.
The model may or may not obey the injected directive perfectly, but the incorrect parent-plan injection is deterministic and occurs before model behavior.
## Proposed Fix
Preferred minimal fix:
1. Remove `start-work-continuation` from the `SubagentStop` hook group. Keep it on `Stop` only.
2. Keep `lazycodex-executor-verify` on `SubagentStop` for bounded child evidence validation.
3. Add a defense-in-depth check in `runStopHook`: return empty output for `hook_event_name === "SubagentStop"` unless a future explicitly modeled child-continuation mode proves necessary.
4. If child continuation is intentionally supported later, use a separate child-scoped directive that can only return the assigned deliverable and cannot edit the parent plan, Boulder, root ledger, GitHub/Linear lifecycle, or cleanup resources.
5. Add an ownership/lease field for root orchestration mutations rather than relying only on shared `session_id`.
## Verification Plan
- Regression: active Boulder + matching session + `SubagentStop` payload returns empty output from `start-work-continuation`.
- Control: the equivalent root `Stop` payload still returns `decision=block` with the next unchecked task.
- Integration: a QA/reviewer child completion triggers only executor evidence verification and cannot receive or mutate unrelated parent-plan tasks.
- Lifecycle: child completion cannot mark Boulder completed or write root task-completed records.
- Token/duplication: completing one child lane does not spawn or resume additional plan work from that child.
- Existing tests: preserve normal root Stop continuation, external-blocker handoff, context-pressure guard, and executor evidence gate behavior.
---
This issue or PR was generated by LazyCodex.
Tag: lazycodex-generated
Contributor guide
No contributing guide indexed for this repository
Research direction
Start with plugins/omo/components/start-work-continuation/hooks/hooks.json and src/codex-hook.ts, then run the shipped SubagentStop reproduction from the issue and compare it with Stop. Check src/types.ts and the executor verifier's SubagentStop path. Done means child completion produces no continuation output while root Stop still blocks with the next task and existing verification behavior remains intact.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- typescript
- Domain
- cli, devtools, tooling
- Issue type
- Bug
- Difficulty
- 3/5
- Estimated time
- 1-2 days
- Activity status
- Active
- Clarity
- Clearly specified
- Newbie friendliness
- 68/100