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
- Create an active
.omo/boulder.json whose session_ids contains codex:repro-session, pointing to a plan with one unchecked top-level task.
- Run the shipped component with a matching
SubagentStop payload:
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
- Observe that the output is a blocking root-plan continuation. A reduced observable from the reproduced run was:
{"surface":"subagent","decision":"block","hasDirective":true,"nextParentTask":true,"trackedRootSession":true}
- Run the equivalent
Stop payload. It produces the same reduced result:
{"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:
- A QA child was assigned only one final manual-QA lane.
- On completion it received the root
<start-work-continuation> directive.
- 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.
- 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.
- 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:
SubagentStop => decision=block, hasDirective=true, nextParentTask=true
Stop => decision=block, hasDirective=true, nextParentTask=true
Relevant source at LazyCodex 10f95587d3aeacf208cc1fee88a91315962d31e8:
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:
- The continuation component is deliberately registered for
SubagentStop.
- The hook accepts
Stop and SubagentStop through the same code path.
- 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.
- 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:
- Remove
start-work-continuation from the SubagentStop hook group. Keep it on Stop only.
- Keep
lazycodex-executor-verify on SubagentStop for bounded child evidence validation.
- 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.
- 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.
- 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
Summary
The
start-work-continuationhook is registered for bothStopandSubagentStopand treats them identically. When a child finishes one bounded task, the hook can inject the root agent's entire active$start-workplan 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
4.19.4codex-cli 0.147.010f95587d3aeacf208cc1fee88a91315962d31e8a04940cb12cca43510aaf8d601ce42352f0902cbRepository Decision
code-yeongyu/lazycodexstart-work-continuationforSubagentStop, parsesSubagentStopas a normal stop input, and executes the same Boulder lookup and parent-plan directive rendering for both event types.plugins/omo/components/start-work-continuation/hooks/hooks.jsonregisters the component for bothStopandSubagentStop.plugins/omo/components/start-work-continuation/src/codex-hook.tsaccepts both event names and callsreadContinuationState(input.cwd, input.session_id)without a root/child ownership check.plugins/omo/components/start-work-continuation/src/types.tsmodels both events but does not retainagent_id,agent_type, oragent_transcript_path.codex-rs/hooks/src/schema.rs::SubagentStopCommandInputsuppliesagent_id,agent_type, andagent_transcript_pathin addition to the distinctSubagentStopevent.codex-rs/hooks/src/events/stop.rsserializes those fields for SubagentStop. LazyCodex therefore has enough information to avoid treating child completion as root-plan continuation.Reproduction
.omo/boulder.jsonwhosesession_idscontainscodex:repro-session, pointing to a plan with one unchecked top-level task.SubagentStoppayload:{"surface":"subagent","decision":"block","hasDirective":true,"nextParentTask":true,"trackedRootSession":true}Stoppayload. It produces the same reduced result:{"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-workcontinuation and own plan, Boulder, root-ledger, and lifecycle transitions.SubagentStopmay still run the separatelazycodex-executor-verifyevidence hook, but it should not inject the root plan into the child.Actual Behavior
In the real incident:
<start-work-continuation>directive.This directly reverses the advertised context/token benefit of subagent delegation.
Evidence
Minimal runtime comparison against the shipped
dist/cli.js:Relevant source at LazyCodex
10f95587d3aeacf208cc1fee88a91315962d31e8: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:
SubagentStop.StopandSubagentStopthrough the same code path.cwd + session_id. Codex's SubagentStop payload uses the owning session id, so the child matches the root's Boulder entry.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:
start-work-continuationfrom theSubagentStophook group. Keep it onStoponly.lazycodex-executor-verifyonSubagentStopfor bounded child evidence validation.runStopHook: return empty output forhook_event_name === "SubagentStop"unless a future explicitly modeled child-continuation mode proves necessary.session_id.Verification Plan
SubagentStoppayload returns empty output fromstart-work-continuation.Stoppayload still returnsdecision=blockwith the next unchecked task.This issue or PR was generated by LazyCodex.
Tag: lazycodex-generated